mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
6df0fad3b7
commit
fc117d7cd9
2 files changed
+41
-2
No files matched your search
@@ -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({
|
||||
|
||||
@@ -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", {
|
||||
|
||||
Reference in new issue
Block a user