From af234f64a4a58ea71b84351568bcb146ee15a79f Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Mon, 7 Sep 2026 22:34:47 +0200 Subject: [PATCH] fix: drop max_age=0 from OIDC re-authentication requests #938 --- app/api/auth/sso/start/route.ts | 9 ++- lib/__tests__/sso-start-route.test.ts | 104 ++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 4 deletions(-) create mode 100644 lib/__tests__/sso-start-route.test.ts diff --git a/app/api/auth/sso/start/route.ts b/app/api/auth/sso/start/route.ts index f7978c54a..8b4c6ade3 100644 --- a/app/api/auth/sso/start/route.ts +++ b/app/api/auth/sso/start/route.ts @@ -121,12 +121,13 @@ export async function POST(request: NextRequest) { authUrl.searchParams.set('ui_locales', locale); } - // Force a fresh credential entry for step-up re-auth. prompt=login and - // max_age=0 both ask the IdP to re-authenticate even if it has an active - // session; honoring them depends on the IdP supporting these OIDC params. + // Force a fresh credential entry for step-up re-auth. prompt=login asks + // the IdP to re-authenticate even if it has an active session; honoring it + // depends on the IdP supporting this OIDC param. Deliberately no max_age=0: + // some IdPs (Authelia) re-evaluate max_age at the consent step and loop + // back to login forever, and nothing on our side reads auth_time. if (isReauth) { authUrl.searchParams.set('prompt', 'login'); - authUrl.searchParams.set('max_age', '0'); } return NextResponse.json({ diff --git a/lib/__tests__/sso-start-route.test.ts b/lib/__tests__/sso-start-route.test.ts new file mode 100644 index 000000000..83527fa3c --- /dev/null +++ b/lib/__tests__/sso-start-route.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('next/server', () => ({ + NextResponse: { + json: (data: unknown, init?: { status?: number }) => ({ + json: async () => data, + status: init?.status ?? 200, + }), + }, +})); + +const cookieSet = vi.fn(); +vi.mock('next/headers', () => ({ + cookies: async () => ({ set: cookieSet }), +})); + +vi.mock('@/lib/logger', () => ({ + logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() }, +})); + +vi.mock('@/lib/auth/crypto', () => ({ + encryptPayload: () => 'encrypted', +})); + +vi.mock('@/lib/oauth/pkce-server', () => ({ + generateCodeVerifierServer: () => 'verifier', + generateCodeChallengeServer: () => 'challenge', + generateStateServer: () => 'state-123', +})); + +vi.mock('@/lib/oauth/token-exchange', () => ({ + getRequiredConfig: () => ({ clientId: 'client', discoveryUrl: 'https://idp.example.com' }), + getDiscoveryValidator: () => undefined, +})); + +vi.mock('@/lib/oauth/discovery', () => ({ + discoverOAuth: async () => ({ + issuer: 'https://idp.example.com', + authorization_endpoint: 'https://idp.example.com/authorize', + token_endpoint: 'https://idp.example.com/token', + }), +})); + +vi.mock('@/lib/oauth/tokens', () => ({ + getOauthScopes: () => 'openid email', +})); + +vi.mock('@/lib/oauth/cookie-config', () => ({ + getCookieOptions: () => ({ httpOnly: true }), +})); + +vi.mock('@/lib/auth/session-secret', () => ({ + hasSessionSecret: () => true, +})); + +vi.mock('@/lib/admin/config-manager', () => ({ + configManager: { get: () => '' }, +})); + +function mockRequest(body: Record): unknown { + return { + json: async () => body, + headers: { get: (k: string) => (k === 'origin' ? 'https://mail.example.com' : null) }, + nextUrl: { origin: 'https://mail.example.com' }, + }; +} + +async function callRoute(body: Record) { + const { POST } = await import('@/app/api/auth/sso/start/route'); + const res = (await POST(mockRequest(body) as Parameters[0])) as unknown as { + status: number; + json: () => Promise<{ authorize_url: string }>; + }; + return { status: res.status, body: await res.json() }; +} + +describe('sso start route', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('forces prompt=login without max_age for re-authentication', async () => { + const { status, body } = await callRoute({ + redirect_uri: 'https://mail.example.com/auth/callback', + purpose: 'reauth', + }); + + expect(status).toBe(200); + const url = new URL(body.authorize_url); + expect(url.searchParams.get('prompt')).toBe('login'); + expect(url.searchParams.has('max_age')).toBe(false); + }); + + it('sends neither prompt nor max_age for a regular login', async () => { + const { status, body } = await callRoute({ + redirect_uri: 'https://mail.example.com/auth/callback', + }); + + expect(status).toBe(200); + const url = new URL(body.authorize_url); + expect(url.searchParams.has('prompt')).toBe(false); + expect(url.searchParams.has('max_age')).toBe(false); + }); +});