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

+23 -9
View File
@@ -39,6 +39,11 @@ import { formatDate, formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import {
recentProofErrorMessage,
throwIfReproofRequired,
useRecentProof,
} from "@/components/settings/security-section/use-recent-proof";
interface ApiTokenInfo {
id: string;
@@ -138,6 +143,9 @@ function IngestTokenCard({
const [creating, setCreating] = useState(false);
const [tokenMsg, setTokenMsg] = useState<string | null>(null);
const [tokenCopied, setTokenCopied] = useState(false);
// The token outlives the session, so minting one asks for a fresh proof
// unless the session signed in or re-proved within five minutes.
const recentProof = useRecentProof();
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
@@ -145,13 +153,18 @@ function IngestTokenCard({
setTokenMsg(null);
setNewToken(null);
try {
const res = await apiFetchRaw(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
// No scope field: the endpoint mints one shape, so there is nothing
// here for a caller to choose or for this form to get wrong.
body: JSON.stringify({ name: newName.trim() }),
});
// No scope field: the endpoint mints one shape, so there is nothing
// here for a caller to choose or for this form to get wrong.
const body = JSON.stringify({ name: newName.trim() });
const res = await recentProof.run(async () =>
throwIfReproofRequired(
await apiFetchRaw(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
}),
),
);
const json = await res.json();
if (res.ok) {
setNewToken(json.data.token);
@@ -164,8 +177,8 @@ function IngestTokenCard({
// this specific action when it did not.
setTokenMsg(json.error || copy.createFailed);
}
} catch {
setTokenMsg(t("common.networkError"));
} catch (err) {
setTokenMsg(recentProofErrorMessage(err, t("common.networkError")));
} finally {
setCreating(false);
}
@@ -185,6 +198,7 @@ function IngestTokenCard({
return (
<SettingsCard>
{recentProof.dialog}
<SettingsCardHeader
icon={icon}
title={copy.title}