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

+14
View File
@@ -153,6 +153,13 @@ export class RecordSessionChangedError extends SharingAuthError {
}
}
/**
* The session carries no proof recent enough for the action, and the request
* brought none. The client asks for one (`POST /api/auth/reproof`, or a proof
* in the body on the enrollment routes) and retries.
*/
export const REPROOF_REQUIRED_CODE = "auth.reproof.required";
/**
* Error thrown when a step-up gate is not satisfied. Carries `errorCode` so
* the route can surface a stable machine code (`auth.stepup.required`) the
@@ -163,6 +170,13 @@ export class StepUpRequiredError extends HttpError {
constructor(
public errorCode: string = "auth.stepup.required",
message = "Recent second-factor verification required",
/**
* Extra machine fields for the envelope's `meta`, beside `errorCode`. The
* recent-proof gate names the proofs the account can give here
* (`methods`), so the client offers exactly those and nothing it would
* then refuse.
*/
public extraMeta: Record<string, unknown> = {},
) {
super(401, message);
this.name = "StepUpRequiredError";