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

+139
View File
@@ -0,0 +1,139 @@
/**
* POST /api/auth/reproof
*
* A browser session re-proves a credential the account holds, so that for the
* next five minutes it may export the whole record, make a share link, mint a
* token, or run an admin backup, restore, wipe or reset. Those routes answer
* 401 `auth.reproof.required` with `meta.methods` when the session carries no
* recent proof; the web opens the re-proof dialog, posts the proof here, and
* retries.
*
* What a proof stamps on the session row:
* - `totp`, `webauthn` or `passkey`: `mfaVerifiedAt` and `reproofAt`. The
* same stamp a completed second factor or passkey sign-in writes, from the
* same factor, so it also clears the fresh-factor routes, exactly as
* signing in again would.
* - `password`: `reproofAt` only. Accepted only on an account without a
* second factor; an account with one is told which proofs it can give
* instead (422 `auth.reproof.too_weak`), because a password has never
* stood in for the second factor.
*
* Cookie-only. The Bearer transport has its own proof flow (`POST
* /api/auth/step-up`, token-bound single-use elevations), and a stamp on a
* session row means nothing to a token.
*
* GET asks the same question the gated routes ask, without acting: 200 when
* the session's proof is recent, otherwise the same 401 `auth.reproof.required`
* with `meta.methods`. The web calls it before a large upload, so the file is
* not sent twice around a re-proof.
*
* Proofs draw on the account's shared re-proof budget (five per fifteen
* minutes, the same bucket as the step-up mint and the enrollment routes) and
* every refusal is audited. Failures are indistinguishable on the wire.
*/
import { NextRequest } from "next/server";
import {
apiHandler,
assertRecentCookieProof,
recentProofMethods,
requireCookieAuth,
} from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import {
REPROOF_FAILED_CODE,
recordReproofFailure,
throttleReproof,
verifyExistingFactorProof,
} from "@/lib/auth/existing-factor-proof";
import { stepUpMintSchema } from "@/lib/validations/step-up";
export const dynamic = "force-dynamic";
export const POST = apiHandler(async (request: NextRequest) => {
const { user, session } = await requireCookieAuth();
const ip = getClientIp(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = stepUpMintSchema.safeParse(body);
if (!parsed.success) {
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const proof = parsed.data;
// Refuse a proof that could not satisfy the gate BEFORE spending a guess on
// it: a password on an account with a second factor would verify and still
// leave every gated action refused.
const methods = await recentProofMethods(user);
if (!methods.includes(proof.method)) {
annotate({
action: { name: "auth.reproof.method_refused" },
meta: { method: proof.method },
});
return apiError("Use one of the listed ways to confirm it is you", 422, {
errorCode: "auth.reproof.too_weak",
methods,
});
}
const limited = await throttleReproof(user.id, ip, "reproof");
if (limited) return limited;
const outcome = await verifyExistingFactorProof(user, proof);
if (!outcome.ok) {
await recordReproofFailure(
user.id,
ip,
"reproof",
proof.method,
outcome.reason,
);
return apiError("Verification failed", 401, {
errorCode: REPROOF_FAILED_CODE,
});
}
const now = new Date();
await prisma.session.update({
where: { id: session.id },
data:
proof.method === "password"
? { reproofAt: now }
: { reproofAt: now, mfaVerifiedAt: now },
});
await auditLog("auth.reproof.succeeded", {
userId: user.id,
ipAddress: ip,
details: { method: proof.method },
});
annotate({
action: { name: "auth.reproof.succeeded" },
meta: { method: proof.method },
});
return apiSuccess({ method: proof.method, verifiedAt: now.toISOString() });
});
export const GET = apiHandler(async () => {
const { user, session } = await requireCookieAuth();
await assertRecentCookieProof(user, session.id);
return apiSuccess({ recent: true });
});