change oidc behavior with remember me on by default when oidc only enabled (#2397)

This commit is contained in:
jubnl
2026-09-19 09:12:15 +02:00
committed by GitHub
parent 902dbbf84c
commit ac47066a93
8 changed files with 30 additions and 13 deletions
+3 -1
View File
@@ -498,7 +498,9 @@ export default function LoginPage(): React.ReactElement {
)}
<a
onClick={clearSignedOut}
href={`/api/auth/oidc/login${inviteToken ? '?invite=' + encodeURIComponent(inviteToken) : ''}`}
// No remember-me switch in OIDC-only mode: the IdP owns the session
// policy, so always ask for the remembered lifetime (#1927).
href={`/api/auth/oidc/login${inviteToken ? '?invite=' + encodeURIComponent(inviteToken) + '&' : '?'}remember=1`}
style={{
width: '100%',
padding: '12px',
+3 -2
View File
@@ -203,7 +203,8 @@ describe('LoginPage — OIDC-only mode', () => {
expect(screen.queryByPlaceholderText(EMAIL_PLACEHOLDER)).toBeNull();
const link = screen.getByRole('link', { name: /sign in with authentik/i });
expect(link).toHaveAttribute('href', '/api/auth/oidc/login');
// No switch in OIDC-only mode: the link always asks for the remembered lifetime.
expect(link).toHaveAttribute('href', '/api/auth/oidc/login?remember=1');
expect(
screen.getByText('Password authentication is disabled. Please sign in using your SSO provider.'),
).toBeInTheDocument();
@@ -222,7 +223,7 @@ describe('LoginPage — OIDC-only mode', () => {
expect(screen.getByText('Invalid session. Please try again.')).toBeInTheDocument();
expect(screen.getByRole('link', { name: /sign in with sso/i })).toHaveAttribute(
'href',
'/api/auth/oidc/login?invite=inv%2F42',
'/api/auth/oidc/login?invite=inv%2F42&remember=1',
);
});
+3 -1
View File
@@ -180,7 +180,9 @@ describe('useLogin — app config probe', () => {
await ready(result);
expect(result.current.oidcOnly).toBe(true);
await waitFor(() => expect(window.location.href).toBe('/api/auth/oidc/login'));
// OIDC-only has no remember-me switch and the IdP owns the session policy,
// so the auto-redirect always asks for the remembered lifetime.
await waitFor(() => expect(window.location.href).toBe('/api/auth/oidc/login?remember=1'));
});
it('FE-LOGIN-HOOK-008: does not bounce back to the IdP right after a logout', async () => {
+3 -1
View File
@@ -186,7 +186,9 @@ export function useLogin() {
// Skip auto-redirect when config is from cache — network is unreliable
// and auto-redirecting to the IdP could loop if the proxy changed.
if (!fromCache && !config.password_login && config.oidc_login && config.oidc_configured && config.has_users && !invite && !noRedirect) {
window.location.href = '/api/auth/oidc/login'
// No switch to consult on this path: OIDC-only always asks for the
// remembered lifetime, matching the SSO button on the panel (#1927).
window.location.href = '/api/auth/oidc/login?remember=1'
}
}
})
+4 -1
View File
@@ -166,7 +166,10 @@ export class OidcController {
if ('error' in result) return f('/login?oidc_error=' + result.error);
this.oidc.touchLastLogin(result.user.id);
const jwtToken = this.oidc.generateToken(result.user, pending.remember === true);
// Pass the flag through untouched: `undefined` must reach the token as
// "absent", not `false`, or the sliding renewal would later downgrade the
// default persistent cookie to a browser-session one (remember-me, #1927).
const jwtToken = this.oidc.generateToken(result.user, pending.remember);
const { code: authCode, binding } = this.oidc.createAuthCode(jwtToken, pending.remember);
// Bind the code to THIS browser, the way the state cookie binds the callback.
// The code rides home in a URL, so it is readable from history, from a
+6
View File
@@ -429,6 +429,12 @@ describe('OIDC remember-me (#1927)', () => {
const res = await runFlow('', 'sub-rm-abs', 'rmabs@example.com');
expect(res.status).toBe(200);
expect(sessionCookie(res)).toContain('Max-Age=86400');
// The JWT must not carry `remember: false` either — the sliding renewal
// re-issues the cookie from that claim, and `false` would turn the
// persistent default into a browser-session cookie half a day later.
const jwt = require('jsonwebtoken');
const decoded = jwt.decode(res.body.token) as { remember?: boolean };
expect(decoded.remember).toBeUndefined();
});
});
@@ -358,12 +358,11 @@ describe('OidcController /callback', () => {
expect(res.redirectedTo).toBe('https://app/login?oidc_error=server_error');
});
it('threads the pending remember flag into generateToken and createAuthCode', async () => {
for (const [remember, expectedGenerate] of [
[true, true],
[false, false],
[undefined, false],
] as const) {
it('threads the pending remember flag unchanged into generateToken and createAuthCode', async () => {
// An absent flag must stay absent in the token: coercing it to `false`
// would let the sliding renewal downgrade the default persistent cookie
// to a browser-session cookie half a lifetime later.
for (const remember of [true, false, undefined] as const) {
const generateToken = vi.fn().mockReturnValue('jwt');
const createAuthCode = vi.fn().mockReturnValue({ code: 'ac', binding: 'bnd' });
const res = makeRes();
@@ -376,7 +375,7 @@ describe('OidcController /callback', () => {
generateToken,
createAuthCode,
})).callback('c', 's', undefined, reqCb('s'), res);
expect(generateToken).toHaveBeenCalledWith({ id: 1 }, expectedGenerate);
expect(generateToken).toHaveBeenCalledWith({ id: 1 }, remember);
expect(createAuthCode).toHaveBeenCalledWith('jwt', remember);
}
});
+2
View File
@@ -24,6 +24,8 @@ OpenID Connect (OIDC) lets users log in with an existing identity provider — G
| `0` | `SESSION_DURATION` (default `24h`) | browser-session cookie, cleared when the browser closes |
| omitted | `SESSION_DURATION` (default `24h`) | persistent, `maxAge` matches |
In OIDC-only mode (password login disabled) there is no switch: both the SSO button and the automatic redirect to the provider always send `remember=1`, so SSO sessions get the `SESSION_DURATION_REMEMBER` lifetime. Tune that variable if you want shorter sessions on an OIDC-only instance.
If SSO sessions are dying at browser close, the login link is sending `remember=0`.
## Prerequisites