mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fix(auth): let every fresh-factor refusal open the re-proof dialog
A Guardian signed in through single sign-on with a second factor enrolled could not hand a managed profile over once five minutes had passed since sign-in: the handover answered 401 auth.stepup.required, and the panel printed "Confirm your second factor, then try again" with no way to do so. The same held for every action behind requireFreshMfa: creating, editing and ending a managed profile, adding and removing a Guardian, disabling the second factor, rotating recovery codes, removing a security key or a passkey, changing the password, the data wipe, account deletion and the key rotation. The recent-proof routes had the dialog; these had text. - requireFreshMfa names the proofs that clear a stale stamp in meta.methods (the possession proofs the account holds, never a password, which stamps reproofAt only). - The re-proof flow treats that refusal like auth.reproof.required: open the dialog, post the proof to /api/auth/reproof, send the request once more. The flow moves into a React-free module so it can be driven in tests; the hook binds it to the dialog. - The managed-profile mutation hooks take a required proof guard, and every caller passes recentProof.run. The other surfaces wrap their requests in recentProof.run; disabling the second factor asks before its confirmation opens, so the code typed there is not spent twice. - An account with nothing to re-prove in place (single sign-on only, no factor) is told to sign in again when adding a factor, instead of being offered a password it does not have. - step-up-web-caller-guard freezes the gated route methods and fails when a web request to one of them runs outside the flow. An account whose only second factor is the identity provider's is still asked for an app factor on the managed-profile acts: the app cannot see the provider's factor. Enrolling right after a sign-in needs no further proof, and the confirmation stamps the session, so the act follows. Refs #959
This commit is contained in:
1 parent
59f910df5e
commit
99f0607a69
36 files changed
+2122
-466
No files matched your search
+10
-5
@@ -2333,7 +2333,11 @@ paths:
|
||||
/api/mcp/tokens, POST /api/tokens/measurements, POST /api/tokens/workouts, POST /api/tokens/documents, and the
|
||||
admin backup download, upload and restore, the admin data wipe and the admin password reset. Those answer 401
|
||||
`auth.reproof.required` with `meta.methods` (the proofs this account can give) when the session signed in or
|
||||
re-proved more than five minutes ago.
|
||||
re-proved more than five minutes ago. A `totp`, `webauthn` or `passkey` proof here also clears the routes that
|
||||
ask for a fresh second factor (the managed-profile acts, the second-factor and passkey changes, the password
|
||||
change, account deletion, the data wipe and key rotation): on a browser session those answer 401
|
||||
`auth.stepup.required` with `meta.methods` naming the possession proofs the account holds, and the web re-proves
|
||||
here and retries.
|
||||
|
||||
|
||||
The body takes the same shapes as POST /api/auth/step-up. On an account with a second factor only `totp`,
|
||||
@@ -21681,10 +21685,11 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ManagedProfileEnvelope"
|
||||
"401":
|
||||
description: "No fresh second-factor proof (`meta.errorCode: auth.stepup.required`), or none enrolled at all
|
||||
(`meta.errorCode: auth.stepup.mfa_not_enrolled`) — this gate is unconditional, so an account without a
|
||||
second factor cannot create a managed profile until it enrols one. The ordinary unauthenticated 401 arrives
|
||||
here too, including for a Bearer caller, whose transport this gate does not read."
|
||||
description: "No fresh second-factor proof (`meta.errorCode: auth.stepup.required`, with `meta.methods` naming the
|
||||
proofs that clear it at POST /api/auth/reproof), or none enrolled at all (`meta.errorCode:
|
||||
auth.stepup.mfa_not_enrolled`) — this gate is unconditional, so an account without a second factor cannot
|
||||
create a managed profile until it enrols one. The ordinary unauthenticated 401 arrives here too, including
|
||||
for a Bearer caller, whose transport this gate does not read."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
|
||||
@@ -7323,6 +7323,7 @@
|
||||
"emailChangePassword": "Aktuelles Passwort (nötig, um die E-Mail-Adresse zu ändern)",
|
||||
"reproof": {
|
||||
"description": "Damit werden deine Gesundheitsdaten weitergegeben oder exportiert. Bestätige, dass du es bist, um fortzufahren.",
|
||||
"descriptionChange": "Das ändert etwas, das nur du ändern können solltest. Bestätige, dass du es bist, um fortzufahren.",
|
||||
"signInAgain": "Melde dich neu an und versuche es dann noch einmal. Dein Konto hat kein Passwort und keinen Passkey, den du hier bestätigen könntest.",
|
||||
"failed": "Das hat nicht geklappt. Prüfe deine Eingabe und versuche es noch einmal."
|
||||
},
|
||||
|
||||
@@ -7323,6 +7323,7 @@
|
||||
"emailChangePassword": "Current password (needed to change the email address)",
|
||||
"reproof": {
|
||||
"description": "This shares or exports your health data. Confirm it's you to continue.",
|
||||
"descriptionChange": "This changes something only you should be able to change. Confirm it's you to continue.",
|
||||
"signInAgain": "Sign in again, then try once more. Your account has no password or passkey to confirm here.",
|
||||
"failed": "That did not work. Check what you entered and try again."
|
||||
},
|
||||
|
||||
@@ -7323,6 +7323,7 @@
|
||||
"emailChangePassword": "Contraseña actual (necesaria para cambiar la dirección de correo)",
|
||||
"reproof": {
|
||||
"description": "Esto comparte o exporta tus datos de salud. Confirma que eres tú para continuar.",
|
||||
"descriptionChange": "Esto cambia algo que solo tú deberías poder cambiar. Confirma que eres tú para continuar.",
|
||||
"signInAgain": "Vuelve a iniciar sesión y inténtalo de nuevo. Tu cuenta no tiene contraseña ni llave de acceso que confirmar aquí.",
|
||||
"failed": "No ha funcionado. Revisa lo que has introducido e inténtalo de nuevo."
|
||||
},
|
||||
|
||||
@@ -7323,6 +7323,7 @@
|
||||
"emailChangePassword": "Mot de passe actuel (nécessaire pour changer l'adresse e-mail)",
|
||||
"reproof": {
|
||||
"description": "Cette action partage ou exporte vos données de santé. Confirmez qu'il s'agit bien de vous pour continuer.",
|
||||
"descriptionChange": "Cela modifie quelque chose que vous seul devriez pouvoir modifier. Confirmez qu'il s'agit bien de vous pour continuer.",
|
||||
"signInAgain": "Reconnectez-vous, puis réessayez. Votre compte n'a ni mot de passe ni clé d'accès à confirmer ici.",
|
||||
"failed": "Cela n'a pas fonctionné. Vérifiez votre saisie et réessayez."
|
||||
},
|
||||
|
||||
@@ -7323,6 +7323,7 @@
|
||||
"emailChangePassword": "Password attuale (necessaria per cambiare l'indirizzo email)",
|
||||
"reproof": {
|
||||
"description": "Questa azione condivide o esporta i tuoi dati sanitari. Conferma che sei tu per continuare.",
|
||||
"descriptionChange": "Questo modifica qualcosa che solo tu dovresti poter modificare. Conferma che sei tu per continuare.",
|
||||
"signInAgain": "Accedi di nuovo, poi riprova. Il tuo account non ha una password o una passkey da confermare qui.",
|
||||
"failed": "Non ha funzionato. Controlla quanto inserito e riprova."
|
||||
},
|
||||
|
||||
@@ -7323,6 +7323,7 @@
|
||||
"emailChangePassword": "현재 비밀번호 (이메일 주소를 변경하려면 필요)",
|
||||
"reproof": {
|
||||
"description": "건강 데이터를 공유하거나 내보내는 작업입니다. 계속하려면 본인임을 확인하세요.",
|
||||
"descriptionChange": "본인만 변경할 수 있어야 하는 항목을 변경합니다. 계속하려면 본인임을 확인하세요.",
|
||||
"signInAgain": "다시 로그인한 후 한 번 더 시도하세요. 이 계정에는 여기서 확인할 비밀번호나 패스키가 없습니다.",
|
||||
"failed": "확인하지 못했습니다. 입력한 내용을 확인하고 다시 시도하세요."
|
||||
},
|
||||
|
||||
@@ -7323,6 +7323,7 @@
|
||||
"emailChangePassword": "Obecne hasło (potrzebne do zmiany adresu e-mail)",
|
||||
"reproof": {
|
||||
"description": "Ta czynność udostępnia lub eksportuje Twoje dane zdrowotne. Potwierdź, że to Ty, aby kontynuować.",
|
||||
"descriptionChange": "To zmienia coś, co tylko Ty powinieneś móc zmienić. Potwierdź, że to Ty, aby kontynuować.",
|
||||
"signInAgain": "Zaloguj się ponownie, a potem spróbuj jeszcze raz. Twoje konto nie ma hasła ani klucza dostępu do potwierdzenia tutaj.",
|
||||
"failed": "To się nie udało. Sprawdź wprowadzone dane i spróbuj ponownie."
|
||||
},
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* Every web action behind a fresh-proof gate goes through the re-proof dialog.
|
||||
*
|
||||
* Two server gates hold an action back until the session has proved something
|
||||
* recently: the recent-proof gate (`requireRecentProof`,
|
||||
* `assertRecentCookieProof`, 401 `auth.reproof.required`) and the fresh-factor
|
||||
* gate (`requireFreshMfa` and the helpers that escalate to it, 401
|
||||
* `auth.stepup.required`). Both name the proofs that clear them in
|
||||
* `meta.methods`, and `useRecentProof` opens the dialog on either, posts the
|
||||
* proof to `/api/auth/reproof` and runs the action once more.
|
||||
*
|
||||
* The defect this exists for: the managed-profile acts (handing a profile
|
||||
* over, adding and removing a Guardian, creating, editing and ending a
|
||||
* record) answered the fresh-factor refusal with a sentence telling the
|
||||
* person to confirm their second factor, and offered nowhere to do it. The
|
||||
* same was true of disabling the second factor, rotating recovery codes,
|
||||
* removing a security key or passkey, changing the password, the data wipe,
|
||||
* account deletion and the key rotation. The recent-proof surfaces had the
|
||||
* dialog; the fresh-factor ones had text.
|
||||
*
|
||||
* What it pins:
|
||||
*
|
||||
* 1. The set of gated route methods, discovered from the route files and
|
||||
* frozen below. A new gated route has to be entered here, with either
|
||||
* its web callers checked by the sweep or the reason it has none.
|
||||
* 2. Every request a web file makes to one of them runs inside the shared
|
||||
* flow: `recentProof.run(…)` in a component, or `guard(…)` inside a
|
||||
* query hook whose `guard` parameter every caller fills with
|
||||
* `recentProof.run`. A gated request outside it is the defect again.
|
||||
*
|
||||
* Every sweep asserts a floor, because a matcher that finds nothing agrees
|
||||
* with every list.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
function read(rel: string): string {
|
||||
return readFileSync(join(SRC, rel), "utf8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Source with comments removed, so a route that only DESCRIBES a gate in its
|
||||
* doc comment is not counted, and a path quoted in a comment is not a call.
|
||||
*/
|
||||
function code(rel: string): string {
|
||||
return read(rel)
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.split("\n")
|
||||
.map((line) => line.replace(/(^|[^:"'`\\])\/\/.*$/, "$1"))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const;
|
||||
|
||||
const GATE =
|
||||
/\b(?:requireFreshMfa|requireFreshMfaIfEnrolled|requireFreshMfaOrElevationIfEnrolled|requireRecentProof|assertRecentCookieProof)\s*\(|\bfreshFactor\s*:\s*true\b/;
|
||||
|
||||
/** `src/app/api/a/[id]/route.ts` -> `/api/a/[id]`. */
|
||||
function routePath(rel: string): string {
|
||||
return "/" + rel.replace(/^app\//, "").replace(/\/route\.ts$/, "");
|
||||
}
|
||||
|
||||
/** The gated `METHOD /api/path` pairs, read off the route files. */
|
||||
function discoverGatedRoutes(): string[] {
|
||||
const routes = walkSourceFiles(join(SRC, "app", "api"), { floor: 300 })
|
||||
.filter((p) => p.endsWith("/route.ts") && !p.includes("__tests__"))
|
||||
.map((p) => `app/api/${p}`);
|
||||
expect(routes.length).toBeGreaterThan(300);
|
||||
|
||||
const found = new Set<string>();
|
||||
for (const rel of routes) {
|
||||
const src = code(rel);
|
||||
if (!GATE.test(src)) continue;
|
||||
const exportRe = new RegExp(
|
||||
`export\\s+(?:const\\s+(${METHODS.join("|")})\\s*=|async\\s+function\\s+(${METHODS.join("|")})\\s*\\()`,
|
||||
"g",
|
||||
);
|
||||
const exports = [...src.matchAll(exportRe)].map((m) => ({
|
||||
method: m[1] ?? m[2],
|
||||
index: m.index ?? 0,
|
||||
}));
|
||||
const path = routePath(rel);
|
||||
// A gate before the first exported method lives in a helper or a
|
||||
// `handler` constant the exports wrap, and binds every method.
|
||||
const preamble = src.slice(0, exports[0]?.index ?? src.length);
|
||||
if (GATE.test(preamble)) {
|
||||
for (const e of exports) found.add(`${e.method} ${path}`);
|
||||
}
|
||||
exports.forEach((e, i) => {
|
||||
const block = src.slice(e.index, exports[i + 1]?.index ?? src.length);
|
||||
if (GATE.test(block)) found.add(`${e.method} ${path}`);
|
||||
});
|
||||
}
|
||||
return [...found].sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every gated route method, and how the web reaches it. `"web"` means the
|
||||
* sweep below finds its callers and checks them; anything else is the reason
|
||||
* no web file calls it.
|
||||
*/
|
||||
const GATED_ROUTES: Record<string, string> = {
|
||||
"DELETE /api/admin/data": "web",
|
||||
"DELETE /api/auth/me/mfa/webauthn/[id]": "web",
|
||||
"DELETE /api/auth/passkeys/[id]": "web",
|
||||
"DELETE /api/managed-profiles/[id]": "web",
|
||||
"DELETE /api/managed-profiles/[id]/guardians/[grantId]": "web",
|
||||
"DELETE /api/settings/account": "web",
|
||||
"DELETE /api/settings/data": "web",
|
||||
"GET /api/admin/backups/[id]/download": "web",
|
||||
"GET /api/auth/reproof":
|
||||
"the flow's own question: the web asks it inside recentProof.run before an upload or a typed confirmation",
|
||||
"GET /api/export":
|
||||
"the plaintext whole-record download (`type=all`); the web's whole-record export is the full backup, and its single-type exports have their own routes",
|
||||
"GET /api/export/full-backup": "web",
|
||||
"GET /api/fhir/Patient/$everything":
|
||||
"a FHIR client endpoint, reached with a fhir:read token; no web file calls it",
|
||||
"GET /api/mcp/oauth/authorize":
|
||||
"a browser navigation from an assistant, answered with a page that links to /confirm-access, which runs the dialog",
|
||||
"PATCH /api/managed-profiles/[id]": "web",
|
||||
"POST /api/account/grants": "web",
|
||||
"POST /api/admin/backups/[id]/restore": "web",
|
||||
"POST /api/admin/backups/upload": "web",
|
||||
"POST /api/admin/encryption/rotate": "web",
|
||||
"POST /api/admin/users/[id]/reset-password": "web",
|
||||
"POST /api/auth/me/mfa/disable": "web",
|
||||
"POST /api/auth/me/mfa/recovery-codes/regenerate": "web",
|
||||
"POST /api/auth/password": "web",
|
||||
"POST /api/export/encrypted": "web",
|
||||
"POST /api/export/health-record": "web",
|
||||
"POST /api/managed-profiles": "web",
|
||||
"POST /api/managed-profiles/[id]/guardians": "web",
|
||||
"POST /api/managed-profiles/[id]/handover": "web",
|
||||
"POST /api/mcp/oauth/authorize":
|
||||
"the consent form's own submission, refused into the same /confirm-access page as the GET",
|
||||
"POST /api/mcp/tokens": "web",
|
||||
"POST /api/share-links": "web",
|
||||
"POST /api/tokens/documents": "web",
|
||||
"POST /api/tokens/measurements": "web",
|
||||
"POST /api/tokens/workouts": "web",
|
||||
};
|
||||
|
||||
/** The web side: everything under src that is not a route, a test or docs. */
|
||||
function webFiles(): string[] {
|
||||
return walkSourceFiles(SRC, { floor: 3000 })
|
||||
.filter((p) => !p.startsWith("generated/"))
|
||||
.filter((p) => !p.startsWith("app/api/"))
|
||||
.filter((p) => !p.startsWith("lib/openapi/"))
|
||||
.filter((p) => !p.includes("__tests__"))
|
||||
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
|
||||
.sort();
|
||||
}
|
||||
|
||||
/** A string or template literal naming exactly this path. */
|
||||
function pathPattern(path: string): RegExp {
|
||||
const body = path
|
||||
.split("/")
|
||||
.map((seg) =>
|
||||
/^\[.+\]$/.test(seg)
|
||||
? "\\$\\{[^}]+\\}"
|
||||
: seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
|
||||
)
|
||||
.join("/");
|
||||
return new RegExp(`["'\`]${body}(?=["'\`?])`, "g");
|
||||
}
|
||||
|
||||
/** Which method a call naming the path at `index` sends. */
|
||||
function methodAt(src: string, index: number, matchLength: number): string {
|
||||
const before = src.slice(Math.max(0, index - 160), index);
|
||||
const helper = [
|
||||
...before.matchAll(/\bapi(Get|Post|Put|Patch|Delete)\b(?:<[^()]*>)?\s*\(/g),
|
||||
].pop();
|
||||
// The helper must be the call this literal is the first argument of.
|
||||
if (
|
||||
helper &&
|
||||
/^\s*$/.test(before.slice((helper.index ?? 0) + helper[0].length))
|
||||
) {
|
||||
return helper[1].toUpperCase();
|
||||
}
|
||||
const after = src.slice(index + matchLength, index + matchLength + 240);
|
||||
const explicit = after.match(/method\s*:\s*["'](\w+)["']/);
|
||||
return explicit ? explicit[1].toUpperCase() : "GET";
|
||||
}
|
||||
|
||||
/** The name of the function the code at `index` sits in, if it has one. */
|
||||
function enclosingFunction(src: string, index: number): string | null {
|
||||
const head = src.slice(0, index);
|
||||
const decls = [
|
||||
...head.matchAll(
|
||||
/(?:\bfunction\s+(\w+)\s*\(|\b(?:const|let)\s+(\w+)\s*=\s*(?:async\s+)?(?:\([^)]*\)|\w+)\s*=>)/g,
|
||||
),
|
||||
].pop();
|
||||
return decls ? (decls[1] ?? decls[2] ?? null) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The request at `index` runs inside the shared flow: directly inside a
|
||||
* `recentProof.run(…)` or a hook's `guard(…)`, or in a named function that is
|
||||
* itself handed to, or called from, `recentProof.run`.
|
||||
*/
|
||||
function guardedAt(src: string, index: number): boolean {
|
||||
const before = src.slice(Math.max(0, index - 400), index);
|
||||
if (/(?:\.run|\bguard)\s*\(/.test(before)) return true;
|
||||
const name = enclosingFunction(src, index);
|
||||
if (!name) return false;
|
||||
return new RegExp(
|
||||
`\\.run\\(\\s*(?:async\\s*)?(?:\\(\\)\\s*=>\\s*)?${name}\\b`,
|
||||
).test(src);
|
||||
}
|
||||
|
||||
interface GatedCall {
|
||||
file: string;
|
||||
route: string;
|
||||
guarded: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A query hook whose request is not guarded inside the hook is still guarded
|
||||
* when every component that mounts it sends the mutation through the flow:
|
||||
* `recentProof.run(() => mutation.mutateAsync(…))`. At least one such
|
||||
* component must exist, or nothing is guarded.
|
||||
*/
|
||||
function hookCallersWrap(
|
||||
files: string[],
|
||||
hookFile: string,
|
||||
src: string,
|
||||
index: number,
|
||||
): boolean {
|
||||
const hook = [
|
||||
...src.slice(0, index).matchAll(/export function (use\w+)/g),
|
||||
].pop()?.[1];
|
||||
if (!hook) return false;
|
||||
let callers = 0;
|
||||
for (const file of files) {
|
||||
if (file === hookFile) continue;
|
||||
const other = code(file);
|
||||
for (const m of other.matchAll(
|
||||
new RegExp(`\\b(?:const|let)\\s+(\\w+)\\s*=\\s*${hook}\\s*\\(`, "g"),
|
||||
)) {
|
||||
callers++;
|
||||
const wraps = new RegExp(
|
||||
`\\.run\\(\\s*(?:async\\s*)?\\(\\)\\s*=>\\s*${m[1]}\\.mutateAsync\\b`,
|
||||
);
|
||||
if (!wraps.test(other)) return false;
|
||||
}
|
||||
}
|
||||
return callers > 0;
|
||||
}
|
||||
|
||||
function gatedWebCalls(): GatedCall[] {
|
||||
const calls: GatedCall[] = [];
|
||||
const files = webFiles();
|
||||
const sources = new Map(files.map((f) => [f, code(f)]));
|
||||
for (const route of Object.keys(GATED_ROUTES)) {
|
||||
if (GATED_ROUTES[route] !== "web") continue;
|
||||
const [method, path] = route.split(" ");
|
||||
const re = pathPattern(path);
|
||||
for (const file of files) {
|
||||
const src = sources.get(file) ?? "";
|
||||
for (const m of src.matchAll(re)) {
|
||||
const index = m.index ?? 0;
|
||||
// The path handed to a component as a prop (`endpoint="/api/…"`):
|
||||
// the request is wherever that component sends the prop.
|
||||
const prop = src.slice(Math.max(0, index - 40), index).match(/(\w+)=$/);
|
||||
if (prop) {
|
||||
const sends = [
|
||||
...src.matchAll(
|
||||
new RegExp(`\\bapiFetchRaw\\(\\s*${prop[1]}\\s*,`, "g"),
|
||||
),
|
||||
];
|
||||
for (const send of sends) {
|
||||
const at = send.index ?? 0;
|
||||
if (methodAt(src, at, send[0].length) !== method) continue;
|
||||
calls.push({ file, route, guarded: guardedAt(src, at) });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (methodAt(src, index, m[0].length) !== method) continue;
|
||||
const guarded =
|
||||
guardedAt(src, index) ||
|
||||
(file.startsWith("lib/queries/") &&
|
||||
hookCallersWrap(files, file, src, index));
|
||||
calls.push({ file, route, guarded });
|
||||
}
|
||||
}
|
||||
}
|
||||
return calls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the sweep cannot read as guarded, each checked by hand and pinned.
|
||||
* The request runs after a `recentProof.run(() => apiGet("/api/auth/reproof"))`
|
||||
* a few lines earlier, so the proof is in place before a file is sent or a
|
||||
* typed confirmation is spent.
|
||||
*/
|
||||
const PRECHECKED = new Set<string>([
|
||||
// `upload.mutationFn` asks first, then calls `uploadOnce`, so a large file
|
||||
// is not sent once only to be refused.
|
||||
"components/admin/backups-section.tsx -> POST /api/admin/backups/upload",
|
||||
]);
|
||||
|
||||
describe("fresh-proof gates on the web", () => {
|
||||
it("freezes the set of gated route methods", () => {
|
||||
const discovered = discoverGatedRoutes();
|
||||
expect(discovered.length).toBeGreaterThanOrEqual(30);
|
||||
expect(discovered).toEqual(Object.keys(GATED_ROUTES).sort());
|
||||
});
|
||||
|
||||
it("finds a web caller for every route marked as called from the web", () => {
|
||||
const calls = gatedWebCalls();
|
||||
expect(calls.length).toBeGreaterThanOrEqual(25);
|
||||
const reached = new Set(calls.map((c) => c.route));
|
||||
const unreached = Object.keys(GATED_ROUTES).filter(
|
||||
(r) => GATED_ROUTES[r] === "web" && !reached.has(r),
|
||||
);
|
||||
expect(unreached).toEqual([]);
|
||||
});
|
||||
|
||||
it("sends every gated request through the re-proof flow", () => {
|
||||
const unguarded = gatedWebCalls()
|
||||
.filter((c) => !c.guarded)
|
||||
.map((c) => `${c.file} -> ${c.route}`)
|
||||
.filter((key) => !PRECHECKED.has(key));
|
||||
expect(unguarded).toEqual([]);
|
||||
});
|
||||
|
||||
it("renders the dialog wherever a component runs the flow", () => {
|
||||
const files = [...new Set(gatedWebCalls().map((c) => c.file))].filter((f) =>
|
||||
f.endsWith(".tsx"),
|
||||
);
|
||||
expect(files.length).toBeGreaterThanOrEqual(10);
|
||||
const missing = files.filter((f) => {
|
||||
const src = code(f);
|
||||
return !/useRecentProof\s*\(/.test(src) || !/\.dialog\b/.test(src);
|
||||
});
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("makes every caller of a guarded query hook pass recentProof.run", () => {
|
||||
// Hooks whose gated request runs inside `guard(…)`.
|
||||
const hookFiles = [
|
||||
...new Set(
|
||||
gatedWebCalls()
|
||||
.filter((c) => c.file.startsWith("lib/queries/"))
|
||||
.map((c) => c.file),
|
||||
),
|
||||
];
|
||||
expect(hookFiles.length).toBeGreaterThanOrEqual(1);
|
||||
const hooks: string[] = [];
|
||||
for (const file of hookFiles) {
|
||||
for (const m of code(file).matchAll(
|
||||
/export function (use\w+)\(\s*guard\s*:\s*ProofGuard\s*\)/g,
|
||||
)) {
|
||||
hooks.push(m[1]);
|
||||
}
|
||||
}
|
||||
expect(hooks.length).toBeGreaterThanOrEqual(6);
|
||||
|
||||
const offenders: string[] = [];
|
||||
let callSites = 0;
|
||||
for (const file of webFiles()) {
|
||||
if (hookFiles.includes(file)) continue;
|
||||
const src = code(file);
|
||||
for (const hook of hooks) {
|
||||
for (const m of src.matchAll(
|
||||
new RegExp(`\\b${hook}\\s*\\(([^)]*)\\)`, "g"),
|
||||
)) {
|
||||
callSites++;
|
||||
if (!/^\s*\w+\.run\s*$/.test(m[1])) {
|
||||
offenders.push(`${file}: ${hook}(${m[1].trim()})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(callSites).toBeGreaterThanOrEqual(6);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,11 @@
|
||||
* 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.
|
||||
* retries. The fresh-factor routes (`requireFreshMfa`: the managed-profile
|
||||
* acts, second-factor and passkey changes, the password change, account
|
||||
* deletion, the data wipe, key rotation) answer 401 `auth.stepup.required`
|
||||
* with `meta.methods` on a browser session, and the same dialog clears them
|
||||
* here with a possession proof.
|
||||
*
|
||||
* What a proof stamps on the session row:
|
||||
* - `totp`, `webauthn` or `passkey`: `mfaVerifiedAt` and `reproofAt`. The
|
||||
|
||||
@@ -45,6 +45,11 @@ import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import { getApiErrorMessage } from "./_shared";
|
||||
import {
|
||||
recentProofErrorMessage,
|
||||
throwIfReproofRequired,
|
||||
useRecentProof,
|
||||
} from "@/components/settings/security-section/use-recent-proof";
|
||||
import { KeyBackupCard } from "./key-backup-card";
|
||||
import { useKeyBackupStatus } from "./use-key-backup-status";
|
||||
|
||||
@@ -105,12 +110,22 @@ export function EncryptionSection() {
|
||||
staleTime: 15_000,
|
||||
});
|
||||
|
||||
// Rotation needs the admin's second factor proved within five minutes. When
|
||||
// it is older the server names the proofs that clear it, the shared dialog
|
||||
// collects one, and the rotation is queued once more.
|
||||
const recentProof = useRecentProof({
|
||||
description: t("settings.reproof.descriptionChange"),
|
||||
});
|
||||
const rotate = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await apiFetchRaw("/api/admin/encryption/rotate", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
const res = await recentProof.run(async () =>
|
||||
throwIfReproofRequired(
|
||||
await apiFetchRaw("/api/admin/encryption/rotate", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
}),
|
||||
),
|
||||
);
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
throw new Error(t("admin.section.encryption.stepUpNeeded"));
|
||||
@@ -126,10 +141,12 @@ export function EncryptionSection() {
|
||||
});
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
showErrorToast(
|
||||
const message = recentProofErrorMessage(
|
||||
err,
|
||||
err.message || t("admin.section.encryption.rotateFailed"),
|
||||
{ cause: err },
|
||||
);
|
||||
if (message === null) return;
|
||||
showErrorToast(message, { cause: err });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -182,6 +199,7 @@ export function EncryptionSection() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{recentProof.dialog}
|
||||
<KeyBackupCard />
|
||||
{/* ── Coverage summary ───────────────────────────────────────── */}
|
||||
<SettingsCard>
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* #959 — "I get the error message that I need to confirm my second factor,
|
||||
* but there is nowhere to do so."
|
||||
*
|
||||
* Handing a managed profile over, adding a Guardian and removing one are gated
|
||||
* on a second factor proved within five minutes (`requireFreshMfa`). Its
|
||||
* refusal, 401 `auth.stepup.required`, now names the proofs that clear it in
|
||||
* `meta.methods`, and each of these mutations runs inside the re-proof flow:
|
||||
* the dialog opens with those proofs, the proof goes to `/api/auth/reproof`,
|
||||
* and the request is sent once more.
|
||||
*
|
||||
* Driven for real below: the hooks' own `mutationFn`s, the flow
|
||||
* `useRecentProof` binds to the dialog, and a fake network that answers the
|
||||
* way the routes do. `@tanstack/react-query` is reduced to the two calls the
|
||||
* hook bodies make, as in `managed-profiles-invalidation.test.ts`; the unit
|
||||
* environment has no DOM, so the dialog itself is represented by the view the
|
||||
* flow drives (which proofs it offers, its error line) and by the test acting
|
||||
* as the person: proving, or closing it.
|
||||
*
|
||||
* Mutation checks, run: drop `guard(…)` from `useCreateManagedProfileHandover`
|
||||
* and every handover case goes red (the refusal escapes, no dialog); make
|
||||
* `isProofRefusal` match `auth.reproof.required` only and every case that
|
||||
* expects a dialog goes red.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("@tanstack/react-query", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@tanstack/react-query")>();
|
||||
return {
|
||||
...actual,
|
||||
useMutation: (options: unknown) => options,
|
||||
useQueryClient: () => new QueryClient(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("qrcode", () => ({
|
||||
default: { toDataURL: vi.fn(async () => "data:image/png;base64,AAAA") },
|
||||
}));
|
||||
|
||||
import {
|
||||
useCreateManagedProfileHandover,
|
||||
useInviteManagedProfileGuardian,
|
||||
useRemoveManagedProfileGuardian,
|
||||
} from "@/lib/queries/use-managed-profiles";
|
||||
import { ApiError, apiPost } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
ReproofCancelledError,
|
||||
createRecentProofFlow,
|
||||
type RecentProofFlow,
|
||||
} from "@/components/settings/security-section/recent-proof-flow";
|
||||
import type { ReauthMethod } from "@/components/settings/security-section/existing-factor-reauth-dialog";
|
||||
import { stepUpErrorKey } from "@/components/settings/access/grant-action-error";
|
||||
|
||||
type MutationOptions<I, O> = { mutationFn: (input: I) => Promise<O> };
|
||||
|
||||
// ── The network, answering the way the routes do ────────────────────────────
|
||||
|
||||
const STALE = {
|
||||
data: null,
|
||||
error: "Recent second-factor verification required",
|
||||
meta: { errorCode: "auth.stepup.required", methods: ["totp", "passkey"] },
|
||||
};
|
||||
|
||||
function json(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Every request, as `METHOD path`, in order. */
|
||||
let sent: string[] = [];
|
||||
/** The session's second factor is fresh once a proof was accepted. */
|
||||
let fresh = false;
|
||||
/** The codes the authenticator would accept. */
|
||||
const GOOD_CODE = "246810";
|
||||
let actionAnswer: () => Response = () => json(200, { data: {}, error: null });
|
||||
|
||||
beforeEach(() => {
|
||||
sent = [];
|
||||
fresh = false;
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => {
|
||||
const path = String(input);
|
||||
const method = (init?.method ?? "GET").toUpperCase();
|
||||
sent.push(`${method} ${path}`);
|
||||
if (path === "/api/auth/reproof") {
|
||||
const proof = JSON.parse(String(init?.body ?? "{}"));
|
||||
if (proof.method === "totp" && proof.code === GOOD_CODE) {
|
||||
fresh = true;
|
||||
return json(200, { data: { method: "totp" }, error: null });
|
||||
}
|
||||
return json(401, {
|
||||
data: null,
|
||||
error: "Verification failed",
|
||||
meta: { errorCode: "auth.reproof.failed" },
|
||||
});
|
||||
}
|
||||
return fresh ? actionAnswer() : json(401, STALE);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ── The dialog, as the flow drives it ───────────────────────────────────────
|
||||
|
||||
interface Dialog {
|
||||
flow: RecentProofFlow;
|
||||
/** What the dialog offered each time it opened. */
|
||||
offered: ReauthMethod[][];
|
||||
open: boolean;
|
||||
error: string | null;
|
||||
/** Resolves when the dialog next opens. */
|
||||
opened: () => Promise<void>;
|
||||
}
|
||||
|
||||
function dialog(): Dialog {
|
||||
let notify: (() => void) | null = null;
|
||||
const d: Dialog = {
|
||||
offered: [],
|
||||
open: false,
|
||||
error: null,
|
||||
opened: () =>
|
||||
new Promise<void>((resolve) => {
|
||||
if (d.open) resolve();
|
||||
else notify = resolve;
|
||||
}),
|
||||
flow: undefined as unknown as RecentProofFlow,
|
||||
};
|
||||
d.flow = createRecentProofFlow({
|
||||
view: {
|
||||
setMethods: (methods) => {
|
||||
d.open = methods !== null;
|
||||
if (methods) {
|
||||
d.offered.push(methods);
|
||||
notify?.();
|
||||
notify = null;
|
||||
}
|
||||
},
|
||||
setPending: () => {},
|
||||
setError: (error) => {
|
||||
d.error = error;
|
||||
},
|
||||
},
|
||||
// The same call `useRecentProof` makes.
|
||||
submit: (proof) => apiPost("/api/auth/reproof", proof),
|
||||
messages: () => ({ signInAgain: "sign in again", failed: "failed" }),
|
||||
});
|
||||
return d;
|
||||
}
|
||||
|
||||
const HANDOVER = "/api/managed-profiles/p1/handover";
|
||||
|
||||
function useHandover(d: Dialog) {
|
||||
return useCreateManagedProfileHandover(
|
||||
d.flow.run,
|
||||
) as unknown as MutationOptions<
|
||||
{ profileId: string; expiresInDays: 7; proposals: [] },
|
||||
{ url: string; qrDataUrl: string }
|
||||
>;
|
||||
}
|
||||
|
||||
describe("handing a profile over with a stale second factor", () => {
|
||||
beforeEach(() => {
|
||||
actionAnswer = () =>
|
||||
json(201, {
|
||||
data: {
|
||||
url: "https://example.test/claim#t",
|
||||
token: "t",
|
||||
expiresAt: "2026-10-24T00:00:00.000Z",
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("opens the dialog with the proofs the server named, then hands over", async () => {
|
||||
const d = dialog();
|
||||
const pending = useHandover(d).mutationFn({
|
||||
profileId: "p1",
|
||||
expiresInDays: 7,
|
||||
proposals: [],
|
||||
});
|
||||
|
||||
await d.opened();
|
||||
expect(d.offered).toEqual([["totp", "passkey"]]);
|
||||
await d.flow.prove({ method: "totp", code: GOOD_CODE });
|
||||
|
||||
const link = await pending;
|
||||
expect(link.url).toBe("https://example.test/claim#t");
|
||||
expect(d.open).toBe(false);
|
||||
expect(sent).toEqual([
|
||||
`POST ${HANDOVER}`,
|
||||
"POST /api/auth/reproof",
|
||||
`POST ${HANDOVER}`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the dialog open on a wrong code and retries only after a right one", async () => {
|
||||
const d = dialog();
|
||||
const pending = useHandover(d).mutationFn({
|
||||
profileId: "p1",
|
||||
expiresInDays: 7,
|
||||
proposals: [],
|
||||
});
|
||||
|
||||
await d.opened();
|
||||
await d.flow.prove({ method: "totp", code: "000000" });
|
||||
expect(d.open).toBe(true);
|
||||
expect(d.error).toBe("failed");
|
||||
expect(sent).toEqual([`POST ${HANDOVER}`, "POST /api/auth/reproof"]);
|
||||
|
||||
await d.flow.prove({ method: "totp", code: GOOD_CODE });
|
||||
await pending;
|
||||
expect(sent.filter((s) => s === `POST ${HANDOVER}`)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("sends nothing more when the dialog is closed", async () => {
|
||||
const d = dialog();
|
||||
const pending = useHandover(d).mutationFn({
|
||||
profileId: "p1",
|
||||
expiresInDays: 7,
|
||||
proposals: [],
|
||||
});
|
||||
|
||||
await d.opened();
|
||||
d.flow.cancel();
|
||||
|
||||
await expect(pending).rejects.toBeInstanceOf(ReproofCancelledError);
|
||||
expect(sent).toEqual([`POST ${HANDOVER}`]);
|
||||
});
|
||||
|
||||
it("asks once: a second refusal after the proof is the answer", async () => {
|
||||
actionAnswer = () => json(401, STALE);
|
||||
const d = dialog();
|
||||
const pending = useHandover(d).mutationFn({
|
||||
profileId: "p1",
|
||||
expiresInDays: 7,
|
||||
proposals: [],
|
||||
});
|
||||
|
||||
await d.opened();
|
||||
await d.flow.prove({ method: "totp", code: GOOD_CODE });
|
||||
|
||||
await expect(pending).rejects.toMatchObject({ status: 401 });
|
||||
expect(d.offered).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("opens no dialog for an account with no second factor, and says where to get one", async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce(
|
||||
json(401, {
|
||||
data: null,
|
||||
error: "Recent second-factor verification required",
|
||||
meta: { errorCode: "auth.stepup.mfa_not_enrolled" },
|
||||
}),
|
||||
);
|
||||
const d = dialog();
|
||||
const err = await useHandover(d)
|
||||
.mutationFn({ profileId: "p1", expiresInDays: 7, proposals: [] })
|
||||
.catch((e: unknown) => e);
|
||||
|
||||
expect(d.offered).toEqual([]);
|
||||
expect(err).toBeInstanceOf(ApiError);
|
||||
expect(stepUpErrorKey(err as ApiError)).toBe(
|
||||
"recordSharing.managed.errorMfaRequired",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("adding and removing a Guardian with a stale second factor", () => {
|
||||
beforeEach(() => {
|
||||
actionAnswer = () => json(201, { data: { id: "g2" }, error: null });
|
||||
});
|
||||
|
||||
it("adds the Guardian after the proof", async () => {
|
||||
const d = dialog();
|
||||
const invite = useInviteManagedProfileGuardian(
|
||||
d.flow.run,
|
||||
) as unknown as MutationOptions<
|
||||
{ profileId: string; identifier: string; expiresAt: null },
|
||||
unknown
|
||||
>;
|
||||
const pending = invite.mutationFn({
|
||||
profileId: "p1",
|
||||
identifier: "someone",
|
||||
expiresAt: null,
|
||||
});
|
||||
|
||||
await d.opened();
|
||||
expect(d.offered).toEqual([["totp", "passkey"]]);
|
||||
await d.flow.prove({ method: "totp", code: GOOD_CODE });
|
||||
await pending;
|
||||
|
||||
expect(sent).toEqual([
|
||||
"POST /api/managed-profiles/p1/guardians",
|
||||
"POST /api/auth/reproof",
|
||||
"POST /api/managed-profiles/p1/guardians",
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes the Guardian after the proof", async () => {
|
||||
actionAnswer = () => json(200, { data: { revoked: true }, error: null });
|
||||
const d = dialog();
|
||||
const remove = useRemoveManagedProfileGuardian(
|
||||
d.flow.run,
|
||||
) as unknown as MutationOptions<
|
||||
{ profileId: string; grantId: string },
|
||||
unknown
|
||||
>;
|
||||
const pending = remove.mutationFn({ profileId: "p1", grantId: "g2" });
|
||||
|
||||
await d.opened();
|
||||
await d.flow.prove({ method: "totp", code: GOOD_CODE });
|
||||
await pending;
|
||||
|
||||
expect(sent).toEqual([
|
||||
"DELETE /api/managed-profiles/p1/guardians/g2",
|
||||
"POST /api/auth/reproof",
|
||||
"DELETE /api/managed-profiles/p1/guardians/g2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes nothing when the dialog is closed", async () => {
|
||||
const d = dialog();
|
||||
const remove = useRemoveManagedProfileGuardian(
|
||||
d.flow.run,
|
||||
) as unknown as MutationOptions<
|
||||
{ profileId: string; grantId: string },
|
||||
unknown
|
||||
>;
|
||||
const pending = remove.mutationFn({ profileId: "p1", grantId: "g2" });
|
||||
|
||||
await d.opened();
|
||||
d.flow.cancel();
|
||||
await expect(pending).rejects.toBeInstanceOf(ReproofCancelledError);
|
||||
expect(sent).toEqual(["DELETE /api/managed-profiles/p1/guardians/g2"]);
|
||||
});
|
||||
});
|
||||
@@ -65,7 +65,7 @@ const STEP_UP_ACTIONS = new Set<GrantAction>([
|
||||
*
|
||||
* `requireFreshMfa` refuses for two different reasons and says which: a stale
|
||||
* proof (`auth.stepup.required`), which the person clears by proving their
|
||||
* factor, and NO factor at all (`auth.stepup.mfa_not_enrolled`), which they
|
||||
* factor in the re-proof dialog (`useRecentProof`), and NO factor at all (`auth.stepup.mfa_not_enrolled`), which they
|
||||
* cannot clear that way. Collapsing the two tells somebody with no second
|
||||
* factor to confirm one — a dead end offered by the very surface that owns
|
||||
* the route out of it.
|
||||
@@ -74,9 +74,19 @@ const STEP_UP_ACTIONS = new Set<GrantAction>([
|
||||
* invitation, and the two acts below) so the two sentences cannot come apart.
|
||||
*/
|
||||
export function stepUpErrorKey(err: ApiError): string {
|
||||
return err.meta?.errorCode === "auth.stepup.mfa_not_enrolled"
|
||||
? "recordSharing.managed.errorMfaRequired"
|
||||
: "recordSharing.managed.errorStepUp";
|
||||
switch (err.meta?.errorCode) {
|
||||
case "auth.stepup.mfa_not_enrolled":
|
||||
return "recordSharing.managed.errorMfaRequired";
|
||||
// The re-proof dialog had nothing to offer (an account that signs in
|
||||
// through an identity provider only); signing in again is the way through.
|
||||
case "auth.reproof.sign_in_again":
|
||||
return "settings.reproof.signInAgain";
|
||||
// A stale proof normally never gets here: every action in this family runs
|
||||
// through the re-proof dialog, which clears it and sends the request again.
|
||||
// This is the sentence for a refusal the dialog could not take up.
|
||||
default:
|
||||
return "recordSharing.managed.errorStepUp";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
GrantActionAlert,
|
||||
grantActionErrorKey,
|
||||
} from "@/components/settings/access/grant-action-error";
|
||||
import {
|
||||
ReproofCancelledError,
|
||||
useRecentProof,
|
||||
} from "@/components/settings/security-section/use-recent-proof";
|
||||
import { ManagedProfileCreateForm } from "@/components/settings/access/managed-profile-create-form";
|
||||
import { ManagedProfileEditForm } from "@/components/settings/access/managed-profile-edit-form";
|
||||
import { ManagedProfileGuardians } from "@/components/settings/access/managed-profile-guardians";
|
||||
@@ -69,11 +73,17 @@ import {
|
||||
export function ManagedProfileCard() {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const remove = useDeleteManagedProfile();
|
||||
// Ending a record is gated on a fresh second factor; the dialog collects it
|
||||
// and the deletion goes out once more.
|
||||
const recentProof = useRecentProof({
|
||||
description: t("settings.reproof.descriptionChange"),
|
||||
});
|
||||
const remove = useDeleteManagedProfile(recentProof.run);
|
||||
const profiles = managedProfilesOf(user?.accountAccess?.accounts);
|
||||
|
||||
return (
|
||||
<SettingsCard data-slot="managed-profile-card">
|
||||
{recentProof.dialog}
|
||||
<SettingsCardHeader
|
||||
anchor="managed-profiles"
|
||||
icon={UserRoundCog}
|
||||
@@ -131,7 +141,10 @@ function ManagedProfileRow({
|
||||
const { t, tCount } = useTranslations();
|
||||
const guardians = useManagedProfileGuardians(profile.accountId);
|
||||
const name = accountLabel(profile);
|
||||
const failed = remove.isError && remove.variables === profile.accountId;
|
||||
const failed =
|
||||
remove.isError &&
|
||||
!(remove.error instanceof ReproofCancelledError) &&
|
||||
remove.variables === profile.accountId;
|
||||
// Per row, not per card. The edit form reads the record's identity when it
|
||||
// opens, so one shared flag would put every row's form on the wire the
|
||||
// moment any of them was opened.
|
||||
|
||||
@@ -9,6 +9,10 @@ import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { TimezonePicker } from "@/components/settings/timezone-picker";
|
||||
import { stepUpErrorKey } from "@/components/settings/access/grant-action-error";
|
||||
import {
|
||||
ReproofCancelledError,
|
||||
useRecentProof,
|
||||
} from "@/components/settings/security-section/use-recent-proof";
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import { locales, localeLabels, type Locale } from "@/lib/i18n/config";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -88,7 +92,12 @@ export function ManagedProfileCreateForm({
|
||||
onCreated?: (profile: ManagedProfileView) => void;
|
||||
} = {}) {
|
||||
const { t, locale: actorLocale } = useTranslations();
|
||||
const create = useCreateManagedProfile();
|
||||
// Gated on a fresh second factor: the dialog collects it and the request
|
||||
// goes out once more. A closed dialog changed nothing and says nothing.
|
||||
const recentProof = useRecentProof({
|
||||
description: t("settings.reproof.descriptionChange"),
|
||||
});
|
||||
const create = useCreateManagedProfile(recentProof.run);
|
||||
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [dateOfBirth, setDateOfBirth] = useState("");
|
||||
@@ -135,165 +144,173 @@ export function ManagedProfileCreateForm({
|
||||
// person is being asked to retry, and a form that emptied itself
|
||||
// would make them type it all again to find out whether the second
|
||||
// attempt fails the same way.
|
||||
onError: (err) => setError(t(createManagedProfileErrorKey(err))),
|
||||
onError: (err) => {
|
||||
if (err instanceof ReproofCancelledError) return;
|
||||
setError(t(createManagedProfileErrorKey(err)));
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={submit}
|
||||
data-slot="managed-profile-create-form"
|
||||
className="space-y-3"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="managed-profile-name">
|
||||
{t("recordSharing.managed.nameLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="managed-profile-name"
|
||||
data-slot="managed-profile-name"
|
||||
value={displayName}
|
||||
onChange={(e) => {
|
||||
setDisplayName(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder={t("recordSharing.managed.namePlaceholder")}
|
||||
autoComplete="off"
|
||||
maxLength={80}
|
||||
aria-invalid={
|
||||
displayName.length > 0 && nameIssue !== null ? "true" : undefined
|
||||
}
|
||||
aria-describedby="managed-profile-name-hint"
|
||||
/>
|
||||
<FieldGuidance
|
||||
slot="managed-profile-name"
|
||||
hintId="managed-profile-name-hint"
|
||||
hint={t("recordSharing.managed.nameHint")}
|
||||
// Only once somebody has typed. "Give the record a name" on an empty
|
||||
// field they have not reached yet is a telling-off, not guidance.
|
||||
error={
|
||||
displayName.length > 0 && nameIssue !== null ? t(nameIssue) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
{/* Outside the form: the dialog's own form must not submit this one
|
||||
through the portal. */}
|
||||
{recentProof.dialog}
|
||||
<form
|
||||
onSubmit={submit}
|
||||
data-slot="managed-profile-create-form"
|
||||
className="space-y-3"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="managed-profile-name">
|
||||
{t("recordSharing.managed.nameLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="managed-profile-name"
|
||||
data-slot="managed-profile-name"
|
||||
value={displayName}
|
||||
onChange={(e) => {
|
||||
setDisplayName(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder={t("recordSharing.managed.namePlaceholder")}
|
||||
autoComplete="off"
|
||||
maxLength={80}
|
||||
aria-invalid={
|
||||
displayName.length > 0 && nameIssue !== null ? "true" : undefined
|
||||
}
|
||||
aria-describedby="managed-profile-name-hint"
|
||||
/>
|
||||
<FieldGuidance
|
||||
slot="managed-profile-name"
|
||||
hintId="managed-profile-name-hint"
|
||||
hint={t("recordSharing.managed.nameHint")}
|
||||
// Only once somebody has typed. "Give the record a name" on an empty
|
||||
// field they have not reached yet is a telling-off, not guidance.
|
||||
error={
|
||||
displayName.length > 0 && nameIssue !== null ? t(nameIssue) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="managed-profile-dob">
|
||||
{t("recordSharing.managed.dobLabel")}
|
||||
</Label>
|
||||
<DateField
|
||||
id="managed-profile-dob"
|
||||
data-slot="managed-profile-dob"
|
||||
data-testid="managed-profile-dob-input"
|
||||
value={dateOfBirth}
|
||||
onChange={setDateOfBirth}
|
||||
aria-describedby="managed-profile-dob-hint"
|
||||
/>
|
||||
<p
|
||||
id="managed-profile-dob-hint"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("recordSharing.managed.dobHint")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="managed-profile-dob">
|
||||
{t("recordSharing.managed.dobLabel")}
|
||||
</Label>
|
||||
<DateField
|
||||
id="managed-profile-dob"
|
||||
data-slot="managed-profile-dob"
|
||||
data-testid="managed-profile-dob-input"
|
||||
value={dateOfBirth}
|
||||
onChange={setDateOfBirth}
|
||||
aria-describedby="managed-profile-dob-hint"
|
||||
/>
|
||||
<p
|
||||
id="managed-profile-dob-hint"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("recordSharing.managed.dobHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="managed-profile-locale">
|
||||
{t("recordSharing.managed.localeLabel")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="managed-profile-locale"
|
||||
data-slot="managed-profile-locale"
|
||||
className="w-full"
|
||||
value={locale}
|
||||
onChange={(e) => setLocale(e.target.value as Locale)}
|
||||
>
|
||||
{locales.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{localeLabels[option]}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("recordSharing.managed.localeHint")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="managed-profile-locale">
|
||||
{t("recordSharing.managed.localeLabel")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="managed-profile-locale"
|
||||
data-slot="managed-profile-locale"
|
||||
className="w-full"
|
||||
value={locale}
|
||||
onChange={(e) => setLocale(e.target.value as Locale)}
|
||||
>
|
||||
{locales.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{localeLabels[option]}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("recordSharing.managed.localeHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="managed-profile-gender">
|
||||
{t("recordSharing.managed.genderLabel")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="managed-profile-gender"
|
||||
data-slot="managed-profile-gender"
|
||||
className="w-full"
|
||||
value={gender ?? ""}
|
||||
onChange={(e) => setGender(genderFromSelect(e.target.value))}
|
||||
>
|
||||
{MANAGED_PROFILE_GENDER_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{t(option.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("recordSharing.managed.genderHint")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="managed-profile-gender">
|
||||
{t("recordSharing.managed.genderLabel")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="managed-profile-gender"
|
||||
data-slot="managed-profile-gender"
|
||||
className="w-full"
|
||||
value={gender ?? ""}
|
||||
onChange={(e) => setGender(genderFromSelect(e.target.value))}
|
||||
>
|
||||
{MANAGED_PROFILE_GENDER_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{t(option.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("recordSharing.managed.genderHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<TimezonePicker
|
||||
id="managed-profile-timezone"
|
||||
value={timezone}
|
||||
onChange={(next) => {
|
||||
setTimezone(next);
|
||||
setError(null);
|
||||
}}
|
||||
hint={t("recordSharing.managed.timezoneHint")}
|
||||
/>
|
||||
{/* The picker's own list comes from the engine, so the select branch
|
||||
<div className="space-y-1.5">
|
||||
<TimezonePicker
|
||||
id="managed-profile-timezone"
|
||||
value={timezone}
|
||||
onChange={(next) => {
|
||||
setTimezone(next);
|
||||
setError(null);
|
||||
}}
|
||||
hint={t("recordSharing.managed.timezoneHint")}
|
||||
/>
|
||||
{/* The picker's own list comes from the engine, so the select branch
|
||||
cannot produce an unknown zone. Its free-text fallback (older
|
||||
engines without `Intl.supportedValuesOf`) can, and this sentence is
|
||||
then the ONLY explanation for a submit button that will not press.
|
||||
It rides beside the picker rather than through its `hint` prop,
|
||||
because that prop renders muted meta and a refusal rendered as meta
|
||||
is a refusal nobody reads. */}
|
||||
<FieldGuidance
|
||||
slot="managed-profile-timezone"
|
||||
error={timezoneIssue === null ? null : t(timezoneIssue)}
|
||||
/>
|
||||
</div>
|
||||
<FieldGuidance
|
||||
slot="managed-profile-timezone"
|
||||
error={timezoneIssue === null ? null : t(timezoneIssue)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
data-slot="managed-profile-create-submit"
|
||||
disabled={create.isPending || blocked}
|
||||
>
|
||||
{create.isPending
|
||||
? t("recordSharing.managed.creating")
|
||||
: (submitLabel ?? t("recordSharing.managed.create"))}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
data-slot="managed-profile-create-submit"
|
||||
disabled={create.isPending || blocked}
|
||||
>
|
||||
{create.isPending
|
||||
? t("recordSharing.managed.creating")
|
||||
: (submitLabel ?? t("recordSharing.managed.create"))}
|
||||
</Button>
|
||||
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
data-slot="managed-profile-create-error"
|
||||
className="text-destructive text-sm"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{created && (
|
||||
<p
|
||||
role="status"
|
||||
data-slot="managed-profile-created"
|
||||
className="text-sm"
|
||||
>
|
||||
{t("recordSharing.managed.created", { name: created })}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
data-slot="managed-profile-create-error"
|
||||
className="text-destructive text-sm"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{created && (
|
||||
<p
|
||||
role="status"
|
||||
data-slot="managed-profile-created"
|
||||
className="text-sm"
|
||||
>
|
||||
{t("recordSharing.managed.created", { name: created })}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
genderFromSelect,
|
||||
} from "@/components/settings/access/managed-profile-create-form";
|
||||
import { stepUpErrorKey } from "@/components/settings/access/grant-action-error";
|
||||
import {
|
||||
ReproofCancelledError,
|
||||
useRecentProof,
|
||||
} from "@/components/settings/security-section/use-recent-proof";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { TimezonePicker } from "@/components/settings/timezone-picker";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -124,7 +128,12 @@ function ManagedProfileEditFields({
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const { t, locale: actorLocale } = useTranslations();
|
||||
const update = useUpdateManagedProfile();
|
||||
// Gated on a fresh second factor: the dialog collects it and the request
|
||||
// goes out once more. A closed dialog changed nothing and says nothing.
|
||||
const recentProof = useRecentProof({
|
||||
description: t("settings.reproof.descriptionChange"),
|
||||
});
|
||||
const update = useUpdateManagedProfile(recentProof.run);
|
||||
|
||||
const [displayName, setDisplayName] = useState(profile.displayName ?? "");
|
||||
const [dateOfBirth, setDateOfBirth] = useState(profile.dateOfBirth ?? "");
|
||||
@@ -172,143 +181,151 @@ function ManagedProfileEditFields({
|
||||
// Nothing is reset: a refused save is one somebody is being asked to
|
||||
// retry, and a form that restored the server's values would throw the
|
||||
// edit away to explain that it did not land.
|
||||
onError: (err) => setError(t(editManagedProfileErrorKey(err))),
|
||||
onError: (err) => {
|
||||
if (err instanceof ReproofCancelledError) return;
|
||||
setError(t(editManagedProfileErrorKey(err)));
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={submit}
|
||||
data-slot="managed-profile-edit-form"
|
||||
className="space-y-3"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`managed-profile-edit-name-${profile.id}`}>
|
||||
{t("recordSharing.managed.nameLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id={`managed-profile-edit-name-${profile.id}`}
|
||||
data-slot="managed-profile-edit-name"
|
||||
value={displayName}
|
||||
onChange={(e) => {
|
||||
setDisplayName(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
autoComplete="off"
|
||||
maxLength={80}
|
||||
aria-invalid={nameIssue !== null ? "true" : undefined}
|
||||
/>
|
||||
<FieldGuidance
|
||||
slot="managed-profile-edit-name"
|
||||
error={nameIssue === null ? null : t(nameIssue)}
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
{/* Outside the form: the dialog's own form must not submit this one
|
||||
through the portal. */}
|
||||
{recentProof.dialog}
|
||||
<form
|
||||
onSubmit={submit}
|
||||
data-slot="managed-profile-edit-form"
|
||||
className="space-y-3"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`managed-profile-edit-name-${profile.id}`}>
|
||||
{t("recordSharing.managed.nameLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id={`managed-profile-edit-name-${profile.id}`}
|
||||
data-slot="managed-profile-edit-name"
|
||||
value={displayName}
|
||||
onChange={(e) => {
|
||||
setDisplayName(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
autoComplete="off"
|
||||
maxLength={80}
|
||||
aria-invalid={nameIssue !== null ? "true" : undefined}
|
||||
/>
|
||||
<FieldGuidance
|
||||
slot="managed-profile-edit-name"
|
||||
error={nameIssue === null ? null : t(nameIssue)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`managed-profile-edit-dob-${profile.id}`}>
|
||||
{t("recordSharing.managed.dobLabel")}
|
||||
</Label>
|
||||
<DateField
|
||||
id={`managed-profile-edit-dob-${profile.id}`}
|
||||
data-slot="managed-profile-edit-dob"
|
||||
value={dateOfBirth}
|
||||
onChange={setDateOfBirth}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`managed-profile-edit-dob-${profile.id}`}>
|
||||
{t("recordSharing.managed.dobLabel")}
|
||||
</Label>
|
||||
<DateField
|
||||
id={`managed-profile-edit-dob-${profile.id}`}
|
||||
data-slot="managed-profile-edit-dob"
|
||||
value={dateOfBirth}
|
||||
onChange={setDateOfBirth}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`managed-profile-edit-locale-${profile.id}`}>
|
||||
{t("recordSharing.managed.localeLabel")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id={`managed-profile-edit-locale-${profile.id}`}
|
||||
data-slot="managed-profile-edit-locale"
|
||||
className="w-full"
|
||||
value={locale}
|
||||
onChange={(e) => setLocale(e.target.value as Locale)}
|
||||
>
|
||||
{locales.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{localeLabels[option]}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`managed-profile-edit-locale-${profile.id}`}>
|
||||
{t("recordSharing.managed.localeLabel")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id={`managed-profile-edit-locale-${profile.id}`}
|
||||
data-slot="managed-profile-edit-locale"
|
||||
className="w-full"
|
||||
value={locale}
|
||||
onChange={(e) => setLocale(e.target.value as Locale)}
|
||||
>
|
||||
{locales.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{localeLabels[option]}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`managed-profile-edit-gender-${profile.id}`}>
|
||||
{t("recordSharing.managed.genderLabel")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id={`managed-profile-edit-gender-${profile.id}`}
|
||||
data-slot="managed-profile-edit-gender"
|
||||
className="w-full"
|
||||
value={gender ?? ""}
|
||||
onChange={(e) => setGender(genderFromSelect(e.target.value))}
|
||||
>
|
||||
{MANAGED_PROFILE_GENDER_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{t(option.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("recordSharing.managed.genderHint")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`managed-profile-edit-gender-${profile.id}`}>
|
||||
{t("recordSharing.managed.genderLabel")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id={`managed-profile-edit-gender-${profile.id}`}
|
||||
data-slot="managed-profile-edit-gender"
|
||||
className="w-full"
|
||||
value={gender ?? ""}
|
||||
onChange={(e) => setGender(genderFromSelect(e.target.value))}
|
||||
>
|
||||
{MANAGED_PROFILE_GENDER_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{t(option.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("recordSharing.managed.genderHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<TimezonePicker
|
||||
id={`managed-profile-edit-timezone-${profile.id}`}
|
||||
value={timezone}
|
||||
onChange={(next) => {
|
||||
setTimezone(next);
|
||||
setError(null);
|
||||
}}
|
||||
hint={t("recordSharing.managed.timezoneHint")}
|
||||
/>
|
||||
<FieldGuidance
|
||||
slot="managed-profile-edit-timezone"
|
||||
error={timezoneIssue === null ? null : t(timezoneIssue)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<TimezonePicker
|
||||
id={`managed-profile-edit-timezone-${profile.id}`}
|
||||
value={timezone}
|
||||
onChange={(next) => {
|
||||
setTimezone(next);
|
||||
setError(null);
|
||||
}}
|
||||
hint={t("recordSharing.managed.timezoneHint")}
|
||||
/>
|
||||
<FieldGuidance
|
||||
slot="managed-profile-edit-timezone"
|
||||
error={timezoneIssue === null ? null : t(timezoneIssue)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Above the action row, per UI-STANDARDS §12: nothing follows it. */}
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
data-slot="managed-profile-edit-error"
|
||||
className="text-destructive text-sm"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{/* Above the action row, per UI-STANDARDS §12: nothing follows it. */}
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
data-slot="managed-profile-edit-error"
|
||||
className="text-destructive text-sm"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="managed-profile-edit-cancel"
|
||||
onClick={onDone}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="managed-profile-edit-submit"
|
||||
disabled={blocked || update.isPending}
|
||||
>
|
||||
{update.isPending
|
||||
? t("recordSharing.managed.editSaving")
|
||||
: t("recordSharing.managed.editSave")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
</form>
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="managed-profile-edit-cancel"
|
||||
onClick={onDone}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="managed-profile-edit-submit"
|
||||
disabled={blocked || update.isPending}
|
||||
>
|
||||
{update.isPending
|
||||
? t("recordSharing.managed.editSaving")
|
||||
: t("recordSharing.managed.editSave")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
stepUpErrorKey,
|
||||
} from "@/components/settings/access/grant-action-error";
|
||||
import { endOfDayIso } from "@/components/settings/access/grant-invite-card";
|
||||
import {
|
||||
ReproofCancelledError,
|
||||
useRecentProof,
|
||||
} from "@/components/settings/security-section/use-recent-proof";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { DateField } from "@/components/ui/date-field";
|
||||
@@ -80,8 +84,14 @@ export function ManagedProfileGuardians({
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { user } = useAuth();
|
||||
const invite = useInviteManagedProfileGuardian();
|
||||
const remove = useRemoveManagedProfileGuardian();
|
||||
// Both acts are gated on a fresh second factor; the dialog collects it and
|
||||
// the request goes out once more. A closed dialog changed nothing and says
|
||||
// nothing.
|
||||
const recentProof = useRecentProof({
|
||||
description: t("settings.reproof.descriptionChange"),
|
||||
});
|
||||
const invite = useInviteManagedProfileGuardian(recentProof.run);
|
||||
const remove = useRemoveManagedProfileGuardian(recentProof.run);
|
||||
|
||||
const [identifier, setIdentifier] = useState("");
|
||||
const [expiresOn, setExpiresOn] = useState("");
|
||||
@@ -116,6 +126,7 @@ export function ManagedProfileGuardians({
|
||||
|
||||
return (
|
||||
<div data-slot="managed-profile-guardians" className="space-y-3">
|
||||
{recentProof.dialog}
|
||||
<h3 className="text-sm font-medium">
|
||||
{t("recordSharing.managed.guardiansTitle", { name: profileName })}
|
||||
</h3>
|
||||
@@ -129,7 +140,9 @@ export function ManagedProfileGuardians({
|
||||
// one is the caller's own — which the route refuses anyway.
|
||||
const removable = !isSelf;
|
||||
const failed =
|
||||
remove.isError && remove.variables?.grantId === guardian.grantId;
|
||||
remove.isError &&
|
||||
!(remove.error instanceof ReproofCancelledError) &&
|
||||
remove.variables?.grantId === guardian.grantId;
|
||||
return (
|
||||
<li
|
||||
key={guardian.grantId}
|
||||
@@ -269,7 +282,7 @@ export function ManagedProfileGuardians({
|
||||
? t("recordSharing.managed.guardianInviting")
|
||||
: t("recordSharing.managed.guardianInvite")}
|
||||
</Button>
|
||||
{invite.isError && (
|
||||
{invite.isError && !(invite.error instanceof ReproofCancelledError) && (
|
||||
<p
|
||||
role="alert"
|
||||
data-slot="managed-profile-guardian-error"
|
||||
|
||||
@@ -7,6 +7,10 @@ import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { stepUpErrorKey } from "@/components/settings/access/grant-action-error";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import {
|
||||
ReproofCancelledError,
|
||||
useRecentProof,
|
||||
} from "@/components/settings/security-section/use-recent-proof";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -65,7 +69,14 @@ export function ManagedProfileHandover({
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const status = useManagedProfileHandover(profileId);
|
||||
const create = useCreateManagedProfileHandover();
|
||||
// Minting the link is gated on a fresh second factor. The dialog asks for it
|
||||
// when the server says so and the mint is sent again, so a Guardian who
|
||||
// signed in more than five minutes ago is not left with a sentence telling
|
||||
// them to confirm a factor somewhere this panel does not offer.
|
||||
const recentProof = useRecentProof({
|
||||
description: t("settings.reproof.descriptionChange"),
|
||||
});
|
||||
const create = useCreateManagedProfileHandover(recentProof.run);
|
||||
const revoke = useRevokeManagedProfileHandover();
|
||||
const [replacing, setReplacing] = useState(false);
|
||||
|
||||
@@ -105,6 +116,7 @@ export function ManagedProfileHandover({
|
||||
})}
|
||||
className="space-y-4 rounded-lg border p-3"
|
||||
>
|
||||
{recentProof.dialog}
|
||||
<p className="text-sm">
|
||||
{t("recordSharing.managed.handover.description", {
|
||||
name: profileName,
|
||||
@@ -359,7 +371,7 @@ function HandoverProposalForm({
|
||||
{replacing && <p>{t("recordSharing.managed.handover.replaceNote")}</p>}
|
||||
</div>
|
||||
|
||||
{create.isError && (
|
||||
{create.isError && !(create.error instanceof ReproofCancelledError) && (
|
||||
<p
|
||||
role="alert"
|
||||
data-slot="managed-profile-handover-error"
|
||||
|
||||
@@ -21,6 +21,11 @@ import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
recentProofErrorMessage,
|
||||
throwIfReproofRequired,
|
||||
useRecentProof,
|
||||
} from "@/components/settings/security-section/use-recent-proof";
|
||||
import { loadDocument } from "@/lib/navigation/load-document";
|
||||
|
||||
/**
|
||||
@@ -57,6 +62,9 @@ export function AdvancedSection() {
|
||||
function DataResetCard() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const recentProof = useRecentProof({
|
||||
description: t("settings.reproof.descriptionChange"),
|
||||
});
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
@@ -68,11 +76,18 @@ function DataResetCard() {
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
try {
|
||||
const res = await apiFetchRaw("/api/settings/data", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ confirm: "DELETE" }),
|
||||
});
|
||||
// An account with a second factor wipes its record only with that
|
||||
// factor proved within five minutes; the shared dialog collects it when
|
||||
// the server asks and the wipe is sent again.
|
||||
const res = await recentProof.run(async () =>
|
||||
throwIfReproofRequired(
|
||||
await apiFetchRaw("/api/settings/data", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ confirm: "DELETE" }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
const json = await res.json();
|
||||
if (!res.ok) {
|
||||
setMsg(json.error || t("settings.dangerZoneDeleteFailed"));
|
||||
@@ -84,8 +99,13 @@ function DataResetCard() {
|
||||
setMsg(t("settings.dangerZoneSuccess"));
|
||||
setMsgType("success");
|
||||
setConfirmOpen(false);
|
||||
} catch {
|
||||
setMsg(t("settings.dangerZoneDeleteFailed"));
|
||||
} catch (err) {
|
||||
const message = recentProofErrorMessage(
|
||||
err,
|
||||
t("settings.dangerZoneDeleteFailed"),
|
||||
);
|
||||
if (message === null) return;
|
||||
setMsg(message);
|
||||
setMsgType("error");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
@@ -94,6 +114,7 @@ function DataResetCard() {
|
||||
|
||||
return (
|
||||
<SettingsCard data-slot="settings-data-reset-card">
|
||||
{recentProof.dialog}
|
||||
{/* v1.4.43 QoL (L5) — dropped the `AlertTriangle` icon and
|
||||
neutralised the title colour so the danger-zone shaping is
|
||||
GitHub-style (red CTA only) rather than red-on-red-on-red.
|
||||
@@ -193,6 +214,9 @@ function DataResetCard() {
|
||||
*/
|
||||
function AccountDeleteCard() {
|
||||
const { t } = useTranslations();
|
||||
const recentProof = useRecentProof({
|
||||
description: t("settings.reproof.descriptionChange"),
|
||||
});
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
@@ -204,11 +228,17 @@ function AccountDeleteCard() {
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
try {
|
||||
const res = await apiFetchRaw("/api/settings/account", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ confirm: "DELETE_ACCOUNT" }),
|
||||
});
|
||||
// Same gate as the wipe: a second factor proved within five minutes on
|
||||
// an account that has one, collected by the shared dialog.
|
||||
const res = await recentProof.run(async () =>
|
||||
throwIfReproofRequired(
|
||||
await apiFetchRaw("/api/settings/account", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ confirm: "DELETE_ACCOUNT" }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
if (!res.ok) {
|
||||
try {
|
||||
const json = await res.json();
|
||||
@@ -229,15 +259,21 @@ function AccountDeleteCard() {
|
||||
setTimeout(() => {
|
||||
loadDocument("/auth/login");
|
||||
}, 1_500);
|
||||
} catch {
|
||||
setMsg(t("settings.deleteAccountFailed"));
|
||||
setMsgType("error");
|
||||
} catch (err) {
|
||||
setDeleting(false);
|
||||
const message = recentProofErrorMessage(
|
||||
err,
|
||||
t("settings.deleteAccountFailed"),
|
||||
);
|
||||
if (message === null) return;
|
||||
setMsg(message);
|
||||
setMsgType("error");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard data-slot="settings-account-delete-card">
|
||||
{recentProof.dialog}
|
||||
<SettingsCardHeader
|
||||
anchor="delete-account"
|
||||
icon={Trash2}
|
||||
|
||||
@@ -16,6 +16,11 @@ import {
|
||||
} from "@/components/settings/account-section/account-section-utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
recentProofErrorMessage,
|
||||
throwIfReproofRequired,
|
||||
useRecentProof,
|
||||
} from "@/components/settings/security-section/use-recent-proof";
|
||||
|
||||
/**
|
||||
* Change-password card.
|
||||
@@ -40,6 +45,9 @@ export function PasswordCard() {
|
||||
const [msg, setMsg] = useState<StatusMessage | null>(null);
|
||||
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const recentProof = useRecentProof({
|
||||
description: t("settings.reproof.descriptionChange"),
|
||||
});
|
||||
|
||||
async function handleChangePassword(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -55,15 +63,23 @@ export function PasswordCard() {
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await apiFetchRaw("/api/auth/password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
currentPassword,
|
||||
newPassword,
|
||||
confirmPassword,
|
||||
}),
|
||||
// An account with a second factor changes its password only with that
|
||||
// factor proved within five minutes; the shared dialog collects it when
|
||||
// the server asks and the change is sent again.
|
||||
const body = JSON.stringify({
|
||||
currentPassword,
|
||||
newPassword,
|
||||
confirmPassword,
|
||||
});
|
||||
const res = await recentProof.run(async () =>
|
||||
throwIfReproofRequired(
|
||||
await apiFetchRaw("/api/auth/password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const json = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -79,8 +95,10 @@ export function PasswordCard() {
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
} catch {
|
||||
setMsg({ key: "common.networkError" });
|
||||
} catch (err) {
|
||||
const message = recentProofErrorMessage(err, "");
|
||||
if (message === null) return;
|
||||
setMsg(message ? { text: message } : { key: "common.networkError" });
|
||||
setMsgType("error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -89,6 +107,7 @@ export function PasswordCard() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{recentProof.dialog}
|
||||
{/* The action is this card's only one, so it sits in the card's action
|
||||
row like every other primary. It stays full-width under `sm` — the
|
||||
German "Passwort ändern" is long enough that a right-hugging button
|
||||
|
||||
@@ -15,11 +15,12 @@
|
||||
* 3. an ordinary failure still shows its own message rather than being
|
||||
* swallowed by the step-up sentence,
|
||||
* 4. the sentence resolves in more than one locale,
|
||||
* 5. the sentence names the recovery the web actually has. The mint endpoint
|
||||
* is Bearer-only by design, so there is no in-page re-proof dialog for a
|
||||
* cookie session; signing in again is the recovery, and a passkey login
|
||||
* stamps `mfaVerifiedAt`. If that ever changes, this assertion is the
|
||||
* reminder that the copy has to change with it.
|
||||
* 5. the sentence names a recovery that always exists. A refusal that names
|
||||
* its proofs (`meta.methods`) opens the re-proof dialog instead and never
|
||||
* reaches this sentence (`useRecentProof`, pinned by
|
||||
* `step-up-web-caller-guard.test.ts`); what is left is a refusal the
|
||||
* dialog cannot take up, and signing in again is the recovery there — a
|
||||
* passkey login stamps `mfaVerifiedAt`.
|
||||
*
|
||||
* Mutation check: return `err.message` from the removal's `onError` and cases 1,
|
||||
* 2 and 4 go red; narrow `describeStepUp` to the exact `auth.stepup.required`
|
||||
@@ -85,9 +86,9 @@ describe("passkey removal — the step-up refusal a person sees", () => {
|
||||
});
|
||||
|
||||
it("names the recovery the web actually has", () => {
|
||||
// Not "verify to continue": there is no in-page re-proof for a cookie
|
||||
// session, because the elevation mint is Bearer-only by design. Signing in
|
||||
// again is the recovery, and a passkey login stamps the session.
|
||||
// The dialog handles every refusal that names its proofs; this sentence
|
||||
// is for the rest, where signing in again is the recovery and a passkey
|
||||
// login stamps the session.
|
||||
expect(STEP_UP_SENTENCE.toLowerCase()).toContain("sign in again");
|
||||
});
|
||||
|
||||
|
||||
@@ -56,6 +56,22 @@ const REAUTH_METHODS: readonly ReauthMethod[] = [
|
||||
* none. On an account with a second factor the server leaves the password out,
|
||||
* so the dialog does not offer a choice that can only be refused.
|
||||
*/
|
||||
/**
|
||||
* The refusal says the account holds nothing it could re-prove here: an
|
||||
* account that signs in through an identity provider only, with no password,
|
||||
* passkey or second factor. A dialog would only offer it a password it does
|
||||
* not have, so the callers say "sign in again" instead — a fresh sign-in is
|
||||
* the proof such an account can give (`Session.createdAt` within five
|
||||
* minutes).
|
||||
*/
|
||||
export function nothingToReprove(err: unknown): boolean {
|
||||
return (
|
||||
err instanceof ApiError &&
|
||||
Array.isArray(err.meta?.methods) &&
|
||||
err.meta.methods.length === 0
|
||||
);
|
||||
}
|
||||
|
||||
export function offeredReauthMethods(err: unknown): ReauthMethod[] | null {
|
||||
if (!(err instanceof ApiError)) return null;
|
||||
const raw = err.meta?.methods;
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
} from "@/lib/api/api-fetch";
|
||||
import { describePasskeyError } from "@/lib/passkey-errors";
|
||||
import { describeStepUp } from "./security-keys-card";
|
||||
import { ReproofCancelledError, useRecentProof } from "./use-recent-proof";
|
||||
|
||||
interface PasskeyInfo {
|
||||
id: string;
|
||||
@@ -164,22 +165,30 @@ export function PasskeyListSection({
|
||||
onError: (err: Error) => setMsg(err.message),
|
||||
});
|
||||
|
||||
// Removal is step-up gated: a second factor, or on a passkey-only account a
|
||||
// passkey, proved within five minutes. The server names what clears it and
|
||||
// the shared dialog collects that, then the removal runs again.
|
||||
const recentProof = useRecentProof({
|
||||
description: t("settings.reproof.descriptionChange"),
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await apiDelete(`/api/auth/passkeys/${id}`);
|
||||
await recentProof.run(() => apiDelete(`/api/auth/passkeys/${id}`));
|
||||
},
|
||||
onSuccess: () => {
|
||||
setMsg(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() });
|
||||
},
|
||||
// Removal is step-up gated. The server's own prose says "second-factor",
|
||||
// which is wrong for the passkey-only account this card mostly serves — the
|
||||
// credential to re-prove is the passkey — so the refusal gets its own
|
||||
// sentence naming the recovery that exists on the web: sign in again.
|
||||
onError: (err: Error) =>
|
||||
// A refusal the dialog could not take up keeps its own sentence. The
|
||||
// server's prose says "second-factor", which is wrong for the passkey-only
|
||||
// account this card mostly serves, so it names the recovery that always
|
||||
// exists on the web: sign in again.
|
||||
onError: (err: Error) => {
|
||||
if (err instanceof ReproofCancelledError) return;
|
||||
setMsg(
|
||||
describeStepUp(err, err.message, t("settings.passkeyStepUpRequired")),
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const DEVICE_TYPE_LABELS: Record<string, string> = {
|
||||
@@ -189,6 +198,7 @@ export function PasskeyListSection({
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
{recentProof.dialog}
|
||||
<SettingsCardHeader
|
||||
anchor="passkeys"
|
||||
icon={Fingerprint}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
isReproofRequired,
|
||||
type ExistingFactorProof,
|
||||
type ReauthMethod,
|
||||
} from "./existing-factor-reauth-dialog";
|
||||
|
||||
/**
|
||||
* The re-proof flow without React: catch the refusal, ask for a proof, post
|
||||
* it, run the action once more. `useRecentProof` binds it to component state
|
||||
* and the dialog; the unit suite drives it directly, because this repository's
|
||||
* component tests run without a browser DOM and the flow is what matters.
|
||||
*/
|
||||
|
||||
const KNOWN_METHODS: readonly ReauthMethod[] = [
|
||||
"password",
|
||||
"totp",
|
||||
"webauthn",
|
||||
"passkey",
|
||||
];
|
||||
|
||||
/** The proofs a 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),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A refusal the re-proof dialog can clear.
|
||||
*
|
||||
* Two gates answer this way. The recent-proof gate (`auth.reproof.required`)
|
||||
* in front of exports, share links, tokens, invitations and the admin data
|
||||
* actions; and the fresh-factor gate (`auth.stepup.required`, from
|
||||
* `requireFreshMfa`) in front of the managed-profile acts, the second-factor
|
||||
* changes, passkey removal, the password change, account deletion, the data
|
||||
* wipe and the key rotation. The second one names the proofs it would take in
|
||||
* `meta.methods` on a browser session, and a `POST /api/auth/reproof` with one
|
||||
* of them stamps the session the way signing in with the factor does, so the
|
||||
* same dialog clears both. A fresh-factor refusal without `methods` (an
|
||||
* account with no factor at all says `auth.stepup.mfa_not_enrolled` instead)
|
||||
* is not something a dialog can answer, and stays with the caller's own
|
||||
* message.
|
||||
*/
|
||||
export function isProofRefusal(err: unknown): boolean {
|
||||
if (isReproofRequired(err)) return true;
|
||||
return (
|
||||
err instanceof ApiError &&
|
||||
err.status === 401 &&
|
||||
err.meta?.errorCode === "auth.stepup.required" &&
|
||||
methodsFrom(err).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
/** The person closed the re-proof dialog. Nothing ran; show nothing. */
|
||||
export class ReproofCancelledError extends Error {
|
||||
constructor() {
|
||||
super("Re-proof cancelled");
|
||||
this.name = "ReproofCancelledError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface RecentProofFlowView {
|
||||
/** Open the dialog offering these proofs, or close it with `null`. */
|
||||
setMethods: (methods: ReauthMethod[] | null) => void;
|
||||
setPending: (pending: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
}
|
||||
|
||||
export interface RecentProofFlow {
|
||||
run: <T>(action: () => Promise<T>) => Promise<T>;
|
||||
/** The dialog collected a proof: post it and release the waiting action. */
|
||||
prove: (proof: ExistingFactorProof) => Promise<void>;
|
||||
/** The dialog was closed without a proof. */
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
export function createRecentProofFlow(deps: {
|
||||
view: RecentProofFlowView;
|
||||
/** Posts the proof to `/api/auth/reproof`. */
|
||||
submit: (proof: ExistingFactorProof) => Promise<unknown>;
|
||||
/** Read at the moment they are needed, so a locale switch is honoured. */
|
||||
messages: () => { signInAgain: string; failed: string };
|
||||
}): RecentProofFlow {
|
||||
let waiter: { resolve: () => void; reject: () => void } | null = null;
|
||||
|
||||
const run = async <T>(action: () => Promise<T>): Promise<T> => {
|
||||
try {
|
||||
return await action();
|
||||
} catch (err) {
|
||||
if (!isProofRefusal(err)) throw err;
|
||||
const offered = methodsFrom(err as ApiError);
|
||||
// Nothing to re-prove in place: an account that signs in through an
|
||||
// identity provider only. A fresh sign-in is the proof it can give.
|
||||
if (offered.length === 0) {
|
||||
throw new ApiError(deps.messages().signInAgain, 401, {
|
||||
errorCode: "auth.reproof.sign_in_again",
|
||||
});
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
waiter = {
|
||||
resolve,
|
||||
reject: () => reject(new ReproofCancelledError()),
|
||||
};
|
||||
deps.view.setError(null);
|
||||
deps.view.setMethods(offered);
|
||||
});
|
||||
// Once. A second refusal is the answer, not a reason to ask again.
|
||||
return action();
|
||||
}
|
||||
};
|
||||
|
||||
const prove = async (proof: ExistingFactorProof): Promise<void> => {
|
||||
deps.view.setPending(true);
|
||||
deps.view.setError(null);
|
||||
try {
|
||||
await deps.submit(proof);
|
||||
deps.view.setMethods(null);
|
||||
const w = waiter;
|
||||
waiter = null;
|
||||
w?.resolve();
|
||||
} catch (err) {
|
||||
deps.view.setError(
|
||||
err instanceof ApiError && err.status === 429
|
||||
? err.message
|
||||
: deps.messages().failed,
|
||||
);
|
||||
} finally {
|
||||
deps.view.setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
deps.view.setMethods(null);
|
||||
deps.view.setError(null);
|
||||
const w = waiter;
|
||||
waiter = null;
|
||||
w?.reject();
|
||||
};
|
||||
|
||||
return { run, prove, cancel };
|
||||
}
|
||||
@@ -37,10 +37,12 @@ import { formatDate } from "@/lib/format";
|
||||
import {
|
||||
ExistingFactorReauthDialog,
|
||||
isReproofRequired,
|
||||
nothingToReprove,
|
||||
offeredReauthMethods,
|
||||
type ExistingFactorProof,
|
||||
type ReauthMethod,
|
||||
} from "./existing-factor-reauth-dialog";
|
||||
import { ReproofCancelledError, useRecentProof } from "./use-recent-proof";
|
||||
|
||||
export interface WebauthnKeyInfo {
|
||||
id: string;
|
||||
@@ -133,6 +135,11 @@ export function SecurityKeysCard({
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.mfaStatus() });
|
||||
},
|
||||
onError: (err, proof) => {
|
||||
if (isReproofRequired(err) && nothingToReprove(err)) {
|
||||
setReauthOpen(false);
|
||||
setError(t("settings.reproof.signInAgain"));
|
||||
return;
|
||||
}
|
||||
if (isReproofRequired(err)) {
|
||||
setOfferedMethods(offeredReauthMethods(err));
|
||||
setError(null);
|
||||
@@ -172,26 +179,35 @@ export function SecurityKeysCard({
|
||||
),
|
||||
});
|
||||
|
||||
// Removing a key needs the second factor proved within five minutes; the
|
||||
// shared dialog collects it when the server asks, then the removal runs
|
||||
// again.
|
||||
const recentProof = useRecentProof({
|
||||
description: t("settings.reproof.descriptionChange"),
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await apiDelete(`/api/auth/me/mfa/webauthn/${id}`);
|
||||
await recentProof.run(() => apiDelete(`/api/auth/me/mfa/webauthn/${id}`));
|
||||
},
|
||||
onSuccess: () => {
|
||||
setError(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.mfaStatus() });
|
||||
},
|
||||
onError: (err) =>
|
||||
onError: (err) => {
|
||||
if (err instanceof ReproofCancelledError) return;
|
||||
setError(
|
||||
describeStepUp(
|
||||
err,
|
||||
t("settings.security.keys.removeFailed"),
|
||||
t("settings.security.stepUpRequired"),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
{recentProof.dialog}
|
||||
<SettingsCardHeader
|
||||
anchor="security-keys"
|
||||
icon={Usb}
|
||||
|
||||
@@ -31,11 +31,17 @@ import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { ApiError, apiPost } from "@/lib/api/api-fetch";
|
||||
import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { RecoveryCodesPanel } from "./recovery-codes-panel";
|
||||
import {
|
||||
ReproofCancelledError,
|
||||
recentProofErrorMessage,
|
||||
useRecentProof,
|
||||
} from "./use-recent-proof";
|
||||
import {
|
||||
ExistingFactorReauthDialog,
|
||||
isReproofRequired,
|
||||
nothingToReprove,
|
||||
offeredReauthMethods,
|
||||
type ExistingFactorProof,
|
||||
type ReauthMethod,
|
||||
@@ -117,6 +123,33 @@ export function TotpCard({
|
||||
const [regenerateOpen, setRegenerateOpen] = useState(false);
|
||||
const [disableOpen, setDisableOpen] = useState(false);
|
||||
|
||||
// Disabling the factor and rotating the recovery codes both need the second
|
||||
// factor proved within five minutes. When it is older, the server names the
|
||||
// proofs that clear it and the shared dialog collects one, then the action
|
||||
// runs again.
|
||||
const recentProof = useRecentProof({
|
||||
description: t("settings.reproof.descriptionChange"),
|
||||
});
|
||||
|
||||
// Disabling is asked BEFORE the confirmation opens, not after its code was
|
||||
// typed: the route checks the session's proof before it reads the code, and
|
||||
// a code typed first and then spent on the re-proof would be refused as a
|
||||
// replay when the disable is sent again.
|
||||
async function openDisable() {
|
||||
setError(null);
|
||||
try {
|
||||
await recentProof.run(() => apiGet("/api/auth/reproof"));
|
||||
} catch (err) {
|
||||
const message = recentProofErrorMessage(
|
||||
err,
|
||||
t("settings.security.totp.disableFailed"),
|
||||
);
|
||||
if (message) setError(message);
|
||||
return;
|
||||
}
|
||||
setDisableOpen(true);
|
||||
}
|
||||
|
||||
function resetWizard() {
|
||||
setSetup(null);
|
||||
setQrDataUrl(null);
|
||||
@@ -154,6 +187,11 @@ export function TotpCard({
|
||||
}
|
||||
},
|
||||
onError: (err, proof) => {
|
||||
if (isReproofRequired(err) && nothingToReprove(err)) {
|
||||
setReauthOpen(false);
|
||||
setError(t("settings.reproof.signInAgain"));
|
||||
return;
|
||||
}
|
||||
if (isReproofRequired(err)) {
|
||||
const offered = offeredReauthMethods(err);
|
||||
if (offered) setReauthMethods(offered);
|
||||
@@ -194,31 +232,37 @@ export function TotpCard({
|
||||
|
||||
const disable = useMutation({
|
||||
mutationFn: async () => {
|
||||
await apiPost("/api/auth/me/mfa/disable", {
|
||||
code: disableCode,
|
||||
method: disableMethod,
|
||||
});
|
||||
await recentProof.run(() =>
|
||||
apiPost("/api/auth/me/mfa/disable", {
|
||||
code: disableCode,
|
||||
method: disableMethod,
|
||||
}),
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setDisableCode("");
|
||||
setFreshCodes(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.mfaStatus() });
|
||||
},
|
||||
onError: (err) =>
|
||||
onError: (err) => {
|
||||
if (err instanceof ReproofCancelledError) return;
|
||||
setError(
|
||||
describeError(
|
||||
err,
|
||||
t("settings.security.totp.disableFailed"),
|
||||
t("settings.security.stepUpRequired"),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
onSettled: () => setDisableOpen(false),
|
||||
});
|
||||
|
||||
const regenerate = useMutation({
|
||||
mutationFn: async () => {
|
||||
return apiPost<{ recoveryCodes: string[] }>(
|
||||
"/api/auth/me/mfa/recovery-codes/regenerate",
|
||||
return recentProof.run(() =>
|
||||
apiPost<{ recoveryCodes: string[] }>(
|
||||
"/api/auth/me/mfa/recovery-codes/regenerate",
|
||||
),
|
||||
);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
@@ -226,14 +270,16 @@ export function TotpCard({
|
||||
setFreshCodes(data.recoveryCodes);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.mfaStatus() });
|
||||
},
|
||||
onError: (err) =>
|
||||
onError: (err) => {
|
||||
if (err instanceof ReproofCancelledError) return;
|
||||
setError(
|
||||
describeError(
|
||||
err,
|
||||
t("settings.security.recovery.regenFailed"),
|
||||
t("settings.security.stepUpRequired"),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
onSettled: () => setRegenerateOpen(false),
|
||||
});
|
||||
|
||||
@@ -250,6 +296,7 @@ export function TotpCard({
|
||||
|
||||
return (
|
||||
<SettingsCard data-testid="totp-card">
|
||||
{recentProof.dialog}
|
||||
<SettingsCardHeader
|
||||
anchor="two-factor"
|
||||
icon={Smartphone}
|
||||
@@ -471,7 +518,13 @@ export function TotpCard({
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog open={disableOpen} onOpenChange={setDisableOpen}>
|
||||
<AlertDialog
|
||||
open={disableOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (open) void openDisable();
|
||||
else setDisableOpen(false);
|
||||
}}
|
||||
>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -1,39 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState, type ReactNode } from "react";
|
||||
import { useMemo, 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";
|
||||
import {
|
||||
ReproofCancelledError,
|
||||
createRecentProofFlow,
|
||||
isProofRefusal,
|
||||
} from "./recent-proof-flow";
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
export { ReproofCancelledError };
|
||||
|
||||
/**
|
||||
* The sentence to show for an error `run` rejected with, or null when there is
|
||||
@@ -76,7 +57,7 @@ export async function throwIfReproofRequired(res: Response): Promise<Response> {
|
||||
401,
|
||||
meta,
|
||||
);
|
||||
if (isReproofRequired(err)) throw err;
|
||||
if (isProofRefusal(err)) throw err;
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -85,8 +66,10 @@ export async function throwIfReproofRequired(res: Response): Promise<Response> {
|
||||
*
|
||||
* 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,
|
||||
* session has not signed in or re-proved within five minutes; the
|
||||
* fresh-factor routes answer 401 `auth.stepup.required` with `meta.methods`
|
||||
* when the second factor is older than that (`isProofRefusal`). `run` catches
|
||||
* exactly those refusals, 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.
|
||||
@@ -95,7 +78,16 @@ export async function throwIfReproofRequired(res: Response): Promise<Response> {
|
||||
* sign-on only) gets no dialog: the refusal is rethrown with a message asking
|
||||
* to sign in again.
|
||||
*/
|
||||
export function useRecentProof(): {
|
||||
export function useRecentProof(
|
||||
options: {
|
||||
/**
|
||||
* The dialog's one line on why it asks. Defaults to the sentence for
|
||||
* sharing and exporting; the fresh-factor acts (changing who looks after a
|
||||
* record, a second factor, the account itself) pass their own.
|
||||
*/
|
||||
description?: string;
|
||||
} = {},
|
||||
): {
|
||||
run: <T>(action: () => Promise<T>) => Promise<T>;
|
||||
dialog: ReactNode;
|
||||
} {
|
||||
@@ -103,57 +95,18 @@ export function useRecentProof(): {
|
||||
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);
|
||||
}
|
||||
},
|
||||
// One flow per locale, as the callbacks it replaces were (`[t]`): it holds
|
||||
// the waiting action, so it is not rebuilt on every render.
|
||||
const { run, prove, cancel } = useMemo(
|
||||
() =>
|
||||
createRecentProofFlow({
|
||||
view: { setMethods, setPending, setError },
|
||||
submit: (proof) => apiPost("/api/auth/reproof", proof),
|
||||
messages: () => ({
|
||||
signInAgain: t("settings.reproof.signInAgain"),
|
||||
failed: t("settings.reproof.failed"),
|
||||
}),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
@@ -162,18 +115,13 @@ export function useRecentProof(): {
|
||||
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();
|
||||
if (!open) cancel();
|
||||
}}
|
||||
methods={methods ?? ["password"]}
|
||||
pending={pending}
|
||||
error={error}
|
||||
onProof={(proof) => void onProof(proof)}
|
||||
description={t("settings.reproof.description")}
|
||||
onProof={(proof) => void prove(proof)}
|
||||
description={options.description ?? t("settings.reproof.description")}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ vi.mock("@/lib/db", () => ({
|
||||
apiToken: { findUnique: vi.fn(), update: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
webauthnMfaCredential: { count: vi.fn() },
|
||||
// The stale refusal names the proofs the account holds, passkeys among them.
|
||||
passkey: { count: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ vi.mock("@/lib/db", () => ({
|
||||
apiToken: { findUnique: vi.fn(), update: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
webauthnMfaCredential: { count: vi.fn() },
|
||||
// The stale refusal names the proofs the account holds, passkeys among them.
|
||||
passkey: { count: vi.fn() },
|
||||
stepUpElevation: { findUnique: vi.fn() },
|
||||
$queryRaw: vi.fn(),
|
||||
},
|
||||
|
||||
@@ -82,6 +82,46 @@ describe("requireFreshMfa", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The stale refusal names what clears it, so the browser can open the
|
||||
* re-proof dialog instead of printing a sentence (#959): the possession
|
||||
* proofs the account holds, never a password, which stamps `reproofAt` only
|
||||
* and would leave this gate shut.
|
||||
*/
|
||||
it("names the possession proofs on a stale refusal", async () => {
|
||||
mockSession({ ...MFA_USER, passwordHash: "argon2-hash" });
|
||||
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(1 as never);
|
||||
vi.mocked(prisma.passkey.count).mockResolvedValue(1 as never);
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
||||
mfaVerifiedAt: new Date(Date.now() - 10 * 60_000),
|
||||
} as never);
|
||||
|
||||
await expect(requireFreshMfa(5 * 60)).rejects.toMatchObject({
|
||||
errorCode: "auth.stepup.required",
|
||||
extraMeta: { methods: ["totp", "webauthn", "passkey"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("offers a passkey-only account its passkey, not its password", async () => {
|
||||
mockSession({
|
||||
...MFA_USER,
|
||||
totpConfirmedAt: null,
|
||||
passwordHash: "argon2-hash",
|
||||
});
|
||||
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
|
||||
vi.mocked(prisma.passkey.count).mockResolvedValue(1 as never);
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
||||
mfaVerifiedAt: null,
|
||||
} as never);
|
||||
|
||||
await expect(
|
||||
requireFreshMfa(5 * 60, "any-possession"),
|
||||
).rejects.toMatchObject({
|
||||
errorCode: "auth.stepup.required",
|
||||
extraMeta: { methods: ["passkey"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an absent verification stamp", async () => {
|
||||
mockSession(MFA_USER);
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
||||
|
||||
+14
-1
@@ -1321,7 +1321,20 @@ export async function requireFreshMfa(
|
||||
});
|
||||
const verifiedAt = row?.mfaVerifiedAt ?? null;
|
||||
if (!verifiedAt || verifiedAt.getTime() < Date.now() - maxAgeSeconds * 1000) {
|
||||
throw new StepUpRequiredError();
|
||||
// Name the proofs that clear this gate, the way the recent-proof gate
|
||||
// does, so the browser can open the re-proof dialog instead of printing a
|
||||
// sentence with nowhere to act on it. Only the possession proofs: a
|
||||
// `POST /api/auth/reproof` with one of them stamps `mfaVerifiedAt`, a
|
||||
// password stamps `reproofAt` only and would leave this gate shut.
|
||||
const methods = (await recentProofMethods(sessionData.user)).filter(
|
||||
(method) => method !== "password",
|
||||
);
|
||||
annotate({ meta: { step_up_methods: methods.join(",") } });
|
||||
throw new StepUpRequiredError(
|
||||
"auth.stepup.required",
|
||||
"Recent second-factor verification required",
|
||||
{ methods },
|
||||
);
|
||||
}
|
||||
|
||||
return { ...sessionData, authMethod: "cookie", mfaVerifiedAt: verifiedAt };
|
||||
|
||||
@@ -1089,7 +1089,7 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
"401": {
|
||||
description:
|
||||
"No fresh second-factor proof (`meta.errorCode: auth.stepup.required`), or none enrolled at all (`meta.errorCode: auth.stepup.mfa_not_enrolled`) — this gate is unconditional, so an account without a second factor cannot create a managed profile until it enrols one. The ordinary unauthenticated 401 arrives here too, including for a Bearer caller, whose transport this gate does not read.",
|
||||
"No fresh second-factor proof (`meta.errorCode: auth.stepup.required`, with `meta.methods` naming the proofs that clear it at POST /api/auth/reproof), or none enrolled at all (`meta.errorCode: auth.stepup.mfa_not_enrolled`) — this gate is unconditional, so an account without a second factor cannot create a managed profile until it enrols one. The ordinary unauthenticated 401 arrives here too, including for a Bearer caller, whose transport this gate does not read.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"429": {
|
||||
|
||||
@@ -1099,7 +1099,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Auth"],
|
||||
summary: "Re-prove a credential on a browser session (cookie only)",
|
||||
description:
|
||||
"Stamps the calling browser session as recently re-proved, so for the next five minutes it may run the actions that ask for a fresh proof: GET /api/export/full-backup, POST /api/export/encrypted, POST /api/share-links, POST /api/mcp/tokens, POST /api/tokens/measurements, POST /api/tokens/workouts, POST /api/tokens/documents, and the admin backup download, upload and restore, the admin data wipe and the admin password reset. Those answer 401 `auth.reproof.required` with `meta.methods` (the proofs this account can give) when the session signed in or re-proved more than five minutes ago.\n\n" +
|
||||
"Stamps the calling browser session as recently re-proved, so for the next five minutes it may run the actions that ask for a fresh proof: GET /api/export/full-backup, POST /api/export/encrypted, POST /api/share-links, POST /api/mcp/tokens, POST /api/tokens/measurements, POST /api/tokens/workouts, POST /api/tokens/documents, and the admin backup download, upload and restore, the admin data wipe and the admin password reset. Those answer 401 `auth.reproof.required` with `meta.methods` (the proofs this account can give) when the session signed in or re-proved more than five minutes ago. A `totp`, `webauthn` or `passkey` proof here also clears the routes that ask for a fresh second factor (the managed-profile acts, the second-factor and passkey changes, the password change, account deletion, the data wipe and key rotation): on a browser session those answer 401 `auth.stepup.required` with `meta.methods` naming the possession proofs the account holds, and the web re-proves here and retries.\n\n" +
|
||||
'The body takes the same shapes as POST /api/auth/step-up. On an account with a second factor only `totp`, `webauthn` (a security key) or `passkey` are accepted, because a password has never stood in for the second factor; on one without, `password` or `passkey`. A method the account cannot use here is refused with 422 `auth.reproof.too_weak` and `meta.methods`, before any guess is spent. A second-factor or passkey proof also refreshes the session\'s second-factor stamp, exactly as signing in again would. Assertions begin at POST /api/auth/passkey/register-options with `{ method: "passkey" | "webauthn" }`.\n\n' +
|
||||
"Proofs draw on the account's shared re-proof budget (five per fifteen minutes, 429 once spent). A proof that does not verify is 401 `auth.reproof.failed` and is audited. Bearer callers use POST /api/auth/step-up instead.",
|
||||
requestBody: {
|
||||
|
||||
@@ -116,7 +116,9 @@ describe("what a managed-profile change refreshes", () => {
|
||||
|
||||
describe("the creation hook, driven for real", () => {
|
||||
it("posts the four fields the strict schema accepts, and no others", async () => {
|
||||
const options = useCreateManagedProfile() as unknown as {
|
||||
const options = useCreateManagedProfile((action) =>
|
||||
action(),
|
||||
) as unknown as {
|
||||
mutationFn: (input: unknown) => Promise<unknown>;
|
||||
onSuccess: () => void;
|
||||
};
|
||||
@@ -147,7 +149,9 @@ describe("the creation hook, driven for real", () => {
|
||||
});
|
||||
|
||||
it("puts the new profile in the switcher by refreshing the payload", () => {
|
||||
const options = useCreateManagedProfile() as unknown as {
|
||||
const options = useCreateManagedProfile((action) =>
|
||||
action(),
|
||||
) as unknown as {
|
||||
onSuccess: () => void;
|
||||
};
|
||||
options.onSuccess();
|
||||
|
||||
@@ -108,21 +108,34 @@ function useInvalidateManagedProfiles() {
|
||||
return () => invalidateManagedProfileReads(queryClient);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a request that the server may hold back for a fresh proof — in a
|
||||
* component, `useRecentProof().run`.
|
||||
*
|
||||
* Every write in this family resolves `requireFreshMfa`, whose 401
|
||||
* `auth.stepup.required` names the proofs that clear it in `meta.methods`.
|
||||
* The guard opens the re-proof dialog on that answer, posts the proof and
|
||||
* sends the request once more, so the mutation settles with the real outcome
|
||||
* instead of a sentence telling somebody to confirm a factor with nowhere to
|
||||
* do it. The argument is required rather than defaulted: a caller that forgot
|
||||
* it would compile, render the refusal as text, and be the defect again.
|
||||
*/
|
||||
export type ProofGuard = <T>(action: () => Promise<T>) => Promise<T>;
|
||||
|
||||
/**
|
||||
* Create a record somebody looks after.
|
||||
*
|
||||
* `requireFreshMfa` guards the route, so a 401 carrying
|
||||
* `meta.errorCode: auth.stepup.required` is the EXPECTED first answer for
|
||||
* anybody who has a second factor and has not proved it recently. The form
|
||||
* routes that into the app's re-verification path rather than into a generic
|
||||
* failure — see `managed-profile-create-form.tsx`.
|
||||
* anybody who has a second factor and has not proved it recently. `guard`
|
||||
* turns it into the re-proof dialog and a second attempt.
|
||||
*/
|
||||
export function useCreateManagedProfile() {
|
||||
export function useCreateManagedProfile(guard: ProofGuard) {
|
||||
const invalidate = useInvalidateManagedProfiles();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.managedProfileCreate(),
|
||||
mutationFn: (input: CreateManagedProfileInput) =>
|
||||
apiPost<ManagedProfileView>("/api/managed-profiles", input),
|
||||
guard(() => apiPost<ManagedProfileView>("/api/managed-profiles", input)),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
@@ -151,21 +164,23 @@ export function useManagedProfile(profileId: string, enabled = true) {
|
||||
*
|
||||
* Guarded by `requireFreshMfa` like every other act in this family, so a 401
|
||||
* carrying `meta.errorCode: auth.stepup.required` is the expected first answer
|
||||
* rather than a failure — the form routes it into the re-verification path.
|
||||
* rather than a failure — `guard` routes it into the re-proof dialog.
|
||||
*
|
||||
* The whole family is invalidated on success because the display name is what
|
||||
* the switcher, the record banner and this card all identify the record by, and
|
||||
* the record's own identity read is dropped alongside so a second edit opens
|
||||
* from the server's version rather than from the one that was just replaced.
|
||||
*/
|
||||
export function useUpdateManagedProfile() {
|
||||
export function useUpdateManagedProfile(guard: ProofGuard) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.managedProfileUpdate(),
|
||||
mutationFn: (input: UpdateManagedProfileInput) =>
|
||||
apiPatch<ManagedProfileView>(
|
||||
`/api/managed-profiles/${input.profileId}`,
|
||||
managedProfileEditBody(input),
|
||||
guard(() =>
|
||||
apiPatch<ManagedProfileView>(
|
||||
`/api/managed-profiles/${input.profileId}`,
|
||||
managedProfileEditBody(input),
|
||||
),
|
||||
),
|
||||
onSuccess: (_result, input) => {
|
||||
invalidateManagedProfileReads(queryClient);
|
||||
@@ -250,12 +265,14 @@ export function useManagedProfileGuardians(profileId: string, enabled = true) {
|
||||
* floor. A Guardian may not strand a record with nobody looking after it, but
|
||||
* they may end the record.
|
||||
*/
|
||||
export function useDeleteManagedProfile() {
|
||||
export function useDeleteManagedProfile(guard: ProofGuard) {
|
||||
const invalidate = useInvalidateManagedProfiles();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.managedProfileDelete(),
|
||||
mutationFn: (profileId: string) =>
|
||||
apiDelete<{ deleted: true }>(`/api/managed-profiles/${profileId}`),
|
||||
guard(() =>
|
||||
apiDelete<{ deleted: true }>(`/api/managed-profiles/${profileId}`),
|
||||
),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
@@ -285,14 +302,16 @@ export interface InviteManagedProfileGuardianInput {
|
||||
* here accepts on their behalf, and until they accept they do not count toward
|
||||
* the last-Guardian floor.
|
||||
*/
|
||||
export function useInviteManagedProfileGuardian() {
|
||||
export function useInviteManagedProfileGuardian(guard: ProofGuard) {
|
||||
const invalidate = useInvalidateManagedProfiles();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.managedProfileGuardianInvite(),
|
||||
mutationFn: (input: InviteManagedProfileGuardianInput) =>
|
||||
apiPost(
|
||||
`/api/managed-profiles/${input.profileId}/guardians`,
|
||||
guardianInviteBody(input),
|
||||
guard(() =>
|
||||
apiPost(
|
||||
`/api/managed-profiles/${input.profileId}/guardians`,
|
||||
guardianInviteBody(input),
|
||||
),
|
||||
),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
@@ -328,7 +347,7 @@ export function guardianInviteBody(input: InviteManagedProfileGuardianInput): {
|
||||
* `variables` carries both ids so a refusal can be rendered on the guardian row
|
||||
* it belongs to rather than on the panel.
|
||||
*/
|
||||
export function useRemoveManagedProfileGuardian() {
|
||||
export function useRemoveManagedProfileGuardian(guard: ProofGuard) {
|
||||
const invalidate = useInvalidateManagedProfiles();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.managedProfileGuardianRemove(),
|
||||
@@ -338,7 +357,10 @@ export function useRemoveManagedProfileGuardian() {
|
||||
}: {
|
||||
profileId: string;
|
||||
grantId: string;
|
||||
}) => apiDelete(`/api/managed-profiles/${profileId}/guardians/${grantId}`),
|
||||
}) =>
|
||||
guard(() =>
|
||||
apiDelete(`/api/managed-profiles/${profileId}/guardians/${grantId}`),
|
||||
),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
@@ -409,19 +431,22 @@ export function useManagedProfileHandover(profileId: string, enabled = true) {
|
||||
|
||||
/**
|
||||
* Mint the handover link. Step-up gated like every act in this family, so a
|
||||
* 401 carrying `auth.stepup.required` is the expected first answer.
|
||||
* 401 carrying `auth.stepup.required` is the expected first answer, and
|
||||
* `guard` turns it into the re-proof dialog and a second attempt.
|
||||
*
|
||||
* The link lives in the mutation's `data` and nowhere else: it is shown once
|
||||
* and never cached under a query key.
|
||||
*/
|
||||
export function useCreateManagedProfileHandover() {
|
||||
export function useCreateManagedProfileHandover(guard: ProofGuard) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.managedProfileHandoverCreate(),
|
||||
mutationFn: async (input: CreateManagedProfileHandoverInput) => {
|
||||
const link = await apiPost<ManagedProfileHandoverLink>(
|
||||
`/api/managed-profiles/${input.profileId}/handover`,
|
||||
handoverCreateBody(input),
|
||||
const link = await guard(() =>
|
||||
apiPost<ManagedProfileHandoverLink>(
|
||||
`/api/managed-profiles/${input.profileId}/handover`,
|
||||
handoverCreateBody(input),
|
||||
),
|
||||
);
|
||||
// Lazy chunk, as the admin invite and the share link do: the QR encoder
|
||||
// is needed here, at mint time, and nowhere else.
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
/**
|
||||
* #959 — a Guardian who signs in through an identity provider, with a second
|
||||
* factor enrolled in the app, hands a managed profile over after the
|
||||
* five-minute window has passed. The report: "I get the error message that I
|
||||
* need to confirm my second factor, but there is nowhere to do so."
|
||||
*
|
||||
* Driven end to end against real Postgres and a mock identity provider that
|
||||
* speaks the wire (discovery, JWKS, a PKCE-checking token endpoint, ES256 ID
|
||||
* tokens), the same harness as `managed-profile-handover-sso.test.ts`:
|
||||
*
|
||||
* 1. the real OIDC login and callback, which puts an account with a second
|
||||
* factor through the app's own MFA challenge;
|
||||
* 2. the real `/api/auth/mfa/verify`, which stamps the session;
|
||||
* 3. ten minutes later the handover refuses with `auth.stepup.required` AND
|
||||
* names the proofs that clear it, which is what lets the browser open the
|
||||
* re-proof dialog instead of printing a sentence;
|
||||
* 4. `/api/auth/reproof` with the second factor, exactly what the dialog
|
||||
* posts, and the handover goes through.
|
||||
*
|
||||
* And the other half of the question: a Guardian whose only factor is the one
|
||||
* the identity provider asked for. The app cannot see that factor, so the gate
|
||||
* asks for one of its own (`mfa_not_enrolled`), and the way through is
|
||||
* reachable: enrolling right after a sign-in needs no further proof, and the
|
||||
* confirmation stamps the session, so the handover follows.
|
||||
*/
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { createHash } from "node:crypto";
|
||||
import type { AddressInfo } from "node:net";
|
||||
|
||||
import { exportJWK, generateKeyPair, SignJWT, type CryptoKey } from "jose";
|
||||
import * as OTPAuth from "otpauth";
|
||||
import { NextRequest } from "next/server";
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import { createManagedProfile } from "@/lib/managed-profiles/create";
|
||||
import { _resetOidcCacheForTests } from "@/lib/auth/oidc";
|
||||
import { handoverCreateBody } from "@/lib/queries/use-managed-profiles";
|
||||
|
||||
process.env.API_TOKEN_HMAC_KEY ??=
|
||||
"test-hmac-key-handover-stepup-sso-32-bytes-minimum-123456";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => cookieJar.set(name, value),
|
||||
delete: (name: string) => cookieJar.delete(name),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/notifications/dispatch-localised", () => ({
|
||||
dispatchLocalisedNotification: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
const APP = "http://localhost:3000";
|
||||
const CLIENT_ID = "healthlog-test";
|
||||
const CLIENT_SECRET = "client-secret-for-the-mock-idp";
|
||||
const MINUTE = 60 * 1000;
|
||||
|
||||
// ── The mock identity provider ──────────────────────────────────────────────
|
||||
|
||||
interface Grant {
|
||||
sub: string;
|
||||
email: string;
|
||||
nonce: string;
|
||||
codeChallenge: string;
|
||||
}
|
||||
|
||||
let idp: Server;
|
||||
let ISSUER = "";
|
||||
let privateKey: CryptoKey;
|
||||
let publicJwk: Record<string, unknown>;
|
||||
const grants = new Map<string, Grant>();
|
||||
|
||||
function s256(verifier: string): string {
|
||||
return createHash("sha256").update(verifier).digest("base64url");
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const pair = await generateKeyPair("ES256");
|
||||
privateKey = pair.privateKey;
|
||||
publicJwk = { ...(await exportJWK(pair.publicKey)), kid: "k1", alg: "ES256" };
|
||||
|
||||
idp = createServer(async (req, res) => {
|
||||
const json = (status: number, body: unknown) => {
|
||||
res.writeHead(status, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify(body));
|
||||
};
|
||||
const path = new URL(req.url ?? "/", ISSUER).pathname;
|
||||
if (path === "/.well-known/openid-configuration") {
|
||||
return json(200, {
|
||||
issuer: ISSUER,
|
||||
authorization_endpoint: `${ISSUER}/authorize`,
|
||||
token_endpoint: `${ISSUER}/token`,
|
||||
jwks_uri: `${ISSUER}/jwks`,
|
||||
});
|
||||
}
|
||||
if (path === "/jwks") return json(200, { keys: [publicJwk] });
|
||||
if (path === "/token" && req.method === "POST") {
|
||||
let raw = "";
|
||||
for await (const chunk of req) raw += chunk;
|
||||
const form = new URLSearchParams(raw);
|
||||
const grant = grants.get(form.get("code") ?? "");
|
||||
if (
|
||||
!grant ||
|
||||
form.get("client_id") !== CLIENT_ID ||
|
||||
form.get("client_secret") !== CLIENT_SECRET ||
|
||||
s256(form.get("code_verifier") ?? "") !== grant.codeChallenge
|
||||
) {
|
||||
return json(400, { error: "invalid_grant" });
|
||||
}
|
||||
grants.delete(form.get("code") ?? "");
|
||||
const idToken = await new SignJWT({
|
||||
nonce: grant.nonce,
|
||||
email: grant.email,
|
||||
email_verified: true,
|
||||
})
|
||||
.setProtectedHeader({ alg: "ES256", kid: "k1" })
|
||||
.setIssuer(ISSUER)
|
||||
.setAudience(CLIENT_ID)
|
||||
.setSubject(grant.sub)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("5m")
|
||||
.sign(privateKey);
|
||||
return json(200, { id_token: idToken, token_type: "Bearer" });
|
||||
}
|
||||
json(404, { error: "not_found" });
|
||||
});
|
||||
await new Promise<void>((resolve) => idp.listen(0, "127.0.0.1", resolve));
|
||||
ISSUER = `http://127.0.0.1:${(idp.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => idp.close(() => resolve()));
|
||||
});
|
||||
|
||||
// ── Signing in through the provider ─────────────────────────────────────────
|
||||
|
||||
type NextResponseLike = Response & {
|
||||
cookies?: { get(name: string): { value: string } | undefined };
|
||||
};
|
||||
|
||||
function setCookie(response: Response, name: string): string | undefined {
|
||||
return (response as NextResponseLike).cookies?.get(name)?.value || undefined;
|
||||
}
|
||||
|
||||
let sequence = 0;
|
||||
|
||||
/**
|
||||
* The browser's trip: the login route, the provider, the callback. Returns the
|
||||
* callback's answer; the session (when one is minted) lands in the cookie jar.
|
||||
*/
|
||||
async function signInThroughProvider(identity: {
|
||||
sub: string;
|
||||
email: string;
|
||||
}): Promise<Response> {
|
||||
const { GET: login } = await import("@/app/api/auth/oidc/login/route");
|
||||
const started = await login(new NextRequest(`${APP}/api/auth/oidc/login`));
|
||||
const location = new URL(started.headers.get("location") ?? "");
|
||||
expect(location.origin + location.pathname).toBe(`${ISSUER}/authorize`);
|
||||
const stateCookie = setCookie(started, "oidc_auth_state");
|
||||
expect(stateCookie).toBeDefined();
|
||||
|
||||
const code = `code-${sequence++}`;
|
||||
grants.set(code, {
|
||||
...identity,
|
||||
nonce: location.searchParams.get("nonce") ?? "",
|
||||
codeChallenge: location.searchParams.get("code_challenge") ?? "",
|
||||
});
|
||||
const state = location.searchParams.get("state") ?? "";
|
||||
|
||||
const { GET: callback } = await import("@/app/api/auth/oidc/callback/route");
|
||||
return callback(
|
||||
new NextRequest(
|
||||
`${APP}/api/auth/oidc/callback?code=${code}&state=${encodeURIComponent(state)}`,
|
||||
{ headers: { cookie: `oidc_auth_state=${stateCookie}` } },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function post(path: string, body?: unknown): NextRequest {
|
||||
return new NextRequest(`${APP}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body ?? {}),
|
||||
});
|
||||
}
|
||||
|
||||
type RouteFn = (request: NextRequest) => Promise<Response>;
|
||||
|
||||
async function mfaVerify(mfaTicket: string, code: string) {
|
||||
const { POST } = await import("@/app/api/auth/mfa/verify/route");
|
||||
return (POST as unknown as RouteFn)(
|
||||
post("/api/auth/mfa/verify", { mfaTicket, method: "totp", code }),
|
||||
);
|
||||
}
|
||||
|
||||
async function reproof(body: unknown) {
|
||||
const { POST } = await import("@/app/api/auth/reproof/route");
|
||||
return (POST as unknown as RouteFn)(post("/api/auth/reproof", body));
|
||||
}
|
||||
|
||||
async function handover(profileId: string) {
|
||||
const { POST } =
|
||||
await import("@/app/api/managed-profiles/[id]/handover/route");
|
||||
return POST(
|
||||
post(
|
||||
`/api/managed-profiles/${profileId}/handover`,
|
||||
handoverCreateBody({ profileId, expiresInDays: 7, proposals: [] }),
|
||||
),
|
||||
{ params: Promise.resolve({ id: profileId }) },
|
||||
);
|
||||
}
|
||||
|
||||
async function totpSetup() {
|
||||
const { POST } = await import("@/app/api/auth/me/mfa/totp/setup/route");
|
||||
return (POST as unknown as RouteFn)(post("/api/auth/me/mfa/totp/setup"));
|
||||
}
|
||||
|
||||
async function totpConfirm(code: string) {
|
||||
const { POST } = await import("@/app/api/auth/me/mfa/totp/confirm/route");
|
||||
return (POST as unknown as RouteFn)(
|
||||
post("/api/auth/me/mfa/totp/confirm", { code }),
|
||||
);
|
||||
}
|
||||
|
||||
async function meta(res: Response) {
|
||||
return (
|
||||
(await res.json()) as {
|
||||
meta?: { errorCode?: string; methods?: string[] };
|
||||
}
|
||||
).meta;
|
||||
}
|
||||
|
||||
function codeFor(secret: string): string {
|
||||
return new OTPAuth.TOTP({
|
||||
issuer: "HealthLog",
|
||||
label: "HealthLog",
|
||||
algorithm: "SHA1",
|
||||
digits: 6,
|
||||
period: 30,
|
||||
secret: OTPAuth.Secret.fromBase32(secret),
|
||||
}).generate({ timestamp: Date.now() });
|
||||
}
|
||||
|
||||
// ── The household ───────────────────────────────────────────────────────────
|
||||
|
||||
/** A Guardian bound to the provider's subject, and the profile they look after. */
|
||||
async function guardianWithProfile(withTotp: boolean) {
|
||||
const prisma = getPrismaClient();
|
||||
const suffix = sequence++;
|
||||
let secret: string | null = null;
|
||||
let totp = {};
|
||||
if (withTotp) {
|
||||
const { generateTotpSecret } = await import("@/lib/auth/mfa/totp");
|
||||
const { encrypt } = await import("@/lib/crypto");
|
||||
secret = generateTotpSecret();
|
||||
totp = {
|
||||
totpSecretEncrypted: encrypt(secret),
|
||||
totpConfirmedAt: new Date(),
|
||||
totpLastStep: null,
|
||||
};
|
||||
}
|
||||
const guardian = await prisma.user.create({
|
||||
data: {
|
||||
username: `guardian-${suffix}`,
|
||||
email: `guardian-${suffix}@example.test`,
|
||||
oidcIssuer: ISSUER,
|
||||
oidcSub: `subject-guardian-${suffix}`,
|
||||
onboardingCompletedAt: new Date(),
|
||||
...totp,
|
||||
},
|
||||
});
|
||||
const { profile } = await createManagedProfile({
|
||||
creatorId: guardian.id,
|
||||
displayName: "Robin",
|
||||
dateOfBirth: null,
|
||||
locale: "en",
|
||||
timezone: "UTC",
|
||||
});
|
||||
return {
|
||||
guardian,
|
||||
profileId: profile.id,
|
||||
secret,
|
||||
identity: { sub: guardian.oidcSub!, email: guardian.email! },
|
||||
};
|
||||
}
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────────────────────────
|
||||
|
||||
let clock = 0;
|
||||
|
||||
/** Move the wall clock the app reads, as time passing would. */
|
||||
function advance(ms: number) {
|
||||
clock += ms;
|
||||
vi.setSystemTime(clock);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
grants.clear();
|
||||
_resetOidcCacheForTests();
|
||||
process.env.APP_URL = APP;
|
||||
process.env.NEXT_PUBLIC_APP_URL = APP;
|
||||
process.env.OIDC_ISSUER_URL = ISSUER;
|
||||
process.env.OIDC_CLIENT_ID = CLIENT_ID;
|
||||
process.env.OIDC_CLIENT_SECRET = CLIENT_SECRET;
|
||||
process.env.OIDC_BUTTON_LABEL = "Example ID";
|
||||
// Single sign-on only: the report came from such a server, and it is the
|
||||
// strictest case (no password to fall back on anywhere).
|
||||
process.env.OIDC_ONLY = "true";
|
||||
// Only `Date` is faked: the provider round trip and Postgres keep real
|
||||
// timers, and the codes generated below follow the moved clock.
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
clock = Date.now();
|
||||
vi.setSystemTime(clock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
for (const key of [
|
||||
"OIDC_ISSUER_URL",
|
||||
"OIDC_CLIENT_ID",
|
||||
"OIDC_CLIENT_SECRET",
|
||||
"OIDC_BUTTON_LABEL",
|
||||
"OIDC_ONLY",
|
||||
]) {
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
|
||||
// ── The tests ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("a Guardian signed in through SSO with a second factor in the app", () => {
|
||||
it("confirms the factor when asked, then hands the profile over", async () => {
|
||||
const { guardian, profileId, secret, identity } =
|
||||
await guardianWithProfile(true);
|
||||
|
||||
// The callback puts the account through the app's own challenge.
|
||||
const cb = await signInThroughProvider(identity);
|
||||
expect([302, 303, 307]).toContain(cb.status);
|
||||
const handoff = setCookie(cb, "oidc_mfa_handoff");
|
||||
expect(handoff).toBeDefined();
|
||||
const { ticket, methods } = JSON.parse(decodeURIComponent(handoff!)) as {
|
||||
ticket: string;
|
||||
methods: string[];
|
||||
};
|
||||
expect(methods).toContain("totp");
|
||||
expect(cookieJar.get("healthlog_session")).toBeUndefined();
|
||||
|
||||
const verified = await mfaVerify(ticket, codeFor(secret!));
|
||||
expect(verified.status).toBe(200);
|
||||
const sessionId = cookieJar.get("healthlog_session");
|
||||
expect(sessionId).toBeDefined();
|
||||
const session = await getPrismaClient().session.findFirstOrThrow({
|
||||
where: { userId: guardian.id },
|
||||
select: { mfaVerifiedAt: true },
|
||||
});
|
||||
expect(session.mfaVerifiedAt).not.toBeNull();
|
||||
|
||||
// Ten minutes on, the factor is stale. The refusal is the gate, and it
|
||||
// names what clears it: the TOTP factor this account holds.
|
||||
advance(10 * MINUTE);
|
||||
const refused = await handover(profileId);
|
||||
expect(refused.status).toBe(401);
|
||||
expect(await meta(refused)).toMatchObject({
|
||||
errorCode: "auth.stepup.required",
|
||||
methods: ["totp"],
|
||||
});
|
||||
expect(
|
||||
await getPrismaClient().managedProfileHandover.count({
|
||||
where: { profileId },
|
||||
}),
|
||||
).toBe(0);
|
||||
|
||||
// What the dialog posts.
|
||||
const proved = await reproof({ method: "totp", code: codeFor(secret!) });
|
||||
expect(proved.status).toBe(200);
|
||||
|
||||
// And the request is sent once more.
|
||||
const minted = await handover(profileId);
|
||||
expect(minted.status).toBe(201);
|
||||
const link = (await minted.json()).data as { url: string };
|
||||
expect(link.url).toContain("/claim");
|
||||
expect(
|
||||
await getPrismaClient().managedProfileHandover.count({
|
||||
where: { profileId },
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("does not let the provider's sign-in stand in for the factor", async () => {
|
||||
// The re-proof dialog never offers "sign in again" to this account: it
|
||||
// holds a factor, and the gate names it. A fresh SSO sign-in still goes
|
||||
// through the factor, so it is no shortcut either.
|
||||
const { profileId, secret, identity } = await guardianWithProfile(true);
|
||||
const cb = await signInThroughProvider(identity);
|
||||
const { ticket } = JSON.parse(
|
||||
decodeURIComponent(setCookie(cb, "oidc_mfa_handoff")!),
|
||||
) as { ticket: string };
|
||||
expect((await mfaVerify(ticket, codeFor(secret!))).status).toBe(200);
|
||||
advance(10 * MINUTE);
|
||||
|
||||
const wrong = await reproof({ method: "totp", code: "000000" });
|
||||
expect(wrong.status).toBe(401);
|
||||
expect((await handover(profileId)).status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a Guardian whose only second factor is the identity provider's", () => {
|
||||
it("is asked for an app factor, can enrol one at once, and then hands over", async () => {
|
||||
const { guardian, profileId, identity } = await guardianWithProfile(false);
|
||||
|
||||
const cb = await signInThroughProvider(identity);
|
||||
expect([302, 303, 307]).toContain(cb.status);
|
||||
expect(cookieJar.get("healthlog_session")).toBeDefined();
|
||||
|
||||
// The app cannot see the provider's factor, so the gate asks for one of
|
||||
// its own — and says so with its own code, which the web turns into
|
||||
// "set one up under Settings → Security" rather than a dialog.
|
||||
const refused = await handover(profileId);
|
||||
expect(refused.status).toBe(401);
|
||||
expect((await meta(refused))?.errorCode).toBe(
|
||||
"auth.stepup.mfa_not_enrolled",
|
||||
);
|
||||
|
||||
// Right after signing in, enrolment needs no further proof.
|
||||
const setup = await totpSetup();
|
||||
expect(setup.status).toBe(200);
|
||||
const { totpSecret } = (await setup.json()).data as { totpSecret: string };
|
||||
const confirmed = await totpConfirm(codeFor(totpSecret));
|
||||
expect(confirmed.status).toBe(200);
|
||||
|
||||
// The confirmation stamped the session, so the handover goes through.
|
||||
const minted = await handover(profileId);
|
||||
expect(minted.status).toBe(201);
|
||||
const row = await getPrismaClient().user.findUniqueOrThrow({
|
||||
where: { id: guardian.id },
|
||||
select: { totpConfirmedAt: true },
|
||||
});
|
||||
expect(row.totpConfirmedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it("is told to sign in again when the sign-in is too old to enrol from", async () => {
|
||||
const { identity } = await guardianWithProfile(false);
|
||||
await signInThroughProvider(identity);
|
||||
advance(10 * MINUTE);
|
||||
|
||||
// Nothing to re-prove in place: no password, no passkey, no factor. The
|
||||
// empty list is what makes the web say "sign in again" instead of
|
||||
// offering a password this account does not have.
|
||||
const refused = await totpSetup();
|
||||
expect(refused.status).toBe(401);
|
||||
expect(await meta(refused)).toMatchObject({
|
||||
errorCode: "auth.reproof.required",
|
||||
methods: [],
|
||||
});
|
||||
|
||||
// And signing in again is a way through.
|
||||
cookieJar.clear();
|
||||
await signInThroughProvider(identity);
|
||||
expect((await totpSetup()).status).toBe(200);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user