mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user