mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
feat(auth): let a single-sign-on account confirm it is you at its provider
An account created through OIDC holds no password, passkey or second factor, so the recent-proof gate in front of token minting, MCP connections, exports and the admin reset had nothing to ask it for and the web could only say "sign in again" (#1296). GET /api/auth/oidc/reproof sends the browser back to the configured provider with prompt=login and max_age=0, its own state, nonce and PKCE verifier, and the signed-in account and session sealed into the state cookie. The shared callback branches on that intent: it refuses unless the browser still carries the bound session (checked before the code is redeemed), the ID token names the account's linked issuer and subject, and auth_time lies inside the gate's five-minute window. It then stamps Session.reproofAt exactly as a password re-proof does, creates no session, and returns to the page it left with ?reproof=done|failed. recentProofMethods offers "oidc" only to an account without a second factor whose identity is linked while a provider is configured, and the fresh-factor gate never lists it. GET /api/auth/me/mfa publishes the list as proofMethods, so the passkey card offers what the account can actually give, and register-options takes { method: "oidc" } on a fresh provider re-proof, so such an account can add a passkey as a second way to confirm. Both re-proof dialogs offer the provider and a toast reports the outcome on return. The callback's missing-state branch and the passkey card's enrollment flow move into their own functions to keep both handlers under the size ratchet.
This commit is contained in:
1 parent
35a0ceb12c
commit
bbef4eea99
35 files changed
+1654
-187
No files matched your search
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* Confirming at the identity provider (#1296): which accounts are offered it,
|
||||
* what the callback accepts as fresh, whose identity counts, and what the
|
||||
* sealed state must carry.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const db = vi.hoisted(() => ({
|
||||
findUser: vi.fn<(args: unknown) => Promise<unknown>>(),
|
||||
updateSession: vi.fn<(args: unknown) => Promise<unknown>>(),
|
||||
countKeys: vi.fn<(args: unknown) => Promise<number>>(),
|
||||
countPasskeys: vi.fn<(args: unknown) => Promise<number>>(),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: db.findUser },
|
||||
session: { update: db.updateSession },
|
||||
webauthnMfaCredential: { count: db.countKeys },
|
||||
passkey: { count: db.countPasskeys },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
refundRateLimit: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: vi.fn((s: string) => s),
|
||||
encrypt: vi.fn((s: string) => s),
|
||||
}));
|
||||
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { refundRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
isFirstFactorOnlyProof,
|
||||
recentProofMethods,
|
||||
} from "@/lib/auth/second-factor";
|
||||
import {
|
||||
isFreshAuthTime,
|
||||
isLinkedIdentity,
|
||||
isReproofSession,
|
||||
recordOidcReproof,
|
||||
reproofReturnUrl,
|
||||
} from "@/lib/auth/oidc-reproof";
|
||||
import { readStoredOidcState } from "@/lib/auth/oidc-callback/state";
|
||||
|
||||
const ISSUER = "https://idp.example.test";
|
||||
const OIDC_ENV = {
|
||||
OIDC_ISSUER_URL: ISSUER,
|
||||
OIDC_CLIENT_ID: "client",
|
||||
OIDC_CLIENT_SECRET: "secret",
|
||||
};
|
||||
|
||||
function accountRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
passwordHash: null,
|
||||
oidcIssuer: ISSUER,
|
||||
oidcSub: "sub-1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
Object.assign(process.env, OIDC_ENV);
|
||||
process.env.NEXT_PUBLIC_APP_URL = "https://healthlog.example.test";
|
||||
db.countKeys.mockResolvedValue(0);
|
||||
db.countPasskeys.mockResolvedValue(0);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of Object.keys(OIDC_ENV)) delete process.env[key];
|
||||
});
|
||||
|
||||
describe("recentProofMethods offers the identity provider", () => {
|
||||
const user = { id: "u1", totpConfirmedAt: null };
|
||||
|
||||
it("to an account without a second factor whose identity is linked", async () => {
|
||||
db.findUser.mockResolvedValue(accountRow());
|
||||
expect(await recentProofMethods(user, false)).toEqual(["oidc"]);
|
||||
});
|
||||
|
||||
it("beside the password and passkey the account also holds", async () => {
|
||||
db.countPasskeys.mockResolvedValue(1);
|
||||
db.findUser.mockResolvedValue(accountRow({ passwordHash: "hash" }));
|
||||
expect(await recentProofMethods(user, false)).toEqual([
|
||||
"password",
|
||||
"oidc",
|
||||
"passkey",
|
||||
]);
|
||||
});
|
||||
|
||||
it("not to an account without a linked identity", async () => {
|
||||
db.findUser.mockResolvedValue(
|
||||
accountRow({ oidcIssuer: null, oidcSub: null }),
|
||||
);
|
||||
expect(await recentProofMethods(user, false)).toEqual([]);
|
||||
});
|
||||
|
||||
it("not when the instance has no provider configured", async () => {
|
||||
delete process.env.OIDC_CLIENT_SECRET;
|
||||
db.findUser.mockResolvedValue(accountRow());
|
||||
expect(await recentProofMethods(user, false)).toEqual([]);
|
||||
});
|
||||
|
||||
it("not to an account with a second factor, where it could not clear the gate", async () => {
|
||||
db.countKeys.mockResolvedValue(1);
|
||||
db.findUser.mockResolvedValue(accountRow());
|
||||
expect(await recentProofMethods(user, true)).toEqual(["webauthn"]);
|
||||
});
|
||||
|
||||
it("and never counts it as a second factor", () => {
|
||||
expect(isFirstFactorOnlyProof("oidc")).toBe(true);
|
||||
expect(isFirstFactorOnlyProof("password")).toBe(true);
|
||||
expect(isFirstFactorOnlyProof("passkey")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isFreshAuthTime", () => {
|
||||
const now = 1_800_000_000_000;
|
||||
const seconds = (ms: number) => Math.floor(ms / 1000);
|
||||
|
||||
it("accepts an authentication inside the five-minute window", () => {
|
||||
expect(isFreshAuthTime(seconds(now - 10_000), now)).toBe(true);
|
||||
expect(isFreshAuthTime(seconds(now - 300_000), now)).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses one older than the window", () => {
|
||||
expect(isFreshAuthTime(seconds(now - 301_000), now)).toBe(false);
|
||||
expect(isFreshAuthTime(seconds(now - 3_600_000), now)).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a token that does not say when", () => {
|
||||
expect(isFreshAuthTime(null, now)).toBe(false);
|
||||
});
|
||||
|
||||
it("tolerates a provider clock a minute ahead, and no more", () => {
|
||||
expect(isFreshAuthTime(seconds(now + 30_000), now)).toBe(true);
|
||||
expect(isFreshAuthTime(seconds(now + 120_000), now)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLinkedIdentity", () => {
|
||||
it("needs both the issuer and the subject to match", () => {
|
||||
const account = { oidcIssuer: ISSUER, oidcSub: "sub-1" };
|
||||
expect(isLinkedIdentity(account, ISSUER, "sub-1")).toBe(true);
|
||||
expect(isLinkedIdentity(account, ISSUER, "sub-2")).toBe(false);
|
||||
expect(isLinkedIdentity(account, "https://other.test", "sub-1")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
isLinkedIdentity({ oidcIssuer: null, oidcSub: null }, ISSUER, "sub-1"),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isReproofSession", () => {
|
||||
const bound = { reproofUserId: "u1", reproofSessionId: "s1" };
|
||||
|
||||
it("holds only for the session and account the start bound", () => {
|
||||
expect(
|
||||
isReproofSession(bound, { session: { id: "s1" }, user: { id: "u1" } }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isReproofSession(bound, { session: { id: "s2" }, user: { id: "u1" } }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isReproofSession(bound, { session: { id: "s1" }, user: { id: "u2" } }),
|
||||
).toBe(false);
|
||||
expect(isReproofSession(bound, null)).toBe(false);
|
||||
expect(
|
||||
isReproofSession({}, { session: { id: "s1" }, user: { id: "u1" } }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordOidcReproof", () => {
|
||||
const now = 1_800_000_000_000;
|
||||
const args = {
|
||||
userId: "u1",
|
||||
sessionId: "s1",
|
||||
issuer: ISSUER,
|
||||
ipAddress: "203.0.113.5",
|
||||
nowMs: now,
|
||||
};
|
||||
|
||||
it("stamps reproofAt only, refunds the attempt and audits the success", async () => {
|
||||
db.findUser.mockResolvedValue(accountRow());
|
||||
const outcome = await recordOidcReproof({
|
||||
...args,
|
||||
identity: { sub: "sub-1", authTime: Math.floor(now / 1000) - 5 },
|
||||
});
|
||||
expect(outcome).toEqual({ ok: true });
|
||||
expect(db.updateSession).toHaveBeenCalledWith({
|
||||
where: { id: "s1" },
|
||||
data: { reproofAt: new Date(now) },
|
||||
});
|
||||
expect(refundRateLimit).toHaveBeenCalledWith("auth:step-up:u1");
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"auth.reproof.succeeded",
|
||||
expect.objectContaining({ details: { method: "oidc" } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a stale auth_time without stamping", async () => {
|
||||
db.findUser.mockResolvedValue(accountRow());
|
||||
const outcome = await recordOidcReproof({
|
||||
...args,
|
||||
identity: { sub: "sub-1", authTime: Math.floor(now / 1000) - 3600 },
|
||||
});
|
||||
expect(outcome).toEqual({ ok: false, reason: "stale_auth_time" });
|
||||
expect(db.updateSession).not.toHaveBeenCalled();
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"auth.mfa.failed",
|
||||
expect.objectContaining({
|
||||
details: {
|
||||
stage: "oidc_reproof",
|
||||
method: "oidc",
|
||||
reason: "stale_auth_time",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a different subject without stamping", async () => {
|
||||
db.findUser.mockResolvedValue(accountRow());
|
||||
const outcome = await recordOidcReproof({
|
||||
...args,
|
||||
identity: { sub: "someone-else", authTime: Math.floor(now / 1000) },
|
||||
});
|
||||
expect(outcome).toEqual({ ok: false, reason: "identity_mismatch" });
|
||||
expect(db.updateSession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the re-proof return and state", () => {
|
||||
it("returns to the page it left with the outcome attached", () => {
|
||||
const url = reproofReturnUrl("/settings/api?tab=mcp", "done");
|
||||
expect(url.origin).toBe("https://healthlog.example.test");
|
||||
expect(url.pathname).toBe("/settings/api");
|
||||
expect(url.searchParams.get("tab")).toBe("mcp");
|
||||
expect(url.searchParams.get("reproof")).toBe("done");
|
||||
});
|
||||
|
||||
it("reads a reproof state only with both bindings and no other intent's fields", () => {
|
||||
const base = { state: "s", nonce: "n", codeVerifier: "v", next: "/" };
|
||||
const ok = readStoredOidcState(
|
||||
JSON.stringify({
|
||||
...base,
|
||||
intent: "reproof",
|
||||
reproofUserId: "u1",
|
||||
reproofSessionId: "s1",
|
||||
}),
|
||||
);
|
||||
expect(ok?.intent).toBe("reproof");
|
||||
expect(
|
||||
readStoredOidcState(
|
||||
JSON.stringify({ ...base, intent: "reproof", reproofUserId: "u1" }),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
readStoredOidcState(
|
||||
JSON.stringify({
|
||||
...base,
|
||||
intent: "reproof",
|
||||
reproofUserId: "u1",
|
||||
reproofSessionId: "s1",
|
||||
native: true,
|
||||
appCodeChallenge: "c",
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
readStoredOidcState(JSON.stringify({ ...base, intent: "other" })),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,15 @@ const ENV_KEYS = [
|
||||
] as const;
|
||||
const ORIGINAL_ENV: Record<string, string | undefined> = {};
|
||||
|
||||
/**
|
||||
* A verification result carrying only what `verifyIdToken` reads: the claims
|
||||
* and the header `jose` returns beside them.
|
||||
*/
|
||||
type VerifyResult = Awaited<ReturnType<typeof mockJwtVerify>>;
|
||||
function verified(payload: Record<string, unknown>): VerifyResult {
|
||||
return { payload, protectedHeader: { alg: "RS256" } } as VerifyResult;
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, ok = true, status = 200): Response {
|
||||
return {
|
||||
ok,
|
||||
@@ -270,6 +279,25 @@ describe("buildAuthorizationUrl", () => {
|
||||
}),
|
||||
);
|
||||
expect(url.searchParams.get("prompt")).toBe("login");
|
||||
expect(url.searchParams.has("max_age")).toBe(false);
|
||||
});
|
||||
|
||||
it("asks for a fresh, timed authentication for a re-proof", () => {
|
||||
configureEnv();
|
||||
const url = new URL(
|
||||
buildAuthorizationUrl({
|
||||
metadata: METADATA_DOC,
|
||||
config: getOidcConfig()!,
|
||||
state: "state-1",
|
||||
nonce: "nonce-1",
|
||||
codeChallenge: "challenge-1",
|
||||
redirectUri: "https://healthlog.example.com/api/auth/oidc/callback",
|
||||
reauthenticate: true,
|
||||
}),
|
||||
);
|
||||
expect(url.searchParams.get("prompt")).toBe("login");
|
||||
expect(url.searchParams.get("max_age")).toBe("0");
|
||||
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -332,16 +360,15 @@ describe("verifyIdToken", () => {
|
||||
beforeEach(() => configureEnv());
|
||||
|
||||
it("returns the identity claims on success", async () => {
|
||||
// Test payloads deliberately omit the full JWTVerifyResult shape.
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue({
|
||||
payload: {
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue(
|
||||
verified({
|
||||
sub: "user-sub-1",
|
||||
email: "person@example.com",
|
||||
email_verified: true,
|
||||
name: "Person",
|
||||
nonce: "nonce-1",
|
||||
},
|
||||
} as never);
|
||||
}),
|
||||
);
|
||||
const config = getOidcConfig()!;
|
||||
const identity = await verifyIdToken({
|
||||
metadata: METADATA_DOC,
|
||||
@@ -354,13 +381,43 @@ describe("verifyIdToken", () => {
|
||||
email: "person@example.com",
|
||||
emailVerified: true,
|
||||
name: "Person",
|
||||
authTime: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns auth_time when the provider states it, and null otherwise", async () => {
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue(
|
||||
verified({
|
||||
sub: "user-sub-1",
|
||||
nonce: "nonce-1",
|
||||
auth_time: 1_700_000_000,
|
||||
}),
|
||||
);
|
||||
const config = getOidcConfig()!;
|
||||
const stated = await verifyIdToken({
|
||||
metadata: METADATA_DOC,
|
||||
config,
|
||||
idToken: "id-token",
|
||||
nonce: "nonce-1",
|
||||
});
|
||||
expect(stated.authTime).toBe(1_700_000_000);
|
||||
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue(
|
||||
verified({ sub: "user-sub-1", nonce: "nonce-1", auth_time: "recent" }),
|
||||
);
|
||||
const unstated = await verifyIdToken({
|
||||
metadata: METADATA_DOC,
|
||||
config,
|
||||
idToken: "id-token",
|
||||
nonce: "nonce-1",
|
||||
});
|
||||
expect(unstated.authTime).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a nonce mismatch", async () => {
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue({
|
||||
payload: { sub: "user-sub-1", nonce: "wrong-nonce" },
|
||||
} as never);
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue(
|
||||
verified({ sub: "user-sub-1", nonce: "wrong-nonce" }),
|
||||
);
|
||||
const config = getOidcConfig()!;
|
||||
await expect(
|
||||
verifyIdToken({
|
||||
@@ -373,9 +430,7 @@ describe("verifyIdToken", () => {
|
||||
});
|
||||
|
||||
it("rejects a token missing sub", async () => {
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue({
|
||||
payload: { nonce: "nonce-1" },
|
||||
} as never);
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue(verified({ nonce: "nonce-1" }));
|
||||
const config = getOidcConfig()!;
|
||||
await expect(
|
||||
verifyIdToken({
|
||||
@@ -388,9 +443,9 @@ describe("verifyIdToken", () => {
|
||||
});
|
||||
|
||||
it("verifies with a closed asymmetric algorithm allowlist and bounded clock tolerance", async () => {
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue({
|
||||
payload: { sub: "user-sub-1", nonce: "nonce-1" },
|
||||
} as never);
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue(
|
||||
verified({ sub: "user-sub-1", nonce: "nonce-1" }),
|
||||
);
|
||||
const config = getOidcConfig()!;
|
||||
await verifyIdToken({
|
||||
metadata: METADATA_DOC,
|
||||
@@ -411,9 +466,9 @@ describe("verifyIdToken", () => {
|
||||
});
|
||||
|
||||
it("pins the JWKS fetch to the issuer's host with a bounded timeout", async () => {
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue({
|
||||
payload: { sub: "user-sub-1", nonce: "nonce-1" },
|
||||
} as never);
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue(
|
||||
verified({ sub: "user-sub-1", nonce: "nonce-1" }),
|
||||
);
|
||||
const config = getOidcConfig()!;
|
||||
await verifyIdToken({
|
||||
metadata: METADATA_DOC,
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import {
|
||||
hasSecondFactorEnrolled,
|
||||
recentProofMethods,
|
||||
type RecentProofMethod,
|
||||
} from "@/lib/auth/second-factor";
|
||||
import { verifyPassword } from "@/lib/auth/password";
|
||||
import { verifyAuthentication } from "@/lib/auth/passkey";
|
||||
@@ -171,7 +172,7 @@ export async function recordReproofFailure(
|
||||
userId: string,
|
||||
ipAddress: string | null,
|
||||
stage: string,
|
||||
method: ExistingFactorProof["method"],
|
||||
method: RecentProofMethod,
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
await auditLog("auth.mfa.failed", {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import {
|
||||
hasSecondFactorEnrolled,
|
||||
isFirstFactorOnlyProof,
|
||||
recentProofMethods,
|
||||
} from "@/lib/auth/second-factor";
|
||||
import { meterAccountWrite } from "@/lib/auth/write-meter";
|
||||
@@ -80,9 +81,10 @@ export async function requireFreshMfa(
|
||||
// does, so the browser can open the re-proof dialog instead of printing a
|
||||
// sentence with nowhere to act on it. Only the possession proofs: a
|
||||
// `POST /api/auth/reproof` with one of them stamps `mfaVerifiedAt`, a
|
||||
// password stamps `reproofAt` only and would leave this gate shut.
|
||||
// password or a provider sign-in stamps `reproofAt` only and would leave
|
||||
// this gate shut.
|
||||
const methods = (await recentProofMethods(sessionData.user)).filter(
|
||||
(method) => method !== "password",
|
||||
(method) => !isFirstFactorOnlyProof(method),
|
||||
);
|
||||
annotate({ meta: { step_up_methods: methods.join(",") } });
|
||||
throw new StepUpRequiredError(
|
||||
|
||||
@@ -27,8 +27,17 @@ export interface StoredOidcState {
|
||||
* names instead of signing in, and only when the cookie still carries this
|
||||
* exact binding. Web only: never together with `native`.
|
||||
*/
|
||||
intent?: "claim";
|
||||
intent?: "claim" | "reproof";
|
||||
claimBinding?: string;
|
||||
/**
|
||||
* Set only for `intent: "reproof"` (`GET /api/auth/oidc/reproof`): the
|
||||
* signed-in account and the session row the round trip confirms. The
|
||||
* callback stamps nothing unless the browser still carries exactly this
|
||||
* session and the provider names exactly this account's linked identity.
|
||||
* Web only, like the claim.
|
||||
*/
|
||||
reproofUserId?: string;
|
||||
reproofSessionId?: string;
|
||||
}
|
||||
|
||||
function isStoredOidcState(value: unknown): value is StoredOidcState {
|
||||
@@ -50,11 +59,24 @@ function isStoredOidcState(value: unknown): value is StoredOidcState {
|
||||
if (typeof v.appCodeChallenge !== "string") return false;
|
||||
}
|
||||
if (v.intent !== undefined) {
|
||||
if (v.intent !== "claim") return false;
|
||||
if (typeof v.claimBinding !== "string" || v.claimBinding.length === 0) {
|
||||
if (v.native !== undefined) return false;
|
||||
if (v.intent === "claim") {
|
||||
if (typeof v.claimBinding !== "string" || v.claimBinding.length === 0) {
|
||||
return false;
|
||||
}
|
||||
} else if (v.intent === "reproof") {
|
||||
if (
|
||||
typeof v.reproofUserId !== "string" ||
|
||||
v.reproofUserId.length === 0 ||
|
||||
typeof v.reproofSessionId !== "string" ||
|
||||
v.reproofSessionId.length === 0 ||
|
||||
v.claimBinding !== undefined
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
if (v.native !== undefined) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Confirming it is you through the identity provider.
|
||||
*
|
||||
* An account created by single sign-on holds no password, passkey or second
|
||||
* factor, so the recent-proof gate (token minting, exports, share links, the
|
||||
* admin data actions) had nothing to ask it for. This is the proof such an
|
||||
* account can give: the browser goes back to the provider with
|
||||
* `prompt=login` and `max_age=0`, and the callback counts the round trip only
|
||||
* when the ID token says the provider authenticated the person inside the
|
||||
* gate's own window and names the identity linked to the signed-in account.
|
||||
*
|
||||
* It records the proof exactly as a password re-proof does, `Session.reproofAt`
|
||||
* and nothing else, so it never stands in for a second factor. It creates no
|
||||
* session and switches none: the callback stamps the session row the start
|
||||
* bound into the sealed state, and only while the browser still carries it.
|
||||
*
|
||||
* Start: `GET /api/auth/oidc/reproof?next=<path>`. Finish: the shared
|
||||
* `GET /api/auth/oidc/callback` with `intent: "reproof"` in the state blob.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { oidcAppUrl, type OidcIdentity } from "@/lib/auth/oidc";
|
||||
import {
|
||||
RECENT_PROOF_MAX_AGE_MS,
|
||||
recordReproofFailure,
|
||||
refundReproof,
|
||||
} from "@/lib/auth/existing-factor-proof";
|
||||
|
||||
/** The query parameter the browser comes back with, and its two values. */
|
||||
export const OIDC_REPROOF_RESULT_PARAM = "reproof";
|
||||
export type OidcReproofResult = "done" | "failed";
|
||||
|
||||
/** The stage name the shared re-proof bucket and the audit rows carry. */
|
||||
export const OIDC_REPROOF_STAGE = "oidc_reproof";
|
||||
|
||||
/**
|
||||
* How far in the future `auth_time` may sit and still count: the clock
|
||||
* tolerance `verifyIdToken` already grants `iat` and `exp`, for an identity
|
||||
* provider on another box whose clock runs a little ahead.
|
||||
*/
|
||||
const AUTH_TIME_FUTURE_TOLERANCE_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Did the provider authenticate the person inside the gate's window (the five
|
||||
* minutes every recent-proof surface uses)? A token
|
||||
* without `auth_time` is refused: with `max_age` in the request the provider
|
||||
* owes the claim (OpenID Connect Core 2), and without it nothing says the
|
||||
* person signed in now rather than an hour ago.
|
||||
*/
|
||||
export function isFreshAuthTime(
|
||||
authTime: number | null,
|
||||
nowMs: number,
|
||||
maxAgeMs: number = RECENT_PROOF_MAX_AGE_MS,
|
||||
): boolean {
|
||||
if (authTime === null) return false;
|
||||
const at = authTime * 1000;
|
||||
return nowMs - at <= maxAgeMs && at - nowMs <= AUTH_TIME_FUTURE_TOLERANCE_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the identity the provider named the one linked to this account? Both
|
||||
* halves, compared exactly: the subject is only unique per issuer.
|
||||
*/
|
||||
export function isLinkedIdentity(
|
||||
account: { oidcIssuer: string | null; oidcSub: string | null },
|
||||
issuer: string,
|
||||
sub: string,
|
||||
): boolean {
|
||||
return (
|
||||
account.oidcIssuer !== null &&
|
||||
account.oidcSub !== null &&
|
||||
account.oidcIssuer === issuer &&
|
||||
account.oidcSub === sub
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the browser still carry the session the round trip was started from?
|
||||
* Checked before the code is redeemed, so a mismatch spends nothing at the
|
||||
* provider.
|
||||
*/
|
||||
export function isReproofSession(
|
||||
bound: { reproofUserId?: string; reproofSessionId?: string },
|
||||
live: { session: { id: string }; user: { id: string } } | null,
|
||||
): boolean {
|
||||
return (
|
||||
live !== null &&
|
||||
typeof bound.reproofSessionId === "string" &&
|
||||
typeof bound.reproofUserId === "string" &&
|
||||
live.session.id === bound.reproofSessionId &&
|
||||
live.user.id === bound.reproofUserId
|
||||
);
|
||||
}
|
||||
|
||||
/** Where the browser lands: the page it left, with the outcome attached. */
|
||||
export function reproofReturnUrl(next: string, result: OidcReproofResult): URL {
|
||||
const url = oidcAppUrl(next);
|
||||
url.searchParams.set(OIDC_REPROOF_RESULT_PARAM, result);
|
||||
return url;
|
||||
}
|
||||
|
||||
export type OidcReproofOutcome =
|
||||
{ ok: true } | { ok: false; reason: "identity_mismatch" | "stale_auth_time" };
|
||||
|
||||
/**
|
||||
* Check the verified ID token against the account and stamp the session.
|
||||
* Every refusal is audited like a failed password re-proof; a success gives
|
||||
* back the attempt the start charged to the shared bucket.
|
||||
*/
|
||||
export async function recordOidcReproof(args: {
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
issuer: string;
|
||||
identity: Pick<OidcIdentity, "sub" | "authTime">;
|
||||
ipAddress: string | null;
|
||||
nowMs?: number;
|
||||
}): Promise<OidcReproofOutcome> {
|
||||
const { userId, sessionId, issuer, identity, ipAddress } = args;
|
||||
const nowMs = args.nowMs ?? Date.now();
|
||||
|
||||
const account = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { oidcIssuer: true, oidcSub: true },
|
||||
});
|
||||
let refusal: "identity_mismatch" | "stale_auth_time" | null = null;
|
||||
if (!account || !isLinkedIdentity(account, issuer, identity.sub)) {
|
||||
refusal = "identity_mismatch";
|
||||
} else if (!isFreshAuthTime(identity.authTime, nowMs)) {
|
||||
refusal = "stale_auth_time";
|
||||
}
|
||||
if (refusal) {
|
||||
await recordReproofFailure(
|
||||
userId,
|
||||
ipAddress,
|
||||
OIDC_REPROOF_STAGE,
|
||||
"oidc",
|
||||
refusal,
|
||||
);
|
||||
return { ok: false, reason: refusal };
|
||||
}
|
||||
|
||||
await prisma.session.update({
|
||||
where: { id: sessionId },
|
||||
data: { reproofAt: new Date(nowMs) },
|
||||
});
|
||||
await refundReproof(userId);
|
||||
await auditLog("auth.reproof.succeeded", {
|
||||
userId,
|
||||
ipAddress,
|
||||
details: { method: "oidc" },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.reproof.succeeded" },
|
||||
meta: { method: "oidc" },
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
+20
-1
@@ -244,6 +244,13 @@ export function buildAuthorizationUrl(params: {
|
||||
* keyboard now, not a session someone else left open in this browser.
|
||||
*/
|
||||
forceLogin?: boolean;
|
||||
/**
|
||||
* Ask the provider to authenticate afresh AND to say when it did: `prompt=login`
|
||||
* plus `max_age=0` (OpenID Connect Core 3.1.2.1). With `max_age` in the
|
||||
* request the ID token must carry `auth_time`, which is what the re-proof
|
||||
* leg checks before it counts the round trip as a proof.
|
||||
*/
|
||||
reauthenticate?: boolean;
|
||||
}): string {
|
||||
const url = new URL(params.metadata.authorization_endpoint);
|
||||
url.search = new URLSearchParams({
|
||||
@@ -255,7 +262,8 @@ export function buildAuthorizationUrl(params: {
|
||||
nonce: params.nonce,
|
||||
code_challenge: params.codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
...(params.forceLogin ? { prompt: "login" } : {}),
|
||||
...(params.forceLogin || params.reauthenticate ? { prompt: "login" } : {}),
|
||||
...(params.reauthenticate ? { max_age: "0" } : {}),
|
||||
}).toString();
|
||||
return url.toString();
|
||||
}
|
||||
@@ -304,6 +312,12 @@ export interface OidcIdentity {
|
||||
email: string | null;
|
||||
emailVerified: boolean | undefined;
|
||||
name: string | null;
|
||||
/**
|
||||
* When the provider last authenticated the person (`auth_time`, seconds
|
||||
* since the epoch), or null when the token does not say. Only the re-proof
|
||||
* leg reads it, and it refuses a null.
|
||||
*/
|
||||
authTime: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -350,6 +364,11 @@ export async function verifyIdToken(params: {
|
||||
? payload.email_verified
|
||||
: undefined,
|
||||
name: typeof payload.name === "string" ? payload.name : null,
|
||||
authTime:
|
||||
typeof payload.auth_time === "number" &&
|
||||
Number.isFinite(payload.auth_time)
|
||||
? payload.auth_time
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { isOidcConfigured } from "@/lib/auth/oidc";
|
||||
|
||||
/**
|
||||
* Does this account have a SECOND FACTOR at all?
|
||||
@@ -40,11 +41,25 @@ export async function hasSecondFactorEnrolled(user: {
|
||||
}
|
||||
|
||||
/**
|
||||
* A proof the recent-proof gate can ask for. `password` and `passkey` on an
|
||||
* account without a second factor; `totp`, `webauthn` and `passkey` on one
|
||||
* with a second factor, where a password alone has never been enough.
|
||||
* A proof the recent-proof gate can ask for. `password`, `oidc` and `passkey`
|
||||
* on an account without a second factor; `totp`, `webauthn` and `passkey` on
|
||||
* one with a second factor, where a password alone has never been enough.
|
||||
*
|
||||
* `oidc` is a fresh sign-in at the account's linked identity provider
|
||||
* (`GET /api/auth/oidc/reproof`), not a body proof: it stamps
|
||||
* `Session.reproofAt` exactly as a password does, and like a password it never
|
||||
* stands in for a second factor.
|
||||
*/
|
||||
export type RecentProofMethod = "password" | "totp" | "webauthn" | "passkey";
|
||||
export type RecentProofMethod =
|
||||
"password" | "oidc" | "totp" | "webauthn" | "passkey";
|
||||
|
||||
/**
|
||||
* The proofs that stamp only `Session.reproofAt` and so never clear a gate that
|
||||
* asks for a second factor (`requireFreshMfa`).
|
||||
*/
|
||||
export function isFirstFactorOnlyProof(method: RecentProofMethod): boolean {
|
||||
return method === "password" || method === "oidc";
|
||||
}
|
||||
|
||||
/**
|
||||
* The proofs that would satisfy `assertRecentCookieProof` for this
|
||||
@@ -63,15 +78,21 @@ export async function recentProofMethods(
|
||||
prisma.passkey.count({ where: { userId: user.id } }),
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { passwordHash: true },
|
||||
select: { passwordHash: true, oidcIssuer: true, oidcSub: true },
|
||||
}),
|
||||
]);
|
||||
const methods: RecentProofMethod[] = [];
|
||||
if (hasSecond) {
|
||||
if (user.totpConfirmedAt) methods.push("totp");
|
||||
if (keys > 0) methods.push("webauthn");
|
||||
} else if (credential?.passwordHash) {
|
||||
methods.push("password");
|
||||
} else {
|
||||
if (credential?.passwordHash) methods.push("password");
|
||||
// A linked identity is only a way to confirm while the instance still
|
||||
// talks to a provider: with the provider group unset the round trip has
|
||||
// nowhere to go.
|
||||
if (credential?.oidcIssuer && credential.oidcSub && isOidcConfigured()) {
|
||||
methods.push("oidc");
|
||||
}
|
||||
}
|
||||
if (passkeys > 0) methods.push("passkey");
|
||||
return methods;
|
||||
|
||||
@@ -342,6 +342,11 @@ const mfaStatusResponse = z
|
||||
recoveryCodesRemaining: z.number().int(),
|
||||
webauthn: z.array(webauthnCredentialInfo),
|
||||
passkeyNudgeDismissed: z.boolean(),
|
||||
proofMethods: z
|
||||
.array(z.enum(["password", "oidc", "totp", "webauthn", "passkey"]))
|
||||
.describe(
|
||||
"The ways this account can confirm it is you, the same list a recent-proof refusal names in `meta.methods`. `password` and `oidc` only on an account without a second factor; `oidc` is a browser round trip to the identity provider (`GET /api/auth/oidc/reproof`) and is never offered to a token.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "MfaStatusResponse" });
|
||||
|
||||
@@ -599,6 +604,14 @@ const changePasswordRequest = changePasswordSchema.meta({
|
||||
"Password rotation. `newPassword` and `confirmPassword` must match, and `newPassword` must differ from the current one. The strength floor is 12 characters plus a scored check against the account's own username and email.",
|
||||
});
|
||||
|
||||
const passkeyProviderProofRequest = z
|
||||
.object({ method: z.literal("oidc") })
|
||||
.meta({
|
||||
id: "PasskeyProviderProofRequest",
|
||||
description:
|
||||
"Take the session's recent identity-provider re-proof (GET /api/auth/oidc/reproof) as the proof. Accepted only for an account whose `proofMethods` list `oidc` and only within five minutes of that re-proof; otherwise 401 `auth.reproof.required` with `meta.methods`. Web only.",
|
||||
});
|
||||
|
||||
const passkeyReauthRequest = z
|
||||
.object({
|
||||
method: z
|
||||
@@ -1130,7 +1143,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
summary: "Re-prove a credential on a browser session (cookie only)",
|
||||
description:
|
||||
"Stamps the calling browser session as recently re-proved, so for the next five minutes it may run the actions that ask for a fresh proof: GET /api/export/full-backup, POST /api/export/encrypted, POST /api/share-links, POST /api/mcp/tokens, POST /api/tokens/measurements, POST /api/tokens/workouts, POST /api/tokens/documents, and the admin backup download, upload and restore, the admin data wipe and the admin password reset. Those answer 401 `auth.reproof.required` with `meta.methods` (the proofs this account can give) when the session signed in or re-proved more than five minutes ago. A `totp`, `webauthn` or `passkey` proof here also clears the routes that ask for a fresh second factor (the managed-profile acts, the second-factor and passkey changes, the password change, account deletion, the data wipe and key rotation): on a browser session those answer 401 `auth.stepup.required` with `meta.methods` naming the possession proofs the account holds, and the web re-proves here and retries.\n\n" +
|
||||
'The body takes the same shapes as POST /api/auth/step-up. On an account with a second factor only `totp`, `webauthn` (a security key) or `passkey` are accepted, because a password has never stood in for the second factor; on one without, `password` or `passkey`. A method the account cannot use here is refused with 422 `auth.reproof.too_weak` and `meta.methods`, before any guess is spent. A second-factor or passkey proof also refreshes the session\'s second-factor stamp, exactly as signing in again would. Assertions begin at POST /api/auth/passkey/register-options with `{ method: "passkey" | "webauthn" }`.\n\n' +
|
||||
'The body takes the same shapes as POST /api/auth/step-up. On an account with a second factor only `totp`, `webauthn` (a security key) or `passkey` are accepted, because a password has never stood in for the second factor; on one without, `password` or `passkey`. An account without a second factor whose identity is linked to the configured identity provider is also offered `oidc` in `meta.methods`; that proof is not posted here but given by the browser round trip GET /api/auth/oidc/reproof, which stamps the session exactly as a password proof does. A method the account cannot use here is refused with 422 `auth.reproof.too_weak` and `meta.methods`, before any guess is spent. A second-factor or passkey proof also refreshes the session\'s second-factor stamp, exactly as signing in again would. Assertions begin at POST /api/auth/passkey/register-options with `{ method: "passkey" | "webauthn" }`.\n\n' +
|
||||
"Proofs draw on the account's shared re-proof budget (five per fifteen minutes, 429 once spent). A proof that does not verify is 401 `auth.reproof.failed` and is audited. Bearer callers use POST /api/auth/step-up instead.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
@@ -2454,7 +2467,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
description:
|
||||
"Cookie-only — a Bearer token cannot enroll a sign-in credential on this surface at any scope. The route has TWO arms and the body decides which.\n\n" +
|
||||
'**Re-proof arm.** A body of exactly `{ method: "passkey" }` or `{ method: "webauthn" }` begins an assertion ceremony WITHOUT beginning enrollment, and answers with `reauth: true` alongside the options. Use it to obtain the assertion the second arm needs.\n\n' +
|
||||
"**Enrollment arm.** Any other body must be a full factor proof (the same shape POST /api/auth/step-up takes): a password, a TOTP code, or a completed passkey / security-key assertion. Only then are registration options issued. On an account that already has a second factor a password is refused before it is checked, with 422 `auth.reproof.too_weak` and `meta.methods` naming the proofs that work: a passkey added on the password alone would sign in without the second factor from then on.\n\n" +
|
||||
'**Enrollment arm.** Any other body must be a full factor proof (the same shape POST /api/auth/step-up takes): a password, a TOTP code, or a completed passkey / security-key assertion. Only then are registration options issued. An account confirmed at its identity provider (GET /api/auth/oidc/reproof, within five minutes) sends `{ method: "oidc" }` instead, which takes that re-proof as the proof and, like a password, clears the second-factor stamp. On an account that already has a second factor a password is refused before it is checked, with 422 `auth.reproof.too_weak` and `meta.methods` naming the proofs that work: a passkey added on the password alone would sign in without the second factor from then on.\n\n' +
|
||||
"Which factor you prove changes the session, and the direction is not the obvious one: a strong proof stamps the session second-factor-verified, while a PASSWORD proof deliberately CLEARS that stamp. Registering with a password is authorised by the single-use, session-bound challenge alone and must not silently upgrade password-only authentication into something the rest of the app reads as a second factor.\n\n" +
|
||||
"Every refusal on the proof path is the same 401 with the same prose — a missing content type, an unparseable body, a body that is not a valid proof shape, and a wrong password are indistinguishable to the caller. A `totp` proof counts only from an authenticator whose enrollment was confirmed.\n\n" +
|
||||
"Each proof on the enrollment arm draws on the account's shared re-proof budget (five per fifteen minutes, the same budget as POST /api/auth/step-up), and every failed proof writes an `auth.mfa.failed` audit row. Once the budget is spent the answer is 429 until the window passes, even for a correct proof.",
|
||||
@@ -2462,7 +2475,11 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.union([passkeyReauthRequest, stepUpMintSchema]),
|
||||
schema: z.union([
|
||||
passkeyReauthRequest,
|
||||
passkeyProviderProofRequest,
|
||||
stepUpMintSchema,
|
||||
]),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user