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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-29 11:01:25 +01:00
1 parent 6df0fad3b7
commit fc117d7cd9
2 files changed
+41 -2

No files matched your search

+31 -1
View File
@@ -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({
+10 -1
View File
@@ -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", {