From fc117d7cd9e03a7080b291edf975bff62d4e8cef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 29 Oct 2026 11:01:25 +0100 Subject: [PATCH] fix(auth): date a provider re-proof by its auth_time The re-proof accepted an auth_time up to five minutes old and then stamped the session at callback time, so the proof held for close to ten minutes after the person signed in at the provider. It now stamps the authentication instant, clamped to now for a provider clock that runs ahead, and the gates' five-minute window runs from there. --- src/lib/auth/__tests__/oidc-reproof.test.ts | 32 ++++++++++++++++++++- src/lib/auth/oidc-reproof.ts | 11 ++++++- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/lib/auth/__tests__/oidc-reproof.test.ts b/src/lib/auth/__tests__/oidc-reproof.test.ts index dd396a041..660cdb747 100644 --- a/src/lib/auth/__tests__/oidc-reproof.test.ts +++ b/src/lib/auth/__tests__/oidc-reproof.test.ts @@ -47,6 +47,7 @@ import { reproofReturnUrl, } from "@/lib/auth/oidc-reproof"; import { readStoredOidcState } from "@/lib/auth/oidc-callback/state"; +import { RECENT_PROOF_MAX_AGE_MS } from "@/lib/auth/existing-factor-proof"; const ISSUER = "https://idp.example.test"; const OIDC_ENV = { @@ -195,9 +196,10 @@ describe("recordOidcReproof", () => { identity: { sub: "sub-1", authTime: Math.floor(now / 1000) - 5 }, }); expect(outcome).toEqual({ ok: true }); + // Stamped at the authentication, not at the callback. expect(db.updateSession).toHaveBeenCalledWith({ where: { id: "s1" }, - data: { reproofAt: new Date(now) }, + data: { reproofAt: new Date((Math.floor(now / 1000) - 5) * 1000) }, }); expect(refundRateLimit).toHaveBeenCalledWith("auth:step-up:u1"); expect(auditLog).toHaveBeenCalledWith( @@ -206,6 +208,34 @@ describe("recordOidcReproof", () => { ); }); + it("runs the window from the authentication, so a token near its end buys no extra minutes", async () => { + db.findUser.mockResolvedValue(accountRow()); + const authTime = Math.floor(now / 1000) - 4 * 60; + await recordOidcReproof({ ...args, identity: { sub: "sub-1", authTime } }); + const [call] = db.updateSession.mock.calls[0] as [ + { data: { reproofAt: Date } }, + ]; + const stamped = call.data.reproofAt; + expect(stamped.getTime()).toBe(authTime * 1000); + // Two minutes after the callback the proof is six minutes old: past the + // window every recent-proof gate applies. + expect(now + 2 * 60_000 - stamped.getTime()).toBeGreaterThan( + RECENT_PROOF_MAX_AGE_MS, + ); + }); + + it("never stamps a proof in the future for a provider clock a little ahead", async () => { + db.findUser.mockResolvedValue(accountRow()); + await recordOidcReproof({ + ...args, + identity: { sub: "sub-1", authTime: Math.floor(now / 1000) + 30 }, + }); + expect(db.updateSession).toHaveBeenCalledWith({ + where: { id: "s1" }, + data: { reproofAt: new Date(now) }, + }); + }); + it("refuses a stale auth_time without stamping", async () => { db.findUser.mockResolvedValue(accountRow()); const outcome = await recordOidcReproof({ diff --git a/src/lib/auth/oidc-reproof.ts b/src/lib/auth/oidc-reproof.ts index a84e03948..c87b82eb5 100644 --- a/src/lib/auth/oidc-reproof.ts +++ b/src/lib/auth/oidc-reproof.ts @@ -140,9 +140,18 @@ export async function recordOidcReproof(args: { return { ok: false, reason: refusal }; } + // The proof is as old as the authentication, not the callback: the gate's + // window then runs from the moment the person signed in at the provider, + // so a token near the end of its window buys no extra minutes. A provider + // clock a little ahead is clamped to now, never stamped into the future. + // (A token without `auth_time` was refused above.) + const authenticatedAtMs = + identity.authTime === null + ? nowMs + : Math.min(identity.authTime * 1000, nowMs); await prisma.session.update({ where: { id: sessionId }, - data: { reproofAt: new Date(nowMs) }, + data: { reproofAt: new Date(authenticatedAtMs) }, }); await refundReproof(userId); await auditLog("auth.reproof.succeeded", {