From 0692017f345a90b56e83d573b16b4c86e22d4b2c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 26 Sep 2026 22:15:10 +0200
Subject: [PATCH 1/9] fix(auth): adding a credential beside a second factor
needs that factor
On an account with a second factor, TOTP setup, security-key registration
and passkey registration no longer accept the password, a password re-proof
or a session younger than five minutes. A remembered browser skips the
second factor at sign-in and still creates a fresh session, so the password
alone could add a key that then satisfies every gate the factor protected.
Only a TOTP code, a security key, a passkey or a completed second factor
within five minutes opens enrollment there; the Bearer arm of TOTP setup and
security-key registration asks the same of its elevation. Accounts without
a second factor keep the previous rules.
The refusal names the proofs the account can give, and the enrollment
dialogs offer exactly those. A proof that verifies now gives its attempt
back to the shared re-proof budget, including the current password on a
password change.
---
src/app/api/auth/me/mfa/totp/setup/route.ts | 8 +-
.../me/mfa/webauthn/register/options/route.ts | 7 +-
.../auth/passkey/register-options/route.ts | 26 ++
.../api/auth/password/__tests__/route.test.ts | 6 +-
src/app/api/auth/password/route.ts | 5 +
src/app/api/auth/reproof/route.ts | 3 +
.../existing-factor-reauth-dialog.tsx | 22 ++
.../security-section/security-keys-card.tsx | 12 +-
.../settings/security-section/totp-card.tsx | 11 +-
.../__tests__/require-recent-proof.test.ts | 47 +++-
src/lib/api-handler.ts | 84 ++-----
.../enrollment-proof-second-factor.test.ts | 228 ++++++++++++++++++
src/lib/auth/existing-factor-proof.ts | 82 ++++++-
src/lib/auth/second-factor.ts | 66 +++++
14 files changed, 519 insertions(+), 88 deletions(-)
create mode 100644 src/lib/auth/__tests__/enrollment-proof-second-factor.test.ts
create mode 100644 src/lib/auth/second-factor.ts
diff --git a/src/app/api/auth/me/mfa/totp/setup/route.ts b/src/app/api/auth/me/mfa/totp/setup/route.ts
index e1ac624ea..9c94d919e 100644
--- a/src/app/api/auth/me/mfa/totp/setup/route.ts
+++ b/src/app/api/auth/me/mfa/totp/setup/route.ts
@@ -16,8 +16,10 @@
* factor that later satisfies every step-up gate, so a stolen session must not
* be able to mint one: the cookie arm also passes `checkCookieEnrollmentProof`
* — a sign-in or second factor inside the last five minutes, or a password /
- * factor proof in the body (the step-up mint's shapes). The Bearer arm already
- * carries that proof in its elevation. The recovery-code batch is
+ * factor proof in the body (the step-up mint's shapes); on an account that
+ * already has a second factor, only that factor or a passkey. The Bearer arm
+ * carries the proof in its elevation, under the same rule: on an account with
+ * a second factor the elevation must come from one. The recovery-code batch is
* issued at `/confirm` (after the factor is proven), not here, so an abandoned
* setup never persists codes.
*/
@@ -41,7 +43,7 @@ const SETUP_RATE_LIMIT = 5;
const SETUP_WINDOW_MS = 15 * 60 * 1000;
export const POST = apiHandler(async (req: Request) => {
- const auth = await requireMfaManagementAuth();
+ const auth = await requireMfaManagementAuth({ freshFactorIfEnrolled: true });
const { user } = auth;
const rl = await checkRateLimit(
diff --git a/src/app/api/auth/me/mfa/webauthn/register/options/route.ts b/src/app/api/auth/me/mfa/webauthn/register/options/route.ts
index 998c2f3b5..ff09e4f79 100644
--- a/src/app/api/auth/me/mfa/webauthn/register/options/route.ts
+++ b/src/app/api/auth/me/mfa/webauthn/register/options/route.ts
@@ -8,7 +8,10 @@
*
* The cookie arm additionally passes `checkCookieEnrollmentProof`: a session
* alone cannot add a security key, for the same reason it cannot enroll TOTP —
- * the new key would satisfy every step-up gate from then on.
+ * the new key would satisfy every step-up gate from then on. On an account that
+ * already has a second factor, both arms want that factor (or a passkey): a
+ * password, or a session that skipped the factor on a remembered browser, does
+ * not add a key.
*/
import { apiHandler, requireMfaManagementAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
@@ -20,7 +23,7 @@ import { createMfaRegistrationOptions } from "@/lib/auth/mfa/webauthn";
export const dynamic = "force-dynamic";
export const POST = apiHandler(async (req: Request) => {
- const auth = await requireMfaManagementAuth();
+ const auth = await requireMfaManagementAuth({ freshFactorIfEnrolled: true });
const { user } = auth;
const rl = await checkRateLimit(
diff --git a/src/app/api/auth/passkey/register-options/route.ts b/src/app/api/auth/passkey/register-options/route.ts
index ac848880c..b72bfff13 100644
--- a/src/app/api/auth/passkey/register-options/route.ts
+++ b/src/app/api/auth/passkey/register-options/route.ts
@@ -5,10 +5,16 @@ import {
} from "@/lib/auth/passkey";
import { createMfaAuthenticationOptions } from "@/lib/auth/mfa/webauthn";
import {
+ isSecondFactorProof,
recordReproofFailure,
+ refundReproof,
throttleReproof,
verifyExistingFactorProof,
} from "@/lib/auth/existing-factor-proof";
+import {
+ hasSecondFactorEnrolled,
+ recentProofMethods,
+} from "@/lib/auth/second-factor";
import { prisma } from "@/lib/db";
import {
apiError,
@@ -55,6 +61,25 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = stepUpMintSchema.safeParse(body);
if (!parsed.success) return proofRequired();
+ // On an account with a second factor a password does not add a sign-in
+ // credential, exactly as it does not satisfy the second factor at sign-in:
+ // a passkey registered on the strength of the password alone would from then
+ // on sign in without the second factor at all. Refused before a guess is
+ // spent, with the proofs that would work.
+ if (
+ !isSecondFactorProof(parsed.data.method) &&
+ (await hasSecondFactorEnrolled(user))
+ ) {
+ annotate({
+ action: { name: "auth.reproof.method_refused" },
+ meta: { stage: "passkey_enroll", method: parsed.data.method },
+ });
+ return apiError("Confirm with your second factor or a passkey", 422, {
+ errorCode: "auth.reproof.too_weak",
+ methods: await recentProofMethods(user, true),
+ });
+ }
+
// Every proof counts against the account's shared re-proof bucket and every
// refusal is audited. Without both, a stolen session could guess the account
// password or a TOTP code here as fast as it could send requests.
@@ -73,6 +98,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
);
return proofRequired();
}
+ await refundReproof(user.id);
// A strong existing factor refreshes the session's MFA stamp. Password proof
// deliberately clears it: registration is authorized by this single-use,
diff --git a/src/app/api/auth/password/__tests__/route.test.ts b/src/app/api/auth/password/__tests__/route.test.ts
index e13ecff29..557ac34e8 100644
--- a/src/app/api/auth/password/__tests__/route.test.ts
+++ b/src/app/api/auth/password/__tests__/route.test.ts
@@ -50,6 +50,7 @@ vi.mock("@/lib/auth/password", () => ({
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
+ refundRateLimit: vi.fn(),
}));
vi.mock("@/lib/i18n/server-locale", () => ({
@@ -77,7 +78,7 @@ vi.mock("next/headers", () => ({
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
-import { checkRateLimit } from "@/lib/rate-limit";
+import { checkRateLimit, refundRateLimit } from "@/lib/rate-limit";
import { verifyPassword, checkPasswordStrength } from "@/lib/auth/password";
import {
requireFreshMfaIfEnrolled,
@@ -146,6 +147,9 @@ describe("POST /api/auth/password — MFA step-up (v1.25)", () => {
expect(res.status).toBe(200);
expect(requireFreshMfaIfEnrolled).toHaveBeenCalledWith(expect.any(Number));
expect(prisma.user.update).toHaveBeenCalled();
+ // The right current password was not a guess: the shared re-proof
+ // bucket gets its slot back.
+ expect(refundRateLimit).toHaveBeenCalledWith("auth:step-up:user-1");
});
});
diff --git a/src/app/api/auth/password/route.ts b/src/app/api/auth/password/route.ts
index 47906bc9f..a8935d912 100644
--- a/src/app/api/auth/password/route.ts
+++ b/src/app/api/auth/password/route.ts
@@ -16,6 +16,7 @@ import {
} from "@/lib/api-response";
import {
recordReproofFailure,
+ refundReproof,
throttleReproof,
} from "@/lib/auth/existing-factor-proof";
import {
@@ -72,6 +73,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
);
return apiError("Current password is incorrect", 401);
}
+ // The current password was right, so this attempt was not a guess. Give the
+ // shared re-proof budget its slot back; otherwise a password change followed
+ // by a few confirmations elsewhere runs the owner out of attempts.
+ await refundReproof(user.id);
if (currentPassword === newPassword) {
return apiError("New password must differ from current password", 422);
diff --git a/src/app/api/auth/reproof/route.ts b/src/app/api/auth/reproof/route.ts
index 0f39e7d13..f01203d96 100644
--- a/src/app/api/auth/reproof/route.ts
+++ b/src/app/api/auth/reproof/route.ts
@@ -52,6 +52,7 @@ import { annotate } from "@/lib/logging/context";
import {
REPROOF_FAILED_CODE,
recordReproofFailure,
+ refundReproof,
throttleReproof,
verifyExistingFactorProof,
} from "@/lib/auth/existing-factor-proof";
@@ -110,6 +111,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
+ await refundReproof(user.id);
+
const now = new Date();
await prisma.session.update({
where: { id: session.id },
diff --git a/src/components/settings/security-section/existing-factor-reauth-dialog.tsx b/src/components/settings/security-section/existing-factor-reauth-dialog.tsx
index 88fd9c3b7..553fba98f 100644
--- a/src/components/settings/security-section/existing-factor-reauth-dialog.tsx
+++ b/src/components/settings/security-section/existing-factor-reauth-dialog.tsx
@@ -44,6 +44,28 @@ export function isReproofRequired(err: unknown): boolean {
);
}
+const REAUTH_METHODS: readonly ReauthMethod[] = [
+ "password",
+ "totp",
+ "passkey",
+ "webauthn",
+];
+
+/**
+ * The proofs the refusal says this account can give, or null when it names
+ * 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.
+ */
+export function offeredReauthMethods(err: unknown): ReauthMethod[] | null {
+ if (!(err instanceof ApiError)) return null;
+ const raw = err.meta?.methods;
+ if (!Array.isArray(raw)) return null;
+ const offered = raw.filter((m): m is ReauthMethod =>
+ REAUTH_METHODS.includes(m as ReauthMethod),
+ );
+ return offered.length > 0 ? offered : null;
+}
+
/**
* Collect a fresh proof of a credential the account already holds, before a
* new factor is added. A passkey or security-key assertion begins at the
diff --git a/src/components/settings/security-section/security-keys-card.tsx b/src/components/settings/security-section/security-keys-card.tsx
index deeaca438..7dcb374f2 100644
--- a/src/components/settings/security-section/security-keys-card.tsx
+++ b/src/components/settings/security-section/security-keys-card.tsx
@@ -36,6 +36,7 @@ import { formatDate } from "@/lib/format";
import {
ExistingFactorReauthDialog,
isReproofRequired,
+ offeredReauthMethods,
type ExistingFactorProof,
type ReauthMethod,
} from "./existing-factor-reauth-dialog";
@@ -98,8 +99,14 @@ export function SecurityKeysCard({
// options call is retried with it from the dialog.
const [reauthOpen, setReauthOpen] = useState(false);
const [reauthError, setReauthError] = useState(null);
- const reauthMethods: ReauthMethod[] = [
- "password",
+ // An account with a second factor confirms with that factor or a passkey;
+ // the server refuses a password there and names what it will take.
+ const hasSecondFactor = totpEnabled || keys.length > 0;
+ const [offeredMethods, setOfferedMethods] = useState(
+ null,
+ );
+ const reauthMethods: ReauthMethod[] = offeredMethods ?? [
+ ...(hasSecondFactor ? [] : (["password"] as const)),
...(totpEnabled ? (["totp"] as const) : []),
"passkey",
...(keys.length > 0 ? (["webauthn"] as const) : []),
@@ -126,6 +133,7 @@ export function SecurityKeysCard({
},
onError: (err, proof) => {
if (isReproofRequired(err)) {
+ setOfferedMethods(offeredReauthMethods(err));
setError(null);
setReauthError(null);
setReauthOpen(true);
diff --git a/src/components/settings/security-section/totp-card.tsx b/src/components/settings/security-section/totp-card.tsx
index e35a43d40..7495a9c93 100644
--- a/src/components/settings/security-section/totp-card.tsx
+++ b/src/components/settings/security-section/totp-card.tsx
@@ -36,7 +36,9 @@ import { RecoveryCodesPanel } from "./recovery-codes-panel";
import {
ExistingFactorReauthDialog,
isReproofRequired,
+ offeredReauthMethods,
type ExistingFactorProof,
+ type ReauthMethod,
} from "./existing-factor-reauth-dialog";
interface SetupData {
@@ -128,6 +130,11 @@ export function TotpCard({
// call is retried with it from the dialog.
const [reauthOpen, setReauthOpen] = useState(false);
const [reauthError, setReauthError] = useState(null);
+ const [reauthMethods, setReauthMethods] = useState([
+ "password",
+ "passkey",
+ "webauthn",
+ ]);
const beginSetup = useMutation({
mutationFn: async (proof?: ExistingFactorProof) => {
@@ -148,6 +155,8 @@ export function TotpCard({
},
onError: (err, proof) => {
if (isReproofRequired(err)) {
+ const offered = offeredReauthMethods(err);
+ if (offered) setReauthMethods(offered);
setError(null);
setReauthError(null);
setReauthOpen(true);
@@ -278,7 +287,7 @@ export function TotpCard({
setReauthOpen(open);
if (!open) setReauthError(null);
}}
- methods={["password", "passkey", "webauthn"]}
+ methods={reauthMethods}
pending={beginSetup.isPending}
error={reauthError}
onProof={(proof) => beginSetup.mutate(proof)}
diff --git a/src/lib/__tests__/require-recent-proof.test.ts b/src/lib/__tests__/require-recent-proof.test.ts
index 5f3716fb2..8c0d09fd1 100644
--- a/src/lib/__tests__/require-recent-proof.test.ts
+++ b/src/lib/__tests__/require-recent-proof.test.ts
@@ -38,7 +38,11 @@ vi.mock("next/headers", () => ({
})),
}));
-import { requireRecentProof, StepUpRequiredError } from "../api-handler";
+import {
+ requireMfaManagementAuth,
+ requireRecentProof,
+ StepUpRequiredError,
+} from "../api-handler";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { headers } from "next/headers";
@@ -219,3 +223,44 @@ describe("Bearer", () => {
await refusal(requireRecentProof({ bearer: "elevation" }));
});
});
+
+describe("adding a factor over Bearer (`freshFactorIfEnrolled`)", () => {
+ const passwordElevation = {
+ userId: "user-1",
+ apiTokenId: "tok-1",
+ consumedAt: null,
+ expiresAt: new Date(Date.now() + 60_000),
+ method: "password",
+ };
+
+ it("a password-proved elevation still adds a first factor", async () => {
+ bearer(PLAIN_USER, `hle_${"a".repeat(64)}`);
+ vi.mocked(prisma.stepUpElevation.findUnique).mockResolvedValue(
+ passwordElevation as never,
+ );
+ const auth = await requireMfaManagementAuth({
+ freshFactorIfEnrolled: true,
+ });
+ expect(auth.transport).toBe("bearer");
+ });
+
+ it("a password-proved elevation does not add a factor beside an existing one", async () => {
+ bearer(MFA_USER, `hle_${"a".repeat(64)}`);
+ vi.mocked(prisma.stepUpElevation.findUnique).mockResolvedValue(
+ passwordElevation as never,
+ );
+ await refusal(requireMfaManagementAuth({ freshFactorIfEnrolled: true }));
+ });
+
+ it("a second-factor elevation does", async () => {
+ bearer(MFA_USER, `hle_${"a".repeat(64)}`);
+ vi.mocked(prisma.stepUpElevation.findUnique).mockResolvedValue({
+ ...passwordElevation,
+ method: "totp",
+ } as never);
+ const auth = await requireMfaManagementAuth({
+ freshFactorIfEnrolled: true,
+ });
+ expect(auth.transport).toBe("bearer");
+ });
+});
diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts
index db1087f5a..cf464dea2 100644
--- a/src/lib/api-handler.ts
+++ b/src/lib/api-handler.ts
@@ -12,6 +12,13 @@ import { annotate, eventStorage, getEvent } from "./logging/context";
import { emitIfSampled } from "./logging/transports";
import { redactOptional, redactSecrets } from "./logging/redact";
import { getSession } from "./auth/session";
+import {
+ hasSecondFactorEnrolled,
+ recentProofMethods,
+ type RecentProofMethod,
+} from "./auth/second-factor";
+
+export { recentProofMethods, type RecentProofMethod };
import { auditLog, recordDelegatedAccess } from "./auth/audit";
import {
resolveBearerToken,
@@ -1388,13 +1395,6 @@ export async function requireFreshMfaOrElevationIfEnrolled(
return { ...auth, commitElevation };
}
-/**
- * A proof the recent-proof gate can ask for. `password` and `passkey` on an
- * account without a second factor; `totp`, `webauthn` and `passkey` on one
- * with a second factor, where a password alone has never been enough.
- */
-export type RecentProofMethod = "password" | "totp" | "webauthn" | "passkey";
-
/**
* How the recent-proof gate treats a Bearer caller.
*
@@ -1508,63 +1508,6 @@ export async function assertRecentCookieProof(
);
}
-/**
- * The proofs that would satisfy {@link assertRecentCookieProof} for this
- * account. Also what `POST /api/auth/reproof` accepts, so the dialog and the
- * endpoint cannot disagree.
- */
-export async function recentProofMethods(
- user: Pick,
- enrolled?: boolean,
-): Promise {
- const hasSecond = enrolled ?? (await hasSecondFactorEnrolled(user));
- const [keys, passkeys] = await Promise.all([
- prisma.webauthnMfaCredential.count({ where: { userId: user.id } }),
- prisma.passkey.count({ where: { userId: user.id } }),
- ]);
- const methods: RecentProofMethod[] = [];
- if (hasSecond) {
- if (user.totpConfirmedAt) methods.push("totp");
- if (keys > 0) methods.push("webauthn");
- } else if (user.passwordHash) {
- methods.push("password");
- }
- if (passkeys > 0) methods.push("passkey");
- return methods;
-}
-
-/**
- * Does this account have a SECOND FACTOR at all?
- *
- * Either factor enrols it: a confirmed TOTP secret OR a registered WebAuthn
- * security key. A webauthn-only account must clear step-up too, so every
- * boundary that asks this question tracks `requireFreshMfa`'s either-factor
- * rule rather than reading `totpConfirmedAt` alone. It was written out three
- * times before it was a function, which is two more places for the second arm
- * to be forgotten.
- *
- * DELIBERATELY DOES NOT COUNT A PRIMARY PASSKEY, even though a passkey login
- * stamps `mfaVerifiedAt` and could therefore satisfy the gate. This predicate
- * answers "is there a second factor", and that is the question
- * `requireFreshMfaIfEnrolled` asks before deciding whether account deletion, the
- * data reset, the password change, the encrypted export and key rotation demand
- * a step-up at all. Counting a passkey here would silently pull every
- * passkey-holding account into a gate those routes have never applied to them —
- * a behaviour change to five destructive actions, made in passing, for the
- * benefit of a sixth. {@link canProveFreshFactor} is where the wider question
- * lives.
- */
-async function hasSecondFactorEnrolled(user: {
- id: string;
- totpConfirmedAt: Date | null;
-}): Promise {
- if (user.totpConfirmedAt) return true;
- const webauthnKeyCount = await prisma.webauthnMfaCredential.count({
- where: { userId: user.id },
- });
- return webauthnKeyCount > 0;
-}
-
/**
* Can this account produce a fresh-factor proof at all?
*
@@ -1719,10 +1662,17 @@ export type MfaManagementContext = {
* set. Passkey removal passes `"any-possession"` because a primary passkey IS
* the proof there — see {@link FreshFactorProofSource}. It changes only the
* reachability pre-check; the gate itself is identical either way.
+ * @param options.freshFactorIfEnrolled the Bearer arm of ADDING a factor
+ * (TOTP setup, security-key registration). On an account that already has a
+ * second factor, the elevation must come from a second-factor or passkey
+ * proof, the rule `checkCookieEnrollmentProof` holds on the cookie arm; on
+ * one without, a password-proved elevation is still enough. The cookie arm
+ * is not changed by it: those routes run their own enrollment proof there.
*/
export async function requireMfaManagementAuth(
options: {
freshFactor?: boolean;
+ freshFactorIfEnrolled?: boolean;
proofSource?: FreshFactorProofSource;
} = {},
): Promise {
@@ -1747,9 +1697,13 @@ export async function requireMfaManagementAuth(
// Bearer path. Resolution first: an unknown, revoked, expired, or narrow-scope
// token is refused here and never gets as far as presenting an elevation.
const auth = await requireBearerAuth();
+ const bearerFreshFactor =
+ freshFactor ||
+ (options.freshFactorIfEnrolled === true &&
+ (await hasSecondFactorEnrolled(auth.user)));
const commitElevation = await resolveBearerElevation(
{ user: auth.user, apiTokenId: auth.apiTokenId },
- { freshFactor, proofSource },
+ { freshFactor: bearerFreshFactor, proofSource },
);
return {
diff --git a/src/lib/auth/__tests__/enrollment-proof-second-factor.test.ts b/src/lib/auth/__tests__/enrollment-proof-second-factor.test.ts
new file mode 100644
index 000000000..5cdabf23a
--- /dev/null
+++ b/src/lib/auth/__tests__/enrollment-proof-second-factor.test.ts
@@ -0,0 +1,228 @@
+/**
+ * Adding a credential to an account that already has a second factor.
+ *
+ * A password, a young session or a password re-proof used to be enough to
+ * add a security key, a TOTP authenticator or a passkey even when the account
+ * was protected by a second factor. A session that signed in on a remembered
+ * browser skips the factor and is still young, so the password alone could
+ * add a key that then satisfies every gate the factor protected. On such an
+ * account only possession of a factor counts now; the account without one is
+ * unchanged.
+ */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+vi.mock("@/lib/db", () => ({
+ prisma: {
+ session: { findUnique: vi.fn(), update: vi.fn() },
+ webauthnMfaCredential: { count: vi.fn() },
+ passkey: { count: vi.fn() },
+ authChallenge: { findUnique: vi.fn() },
+ },
+}));
+vi.mock("@/lib/rate-limit", () => ({
+ checkRateLimit: vi.fn(),
+ rateLimitHeaders: vi.fn(() => ({})),
+ refundRateLimit: vi.fn(),
+}));
+vi.mock("@/lib/auth/audit", () => ({
+ auditLog: vi.fn().mockResolvedValue(undefined),
+}));
+vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
+vi.mock("@/lib/auth/password", () => ({ verifyPassword: vi.fn() }));
+vi.mock("@/lib/auth/passkey", () => ({
+ verifyAuthentication: vi.fn(),
+ createAuthenticationOptions: vi.fn(),
+ createRegistrationOptions: vi.fn(),
+}));
+vi.mock("@/lib/auth/mfa/webauthn", () => ({
+ verifyMfaAuthentication: vi.fn(),
+ createMfaAuthenticationOptions: vi.fn(),
+}));
+vi.mock("@/lib/auth/mfa/verify-factor", () => ({ verifyMfaFactor: vi.fn() }));
+vi.mock("@/lib/api-handler", () => ({
+ apiHandler: (fn: unknown) => fn,
+ requireCookieAuth: vi.fn(),
+}));
+
+import { prisma } from "@/lib/db";
+import { checkRateLimit, refundRateLimit } from "@/lib/rate-limit";
+import { verifyPassword } from "@/lib/auth/password";
+import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor";
+import { createRegistrationOptions } from "@/lib/auth/passkey";
+import { requireCookieAuth } from "@/lib/api-handler";
+import { checkCookieEnrollmentProof } from "../existing-factor-proof";
+import { POST as PASSKEY_REGISTER_OPTIONS } from "@/app/api/auth/passkey/register-options/route";
+
+const NOW = Date.now();
+const fresh = new Date(NOW - 60_000);
+const stale = new Date(NOW - 60 * 60_000);
+
+const ENROLLED: {
+ id: string;
+ username: string;
+ email: string;
+ passwordHash: string;
+ totpSecretEncrypted: string | null;
+ totpLastStep: null;
+ totpConfirmedAt: Date | null;
+} = {
+ id: "user-1",
+ username: "u",
+ email: "u@example.com",
+ passwordHash: "hash",
+ totpSecretEncrypted: "enc",
+ totpLastStep: null,
+ totpConfirmedAt: new Date("2026-01-01"),
+};
+const PLAIN = { ...ENROLLED, totpSecretEncrypted: null, totpConfirmedAt: null };
+
+function req(body?: unknown): Request {
+ return new Request("http://localhost/api/auth/me/mfa/totp/setup", {
+ method: "POST",
+ headers: body === undefined ? {} : { "content-type": "application/json" },
+ body: body === undefined ? undefined : JSON.stringify(body),
+ });
+}
+
+function session(stamps: {
+ mfaVerifiedAt?: Date | null;
+ createdAt?: Date;
+ reproofAt?: Date | null;
+}) {
+ vi.mocked(prisma.session.findUnique).mockResolvedValue({
+ mfaVerifiedAt: stamps.mfaVerifiedAt ?? null,
+ createdAt: stamps.createdAt ?? stale,
+ reproofAt: stamps.reproofAt ?? null,
+ } as never);
+}
+
+function gate(user: typeof ENROLLED | typeof PLAIN, request: Request) {
+ return checkCookieEnrollmentProof({
+ user: user as never,
+ sessionId: "sess-1",
+ request,
+ ipAddress: "203.0.113.9",
+ stage: "totp_enroll",
+ });
+}
+
+beforeEach(() => {
+ vi.resetAllMocks();
+ vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
+ vi.mocked(prisma.passkey.count).mockResolvedValue(0 as never);
+ vi.mocked(checkRateLimit).mockResolvedValue({
+ allowed: true,
+ limit: 5,
+ remaining: 4,
+ resetAt: NOW + 1000,
+ });
+});
+
+describe("account with a second factor", () => {
+ it("a session signed in a minute ago is not enough (remembered browser)", async () => {
+ session({ createdAt: fresh });
+ const res = await gate(ENROLLED, req());
+ expect(res?.status).toBe(401);
+ const body = await res!.json();
+ expect(body.meta.errorCode).toBe("auth.reproof.required");
+ expect(body.meta.methods).toEqual(["totp"]);
+ });
+
+ it("a password re-proof stamp is not enough", async () => {
+ session({ reproofAt: fresh });
+ const res = await gate(ENROLLED, req());
+ expect(res?.status).toBe(401);
+ });
+
+ it("a password in the body is refused before it is verified", async () => {
+ session({ createdAt: fresh });
+ const res = await gate(
+ ENROLLED,
+ req({ method: "password", password: "correct horse" }),
+ );
+ expect(res?.status).toBe(401);
+ expect(verifyPassword).not.toHaveBeenCalled();
+ expect(checkRateLimit).not.toHaveBeenCalled();
+ });
+
+ it("a completed second factor within five minutes passes", async () => {
+ session({ mfaVerifiedAt: fresh });
+ expect(await gate(ENROLLED, req())).toBeNull();
+ });
+
+ it("a TOTP code in the body passes and gives the attempt back", async () => {
+ session({});
+ vi.mocked(verifyMfaFactor).mockResolvedValue({ ok: true } as never);
+ expect(await gate(ENROLLED, req({ method: "totp", code: "123456" }))).toBe(
+ null,
+ );
+ expect(refundRateLimit).toHaveBeenCalledWith("auth:step-up:user-1");
+ });
+
+ it("a security key alone makes the account enrolled", async () => {
+ vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(1 as never);
+ session({ createdAt: fresh });
+ const res = await gate(PLAIN, req());
+ expect(res?.status).toBe(401);
+ expect((await res!.json()).meta.methods).toEqual(["webauthn"]);
+ });
+});
+
+describe("account without a second factor (unchanged)", () => {
+ it("a session signed in a minute ago passes", async () => {
+ session({ createdAt: fresh });
+ expect(await gate(PLAIN, req())).toBeNull();
+ });
+
+ it("a password in the body passes", async () => {
+ session({});
+ vi.mocked(verifyPassword).mockResolvedValue(true);
+ expect(
+ await gate(PLAIN, req({ method: "password", password: "pw" })),
+ ).toBeNull();
+ expect(verifyPassword).toHaveBeenCalled();
+ });
+});
+
+describe("POST /api/auth/passkey/register-options", () => {
+ function passkeyReq(body: unknown) {
+ return new Request("http://localhost/api/auth/passkey/register-options", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ }
+
+ it("refuses a password on an account with a second factor, before verifying it", async () => {
+ vi.mocked(requireCookieAuth).mockResolvedValue({
+ user: ENROLLED,
+ session: { id: "sess-1" },
+ } as never);
+ const res = await PASSKEY_REGISTER_OPTIONS(
+ passkeyReq({ method: "password", password: "correct horse" }) as never,
+ );
+ expect(res.status).toBe(422);
+ const body = await res.json();
+ expect(body.meta.errorCode).toBe("auth.reproof.too_weak");
+ expect(body.meta.methods).toEqual(["totp"]);
+ expect(verifyPassword).not.toHaveBeenCalled();
+ expect(createRegistrationOptions).not.toHaveBeenCalled();
+ });
+
+ it("still takes a password on an account without one", async () => {
+ vi.mocked(requireCookieAuth).mockResolvedValue({
+ user: PLAIN,
+ session: { id: "sess-1" },
+ } as never);
+ vi.mocked(verifyPassword).mockResolvedValue(true);
+ vi.mocked(createRegistrationOptions).mockResolvedValue({
+ options: {},
+ challengeId: "ch-1",
+ } as never);
+ const res = await PASSKEY_REGISTER_OPTIONS(
+ passkeyReq({ method: "password", password: "pw" }) as never,
+ );
+ expect(res.status).toBe(200);
+ expect(refundRateLimit).toHaveBeenCalledWith("auth:step-up:user-1");
+ });
+});
diff --git a/src/lib/auth/existing-factor-proof.ts b/src/lib/auth/existing-factor-proof.ts
index 64915503d..e44c3b53c 100644
--- a/src/lib/auth/existing-factor-proof.ts
+++ b/src/lib/auth/existing-factor-proof.ts
@@ -22,7 +22,15 @@ import { apiError } from "@/lib/api-response";
import { REPROOF_REQUIRED_CODE } from "@/lib/api-errors";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
-import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
+import {
+ checkRateLimit,
+ rateLimitHeaders,
+ refundRateLimit,
+} from "@/lib/rate-limit";
+import {
+ hasSecondFactorEnrolled,
+ recentProofMethods,
+} from "@/lib/auth/second-factor";
import { verifyPassword } from "@/lib/auth/password";
import { verifyAuthentication } from "@/lib/auth/passkey";
import { verifyMfaAuthentication } from "@/lib/auth/mfa/webauthn";
@@ -73,6 +81,16 @@ export async function throttleReproof(
});
}
+/**
+ * Give back the attempt a proof charged once it verified. The bucket exists to
+ * stop guessing; a proof that verified was not a guess, and without the refund
+ * an owner who confirms a few actions in a row (an export, a share link, a
+ * password change) runs the shared budget dry and is told to wait.
+ */
+export async function refundReproof(userId: string): Promise {
+ await refundRateLimit(reproofBucket(userId));
+}
+
export type ProofOutcome = { ok: true } | { ok: false; reason: string };
/**
@@ -184,14 +202,15 @@ export const RECENT_PROOF_MAX_AGE_MS = 5 * 60 * 1000;
* session could enroll their own authenticator, and from then on hold a factor
* that satisfies every step-up gate, registers a passkey and survives the
* owner changing the password. So the caller must show that the person at the
- * keyboard can authenticate as the account owner right now, in one of three
- * ways:
+ * keyboard can authenticate as the account owner right now.
*
- * - the session completed a second factor or passkey sign-in within the last
- * five minutes (`Session.mfaVerifiedAt`);
+ * What counts depends on whether the account already has a second factor.
+ *
+ * Without one (password, passkey or single sign-on only), any of:
+ * - the session completed a passkey sign-in or re-proof within the last five
+ * minutes (`Session.mfaVerifiedAt`);
* - the session re-proved a credential at `POST /api/auth/reproof` within
- * the last five minutes (`Session.reproofAt`), which accepts the same
- * proofs this gate takes in the body;
+ * the last five minutes (`Session.reproofAt`);
* - the session itself was created by a sign-in within the last five
* minutes (`Session.createdAt`, which no later write touches) — this is
* also what lets an SSO-only account with nothing else to re-prove enroll
@@ -200,6 +219,13 @@ export const RECENT_PROOF_MAX_AGE_MS = 5 * 60 * 1000;
* `{ method: "totp", code }` or a passkey / security-key assertion, the
* same shapes the step-up mint accepts.
*
+ * With one, only a proof of possession of a factor: `Session.mfaVerifiedAt`
+ * within five minutes, or a TOTP code, security-key or passkey assertion in
+ * the body. A password is not enough and neither is a young session: signing
+ * in on a remembered browser skips the second factor and still creates a
+ * fresh session, so `createdAt` would let the password alone add a new key,
+ * which then satisfies every gate the second factor was protecting.
+ *
* Returns the refusal to send, or null to proceed. Proofs in the body are
* throttled and audited through the shared re-proof bucket.
*/
@@ -212,6 +238,7 @@ export async function checkCookieEnrollmentProof(args: {
}): Promise {
const { user, sessionId, request, ipAddress, stage } = args;
+ const enrolled = await hasSecondFactorEnrolled(user);
const row = await prisma.session.findUnique({
where: { id: sessionId },
select: { mfaVerifiedAt: true, createdAt: true, reproofAt: true },
@@ -219,20 +246,25 @@ export async function checkCookieEnrollmentProof(args: {
const now = Date.now();
const recent = (d: Date | null | undefined) =>
d != null && now - d.getTime() <= RECENT_PROOF_MAX_AGE_MS;
- if (
- recent(row?.mfaVerifiedAt) ||
- recent(row?.createdAt) ||
- recent(row?.reproofAt)
- ) {
+ const sessionSatisfies = enrolled
+ ? recent(row?.mfaVerifiedAt)
+ : recent(row?.mfaVerifiedAt) ||
+ recent(row?.createdAt) ||
+ recent(row?.reproofAt);
+ if (sessionSatisfies) {
annotate({ meta: { enrollment_proof: "recent_session" } });
return null;
}
const proof = await readProof(request);
if (proof === "absent") {
- annotate({ action: { name: "auth.reproof.required" }, meta: { stage } });
+ annotate({
+ action: { name: "auth.reproof.required" },
+ meta: { stage, second_factor: enrolled },
+ });
return apiError("Confirm it is you before adding a sign-in method", 401, {
errorCode: REPROOF_REQUIRED_CODE,
+ methods: await recentProofMethods(user, enrolled),
});
}
if (proof === "invalid") {
@@ -240,6 +272,18 @@ export async function checkCookieEnrollmentProof(args: {
errorCode: REPROOF_REQUIRED_CODE,
});
}
+ // Refused before a guess is spent: a password that verified would still not
+ // open this gate on an account with a second factor.
+ if (enrolled && !isSecondFactorProof(proof.method)) {
+ annotate({
+ action: { name: "auth.reproof.method_refused" },
+ meta: { stage, method: proof.method },
+ });
+ return apiError("Confirm with your second factor or a passkey", 401, {
+ errorCode: REPROOF_REQUIRED_CODE,
+ methods: await recentProofMethods(user, enrolled),
+ });
+ }
const limited = await throttleReproof(user.id, ipAddress, stage);
if (limited) return limited;
@@ -257,10 +301,22 @@ export async function checkCookieEnrollmentProof(args: {
errorCode: REPROOF_FAILED_CODE,
});
}
+ await refundReproof(user.id);
annotate({ meta: { enrollment_proof: proof.method } });
return null;
}
+/**
+ * The proofs that stand for possession of a factor rather than knowledge of
+ * the password. On an account with a second factor these are the only ones
+ * that may add a credential.
+ */
+export function isSecondFactorProof(
+ method: ExistingFactorProof["method"],
+): boolean {
+ return method === "totp" || method === "webauthn" || method === "passkey";
+}
+
async function readProof(
request: Request,
): Promise {
diff --git a/src/lib/auth/second-factor.ts b/src/lib/auth/second-factor.ts
new file mode 100644
index 000000000..48634c9fa
--- /dev/null
+++ b/src/lib/auth/second-factor.ts
@@ -0,0 +1,66 @@
+import type { User } from "@/generated/prisma/client";
+import { prisma } from "@/lib/db";
+
+/**
+ * Does this account have a SECOND FACTOR at all?
+ *
+ * Either factor enrols it: a confirmed TOTP secret OR a registered WebAuthn
+ * security key. A webauthn-only account must clear step-up too, so every
+ * boundary that asks this question tracks `requireFreshMfa`'s either-factor
+ * rule rather than reading `totpConfirmedAt` alone. It was written out three
+ * times before it was a function, which is two more places for the second arm
+ * to be forgotten.
+ *
+ * DELIBERATELY DOES NOT COUNT A PRIMARY PASSKEY, even though a passkey login
+ * stamps `mfaVerifiedAt` and could therefore satisfy the gate. This predicate
+ * answers "is there a second factor", and that is the question
+ * `requireFreshMfaIfEnrolled` asks before deciding whether account deletion, the
+ * data reset, the password change, the encrypted export and key rotation demand
+ * a step-up at all. Counting a passkey here would silently pull every
+ * passkey-holding account into a gate those routes have never applied to them —
+ * a behaviour change to five destructive actions, made in passing, for the
+ * benefit of a sixth. `canProveFreshFactor` in `api-handler.ts` is where the wider question
+ * lives.
+ */
+export async function hasSecondFactorEnrolled(user: {
+ id: string;
+ totpConfirmedAt: Date | null;
+}): Promise {
+ if (user.totpConfirmedAt) return true;
+ const webauthnKeyCount = await prisma.webauthnMfaCredential.count({
+ where: { userId: user.id },
+ });
+ return webauthnKeyCount > 0;
+}
+
+/**
+ * A proof the recent-proof gate can ask for. `password` and `passkey` on an
+ * account without a second factor; `totp`, `webauthn` and `passkey` on one
+ * with a second factor, where a password alone has never been enough.
+ */
+export type RecentProofMethod = "password" | "totp" | "webauthn" | "passkey";
+
+/**
+ * The proofs that would satisfy `assertRecentCookieProof` for this
+ * account. Also what `POST /api/auth/reproof` accepts, so the dialog and the
+ * endpoint cannot disagree.
+ */
+export async function recentProofMethods(
+ user: Pick,
+ enrolled?: boolean,
+): Promise {
+ const hasSecond = enrolled ?? (await hasSecondFactorEnrolled(user));
+ const [keys, passkeys] = await Promise.all([
+ prisma.webauthnMfaCredential.count({ where: { userId: user.id } }),
+ prisma.passkey.count({ where: { userId: user.id } }),
+ ]);
+ const methods: RecentProofMethod[] = [];
+ if (hasSecond) {
+ if (user.totpConfirmedAt) methods.push("totp");
+ if (keys > 0) methods.push("webauthn");
+ } else if (user.passwordHash) {
+ methods.push("password");
+ }
+ if (passkeys > 0) methods.push("passkey");
+ return methods;
+}
From a4d27c618ecb25e8ca2c64818ea11492f54e7ab7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 26 Sep 2026 22:21:43 +0200
Subject: [PATCH 2/9] fix(export): every whole-record export asks for a fresh
proof
GET /api/export with type=all (the default), POST /api/export/health-record
and GET /api/fhir/Patient/$everything hand over the whole record but took a
live session alone, so a stolen browser session could still walk off with
everything the full-backup gate was meant to protect. A browser session now
needs a sign-in or re-proof within five minutes, with the second factor on
an account that has one, and the doctor-report panel opens the re-proof
dialog and retries. When a manager builds the report for a record they
manage, their own session is the one asked.
On a token the shipped app keeps working: health-record and $everything
take the token as before, including a fhir:read token, and the recent-proof
guard names both. GET /api/export?type=all, which no shipped client calls
with a token, wants a step-up elevation there. Single-type exports are
unchanged.
The API reference now also describes the enrollment rule for accounts
with a second factor and the passkey refusal it adds.
---
docs/api/openapi.yaml | 71 ++++++++----
.../recent-proof-surface-guard.test.ts | 27 ++++-
.../__tests__/soft-delete-filter.test.ts | 39 +++++++
.../health-record/__tests__/route.test.ts | 26 +++++
src/app/api/export/health-record/route.ts | 18 ++-
src/app/api/export/route.ts | 22 +++-
src/app/api/fhir/Patient/$everything/route.ts | 20 +++-
.../capability-and-everything.test.ts | 27 +++++
.../api/fhir/__tests__/module-gate.test.ts | 11 ++
.../report-selection/selection-panel.tsx | 104 +++++++++++-------
src/lib/openapi/routes/auth.ts | 9 +-
src/lib/openapi/routes/export.ts | 2 +-
src/lib/openapi/routes/health-record.ts | 4 +-
13 files changed, 304 insertions(+), 76 deletions(-)
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 5953a81d8..0b8a74389 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -864,7 +864,11 @@ paths:
`selection` is REQUIRED and carries the leaf inclusion list — membership is inclusion, absence is exclusion, and
there is no server-side default. An unknown leaf id is a 422 that names it; the retired grouped `sections` shape
is a 422 too, because accepting it would mean choosing a scope the caller did not express. Auth via cookie or
- Bearer; shared `export:` rate bucket (10/h). Strict validation: unknown keys 422."
+ Bearer; shared `export:` rate bucket (10/h). Strict validation: unknown keys 422. A browser session
+ needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes
+ (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with
+ `meta.methods` naming the proofs the account can give. When a manager exports a record they manage, it is their
+ own session that is asked. A Bearer caller is not asked for more than its token."
requestBody:
required: true
content:
@@ -1489,7 +1493,10 @@ paths:
Conditions, Encounters, AllergyIntolerances, FamilyMemberHistories and the DiagnosticReport (present when the
window holds a vital sign) — flattened into one `searchset` Bundle. Scoped to the owner's SAVED report
selection; an account that never saved one gets nothing. Auth: `fhir:read` scope. Offset paging via `_count`
- (≤200) / `_offset`."
+ (≤200) / `_offset`. A browser session needs a fresh proof as well as a live one: a sign-in or `POST
+ /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it
+ the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give. A token,
+ including a `fhir:read` one, is not asked for more."
parameters:
- in: query
name: _count
@@ -1524,7 +1531,13 @@ paths:
notes ride their exported column. Shares the `export:` bucket with the encrypted variant (10 per hour)
and audits every attempt as `export.download`. Cookie or Bearer auth; the caller is always resolved as
themselves, so this download cannot be delegated to a shared record. Not module-gated: a disabled module's rows
- are still exported, because this is the account's own data leaving on request."
+ are still exported, because this is the account's own data leaving on request. `type=all`, the default, is the
+ whole record and asks for a fresh proof like the full backup; the single-type exports do not. A browser session
+ needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes
+ (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with
+ `meta.methods` naming the proofs the account can give. A Bearer caller asking for `type=all` presents an
+ `X-Step-Up` elevation from `POST /api/auth/step-up` (a second-factor one on an account with a second factor) and
+ receives 401 `auth.stepup.required` without it."
parameters:
- in: query
name: format
@@ -2172,14 +2185,18 @@ paths:
/api/auth/step-up in the `X-Step-Up` header; a Bearer token alone is still refused.
- Adding a factor needs a fresh proof, not just a session. On the Bearer path the elevation is that proof (a
- password-proved one is enough). On the cookie path the request passes when the session signed in, or completed a
- second factor, within the last five minutes; otherwise the body must carry a proof in the shape POST
- /api/auth/step-up takes (`password`, a `totp` code from an already confirmed authenticator, or a passkey /
- security-key assertion begun at POST /api/auth/passkey/register-options). Without one the answer is 401
- `auth.reproof.required`; a proof that does not verify is 401 `auth.reproof.failed` and is audited. Proofs draw
- on the account's shared re-proof budget of five per fifteen minutes (429 once spent), the same budget as the
- step-up mint.
+ Adding a factor needs a fresh proof, not just a session. On the Bearer path the elevation is that proof: a
+ password-proved one is enough on an account without a second factor, and on an account that already has one the
+ elevation must come from that factor or a passkey. On the cookie path, for an account without a second factor,
+ the request passes when the session signed in, or completed a passkey sign-in, within the last five minutes;
+ otherwise the body must carry a proof in the shape POST /api/auth/step-up takes (`password`, or a passkey
+ assertion begun at POST /api/auth/passkey/register-options). For an account that already has a second factor,
+ only that factor counts: a second factor completed within the last five minutes, or a `totp` code, security-key
+ or passkey assertion in the body. A password or a young session (for example one that skipped the factor on a
+ remembered browser) is not enough there. Without one the answer is 401 `auth.reproof.required` with
+ `meta.methods` naming the proofs the account can give; a proof that does not verify is 401 `auth.reproof.failed`
+ and is audited. Proofs draw on the account's shared re-proof budget of five per fifteen minutes (429 once
+ spent), the same budget as the step-up mint.
requestBody:
required: false
content:
@@ -2316,14 +2333,18 @@ paths:
refused.
- Adding a factor needs a fresh proof, not just a session. On the Bearer path the elevation is that proof (a
- password-proved one is enough). On the cookie path the request passes when the session signed in, or completed a
- second factor, within the last five minutes; otherwise the body must carry a proof in the shape POST
- /api/auth/step-up takes (`password`, a `totp` code from an already confirmed authenticator, or a passkey /
- security-key assertion begun at POST /api/auth/passkey/register-options). Without one the answer is 401
- `auth.reproof.required`; a proof that does not verify is 401 `auth.reproof.failed` and is audited. Proofs draw
- on the account's shared re-proof budget of five per fifteen minutes (429 once spent), the same budget as the
- step-up mint.
+ Adding a factor needs a fresh proof, not just a session. On the Bearer path the elevation is that proof: a
+ password-proved one is enough on an account without a second factor, and on an account that already has one the
+ elevation must come from that factor or a passkey. On the cookie path, for an account without a second factor,
+ the request passes when the session signed in, or completed a passkey sign-in, within the last five minutes;
+ otherwise the body must carry a proof in the shape POST /api/auth/step-up takes (`password`, or a passkey
+ assertion begun at POST /api/auth/passkey/register-options). For an account that already has a second factor,
+ only that factor counts: a second factor completed within the last five minutes, or a `totp` code, security-key
+ or passkey assertion in the body. A password or a young session (for example one that skipped the factor on a
+ remembered browser) is not enough there. Without one the answer is 401 `auth.reproof.required` with
+ `meta.methods` naming the proofs the account can give; a proof that does not verify is 401 `auth.reproof.failed`
+ and is audited. Proofs draw on the account's shared re-proof budget of five per fifteen minutes (429 once
+ spent), the same budget as the step-up mint.
requestBody:
required: false
content:
@@ -3453,7 +3474,9 @@ paths:
**Enrollment arm.** Any other body must be a full factor proof (the same shape POST /api/auth/step-up takes): a
password, a TOTP code, or a completed passkey / security-key assertion. Only then are registration options
- issued.
+ issued. On an account that already has a second factor a password is refused before it is checked, with 422
+ `auth.reproof.too_weak` and `meta.methods` naming the proofs that work: a passkey added on the password alone
+ would sign in without the second factor from then on.
Which factor you prove changes the session, and the direction is not the obvious one: a strong proof stamps the
@@ -3500,7 +3523,13 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
- "422": *a4
+ "422":
+ description: "`auth.reproof.too_weak`: a password on an account that already has a second factor. `meta.methods` names
+ the proofs that would be accepted. Refused before the password is checked, so it spends no attempt."
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
"429": *a5
/api/auth/passkey/register-verify:
post:
diff --git a/src/__tests__/recent-proof-surface-guard.test.ts b/src/__tests__/recent-proof-surface-guard.test.ts
index 26fce210d..9a1e0f8ae 100644
--- a/src/__tests__/recent-proof-surface-guard.test.ts
+++ b/src/__tests__/recent-proof-surface-guard.test.ts
@@ -73,6 +73,8 @@ function callers(exportName: string): string[] {
/** Routes that resolve their own caller and take the recent-proof gate. */
const RECENT_PROOF_ROUTES: Record = {
+ // `type=all` only; the single-type exports stay on `requireAuth`.
+ "app/api/export/route.ts": "elevation",
"app/api/export/full-backup/route.ts": "elevation",
"app/api/export/encrypted/route.ts": "elevation-if-enrolled",
// The shipped iOS app calls these two on its token without an elevation and
@@ -82,6 +84,17 @@ const RECENT_PROOF_ROUTES: Record = {
"app/api/mcp/tokens/route.ts": "token",
};
+/**
+ * Routes that resolve a cookie OR a token through their own resolver
+ * (`requireAuth` with a scope, `requireRecordAuth`) and add the cookie arm
+ * only. Their Bearer arm is the token, as with the `token` rule above: the
+ * shipped app calls both with its token and no elevation.
+ */
+const TOKEN_ARM_COOKIE_PROOF_ROUTES = [
+ "app/api/export/health-record/route.ts",
+ "app/api/fhir/Patient/$everything/route.ts",
+].sort();
+
/** Cookie-only routes that add the gate after their own resolver. */
const COOKIE_PROOF_ROUTES = [
"app/api/admin/backups/[id]/download/route.ts",
@@ -112,10 +125,20 @@ describe("the recent-proof gate", () => {
expect(found).toEqual(RECENT_PROOF_ROUTES);
});
- it("the cookie arm is added by exactly the known cookie-only routes", () => {
+ it("the cookie arm is added by exactly the known routes", () => {
const found = callers("assertRecentCookieProof");
expect(found.length).toBeGreaterThan(0);
- expect(found).toEqual(COOKIE_PROOF_ROUTES);
+ expect(found).toEqual(
+ [...COOKIE_PROOF_ROUTES, ...TOKEN_ARM_COOKIE_PROOF_ROUTES].sort(),
+ );
+ });
+
+ it("a route that resolves either transport asks only on the cookie", () => {
+ for (const rel of TOKEN_ARM_COOKIE_PROOF_ROUTES) {
+ expect(read(rel), rel).toMatch(
+ /if \(auth\.authMethod === "cookie"\) \{\s*await assertRecentCookieProof\(/,
+ );
+ }
});
it("each cookie-only route resolves a cookie before it asks", () => {
diff --git a/src/app/api/export/__tests__/soft-delete-filter.test.ts b/src/app/api/export/__tests__/soft-delete-filter.test.ts
index 03631ffa8..45cb98d38 100644
--- a/src/app/api/export/__tests__/soft-delete-filter.test.ts
+++ b/src/app/api/export/__tests__/soft-delete-filter.test.ts
@@ -318,3 +318,42 @@ describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => {
}
});
});
+
+describe("GET /api/export — the whole record asks for a fresh proof", () => {
+ const stale = {
+ createdAt: new Date(Date.now() - 60 * 60 * 1000),
+ mfaVerifiedAt: null,
+ reproofAt: null,
+ };
+
+ it("type=all on a session that signed in an hour ago is asked to confirm", async () => {
+ vi.mocked(prisma.session.findUnique).mockResolvedValueOnce(stale as never);
+ const { GET } = await import("../route");
+ const res = await GET(mkReq("http://localhost/api/export?type=all"));
+ expect(res.status).toBe(401);
+ expect((await res.json()).meta.errorCode).toBe("auth.reproof.required");
+ expect(prisma.measurement.findMany).not.toHaveBeenCalled();
+ });
+
+ it("no type means the whole record, and is gated the same way", async () => {
+ vi.mocked(prisma.session.findUnique).mockResolvedValueOnce(stale as never);
+ const { GET } = await import("../route");
+ const res = await GET(mkReq("http://localhost/api/export"));
+ expect(res.status).toBe(401);
+ });
+
+ it("type=all right after signing in goes through", async () => {
+ const { GET } = await import("../route");
+ const res = await GET(mkReq("http://localhost/api/export?type=all"));
+ expect(res.status).toBe(200);
+ });
+
+ it("a single-type export stays on the session alone", async () => {
+ vi.mocked(prisma.session.findUnique).mockResolvedValue(stale as never);
+ const { GET } = await import("../route");
+ const res = await GET(
+ mkReq("http://localhost/api/export?type=measurements"),
+ );
+ expect(res.status).toBe(200);
+ });
+});
diff --git a/src/app/api/export/health-record/__tests__/route.test.ts b/src/app/api/export/health-record/__tests__/route.test.ts
index 9a761419c..664e6fc4f 100644
--- a/src/app/api/export/health-record/__tests__/route.test.ts
+++ b/src/app/api/export/health-record/__tests__/route.test.ts
@@ -32,6 +32,17 @@ vi.mock("@/lib/db", () => ({
userHealthProfile: { findUnique: vi.fn() },
healthProfileFactRevision: { findMany: vi.fn() },
user: { findUnique: vi.fn(), update: vi.fn() },
+ // The whole-record gate reads the session's own stamps: a sign-in a
+ // moment ago is the proof, on an account without a second factor.
+ session: {
+ findUnique: vi.fn(async () => ({
+ createdAt: new Date(),
+ mfaVerifiedAt: null,
+ reproofAt: null,
+ })),
+ },
+ webauthnMfaCredential: { count: vi.fn(async () => 0) },
+ passkey: { count: vi.fn(async () => 0) },
},
toJson: (value: unknown) => value,
}));
@@ -908,3 +919,18 @@ describe("POST /api/export/health-record — the insurance leaf", () => {
expect(text).toContain("A123456789");
});
});
+
+describe("POST /api/export/health-record — a fresh proof on the browser", () => {
+ it("a session that signed in an hour ago is asked to confirm first", async () => {
+ vi.mocked(prisma.session.findUnique).mockResolvedValueOnce({
+ createdAt: new Date(Date.now() - 60 * 60 * 1000),
+ mfaVerifiedAt: null,
+ reproofAt: null,
+ } as never);
+ const { POST } = await import("../route");
+ const res = await POST(mkReq({ format: "pdf", selection: allLeaves() }));
+ expect(res.status).toBe(401);
+ expect((await res.json()).meta.errorCode).toBe("auth.reproof.required");
+ expect(prisma.measurement.findMany).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/app/api/export/health-record/route.ts b/src/app/api/export/health-record/route.ts
index 9937fa4b8..282da0731 100644
--- a/src/app/api/export/health-record/route.ts
+++ b/src/app/api/export/health-record/route.ts
@@ -22,7 +22,11 @@
*/
import { NextRequest, NextResponse } from "next/server";
-import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
+import {
+ apiHandler,
+ assertRecentCookieProof,
+ requireRecordAuth,
+} from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
@@ -88,7 +92,17 @@ export const POST = apiHandler(async (request: NextRequest) => {
// v1.37.0 — MANAGE: the doctor report is the one export an invited manager
// reaches, because it renders a prepared artefact from a declared selection
// rather than handing over the record whole.
- const { user, actor } = await requireRecordAuth("manage", "record");
+ const auth = await requireRecordAuth("manage", "record");
+ const { user, actor } = auth;
+ // A whole-record artefact: a browser session must have signed in or
+ // re-proved within five minutes, the gate every whole-record export takes.
+ // The proof is the person at the keyboard, so it is the actor's session
+ // that is asked, whether they export their own record or one they manage.
+ // A token is its own proof here, as before: the shipped app calls this
+ // route with its token and no elevation.
+ if (auth.authMethod === "cookie") {
+ await assertRecentCookieProof(actor, auth.session.id);
+ }
annotate({ action: { name: "export.health-record.build" } });
// The whole doctor-report surface is the `doctorReport` module. Refuse with
diff --git a/src/app/api/export/route.ts b/src/app/api/export/route.ts
index 82a2c133e..0e26e7560 100644
--- a/src/app/api/export/route.ts
+++ b/src/app/api/export/route.ts
@@ -1,5 +1,5 @@
import { prisma } from "@/lib/db";
-import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { apiHandler, requireAuth, requireRecentProof } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { apiError, getClientIp } from "@/lib/api-response";
@@ -24,12 +24,23 @@ import { NextRequest, NextResponse } from "next/server";
* Export user data.
* Query params:
* format: "csv" | "json" (default: json)
- * type: "measurements" | "medications" | "intake" | "all" (default: all)
+ * type: "measurements" | "medications" | "intake" | "mood" | "all"
+ * (default: all)
*
- * Re-authentication is required via session (user must be logged in).
+ * `type=all` (the default) is the whole record in one file, so it takes the
+ * same fresh-proof gate as every other whole-record export
+ * (`requireRecentProof`): a browser session must have signed in or re-proved
+ * within five minutes, a token must present a step-up elevation. No shipped
+ * client calls this route with a token. The single-type exports stay on the
+ * session alone, like their dedicated routes under `/api/export/*`.
*/
export const GET = apiHandler(async (request: NextRequest) => {
- const { user } = await requireAuth();
+ const wholeRecord =
+ (new URL(request.url).searchParams.get("type") ?? "all") === "all";
+ const auth = wholeRecord
+ ? await requireRecentProof({ bearer: "elevation" })
+ : { ...(await requireAuth()), commitElevation: async () => {} };
+ const { user } = auth;
const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000);
if (!rl.allowed) {
@@ -54,6 +65,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
);
}
+ // Spend a Bearer elevation only once the request is about to act.
+ await auth.commitElevation();
+
const userId = user.id;
await auditLog("export.download", {
userId,
diff --git a/src/app/api/fhir/Patient/$everything/route.ts b/src/app/api/fhir/Patient/$everything/route.ts
index bf596b780..68faf6304 100644
--- a/src/app/api/fhir/Patient/$everything/route.ts
+++ b/src/app/api/fhir/Patient/$everything/route.ts
@@ -13,12 +13,17 @@
* resolves inside the response and the operation can never quietly carry less
* than the document does.
*
- * Read-only; `userId` narrowed from `requireAuth`. Offset paging applies across
+ * Read-only; `userId` narrowed from `requireAuth`. A browser session needs a
+ * recent sign-in or re-proof (`assertRecentCookieProof`). Offset paging applies across
* the flattened resource list via `_count` (clamped ≤200) / `_offset`.
*/
import { NextRequest } from "next/server";
-import { apiHandler, requireAuth } from "@/lib/api-handler";
+import {
+ apiHandler,
+ assertRecentCookieProof,
+ requireAuth,
+} from "@/lib/api-handler";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
@@ -32,7 +37,16 @@ import {
} from "@/lib/fhir/rest";
export const GET = apiHandler(async (request: NextRequest) => {
- const { user } = await requireAuth(FHIR_READ_SCOPE);
+ const auth = await requireAuth(FHIR_READ_SCOPE);
+ const { user } = auth;
+ // The whole record in one response: a browser session must have signed in
+ // or re-proved within five minutes, like every other whole-record export.
+ // A token is its own proof here, as before: the shipped app and the FHIR
+ // read tokens call this route without an elevation, and a refusal would
+ // read to the app as a dead session.
+ if (auth.authMethod === "cookie") {
+ await assertRecentCookieProof(user, auth.session.id);
+ }
annotate({ action: { name: "fhir.everything.read" } });
// v1.30 — the FHIR REST face serves the SAME whole-record aggregate as
diff --git a/src/app/api/fhir/__tests__/capability-and-everything.test.ts b/src/app/api/fhir/__tests__/capability-and-everything.test.ts
index 22f1f077f..c402cf539 100644
--- a/src/app/api/fhir/__tests__/capability-and-everything.test.ts
+++ b/src/app/api/fhir/__tests__/capability-and-everything.test.ts
@@ -15,6 +15,21 @@ import { existsSync } from "node:fs";
import path from "node:path";
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
+vi.mock("@/lib/db", () => ({
+ prisma: {
+ // The whole-record gate reads the session's own stamps: a sign-in a
+ // moment ago is the proof, on an account without a second factor.
+ session: {
+ findUnique: vi.fn(async () => ({
+ createdAt: new Date(),
+ mfaVerifiedAt: null,
+ reproofAt: null,
+ })),
+ },
+ webauthnMfaCredential: { count: vi.fn(async () => 0) },
+ passkey: { count: vi.fn(async () => 0) },
+ },
+}));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
@@ -39,6 +54,7 @@ vi.mock("next/headers", () => ({
import { GET as metadataGet } from "../metadata/route";
import { GET as everythingGet } from "../Patient/$everything/route";
+import { prisma } from "@/lib/db";
import { GET as observationRead } from "../Observation/[id]/route";
import { GET as patientRead } from "../Patient/[id]/route";
import { GET as coverageRead } from "../Coverage/[id]/route";
@@ -291,6 +307,17 @@ describe("GET /api/fhir/{type}/{id} — read", () => {
});
describe("GET /api/fhir/Patient/$everything", () => {
+ it("asks a browser session that signed in an hour ago to confirm first", async () => {
+ vi.mocked(prisma.session.findUnique).mockResolvedValueOnce({
+ createdAt: new Date(Date.now() - 60 * 60 * 1000),
+ mfaVerifiedAt: null,
+ reproofAt: null,
+ } as never);
+ const res = await everythingGet(req("/api/fhir/Patient/$everything"));
+ expect(res.status).toBe(401);
+ expect((await res.json()).meta.errorCode).toBe("auth.reproof.required");
+ });
+
it("returns every resource family the document bundle carries", async () => {
const res = await everythingGet(
req("/api/fhir/Patient/$everything?_count=200"),
diff --git a/src/app/api/fhir/__tests__/module-gate.test.ts b/src/app/api/fhir/__tests__/module-gate.test.ts
index 40d328758..eb97a9833 100644
--- a/src/app/api/fhir/__tests__/module-gate.test.ts
+++ b/src/app/api/fhir/__tests__/module-gate.test.ts
@@ -32,6 +32,17 @@ vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
cycleProfile: { findUnique: vi.fn() },
+ // The whole-record gate reads the session's own stamps: a sign-in a
+ // moment ago is the proof, on an account without a second factor.
+ session: {
+ findUnique: vi.fn(async () => ({
+ createdAt: new Date(),
+ mfaVerifiedAt: null,
+ reproofAt: null,
+ })),
+ },
+ webauthnMfaCredential: { count: vi.fn(async () => 0) },
+ passkey: { count: vi.fn(async () => 0) },
},
}));
vi.mock("@/lib/modules/operator-availability", async (importOriginal) => {
diff --git a/src/components/settings/report-selection/selection-panel.tsx b/src/components/settings/report-selection/selection-panel.tsx
index 3f589a20f..2f9f1875c 100644
--- a/src/components/settings/report-selection/selection-panel.tsx
+++ b/src/components/settings/report-selection/selection-panel.tsx
@@ -48,6 +48,11 @@ import { ReportVisitOffer } from "./report-visit-offer";
import { Switch } from "@/components/ui/switch";
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
import { apiFetchRaw } from "@/lib/api/api-fetch";
+import {
+ recentProofErrorMessage,
+ throwIfReproofRequired,
+ useRecentProof,
+} from "@/components/settings/security-section/use-recent-proof";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -67,6 +72,9 @@ type ExportFormat = "pdf" | "fhir" | "package";
const EXPORT_FORMATS: readonly ExportFormat[] = ["pdf", "fhir", "package"];
const PRESET_RANGES = [30, 90, 180, 365] as const;
+/** A refusal already worded for the panel; shown as it is. */
+class ReportRequestError extends Error {}
+
export function HealthRecordExportPanel() {
const { t, locale } = useTranslations();
const { user } = useAuth();
@@ -119,18 +127,40 @@ export function HealthRecordExportPanel() {
onSelect: (index) => setFormat(EXPORT_FORMATS[index]!),
});
+ // The report is built from the whole record, so it asks for a fresh proof
+ // unless the session signed in or re-proved within five minutes.
+ const recentProof = useRecentProof();
+
async function handleGenerate() {
setBusy(true);
setError(null);
try {
- const range =
- customRange && startDate && endDate
- ? {
- startDate: new Date(`${startDate}T00:00:00Z`).toISOString(),
- endDate: new Date(`${endDate}T23:59:59Z`).toISOString(),
- }
- : { days };
- const res = await apiFetchRaw("/api/export/health-record", {
+ await recentProof.run(generateOnce);
+ } catch (err) {
+ if (err instanceof ReportRequestError) {
+ setError(err.message);
+ return;
+ }
+ const message = recentProofErrorMessage(
+ err,
+ err instanceof Error ? err.message : String(err),
+ );
+ if (message) setError(message);
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ async function generateOnce() {
+ const range =
+ customRange && startDate && endDate
+ ? {
+ startDate: new Date(`${startDate}T00:00:00Z`).toISOString(),
+ endDate: new Date(`${endDate}T23:59:59Z`).toISOString(),
+ }
+ : { days };
+ const res = await throwIfReproofRequired(
+ await apiFetchRaw("/api/export/health-record", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
@@ -144,38 +174,33 @@ export function HealthRecordExportPanel() {
includeCharts,
selection: { v: 2, leaves: orderLeaves(selected) },
}),
- });
- if (!res.ok) {
- setError(
- res.status === 429
- ? t("settings.healthRecord.errorRateLimit")
- : res.status === 403
- ? t("settings.healthRecord.errorModuleDisabled")
- : t("settings.healthRecord.errorGeneric"),
- );
- return;
- }
- const blob = await res.blob();
- const ext = format === "pdf" ? "pdf" : format === "fhir" ? "json" : "zip";
- const url = URL.createObjectURL(blob);
- const a = document.createElement("a");
- a.href = url;
- a.download = `healthlog-health-record-${new Date()
- .toISOString()
- .slice(0, 10)}.${ext}`;
- document.body.appendChild(a);
- a.click();
- a.remove();
- URL.revokeObjectURL(url);
- // The route just persisted the practice name and the selection, so the
- // cached `/me` payload is stale. Refresh it, or a remount would seed from
- // the previous values.
- void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
- } catch (err) {
- setError(err instanceof Error ? err.message : String(err));
- } finally {
- setBusy(false);
+ }),
+ );
+ if (!res.ok) {
+ throw new ReportRequestError(
+ res.status === 429
+ ? t("settings.healthRecord.errorRateLimit")
+ : res.status === 403
+ ? t("settings.healthRecord.errorModuleDisabled")
+ : t("settings.healthRecord.errorGeneric"),
+ );
}
+ const blob = await res.blob();
+ const ext = format === "pdf" ? "pdf" : format === "fhir" ? "json" : "zip";
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `healthlog-health-record-${new Date()
+ .toISOString()
+ .slice(0, 10)}.${ext}`;
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ URL.revokeObjectURL(url);
+ // The route just persisted the practice name and the selection, so the
+ // cached `/me` payload is stale. Refresh it, or a remount would seed from
+ // the previous values.
+ void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
}
return (
@@ -397,6 +422,7 @@ export function HealthRecordExportPanel() {
)}
+ {recentProof.dialog}
);
}
diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts
index 4a43548a5..c26652e3b 100644
--- a/src/lib/openapi/routes/auth.ts
+++ b/src/lib/openapi/routes/auth.ts
@@ -332,7 +332,7 @@ const MFA_MANAGEMENT_AUTH_NOTE =
" Accepts a cookie session, or a Bearer token presenting a single-use elevation from POST /api/auth/step-up in the `X-Step-Up` header; a Bearer token alone is still refused.";
const ENROLLMENT_PROOF_NOTE =
- "\n\nAdding a factor needs a fresh proof, not just a session. On the Bearer path the elevation is that proof (a password-proved one is enough). On the cookie path the request passes when the session signed in, or completed a second factor, within the last five minutes; otherwise the body must carry a proof in the shape POST /api/auth/step-up takes (`password`, a `totp` code from an already confirmed authenticator, or a passkey / security-key assertion begun at POST /api/auth/passkey/register-options). Without one the answer is 401 `auth.reproof.required`; a proof that does not verify is 401 `auth.reproof.failed` and is audited. Proofs draw on the account's shared re-proof budget of five per fifteen minutes (429 once spent), the same budget as the step-up mint.";
+ "\n\nAdding a factor needs a fresh proof, not just a session. On the Bearer path the elevation is that proof: a password-proved one is enough on an account without a second factor, and on an account that already has one the elevation must come from that factor or a passkey. On the cookie path, for an account without a second factor, the request passes when the session signed in, or completed a passkey sign-in, within the last five minutes; otherwise the body must carry a proof in the shape POST /api/auth/step-up takes (`password`, or a passkey assertion begun at POST /api/auth/passkey/register-options). For an account that already has a second factor, only that factor counts: a second factor completed within the last five minutes, or a `totp` code, security-key or passkey assertion in the body. A password or a young session (for example one that skipped the factor on a remembered browser) is not enough there. Without one the answer is 401 `auth.reproof.required` with `meta.methods` naming the proofs the account can give; a proof that does not verify is 401 `auth.reproof.failed` and is audited. Proofs draw on the account's shared re-proof budget of five per fifteen minutes (429 once spent), the same budget as the step-up mint.";
const enrollmentProofResponses = {
"401": {
@@ -2023,7 +2023,7 @@ export const authPaths: NonNullable = {
description:
"Cookie-only — a Bearer token cannot enroll a sign-in credential on this surface at any scope. The route has TWO arms and the body decides which.\n\n" +
'**Re-proof arm.** A body of exactly `{ method: "passkey" }` or `{ method: "webauthn" }` begins an assertion ceremony WITHOUT beginning enrollment, and answers with `reauth: true` alongside the options. Use it to obtain the assertion the second arm needs.\n\n' +
- "**Enrollment arm.** Any other body must be a full factor proof (the same shape POST /api/auth/step-up takes): a password, a TOTP code, or a completed passkey / security-key assertion. Only then are registration options issued.\n\n" +
+ "**Enrollment arm.** Any other body must be a full factor proof (the same shape POST /api/auth/step-up takes): a password, a TOTP code, or a completed passkey / security-key assertion. Only then are registration options issued. On an account that already has a second factor a password is refused before it is checked, with 422 `auth.reproof.too_weak` and `meta.methods` naming the proofs that work: a passkey added on the password alone would sign in without the second factor from then on.\n\n" +
"Which factor you prove changes the session, and the direction is not the obvious one: a strong proof stamps the session second-factor-verified, while a PASSWORD proof deliberately CLEARS that stamp. Registering with a password is authorised by the single-use, session-bound challenge alone and must not silently upgrade password-only authentication into something the rest of the app reads as a second factor.\n\n" +
"Every refusal on the proof path is the same 401 with the same prose — a missing content type, an unparseable body, a body that is not a valid proof shape, and a wrong password are indistinguishable to the caller. A `totp` proof counts only from an authenticator whose enrollment was confirmed.\n\n" +
"Each proof on the enrollment arm draws on the account's shared re-proof budget (five per fifteen minutes, the same budget as POST /api/auth/step-up), and every failed proof writes an `auth.mfa.failed` audit row. Once the budget is spent the answer is 429 until the window passes, even for a correct proof.",
@@ -2060,6 +2060,11 @@ export const authPaths: NonNullable = {
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
+ "422": {
+ description:
+ "`auth.reproof.too_weak`: a password on an account that already has a second factor. `meta.methods` names the proofs that would be accepted. Refused before the password is checked, so it spends no attempt.",
+ content: { "application/json": { schema: errorEnvelope } },
+ },
"401": {
description:
"No cookie session, or no acceptable existing-factor proof. One message covers every proof failure so the response cannot be used to probe which part was wrong.",
diff --git a/src/lib/openapi/routes/export.ts b/src/lib/openapi/routes/export.ts
index 9838e73fc..a19a5d09c 100644
--- a/src/lib/openapi/routes/export.ts
+++ b/src/lib/openapi/routes/export.ts
@@ -168,7 +168,7 @@ export const exportPaths: NonNullable = {
tags: ["Export"],
summary: "Download measurements, medications, intake and mood",
description:
- "The plaintext download. Returns `application/json` or `text/csv` (never the standard response envelope) with a `Content-Disposition: attachment` filename of `healthlog-export-.`. Measurements stream page by page, so a multi-year account does not buffer its whole history in memory; the other sections are assembled in full first. The CSV variant concatenates the selected sections under `# Measurements` / `# Medications` / `# Intake Events` / `# Mood Entries` headings, so it is a CONCATENATION of tables rather than one table — a single-table CSV parser will choke on it. Mood notes are decrypted into the download; measurement notes ride their exported column. Shares the `export:` bucket with the encrypted variant (10 per hour) and audits every attempt as `export.download`. Cookie or Bearer auth; the caller is always resolved as themselves, so this download cannot be delegated to a shared record. Not module-gated: a disabled module's rows are still exported, because this is the account's own data leaving on request.",
+ "The plaintext download. Returns `application/json` or `text/csv` (never the standard response envelope) with a `Content-Disposition: attachment` filename of `healthlog-export-.`. Measurements stream page by page, so a multi-year account does not buffer its whole history in memory; the other sections are assembled in full first. The CSV variant concatenates the selected sections under `# Measurements` / `# Medications` / `# Intake Events` / `# Mood Entries` headings, so it is a CONCATENATION of tables rather than one table — a single-table CSV parser will choke on it. Mood notes are decrypted into the download; measurement notes ride their exported column. Shares the `export:` bucket with the encrypted variant (10 per hour) and audits every attempt as `export.download`. Cookie or Bearer auth; the caller is always resolved as themselves, so this download cannot be delegated to a shared record. Not module-gated: a disabled module's rows are still exported, because this is the account's own data leaving on request. `type=all`, the default, is the whole record and asks for a fresh proof like the full backup; the single-type exports do not. A browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give. A Bearer caller asking for `type=all` presents an `X-Step-Up` elevation from `POST /api/auth/step-up` (a second-factor one on an account with a second factor) and receives 401 `auth.stepup.required` without it.",
requestParams: { query: exportQuery },
responses: {
"200": {
diff --git a/src/lib/openapi/routes/health-record.ts b/src/lib/openapi/routes/health-record.ts
index 6ffd297a5..43845b213 100644
--- a/src/lib/openapi/routes/health-record.ts
+++ b/src/lib/openapi/routes/health-record.ts
@@ -113,7 +113,7 @@ export const healthRecordPaths: NonNullable = {
tags: ["Export"],
summary: "Generate a health-record export (PDF / FHIR / package)",
description:
- "Returns the doctor-handover artefact in the requested `format`: `pdf` → application/pdf, `fhir` → application/fhir+json (HL7 FHIR R4 document Bundle), `package` → application/zip (PDF + FHIR + README). `selection` is REQUIRED and carries the leaf inclusion list — membership is inclusion, absence is exclusion, and there is no server-side default. An unknown leaf id is a 422 that names it; the retired grouped `sections` shape is a 422 too, because accepting it would mean choosing a scope the caller did not express. Auth via cookie or Bearer; shared `export:` rate bucket (10/h). Strict validation: unknown keys 422.",
+ "Returns the doctor-handover artefact in the requested `format`: `pdf` → application/pdf, `fhir` → application/fhir+json (HL7 FHIR R4 document Bundle), `package` → application/zip (PDF + FHIR + README). `selection` is REQUIRED and carries the leaf inclusion list — membership is inclusion, absence is exclusion, and there is no server-side default. An unknown leaf id is a 422 that names it; the retired grouped `sections` shape is a 422 too, because accepting it would mean choosing a scope the caller did not express. Auth via cookie or Bearer; shared `export:` rate bucket (10/h). Strict validation: unknown keys 422. A browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give. When a manager exports a record they manage, it is their own session that is asked. A Bearer caller is not asked for more than its token.",
requestBody: {
required: true,
content: {
@@ -647,7 +647,7 @@ export const healthRecordPaths: NonNullable = {
tags: ["FHIR"],
summary: "FHIR R4 $everything (v1.11.0)",
description:
- "Read-only `Patient/$everything`: every resource the document bundle carries — Composition, Device, Patient, Coverage, Observations (including the cycle ones), MedicationStatements, MedicationAdministrations, Conditions, Encounters, AllergyIntolerances, FamilyMemberHistories and the DiagnosticReport (present when the window holds a vital sign) — flattened into one `searchset` Bundle. Scoped to the owner's SAVED report selection; an account that never saved one gets nothing. Auth: `fhir:read` scope. Offset paging via `_count` (≤200) / `_offset`.",
+ "Read-only `Patient/$everything`: every resource the document bundle carries — Composition, Device, Patient, Coverage, Observations (including the cycle ones), MedicationStatements, MedicationAdministrations, Conditions, Encounters, AllergyIntolerances, FamilyMemberHistories and the DiagnosticReport (present when the window holds a vital sign) — flattened into one `searchset` Bundle. Scoped to the owner's SAVED report selection; an account that never saved one gets nothing. Auth: `fhir:read` scope. Offset paging via `_count` (≤200) / `_offset`. A browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give. A token, including a `fhir:read` one, is not asked for more.",
requestParams: {
query: z.object({
_count: z.coerce.number().optional(),
From a294d7f684b42d52f3effe8d454f9945ba5be733 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 26 Sep 2026 22:34:21 +0200
Subject: [PATCH 3/9] fix(sharing): invitations need a fresh proof and end with
sign out everywhere
Offering access to the record took a live session alone, and an
invitation outlives the session that sent it: whoever held a stolen
session could invite their own account and keep a way into the record
after the owner signed everything out. POST /api/account/grants now takes
the recent-proof gate at every level (a sign-in or re-proof within five
minutes on the browser, with the second factor on an account that has one;
a step-up elevation on a token, which no shipped client uses here), and
the invite form opens the re-proof dialog and sends again.
Sign out everywhere now withdraws every invitation the account offered
that nobody has accepted. Accepted grants are left standing on purpose:
each is a person the owner chose, often a carer who still needs the
record, and a sign-out button must not quietly cut them off. The response
lists them as grantsKept and the sessions card shows each with a one-click
End access.
---
docs/api/openapi.yaml | 64 ++++++++-
messages/de.json | 6 +-
messages/en.json | 6 +-
messages/es.json | 6 +-
messages/fr.json | 6 +-
messages/it.json | 6 +-
messages/ko.json | 6 +-
messages/pl.json | 6 +-
.../recent-proof-surface-guard.test.ts | 2 +
.../__tests__/invite-access-level.test.ts | 10 +-
.../__tests__/invite-recent-proof.test.ts | 114 ++++++++++++++++
.../__tests__/invite-scope-and-manage.test.ts | 10 +-
src/app/api/account/grants/route.ts | 22 +++-
.../auth/me/sessions/__tests__/route.test.ts | 50 +++++++
src/app/api/auth/me/sessions/route.ts | 34 ++++-
.../__tests__/grant-invite-expiry.test.tsx | 14 +-
.../settings/access/grant-invite-card.tsx | 57 +++++---
.../settings/security-sessions-card.tsx | 124 +++++++++++++++---
src/lib/auth/session.ts | 19 +++
src/lib/openapi/routes/account-sharing.ts | 4 +-
src/lib/openapi/routes/auth.ts | 25 +++-
.../sessions-and-known-devices.test.ts | 59 +++++++++
22 files changed, 585 insertions(+), 65 deletions(-)
create mode 100644 src/app/api/account/grants/__tests__/invite-recent-proof.test.ts
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 0b8a74389..fbe1426cd 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -2621,7 +2621,10 @@ paths:
(the OAuth connection, so it cannot mint a new access token), every programmatic token (connector, measurement
and document tokens), and every clinician share link. Share links are revoked by default because a link made by
whoever held a lost session or device would otherwise keep opening the record; send `keepShareLinks=1` to keep
- them. A Bearer caller keeps its own device login."
+ them. Record-sharing invitations this account offered that nobody has accepted yet are withdrawn too, because an
+ invitation sent from a stolen session would otherwise wait for the thief's own account. Accepted grants are not
+ ended: the response lists them as `grantsKept` so the client can show who can still read the record and offer to
+ end each one. A Bearer caller keeps its own device login."
parameters:
- in: query
name: keepShareLinks
@@ -19394,7 +19397,12 @@ paths:
(`requireFreshMfaIfEnrolled`, cookie-only): an enrolled account without a fresh factor gets 401
`auth.stepup.required`, and a Bearer caller gets 403 `sharing.invite.manage_browser_only` before anything else
happens, because the gate it would hit resolves through the session cookie and would otherwise answer 'not
- authenticated' to a caller it had just authenticated."
+ authenticated' to a caller it had just authenticated. Every invitation, at any level, also needs a fresh proof:
+ a browser session must have signed in or used `POST /api/auth/reproof` within the last five minutes (with the
+ second factor, on an account that has one), or it gets 401 `auth.reproof.required` with `meta.methods`; a Bearer
+ caller presents an `X-Step-Up` elevation from `POST /api/auth/step-up` (a second-factor one on an account with a
+ second factor) and gets 401 `auth.stepup.required` without it. `DELETE /api/auth/me/sessions` withdraws the
+ invitations nobody has accepted yet."
requestBody:
required: true
content:
@@ -19409,8 +19417,10 @@ paths:
schema:
$ref: "#/components/schemas/AccountGrantEnvelope"
"401":
- description: "Offering MANAGE from an MFA-enrolled account without a recent second-factor proof (`meta.errorCode:
- auth.stepup.required`). Re-prove the factor and retry; READ and WRITE invitations are never gated."
+ description: "No recent proof. A browser session that has not signed in or re-proved within five minutes gets
+ `meta.errorCode: auth.reproof.required` with `meta.methods`; re-prove at POST /api/auth/reproof and retry. A
+ Bearer caller without a valid `X-Step-Up` elevation gets `auth.stepup.required`, as does offering MANAGE
+ from an MFA-enrolled browser session without a recent second-factor proof."
content:
application/json:
schema:
@@ -39023,11 +39033,57 @@ components:
minimum: -9007199254740991
maximum: 9007199254740991
description: Clinician share links revoked; 0 when `keepShareLinks=1` was sent.
+ pendingInvitesRevoked:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ description: "Record-sharing invitations this account offered that nobody had accepted yet, now withdrawn (`revokedBy:
+ GRANTOR`)."
+ grantsKept:
+ type: array
+ items:
+ type: object
+ properties:
+ id:
+ type: string
+ description: The grant id, for DELETE /api/account/grants/{id}.
+ account:
+ type: object
+ properties:
+ id:
+ type: string
+ username:
+ type: string
+ displayName:
+ anyOf:
+ - type: string
+ - type: "null"
+ required:
+ - id
+ - username
+ - displayName
+ additionalProperties: false
+ access:
+ type: string
+ enum:
+ - READ
+ - WRITE
+ - MANAGE
+ required:
+ - id
+ - account
+ - access
+ additionalProperties: false
+ description: Accepted grants this account gave that are still live. Signing out does not end them, because each is a
+ person the owner chose and ending one silently could cut off a carer; they are listed so a client can show
+ who can still read the record and offer to end each with DELETE /api/account/grants/{id}.
required:
- sessionsRevoked
- accessTokensRevoked
- connectorsRevoked
- shareLinksRevoked
+ - pendingInvitesRevoked
+ - grantsKept
additionalProperties: false
SessionRevokeEnvelope:
type: object
diff --git a/messages/de.json b/messages/de.json
index 8b6523d6b..8e7c9e885 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -6556,7 +6556,11 @@
"revokeSessionConfirmBody": "{device} muss sich neu anmelden. An den dort erfassten Daten ändert sich nichts.",
"signOutEverywhereConfirmTitle": "Alle anderen Geräte abmelden?",
"signOutEverywhereConfirmBody": "Alle anderen Geräte, jeder verbundene KI-Assistent und jedes API-Token müssen sich neu anmelden oder neu eingerichtet werden. Dieses Gerät bleibt angemeldet. Deine Einträge bleiben unberührt.",
- "signOutEverywhereShareLinks": "Auch die Freigabelinks für Ärztinnen und Ärzte beenden"
+ "signOutEverywhereShareLinks": "Auch die Freigabelinks für Ärztinnen und Ärzte beenden",
+ "signOutEverywhereInvites": "Einladungen zu deiner Akte, die noch niemand angenommen hat, werden zurückgezogen. Wer schon angenommen hat, behält den Zugriff; du kannst ihn gleich danach beenden.",
+ "signOutEverywhereInvitesDone": "{count} nicht angenommene Einladung(en) zurückgezogen.",
+ "grantsKeptTitle": "Diese Personen sehen deine Akte weiterhin",
+ "grantsKeptBody": "Das Abmelden beendet keinen Zugriff, den du jemandem gegeben hast. Beende ihn hier, wenn du ihn nicht gegeben hast oder nicht mehr willst."
},
"privacy": {
"encryption": {
diff --git a/messages/en.json b/messages/en.json
index 8eeaf9ad7..5fb9c4cf2 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -6556,7 +6556,11 @@
"revokeSessionConfirmBody": "{device} will need to sign in again. Nothing recorded on it is affected.",
"signOutEverywhereConfirmTitle": "Sign out every other device?",
"signOutEverywhereConfirmBody": "Every other device, every connected AI assistant and every API token will need to sign in or be set up again. This device stays signed in. Nothing you have recorded is affected.",
- "signOutEverywhereShareLinks": "Also end the doctor share links"
+ "signOutEverywhereShareLinks": "Also end the doctor share links",
+ "signOutEverywhereInvites": "Invitations to your record that nobody has accepted yet are withdrawn. People who already accepted keep their access; you can end it next.",
+ "signOutEverywhereInvitesDone": "Withdrew {count} invitation(s) nobody had accepted.",
+ "grantsKeptTitle": "These people can still see your record",
+ "grantsKeptBody": "Signing out does not end access you gave someone. End it here if you did not give it, or no longer want them to have it."
},
"privacy": {
"encryption": {
diff --git a/messages/es.json b/messages/es.json
index 193dd1fea..3caf7e3d1 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -6556,7 +6556,11 @@
"revokeSessionConfirmBody": "{device} tendrá que iniciar sesión de nuevo. Lo registrado en él no se ve afectado.",
"signOutEverywhereConfirmTitle": "¿Cerrar la sesión en los demás dispositivos?",
"signOutEverywhereConfirmBody": "Todos los demás dispositivos, cada asistente de IA conectado y cada token de API tendrán que volver a iniciar sesión o configurarse de nuevo. Este dispositivo sigue con la sesión iniciada. Nada de lo que has registrado se ve afectado.",
- "signOutEverywhereShareLinks": "Terminar también los enlaces compartidos con profesionales médicos"
+ "signOutEverywhereShareLinks": "Terminar también los enlaces compartidos con profesionales médicos",
+ "signOutEverywhereInvites": "Se retiran las invitaciones a tu historial que nadie ha aceptado todavía. Quien ya aceptó conserva el acceso; puedes quitárselo a continuación.",
+ "signOutEverywhereInvitesDone": "Se retiraron {count} invitación(es) que nadie había aceptado.",
+ "grantsKeptTitle": "Estas personas todavía pueden ver tu historial",
+ "grantsKeptBody": "Cerrar sesión no quita el acceso que diste a alguien. Quítalo aquí si no lo diste o si ya no quieres que lo tenga."
},
"privacy": {
"encryption": {
diff --git a/messages/fr.json b/messages/fr.json
index 2fe9be017..f362b2bae 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -6556,7 +6556,11 @@
"revokeSessionConfirmBody": "{device} devra se reconnecter. Ce qui y est enregistré n'est pas affecté.",
"signOutEverywhereConfirmTitle": "Déconnecter tous les autres appareils ?",
"signOutEverywhereConfirmBody": "Tous les autres appareils, chaque assistant IA connecté et chaque jeton d'API devront se reconnecter ou être reconfigurés. Cet appareil reste connecté. Rien de ce que vous avez enregistré n'est touché.",
- "signOutEverywhereShareLinks": "Mettre fin aussi aux liens de partage avec les médecins"
+ "signOutEverywhereShareLinks": "Mettre fin aussi aux liens de partage avec les médecins",
+ "signOutEverywhereInvites": "Les invitations à votre dossier que personne n'a encore acceptées sont retirées. Les personnes qui ont déjà accepté gardent leur accès ; vous pourrez y mettre fin ensuite.",
+ "signOutEverywhereInvitesDone": "{count} invitation(s) non acceptée(s) retirée(s).",
+ "grantsKeptTitle": "Ces personnes peuvent toujours voir votre dossier",
+ "grantsKeptBody": "Se déconnecter ne met pas fin à l'accès que vous avez donné à quelqu'un. Mettez-y fin ici si vous ne l'avez pas donné ou si vous ne le souhaitez plus."
},
"privacy": {
"encryption": {
diff --git a/messages/it.json b/messages/it.json
index 4f527a583..404551bb8 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -6556,7 +6556,11 @@
"revokeSessionConfirmBody": "{device} dovrà accedere di nuovo. Quanto registrato su di esso non viene toccato.",
"signOutEverywhereConfirmTitle": "Disconnettere tutti gli altri dispositivi?",
"signOutEverywhereConfirmBody": "Tutti gli altri dispositivi, ogni assistente IA collegato e ogni token API dovranno accedere di nuovo o essere configurati di nuovo. Questo dispositivo resta connesso. Nulla di ciò che hai registrato viene toccato.",
- "signOutEverywhereShareLinks": "Termina anche i link di condivisione con i medici"
+ "signOutEverywhereShareLinks": "Termina anche i link di condivisione con i medici",
+ "signOutEverywhereInvites": "Gli inviti alla tua cartella che nessuno ha ancora accettato vengono ritirati. Chi ha già accettato mantiene l'accesso; puoi revocarlo subito dopo.",
+ "signOutEverywhereInvitesDone": "Ritirati {count} inviti non accettati.",
+ "grantsKeptTitle": "Queste persone possono ancora vedere la tua cartella",
+ "grantsKeptBody": "Disconnettersi non revoca l'accesso che hai dato a qualcuno. Revocalo qui se non l'hai dato tu o se non vuoi più che lo abbia."
},
"privacy": {
"encryption": {
diff --git a/messages/ko.json b/messages/ko.json
index 5e80dc3b9..85af2c610 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -6556,7 +6556,11 @@
"revokeSessionConfirmBody": "{device} — 다시 로그인해야 해요. 기기에 기록된 내용은 영향받지 않아요.",
"signOutEverywhereConfirmTitle": "다른 기기를 모두 로그아웃할까요?",
"signOutEverywhereConfirmBody": "다른 모든 기기, 연결된 모든 AI 어시스턴트와 모든 API 토큰은 다시 로그인하거나 다시 설정해야 합니다. 이 기기는 로그인 상태로 유지됩니다. 기록한 내용에는 영향이 없습니다.",
- "signOutEverywhereShareLinks": "의료진 공유 링크도 종료"
+ "signOutEverywhereShareLinks": "의료진 공유 링크도 종료",
+ "signOutEverywhereInvites": "아직 아무도 수락하지 않은 기록 공유 초대는 철회됩니다. 이미 수락한 사람은 접근 권한을 유지하며, 이어서 끝낼 수 있어요.",
+ "signOutEverywhereInvitesDone": "수락되지 않은 초대 {count}개를 철회했어요.",
+ "grantsKeptTitle": "이 사람들은 여전히 내 기록을 볼 수 있어요",
+ "grantsKeptBody": "로그아웃해도 다른 사람에게 준 접근 권한은 끝나지 않아요. 직접 준 적이 없거나 더 이상 원하지 않으면 여기에서 끝내세요."
},
"privacy": {
"encryption": {
diff --git a/messages/pl.json b/messages/pl.json
index f04de5400..541ad9267 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -6556,7 +6556,11 @@
"revokeSessionConfirmBody": "{device} będzie musiało zalogować się ponownie. Zapisane na nim dane pozostają bez zmian.",
"signOutEverywhereConfirmTitle": "Wylogować wszystkie pozostałe urządzenia?",
"signOutEverywhereConfirmBody": "Wszystkie inne urządzenia, każdy połączony asystent AI i każdy token API będą musiały zalogować się ponownie lub zostać skonfigurowane od nowa. To urządzenie pozostaje zalogowane. Zapisane dane pozostają bez zmian.",
- "signOutEverywhereShareLinks": "Zakończ także linki udostępniania dla lekarzy"
+ "signOutEverywhereShareLinks": "Zakończ także linki udostępniania dla lekarzy",
+ "signOutEverywhereInvites": "Zaproszenia do Twojej karty, których nikt jeszcze nie przyjął, zostaną wycofane. Osoby, które już je przyjęły, zachowują dostęp; możesz go zakończyć zaraz potem.",
+ "signOutEverywhereInvitesDone": "Wycofano nieprzyjęte zaproszenia: {count}.",
+ "grantsKeptTitle": "Te osoby nadal widzą Twoją kartę",
+ "grantsKeptBody": "Wylogowanie nie kończy dostępu, który komuś dałeś. Zakończ go tutaj, jeśli go nie dawałeś albo już nie chcesz, żeby go miał."
},
"privacy": {
"encryption": {
diff --git a/src/__tests__/recent-proof-surface-guard.test.ts b/src/__tests__/recent-proof-surface-guard.test.ts
index 9a1e0f8ae..cb6bb3878 100644
--- a/src/__tests__/recent-proof-surface-guard.test.ts
+++ b/src/__tests__/recent-proof-surface-guard.test.ts
@@ -73,6 +73,8 @@ function callers(exportName: string): string[] {
/** Routes that resolve their own caller and take the recent-proof gate. */
const RECENT_PROOF_ROUTES: Record = {
+ // Offering access to the record. No shipped client calls it on a token.
+ "app/api/account/grants/route.ts": "elevation",
// `type=all` only; the single-type exports stay on `requireAuth`.
"app/api/export/route.ts": "elevation",
"app/api/export/full-backup/route.ts": "elevation",
diff --git a/src/app/api/account/grants/__tests__/invite-access-level.test.ts b/src/app/api/account/grants/__tests__/invite-access-level.test.ts
index 3798cc52f..431ad44e0 100644
--- a/src/app/api/account/grants/__tests__/invite-access-level.test.ts
+++ b/src/app/api/account/grants/__tests__/invite-access-level.test.ts
@@ -27,11 +27,19 @@ vi.mock("@/lib/api-handler", async () => {
await vi.importActual(
"@/lib/api-handler",
);
+ const requireAuth = vi.fn();
return {
...actual,
apiHandler: Promise>(h: T): T =>
h,
- requireAuth: vi.fn(),
+ requireAuth,
+ // The invitation takes the fresh-proof gate; here it resolves the caller
+ // the stubbed `requireAuth` names, so these cases stay about the row.
+ // The gate itself has its own case in `invite-recent-proof.test.ts`.
+ requireRecentProof: vi.fn(async () => ({
+ ...(await requireAuth()),
+ commitElevation: async () => {},
+ })),
};
});
diff --git a/src/app/api/account/grants/__tests__/invite-recent-proof.test.ts b/src/app/api/account/grants/__tests__/invite-recent-proof.test.ts
new file mode 100644
index 000000000..e4c987074
--- /dev/null
+++ b/src/app/api/account/grants/__tests__/invite-recent-proof.test.ts
@@ -0,0 +1,114 @@
+/**
+ * Offering access to the record asks for a fresh proof.
+ *
+ * An invitation outlives the session that sent it, so a live session alone
+ * was enough for a stolen one to leave a standing way into the record in the
+ * thief's own account. The real gate runs here; only the session, the
+ * database and the headers are stubbed.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { NextRequest } from "next/server";
+
+vi.mock("@/lib/db", () => ({
+ prisma: {
+ session: { findUnique: vi.fn() },
+ webauthnMfaCredential: { count: vi.fn(async () => 0) },
+ passkey: { count: vi.fn(async () => 0) },
+ user: { findFirst: vi.fn() },
+ accountGrant: { create: vi.fn(), findFirst: vi.fn() },
+ },
+}));
+vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
+vi.mock("@/lib/rate-limit", () => ({
+ checkRateLimit: vi.fn(async () => ({ allowed: true })),
+}));
+vi.mock("@/lib/auth/audit", () => ({
+ auditLog: vi.fn().mockResolvedValue(undefined),
+ recordDelegatedAccess: vi.fn(),
+}));
+vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
+vi.mock("@/lib/sharing/grants", async (importOriginal) => ({
+ ...(await importOriginal()),
+ inviteGrant: vi.fn(),
+}));
+vi.mock("next/headers", () => ({
+ headers: vi.fn(async () => ({ get: () => null })),
+ cookies: vi.fn(async () => ({
+ get: () => undefined,
+ set: () => {},
+ delete: () => {},
+ })),
+}));
+
+import { POST } from "../route";
+import { getSession } from "@/lib/auth/session";
+import { prisma } from "@/lib/db";
+import { inviteGrant } from "@/lib/sharing/grants";
+
+const OWNER = {
+ id: "owner-1",
+ role: "USER",
+ username: "owner",
+ totpConfirmedAt: null,
+ passwordHash: "argon",
+};
+
+function invite(): NextRequest {
+ return new NextRequest("http://localhost/api/account/grants", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ identifier: "housemate", access: "READ" }),
+ });
+}
+
+function signedIn(createdAt: Date) {
+ vi.mocked(getSession).mockResolvedValue({
+ session: { id: "sess-1", expiresAt: new Date(Date.now() + 1e6) },
+ user: OWNER,
+ } as never);
+ vi.mocked(prisma.session.findUnique).mockResolvedValue({
+ createdAt,
+ mfaVerifiedAt: null,
+ reproofAt: null,
+ } as never);
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+describe("POST /api/account/grants — fresh proof", () => {
+ it("a session that signed in an hour ago is asked to confirm, and nothing is offered", async () => {
+ signedIn(new Date(Date.now() - 60 * 60 * 1000));
+ const res = await POST(invite());
+ expect(res.status).toBe(401);
+ const body = await res.json();
+ expect(body.meta.errorCode).toBe("auth.reproof.required");
+ expect(body.meta.methods).toEqual(["password"]);
+ expect(prisma.user.findFirst).not.toHaveBeenCalled();
+ expect(inviteGrant).not.toHaveBeenCalled();
+ });
+
+ it("a session that signed in a minute ago offers the invitation", async () => {
+ signedIn(new Date(Date.now() - 60 * 1000));
+ vi.mocked(prisma.user.findFirst).mockResolvedValue({
+ id: "invitee-1",
+ username: "housemate",
+ displayName: null,
+ } as never);
+ vi.mocked(inviteGrant).mockResolvedValue({
+ id: "grant-1",
+ access: "READ",
+ scopeJson: null,
+ invitedAt: new Date(),
+ acceptedAt: null,
+ expiresAt: null,
+ lastUsedAt: null,
+ revokedAt: null,
+ revokedBy: null,
+ } as never);
+ const res = await POST(invite());
+ expect(res.status).toBe(201);
+ expect(inviteGrant).toHaveBeenCalled();
+ });
+});
diff --git a/src/app/api/account/grants/__tests__/invite-scope-and-manage.test.ts b/src/app/api/account/grants/__tests__/invite-scope-and-manage.test.ts
index 265283687..1d79bad00 100644
--- a/src/app/api/account/grants/__tests__/invite-scope-and-manage.test.ts
+++ b/src/app/api/account/grants/__tests__/invite-scope-and-manage.test.ts
@@ -31,11 +31,19 @@ vi.mock("@/lib/api-handler", async () => {
await vi.importActual(
"@/lib/api-handler",
);
+ const requireAuth = vi.fn();
return {
...actual,
apiHandler: Promise>(h: T): T =>
h,
- requireAuth: vi.fn(),
+ requireAuth,
+ // The invitation takes the fresh-proof gate; here it resolves the caller
+ // the stubbed `requireAuth` names, so these cases stay about the row.
+ // The gate itself has its own case in `invite-recent-proof.test.ts`.
+ requireRecentProof: vi.fn(async () => ({
+ ...(await requireAuth()),
+ commitElevation: async () => {},
+ })),
requireFreshMfaIfEnrolled: vi.fn(),
};
});
diff --git a/src/app/api/account/grants/route.ts b/src/app/api/account/grants/route.ts
index 1a0184f55..0cae8e85d 100644
--- a/src/app/api/account/grants/route.ts
+++ b/src/app/api/account/grants/route.ts
@@ -21,9 +21,15 @@
* a code rather than leaving a token caller with an authentication error on a
* request that authenticated fine.
*
- * **Neither is delegable, and that is structural.** Both resolve through bare
- * `requireAuth()`, which refuses outright while the caller is acting on
- * another account. So a delegate cannot invite anybody into the record they
+ * v1.39.3 — offering any level also asks for a fresh proof
+ * (`requireRecentProof`), and "sign out everywhere" withdraws the invitations
+ * nobody has accepted yet. An invitation outlives the session that sent it, so
+ * one offered from a stolen session would otherwise stay live after the owner
+ * signed everything else out.
+ *
+ * **Neither is delegable, and that is structural.** Both resolve through
+ * `requireAuth()` (the invitation through `requireRecentProof`, which calls
+ * it), which refuses outright while the caller is acting on another account. So a delegate cannot invite anybody into the record they
* were given access to, cannot widen their own grant, and cannot pass it on:
* the route does not have a re-delegation check that somebody could soften, it
* has no code path that runs under a switch at all. The grantor is then taken
@@ -45,6 +51,7 @@ import {
apiHandler,
requireAuth,
requireFreshMfaIfEnrolled,
+ requireRecentProof,
MFA_STEP_UP_MAX_AGE_SECONDS,
} from "@/lib/api-handler";
import {
@@ -122,7 +129,12 @@ export const GET = apiHandler(async () => {
});
export const POST = apiHandler(async (request: NextRequest) => {
- const auth = await requireAuth();
+ // Offering access leaves a standing way into the record in somebody else's
+ // hands, one that outlives the session that offered it. So a live session is
+ // not enough: a sign-in or re-proof within five minutes on the browser, with
+ // the second factor on an account that has one, or a step-up elevation on a
+ // token. No shipped client offers access with a token.
+ const auth = await requireRecentProof({ bearer: "elevation" });
const user = auth.user;
const rl = await checkRateLimit(
@@ -194,6 +206,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("No account with that name or e-mail", 404);
}
+ await auth.commitElevation();
+
try {
// `inviteGrant` decides everything about the row except the level: the
// self-grant refusal, and the one-live-grant-per-pair rule the partial
diff --git a/src/app/api/auth/me/sessions/__tests__/route.test.ts b/src/app/api/auth/me/sessions/__tests__/route.test.ts
index d19634e43..8f90a9de1 100644
--- a/src/app/api/auth/me/sessions/__tests__/route.test.ts
+++ b/src/app/api/auth/me/sessions/__tests__/route.test.ts
@@ -12,6 +12,7 @@ vi.mock("@/lib/db", () => ({
update: vi.fn(() => ({ catch: () => undefined })),
},
user: { findUnique: vi.fn() },
+ accountGrant: { findMany: vi.fn(async () => []) },
},
}));
@@ -165,6 +166,7 @@ describe("DELETE /api/auth/me/sessions (sign out everywhere)", () => {
accessTokensRevoked: 0,
connectorsRevoked: 0,
shareLinksRevoked: 0,
+ pendingInvitesRevoked: 0,
});
const res = await DELETE(del());
@@ -205,6 +207,7 @@ describe("DELETE /api/auth/me/sessions (sign out everywhere)", () => {
accessTokensRevoked: 1,
connectorsRevoked: 0,
shareLinksRevoked: 0,
+ pendingInvitesRevoked: 0,
});
const res = await DELETE(del());
@@ -219,6 +222,53 @@ describe("DELETE /api/auth/me/sessions (sign out everywhere)", () => {
});
});
+describe("sign out everywhere and record sharing", () => {
+ it("reports withdrawn invitations and lists the accepted grants it kept", async () => {
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ vi.mocked(destroyOtherSessions).mockResolvedValue({
+ sessionsRevoked: 1,
+ accessTokensRevoked: 0,
+ connectorsRevoked: 0,
+ shareLinksRevoked: 0,
+ pendingInvitesRevoked: 2,
+ });
+ const party = { id: "user-2", username: "carer", displayName: null };
+ const live = {
+ id: "grant-live",
+ access: "READ",
+ acceptedAt: new Date("2026-01-01"),
+ revokedAt: null,
+ expiresAt: null,
+ grantee: party,
+ };
+ const lapsed = {
+ ...live,
+ id: "grant-lapsed",
+ expiresAt: new Date("2026-02-01"),
+ };
+ vi.mocked(prisma.accountGrant.findMany).mockResolvedValue([
+ live,
+ lapsed,
+ ] as never);
+
+ const res = await DELETE(del());
+ const body = await res.json();
+ expect(body.data.pendingInvitesRevoked).toBe(2);
+ // The lapsed one confers nothing any more and is not listed.
+ expect(body.data.grantsKept).toEqual([
+ { id: "grant-live", account: party, access: "READ" },
+ ]);
+ expect(prisma.accountGrant.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: expect.objectContaining({
+ grantorId: "user-1",
+ revokedAt: null,
+ }),
+ }),
+ );
+ });
+});
+
describe("DELETE /api/auth/me/sessions/[id]", () => {
function ctx(id: string) {
return { params: Promise.resolve({ id }) };
diff --git a/src/app/api/auth/me/sessions/route.ts b/src/app/api/auth/me/sessions/route.ts
index 1b9b02c34..9f256b4f4 100644
--- a/src/app/api/auth/me/sessions/route.ts
+++ b/src/app/api/auth/me/sessions/route.ts
@@ -5,7 +5,10 @@
* connection and token, and (unless
* `?keepShareLinks=1`) every clinician share
* link, keeping the caller's current
- * credential. Closes #64.
+ * credential. Closes #64. Also withdraws
+ * record-sharing invitations nobody has
+ * accepted yet, and lists the accepted
+ * grants it left standing (`grantsKept`).
*
* v1.23 — the user-facing session/device-management surface. Distinct from
* `/api/auth/me/devices`, which lists APNs / Web-Push notification devices;
@@ -34,6 +37,8 @@ import {
type CurrentCredential,
} from "@/lib/auth/session";
import { lookupIpLocation } from "@/lib/geo";
+import { GRANT_PARTY_SELECT } from "@/lib/sharing/grant-view";
+import { isGrantActive } from "@/lib/sharing/grants";
import { coarseDeviceLabel, maskIp } from "@/lib/auth/device-fingerprint";
export const dynamic = "force-dynamic";
@@ -131,11 +136,32 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
accessTokensRevoked,
connectorsRevoked,
shareLinksRevoked,
+ pendingInvitesRevoked,
} = await destroyOtherSessions(user.id, current, {
reach: "everything",
keepShareLinks,
});
+ // The accepted grants this account gave, still live. Not ended here: each is
+ // a person the owner chose, often a carer who still needs the record, and a
+ // button labelled "sign out" must not quietly cut them off. They are listed
+ // instead, so the person who just signed everything out can see who can
+ // still read the record and end any of them with one click.
+ const now = new Date();
+ const grantsKept = (
+ await prisma.accountGrant.findMany({
+ where: {
+ grantorId: user.id,
+ acceptedAt: { not: null },
+ revokedAt: null,
+ },
+ orderBy: { acceptedAt: "desc" },
+ include: { grantee: { select: GRANT_PARTY_SELECT } },
+ })
+ )
+ .filter((g) => isGrantActive(g, now))
+ .map((g) => ({ id: g.id, account: g.grantee, access: g.access }));
+
await auditLog("auth.session.revoke_others", {
userId: user.id,
ipAddress: getClientIp(request),
@@ -144,6 +170,8 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
accessTokensRevoked,
connectorsRevoked,
shareLinksRevoked,
+ pendingInvitesRevoked,
+ grantsKept: grantsKept.length,
keepShareLinks,
},
});
@@ -155,6 +183,8 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
access_tokens_revoked: accessTokensRevoked,
connectors_revoked: connectorsRevoked,
share_links_revoked: shareLinksRevoked,
+ pending_invites_revoked: pendingInvitesRevoked,
+ grants_kept: grantsKept.length,
},
});
@@ -163,6 +193,8 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
accessTokensRevoked,
connectorsRevoked,
shareLinksRevoked,
+ pendingInvitesRevoked,
+ grantsKept,
});
});
diff --git a/src/components/settings/access/__tests__/grant-invite-expiry.test.tsx b/src/components/settings/access/__tests__/grant-invite-expiry.test.tsx
index 49bb21f75..faae601af 100644
--- a/src/components/settings/access/__tests__/grant-invite-expiry.test.tsx
+++ b/src/components/settings/access/__tests__/grant-invite-expiry.test.tsx
@@ -70,15 +70,13 @@ describe("grant invitation lapse date", () => {
),
"utf8",
);
- const onError = source.slice(
- source.indexOf("onError: (err) => setError"),
- source.indexOf(
- " );\n };",
- source.indexOf("onError: (err) => setError"),
- ),
- );
+ // The failure arm of the submit: everything from its `catch` to the end
+ // of the handler.
+ const start = source.indexOf("} catch (err) {");
+ expect(start).toBeGreaterThan(-1);
+ const onError = source.slice(start, source.indexOf("\n };", start));
- expect(onError).toContain("setError(t(inviteErrorKey(err)))");
+ expect(onError).toContain("t(inviteErrorKey(err))");
expect(onError).not.toContain("setIdentifier");
expect(onError).not.toContain("setExpiresOn");
expect(source).toContain('role="alert"');
diff --git a/src/components/settings/access/grant-invite-card.tsx b/src/components/settings/access/grant-invite-card.tsx
index 3b86108ea..d9202302c 100644
--- a/src/components/settings/access/grant-invite-card.tsx
+++ b/src/components/settings/access/grant-invite-card.tsx
@@ -18,6 +18,10 @@ import { useModuleEnabled } from "@/hooks/use-module-enabled";
import { ApiError } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { useInviteGrant } from "@/lib/queries/use-account-grants";
+import {
+ ReproofCancelledError,
+ useRecentProof,
+} from "@/components/settings/security-section/use-recent-proof";
import { SHARE_DOMAINS, type ShareDomain } from "@/lib/sharing/scope";
import { DEFAULT_TIMEZONE, isValidTimezone } from "@/lib/tz/format";
import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock";
@@ -79,6 +83,7 @@ import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock";
export function GrantInviteCard() {
const { t } = useTranslations();
const invite = useInviteGrant();
+ const recentProof = useRecentProof();
const ownerTimezone = useInvitationOwnerTimezone();
const [identifier, setIdentifier] = useState("");
const [expiresOn, setExpiresOn] = useState("");
@@ -118,31 +123,40 @@ export function GrantInviteCard() {
setError(null);
};
- const submit = (event: React.FormEvent) => {
+ const submit = async (event: React.FormEvent) => {
event.preventDefault();
const value = identifier.trim();
if (value.length === 0 || choice.blocked) return;
setError(null);
setInvited(null);
- invite.mutate(
- {
- identifier: value,
- access,
- scope: choice.scope,
- expiresAt: endOfDayIso(expiresOn, ownerTimezone),
- },
- {
- onSuccess: (grant) => {
- setIdentifier("");
- setExpiresOn("");
- setAccess("READ");
- setNarrowed(null);
- setSections([]);
- setInvited(grant.account.username);
- },
- onError: (err) => setError(t(inviteErrorKey(err))),
- },
- );
+ // Offering access hands somebody a standing way into the record, so the
+ // server asks for a fresh proof unless the session signed in or re-proved
+ // within five minutes; the dialog collects it and the invitation is sent
+ // again.
+ try {
+ const grant = await recentProof.run(() =>
+ invite.mutateAsync({
+ identifier: value,
+ access,
+ scope: choice.scope,
+ expiresAt: endOfDayIso(expiresOn, ownerTimezone),
+ }),
+ );
+ setIdentifier("");
+ setExpiresOn("");
+ setAccess("READ");
+ setNarrowed(null);
+ setSections([]);
+ setInvited(grant.account.username);
+ } catch (err) {
+ if (err instanceof ReproofCancelledError) return;
+ setError(
+ err instanceof ApiError &&
+ err.meta?.errorCode === "auth.reproof.sign_in_again"
+ ? err.message
+ : t(inviteErrorKey(err)),
+ );
+ }
};
return (
@@ -159,7 +173,7 @@ export function GrantInviteCard() {
"recordSharing.invite.needsAccount",
)}`}
/>
-