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.
This commit is contained in:
Linus Rath
2026-09-27 04:49:54 +02:00
parent 75bffd35e6
commit 5c1da4efa7
2 changed files with 50 additions and 1 deletions
+26 -1
View File
@@ -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(/\/+$/, '');
@@ -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<string, unknown>) =>
`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);
});
});