diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index 16d18311d..9c8dff1b3 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -498,7 +498,9 @@ export default function LoginPage(): React.ReactElement { )} { 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', ); }); diff --git a/client/src/pages/login/useLogin.test.tsx b/client/src/pages/login/useLogin.test.tsx index 1ec430c37..20bb3d76c 100644 --- a/client/src/pages/login/useLogin.test.tsx +++ b/client/src/pages/login/useLogin.test.tsx @@ -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 () => { diff --git a/client/src/pages/login/useLogin.ts b/client/src/pages/login/useLogin.ts index 76454d337..93238361d 100644 --- a/client/src/pages/login/useLogin.ts +++ b/client/src/pages/login/useLogin.ts @@ -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' } } }) diff --git a/server/src/nest/oidc/oidc.controller.ts b/server/src/nest/oidc/oidc.controller.ts index d9dffba4b..5f90d3cdd 100644 --- a/server/src/nest/oidc/oidc.controller.ts +++ b/server/src/nest/oidc/oidc.controller.ts @@ -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 diff --git a/server/tests/integration/oidc.test.ts b/server/tests/integration/oidc.test.ts index 8755d5ea7..76482a275 100644 --- a/server/tests/integration/oidc.test.ts +++ b/server/tests/integration/oidc.test.ts @@ -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(); }); }); diff --git a/server/tests/unit/nest/oidc.controller.test.ts b/server/tests/unit/nest/oidc.controller.test.ts index 3aaf97ca6..ba7d7ccfd 100644 --- a/server/tests/unit/nest/oidc.controller.test.ts +++ b/server/tests/unit/nest/oidc.controller.test.ts @@ -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); } }); diff --git a/wiki/OIDC-SSO.md b/wiki/OIDC-SSO.md index c1aa4ec54..0345da6b2 100644 --- a/wiki/OIDC-SSO.md +++ b/wiki/OIDC-SSO.md @@ -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