fix(auth): ask for a fresh proof before the record or a credential to it leaves

Exporting the whole record, making a clinician share link, minting a token,
and the admin backup download, upload and restore, data wipe and password
reset ran on a live session alone. The plaintext full backup did, while the
encrypted one beside it asked for a second factor. Whoever held a stolen
session could take the record, or leave behind a credential that outlives
the session.

These now go through one gate, requireRecentProof (assertRecentCookieProof
for the routes that resolve their caller themselves). A browser session
passes after a sign-in or re-proof within five minutes: a second factor on
an account that has one, otherwise the password or a passkey. The refusal is
401 auth.reproof.required with meta.methods, and the web opens the re-proof
dialog, posts the proof to the new POST /api/auth/reproof and retries. A
password proof stamps the new Session.reproofAt (migration 0359), never
mfaVerifiedAt, so it cannot stand in for a second factor anywhere.

On Bearer, the full backup asks for an X-Step-Up elevation; the encrypted
export asks for a second-factor elevation on an account that has one. Share
links and connector tokens keep taking the token alone, because the shipped
app calls them without an elevation and reads a 401 there as a dead
session; the guard test names them.

Key rotation keeps its stricter second-factor gate.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 19:54:38 +02:00
1 parent a7f66b8519
commit 0fc83cb0a1
56 files changed
+2028 -235

No files matched your search

+147 -1
View File
@@ -93,6 +93,7 @@ import {
SharingNotPermittedError,
RecordSessionChangedError,
StepUpRequiredError,
REPROOF_REQUIRED_CODE,
} from "./api-errors";
export {
@@ -289,7 +290,7 @@ export function apiHandler<T extends (...args: any[]) => Promise<Response>>(
{
data: null,
error: error.message,
meta: { errorCode: error.errorCode },
meta: { ...error.extraMeta, errorCode: error.errorCode },
},
{ status: error.statusCode },
);
@@ -1387,6 +1388,151 @@ export async function requireFreshMfaOrElevationIfEnrolled(
return { ...auth, commitElevation };
}
/**
* 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.
*/
export type RecentProofMethod = "password" | "totp" | "webauthn" | "passkey";
/**
* How the recent-proof gate treats a Bearer caller.
*
* `elevation` — the Bearer twin of the cookie rule: an `X-Step-Up` elevation
* minted by `POST /api/auth/step-up`. On an account with a second factor it
* must come from a second-factor proof; without one, a password-proved
* elevation is enough. For routes no shipped client calls on Bearer.
*
* `elevation-if-enrolled` — the erasure routes' rule
* (`requireFreshMfaOrElevationIfEnrolled`): an account with a second factor
* presents a second-factor elevation, one without passes on the token. For a
* route the shipped app calls, where the second-factor arm was already
* refused before and the other arm must keep working.
*
* `token` — the token is the proof, as before. Only for routes the shipped
* app calls without an elevation, where a refusal would sign the app out (it
* reads a 401 outside its known paths as a dead session). Each one is named
* in the iOS note and in `recent-proof-surface-guard.test.ts`, so tightening
* it later is a visible edit.
*/
export type RecentProofBearerRule =
"elevation" | "elevation-if-enrolled" | "token";
/**
* The fresh-proof gate in front of the actions that hand the record, or a
* standing credential to it, to somebody: exporting the whole record, making a
* clinician share link, minting a token, and the admin backup, restore, wipe
* and reset actions. A live session is not enough for those, because whoever
* holds a stolen session would otherwise walk off with the record or leave a
* credential behind that outlives the session.
*
* Cookie session, account WITH a second factor: `Session.mfaVerifiedAt`
* within five minutes — exactly `requireFreshMfa`. Signing in completes the
* factor; later, `POST /api/auth/reproof` with a TOTP code, security key or
* passkey stamps it again.
*
* Cookie session, account WITHOUT a second factor: any of the session's
* creation (a sign-in), `mfaVerifiedAt` (a passkey sign-in or re-proof) or
* `reproofAt` (a password re-proof) within five minutes. Such an account
* re-enters its password, or uses a passkey.
*
* The refusal is 401 `auth.reproof.required` with `meta.methods`, the proofs
* this account can give; the web opens the re-proof dialog on it and retries.
* An empty list means the account holds nothing to re-prove in place (an
* SSO-only account), and signing in again is the way through.
*
* Returns the caller plus `commitElevation`, which spends a Bearer elevation
* and is a no-op otherwise. Call it right before acting.
*/
export async function requireRecentProof(options: {
bearer: RecentProofBearerRule;
}): Promise<ErasureAuthContext> {
const auth = await requireAuth();
const noop = async () => {};
if (auth.authMethod === "cookie") {
await assertRecentCookieProof(auth.user, auth.session.id);
return { ...auth, commitElevation: noop };
}
const enrolled = await hasSecondFactorEnrolled(auth.user);
if (options.bearer === "token") {
annotate({ meta: { recent_proof: "bearer_token" } });
return { ...auth, commitElevation: noop };
}
if (options.bearer === "elevation-if-enrolled" && !enrolled) {
annotate({ meta: { recent_proof: "bearer_token" } });
return { ...auth, commitElevation: noop };
}
const commitElevation = await resolveBearerElevation(
{ user: auth.user, apiTokenId: auth.session.id },
{ freshFactor: enrolled, proofSource: "second-factor" },
);
return { ...auth, commitElevation };
}
/**
* The cookie arm of {@link requireRecentProof}, for routes that resolve the
* caller themselves (the admin routes, which must stay on `requireAdmin`).
*/
export async function assertRecentCookieProof(
user: User,
sessionId: string,
): Promise<void> {
const enrolled = await hasSecondFactorEnrolled(user);
const row = await prisma.session.findUnique({
where: { id: sessionId },
select: { mfaVerifiedAt: true, createdAt: true, reproofAt: true },
});
const now = Date.now();
const fresh = (d: Date | null | undefined) =>
d != null && now - d.getTime() <= MFA_STEP_UP_MAX_AGE_SECONDS * 1000;
const satisfied = enrolled
? fresh(row?.mfaVerifiedAt)
: fresh(row?.mfaVerifiedAt) ||
fresh(row?.createdAt) ||
fresh(row?.reproofAt);
if (satisfied) {
annotate({ meta: { recent_proof: "session" } });
return;
}
const methods = await recentProofMethods(user, enrolled);
annotate({
action: { name: "auth.reproof.required" },
meta: { second_factor: enrolled, methods: methods.join(",") },
});
throw new StepUpRequiredError(
REPROOF_REQUIRED_CODE,
"Confirm it is you to continue",
{ methods },
);
}
/**
* The proofs that would satisfy {@link assertRecentCookieProof} for this
* account. Also what `POST /api/auth/reproof` accepts, so the dialog and the
* endpoint cannot disagree.
*/
export async function recentProofMethods(
user: Pick<User, "id" | "totpConfirmedAt" | "passwordHash">,
enrolled?: boolean,
): Promise<RecentProofMethod[]> {
const hasSecond = enrolled ?? (await hasSecondFactorEnrolled(user));
const [keys, passkeys] = await Promise.all([
prisma.webauthnMfaCredential.count({ where: { userId: user.id } }),
prisma.passkey.count({ where: { userId: user.id } }),
]);
const methods: RecentProofMethod[] = [];
if (hasSecond) {
if (user.totpConfirmedAt) methods.push("totp");
if (keys > 0) methods.push("webauthn");
} else if (user.passwordHash) {
methods.push("password");
}
if (passkeys > 0) methods.push("passkey");
return methods;
}
/**
* Does this account have a SECOND FACTOR at all?
*