From 5c1da4efa7fb4d36bb56ebefa6775d8df861b3bd Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 27 Sep 2026 04:49:54 +0200 Subject: [PATCH] fix: refuse a pairing re-auth the identity provider did not make fresh The step-up for SSO accounts asks the provider for a new login with `prompt=login`, but a provider that ignores it answers from its existing session, so anyone at an unlocked desktop could link a phone. When the ID token's `auth_time` is older than the step-up, the re-auth is now refused. --- app/api/auth/reauth/sso/complete/route.ts | 27 ++++++++++++++++++- .../pair-reauth-sso-complete.test.ts | 24 +++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/app/api/auth/reauth/sso/complete/route.ts b/app/api/auth/reauth/sso/complete/route.ts index 5808cc76f..51e26fbad 100644 --- a/app/api/auth/reauth/sso/complete/route.ts +++ b/app/api/auth/reauth/sso/complete/route.ts @@ -22,6 +22,22 @@ import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; const SSO_PENDING_COOKIE = 'sso_pending'; const SSO_PENDING_MAX_AGE_MS = 5 * 60 * 1000; +/** Clock skew allowed between this server and the identity provider. */ +const AUTH_TIME_SKEW_MS = 2 * 60 * 1000; + +/** + * The `auth_time` claim of an ID token received straight from the token + * endpoint (so its signature need not be checked here), or null. + */ +function idTokenAuthTime(idToken: string | undefined): number | null { + if (!idToken) return null; + try { + const payload = JSON.parse(Buffer.from(idToken.split('.')[1] ?? '', 'base64url').toString('utf8')); + return typeof payload?.auth_time === 'number' ? payload.auth_time : null; + } catch { + return null; + } +} export async function POST(request: NextRequest) { // CSRF gate (GHSA-qvr9-m8cq-7wvg): cookies written here are SameSite=Lax, @@ -74,9 +90,18 @@ export async function POST(request: NextRequest) { } // A successful exchange proves the user just authenticated at the IdP (the - // freshness is enforced by prompt=login on the authorize request). + // freshness is asked for with prompt=login on the authorize request). const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri, pendingServerId); + // An IdP that ignores prompt=login answers from its existing session; + // when its ID token says the login is older than this step-up, it was + // no step-up at all. (No auth_time: nothing to check.) + const authTime = idTokenAuthTime(tokens.id_token); + if (authTime !== null && authTime * 1000 < createdAt - AUTH_TIME_SKEW_MS) { + logger.warn('Reauth complete: the provider did not ask for a fresh login'); + return NextResponse.json({ error: 'reauth_not_fresh' }, { status: 401 }); + } + // ...but not as whom. The phone must get the account being paired, not // whichever account the IdP prompt was answered with. const serverUrl = context.serverUrl.replace(/\/+$/, ''); diff --git a/lib/__tests__/pair-reauth-sso-complete.test.ts b/lib/__tests__/pair-reauth-sso-complete.test.ts index 761cba6dd..60f4178bb 100644 --- a/lib/__tests__/pair-reauth-sso-complete.test.ts +++ b/lib/__tests__/pair-reauth-sso-complete.test.ts @@ -314,3 +314,27 @@ describe('reauth/sso/complete', () => { expect(redeemPairingCode(firstCode.code)).toEqual({ ok: false, reason: 'expired' }); }); }); + +describe('reauth/sso/complete freshness', () => { + const idToken = (claims: Record) => + `h.${Buffer.from(JSON.stringify(claims)).toString('base64url')}.s`; + + it('refuses a login the provider did not make fresh (old auth_time)', async () => { + signIn(0); + pending({ created_at: Date.now() }); + exchangeCodeForTokens.mockResolvedValue({ ...FRESH, id_token: idToken({ auth_time: Math.floor(Date.now() / 1000) - 3600 }) }); + const res = await complete(); + expect(res).toEqual({ status: 401, body: { error: 'reauth_not_fresh' } }); + expect(readPairReauthFromStore(cookieStore as never)).toBeNull(); + }); + + it('accepts a fresh auth_time, and an ID token without one', async () => { + signIn(0); + pending({ created_at: Date.now() }); + exchangeCodeForTokens.mockResolvedValue({ ...FRESH, id_token: idToken({ auth_time: Math.floor(Date.now() / 1000) }) }); + expect((await complete()).status).toBe(200); + pending({ created_at: Date.now() }); + exchangeCodeForTokens.mockResolvedValue({ ...FRESH, id_token: idToken({ sub: 'x' }) }); + expect((await complete()).status).toBe(200); + }); +});