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:
Marc-André Bombeck committed 2026-10-29 08:56:51 +01:00
1 parent 35a0ceb12c
commit bbef4eea99
35 files changed
+1654 -187

No files matched your search

+281
View File
@@ -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();
});
});
+72 -17
View File
@@ -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,
+2 -1
View File
@@ -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", {
+4 -2
View File
@@ -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(
+26 -4
View File
@@ -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;
}
+158
View File
@@ -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
View File
@@ -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,
};
}
+28 -7
View File
@@ -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;
+20 -3
View File
@@ -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,
]),
},
},
},