mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
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:
1 parent
a7f66b8519
commit
0fc83cb0a1
56 files changed
+2028
-235
No files matched your search
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Every web surface that calls a route behind the recent-proof gate asks for
|
||||
* the proof through `useRecentProof` and renders its dialog, so a person whose
|
||||
* session is older than five minutes is asked to confirm instead of meeting a
|
||||
* bare failure. Source-oriented, like the enrollment reauth test next door:
|
||||
* the repository keeps component tests free of a browser DOM harness. The
|
||||
* server half is pinned in `tests/integration/recent-proof.test.ts`.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
ReproofCancelledError,
|
||||
recentProofErrorMessage,
|
||||
throwIfReproofRequired,
|
||||
} from "../use-recent-proof";
|
||||
|
||||
const SRC = resolve(__dirname, "..", "..", "..", "..");
|
||||
const read = (file: string) => readFileSync(resolve(SRC, file), "utf8");
|
||||
|
||||
describe("throwIfReproofRequired", () => {
|
||||
it("turns the refusal into an ApiError carrying the methods", async () => {
|
||||
const res = new Response(
|
||||
JSON.stringify({
|
||||
data: null,
|
||||
error: "Confirm it is you to continue",
|
||||
meta: { errorCode: "auth.reproof.required", methods: ["password"] },
|
||||
}),
|
||||
{ status: 401 },
|
||||
);
|
||||
const err = await throwIfReproofRequired(res).catch((e: unknown) => e);
|
||||
expect(err).toBeInstanceOf(ApiError);
|
||||
expect((err as ApiError).meta?.methods).toEqual(["password"]);
|
||||
});
|
||||
|
||||
it("passes every other response through untouched", async () => {
|
||||
const other = new Response(
|
||||
JSON.stringify({ meta: { errorCode: "auth.stepup.required" } }),
|
||||
{ status: 401 },
|
||||
);
|
||||
await expect(throwIfReproofRequired(other)).resolves.toBe(other);
|
||||
const ok = new Response("{}", { status: 200 });
|
||||
await expect(throwIfReproofRequired(ok)).resolves.toBe(ok);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recentProofErrorMessage", () => {
|
||||
it("shows nothing for a cancelled dialog and the sentence for sign-in-again", () => {
|
||||
expect(recentProofErrorMessage(new ReproofCancelledError(), "x")).toBe(
|
||||
null,
|
||||
);
|
||||
expect(
|
||||
recentProofErrorMessage(
|
||||
new ApiError("Sign in again", 401, {
|
||||
errorCode: "auth.reproof.sign_in_again",
|
||||
}),
|
||||
"x",
|
||||
),
|
||||
).toBe("Sign in again");
|
||||
expect(recentProofErrorMessage(new Error("boom"), "fallback")).toBe(
|
||||
"fallback",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([
|
||||
["components/settings/export-section.tsx", "/api/export/full-backup"],
|
||||
["components/settings/export-section.tsx", "/api/export/encrypted"],
|
||||
["components/settings/share-link-create-form.tsx", "/api/share-links"],
|
||||
["components/settings/mcp-section.tsx", "/api/mcp/tokens"],
|
||||
["components/settings/api-section.tsx", "/api/tokens/measurements"],
|
||||
["components/admin/backups-section.tsx", "/download"],
|
||||
["components/admin/backups-section.tsx", "/restore"],
|
||||
["components/admin/backups-section.tsx", "/api/admin/backups/upload"],
|
||||
["components/admin/danger-zone-section.tsx", "/api/admin/data"],
|
||||
["components/admin/user-management-section.tsx", "/reset-password"],
|
||||
])("%s → %s", (file, endpoint) => {
|
||||
const source = read(file);
|
||||
|
||||
it("calls the gated route", () => {
|
||||
expect(source).toContain(endpoint);
|
||||
});
|
||||
|
||||
it("runs it through the recent-proof hook and renders the dialog", () => {
|
||||
expect(source).toContain("useRecentProof()");
|
||||
expect(source).toMatch(/recentProof\.run\(/);
|
||||
expect(source).toContain("{recentProof.dialog}");
|
||||
});
|
||||
});
|
||||
@@ -58,7 +58,10 @@ export function ExistingFactorReauthDialog({
|
||||
pending,
|
||||
error,
|
||||
onProof,
|
||||
description,
|
||||
}: {
|
||||
/** Overrides the default "before adding a second factor" sentence. */
|
||||
description?: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
methods: ReauthMethod[];
|
||||
@@ -134,7 +137,7 @@ export function ExistingFactorReauthDialog({
|
||||
{t("settings.passkeyReauth.title")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.passkeyReauth.factorDescription")}
|
||||
{description ?? t("settings.passkeyReauth.factorDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { ApiError, apiPost } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
ExistingFactorReauthDialog,
|
||||
isReproofRequired,
|
||||
type ExistingFactorProof,
|
||||
type ReauthMethod,
|
||||
} from "./existing-factor-reauth-dialog";
|
||||
|
||||
const KNOWN_METHODS: readonly ReauthMethod[] = [
|
||||
"password",
|
||||
"totp",
|
||||
"webauthn",
|
||||
"passkey",
|
||||
];
|
||||
|
||||
/** The proofs a `auth.reproof.required` refusal says this account can give. */
|
||||
function methodsFrom(err: ApiError): ReauthMethod[] {
|
||||
const raw = err.meta?.methods;
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.filter((m): m is ReauthMethod =>
|
||||
KNOWN_METHODS.includes(m as ReauthMethod),
|
||||
);
|
||||
}
|
||||
|
||||
/** The person closed the re-proof dialog. Nothing ran; show nothing. */
|
||||
export class ReproofCancelledError extends Error {
|
||||
constructor() {
|
||||
super("Re-proof cancelled");
|
||||
this.name = "ReproofCancelledError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The sentence to show for an error `run` rejected with, or null when there is
|
||||
* nothing to show (the dialog was cancelled). Any other error returns
|
||||
* `fallback`.
|
||||
*/
|
||||
export function recentProofErrorMessage(
|
||||
err: unknown,
|
||||
fallback: string,
|
||||
): string | null {
|
||||
if (err instanceof ReproofCancelledError) return null;
|
||||
if (
|
||||
err instanceof ApiError &&
|
||||
err.meta?.errorCode === "auth.reproof.sign_in_again"
|
||||
) {
|
||||
return err.message;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a raw `Response` from a download or upload call site into the
|
||||
* `ApiError` the hook recognises, when it is the recent-proof refusal. Any
|
||||
* other response is returned untouched for the caller's own handling.
|
||||
*/
|
||||
export async function throwIfReproofRequired(res: Response): Promise<Response> {
|
||||
if (res.status !== 401) return res;
|
||||
let body: { error?: unknown; meta?: unknown } | null = null;
|
||||
try {
|
||||
body = await res.clone().json();
|
||||
} catch {
|
||||
return res;
|
||||
}
|
||||
const meta =
|
||||
body && typeof body.meta === "object" && body.meta !== null
|
||||
? (body.meta as Record<string, unknown>)
|
||||
: undefined;
|
||||
const err = new ApiError(
|
||||
typeof body?.error === "string" ? body.error : "",
|
||||
401,
|
||||
meta,
|
||||
);
|
||||
if (isReproofRequired(err)) throw err;
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an action that the server may hold back for a fresh proof.
|
||||
*
|
||||
* Exports of the whole record, share links, token minting and the admin data
|
||||
* actions answer 401 `auth.reproof.required` with `meta.methods` when the
|
||||
* session has not signed in or re-proved within five minutes. `run` catches
|
||||
* exactly that refusal, opens the re-proof dialog with the listed methods,
|
||||
* posts the proof to `/api/auth/reproof` and runs the action once more. Every
|
||||
* other error rejects as usual; a cancelled dialog rejects with
|
||||
* `ReproofCancelledError`. `recentProofErrorMessage` maps both for display.
|
||||
*
|
||||
* An account with nothing to re-prove in place (signed in through single
|
||||
* sign-on only) gets no dialog: the refusal is rethrown with a message asking
|
||||
* to sign in again.
|
||||
*/
|
||||
export function useRecentProof(): {
|
||||
run: <T>(action: () => Promise<T>) => Promise<T>;
|
||||
dialog: ReactNode;
|
||||
} {
|
||||
const { t } = useTranslations();
|
||||
const [methods, setMethods] = useState<ReauthMethod[] | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const waiter = useRef<{
|
||||
resolve: () => void;
|
||||
reject: () => void;
|
||||
} | null>(null);
|
||||
|
||||
const run = useCallback(
|
||||
async <T,>(action: () => Promise<T>): Promise<T> => {
|
||||
try {
|
||||
return await action();
|
||||
} catch (err) {
|
||||
if (!isReproofRequired(err)) throw err;
|
||||
const offered = methodsFrom(err as ApiError);
|
||||
if (offered.length === 0) {
|
||||
throw new ApiError(t("settings.reproof.signInAgain"), 401, {
|
||||
errorCode: "auth.reproof.sign_in_again",
|
||||
});
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
waiter.current = {
|
||||
resolve,
|
||||
reject: () => reject(new ReproofCancelledError()),
|
||||
};
|
||||
setError(null);
|
||||
setMethods(offered);
|
||||
});
|
||||
return action();
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const onProof = useCallback(
|
||||
async (proof: ExistingFactorProof) => {
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiPost("/api/auth/reproof", proof);
|
||||
setMethods(null);
|
||||
const w = waiter.current;
|
||||
waiter.current = null;
|
||||
w?.resolve();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof ApiError && err.status === 429
|
||||
? err.message
|
||||
: t("settings.reproof.failed"),
|
||||
);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const dialog = (
|
||||
<ExistingFactorReauthDialog
|
||||
key={methods?.join(",") ?? "closed"}
|
||||
open={methods !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (open) return;
|
||||
setMethods(null);
|
||||
setError(null);
|
||||
const w = waiter.current;
|
||||
waiter.current = null;
|
||||
w?.reject();
|
||||
}}
|
||||
methods={methods ?? ["password"]}
|
||||
pending={pending}
|
||||
error={error}
|
||||
onProof={(proof) => void onProof(proof)}
|
||||
description={t("settings.reproof.description")}
|
||||
/>
|
||||
);
|
||||
|
||||
return { run, dialog };
|
||||
}
|
||||
Reference in new issue
Block a user