Merge branch 'feat/v1462-auth' into release/v1.46.2

This commit is contained in:
Marc-André Bombeck committed 2026-10-29 09:45:37 +01:00
commit d0be3ae106
74 files changed
+3645 -901

No files matched your search

@@ -1,7 +1,7 @@
{
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 346,
"total": 112297,
"total": 112215,
"entries": {
"app/achievements/page.tsx :: AchievementsPage": 206,
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 286,
@@ -9,7 +9,7 @@
"app/api/admin/settings/route.ts :: apiHandler(arg) #2": 211,
"app/api/auth/login/route.ts :: apiHandler(arg)": 262,
"app/api/auth/me/route.ts :: apiHandler(arg)": 386,
"app/api/auth/oidc/callback/route.ts :: apiHandler(arg)": 281,
"app/api/auth/oidc/callback/route.ts :: apiHandler(arg)": 258,
"app/api/auth/register/route.ts :: apiHandler(arg)": 268,
"app/api/coach/about-me/route.ts :: apiHandler(arg) #2": 309,
"app/api/cycle/calendar/route.ts :: apiHandler(arg)": 230,
@@ -217,7 +217,7 @@
"components/settings/mood-reminder-card.tsx :: MoodReminderCard": 234,
"components/settings/report-selection/selection-panel.tsx :: HealthRecordExportPanel": 370,
"components/settings/score-section.tsx :: ScoreSection": 383,
"components/settings/security-section/passkey-list-section.tsx :: PasskeyListSection": 410,
"components/settings/security-section/passkey-list-section.tsx :: PasskeyListSection": 390,
"components/settings/security-section/security-keys-card.tsx :: SecurityKeysCard": 288,
"components/settings/security-section/totp-card.tsx :: TotpCard": 533,
"components/settings/security-sessions-card.tsx :: SecuritySessionsCard": 245,
+2 -3
View File
@@ -1,7 +1,7 @@
{
"$comment": "as never casts in tests, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.",
"count": 524,
"total": 5488,
"count": 523,
"total": 5483,
"entries": {
"__tests__/api/auth/login/mfa-branch.test.ts": 10,
"__tests__/api/auth/mfa/verify.test.ts": 9,
@@ -337,7 +337,6 @@
"lib/auth/__tests__/mfa-enrollment.test.ts": 8,
"lib/auth/__tests__/native-handoff-flow.test.ts": 10,
"lib/auth/__tests__/oidc-native-handoff.test.ts": 13,
"lib/auth/__tests__/oidc.test.ts": 5,
"lib/auth/__tests__/passkey.test.ts": 7,
"lib/auth/__tests__/profile-update-email-limit.test.ts": 5,
"lib/auth/__tests__/profile-update.test.ts": 4,
@@ -1,7 +1,7 @@
{
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
"count": 2897,
"total": 9236,
"count": 2896,
"total": 9235,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 2,
"__tests__/acting-account-boundary-guard.test.ts": 7,
@@ -2067,7 +2067,6 @@
"lib/google-health/sync-metrics.ts": 1,
"lib/i18n/__tests__/relative-time-updated.test.ts": 2,
"lib/i18n/config.ts": 1,
"lib/i18n/context.tsx": 1,
"lib/i18n/relative-time.ts": 4,
"lib/i18n/shared-resolve.ts": 1,
"lib/i18n/source-labels.ts": 1,
@@ -679,6 +679,17 @@ const REGISTRY: DestructiveEntry[] = [
recovery: "permanent",
confirm: ["AlertDialog"],
},
{
file: "components/admin/delete-user-control.tsx",
destroys:
"another user's whole account, locked at once and erased when the deletion grace window ends (or at once when the window is 0)",
triggers: ["components/admin/user-management-section.tsx"],
// The undo route exists and the row offers it until the purge date
// (`pending-deletion-controls.tsx`); with the window at 0 there is
// nothing to undo, and the dialog says so.
recovery: "restorable",
confirm: ["ConfirmButton"],
},
// ── Integrations ────────────────────────────────────────────────────────
// Every connection renders the shared integration card, whose three-dot
+15 -3
View File
@@ -145,15 +145,27 @@ describe("managed-profile contract", () => {
path.join(root, "src/lib/sharing/grants.ts"),
"utf8",
);
const accountDeletion = await readFile(
path.join(root, "src/app/api/settings/account/route.ts"),
// Both account deletions (the account's own and an admin's) take the one
// request path, and that path decides the Guardian invariant.
const deletionRequest = await readFile(
path.join(root, "src/lib/account-deletion/request.ts"),
"utf8",
);
const deletionRoutes = await Promise.all(
[
"src/app/api/settings/account/route.ts",
"src/app/api/admin/users/[id]/route.ts",
].map((rel) => readFile(path.join(root, rel), "utf8")),
);
expect(lifecycle).toContain("pg_advisory_xact_lock");
expect(lifecycle).toContain("clearManagedProfileMarker");
expect(lifecycle).toContain("deleteManagedProfile");
expect(grants).toContain("reduceManagedProfileGuardian");
expect(accountDeletion).toContain("deleteGuardianAccountWithLifecycle");
expect(deletionRequest).toContain("deleteGuardianAccountWithLifecycle(");
for (const route of deletionRoutes) {
expect(route).toContain("requestAccountDeletion(");
expect(route).not.toContain("lockAccountForDeletion(");
}
});
});
@@ -295,7 +295,7 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
"/api/admin/support-report": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/tokens": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/users": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/users/{id}": { kind: "adminConsole", methods: ["PUT"] },
"/api/admin/users/{id}": { kind: "adminConsole", methods: ["DELETE", "PUT"] },
"/api/admin/users/{id}/force-logout": {
kind: "adminConsole",
methods: ["POST"],
@@ -308,6 +308,10 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
kind: "adminConsole",
methods: ["POST"],
},
// #1296 — the identity-provider re-proof start. A top-level navigation from
// the web's re-proof dialog that ends at the provider's sign-in page; the
// native client never re-proves this way (Bearer has its own step-up).
"/api/auth/oidc/reproof": { kind: "browserHandoff", methods: ["GET"] },
"/api/fitbit/connect": { kind: "browserHandoff", methods: ["GET"] },
"/api/google-health/connect": { kind: "browserHandoff", methods: ["GET"] },
// `/api/nightscout/connect` sat here as a `browserHandoff` and is published
@@ -111,6 +111,7 @@ const COOKIE_PROOF_ROUTES = [
"app/api/admin/backups/upload/route.ts",
"app/api/admin/data/route.ts",
"app/api/admin/users/[id]/reset-password/route.ts",
"app/api/admin/users/[id]/route.ts",
"app/api/auth/reproof/route.ts",
"app/api/tokens/documents/route.ts",
"app/api/tokens/measurements/route.ts",
+4 -1
View File
@@ -102,8 +102,11 @@ describe("S1 — the direct session-resolution set is frozen", () => {
// replayed callback redirects instead of minting a second identity, and
// (v1.43, #959) so a single-sign-on claim is refused over a live session,
// as the claim routes refuse it.
// And the identity-provider re-proof (#1296), which stamps only
// the session the start bound into the sealed state, and only while the
// browser still carries it.
"app/api/auth/oidc/callback/route.ts":
"duplicate-callback probe and the claim's live-session refusal on an otherwise anonymous route",
"duplicate-callback probe, the claim's live-session refusal and the re-proof's session binding on an otherwise anonymous route",
// Public liveness probe. The session read only decides whether the body
// carries operational detail; the status code is computed without it.
"app/api/health/route.ts": "admin-only detail on a public probe",
@@ -112,6 +112,7 @@ function discoverGatedRoutes(): string[] {
*/
const GATED_ROUTES: Record<string, string> = {
"DELETE /api/admin/data": "web",
"DELETE /api/admin/users/[id]": "web",
"DELETE /api/auth/me/mfa/webauthn/[id]": "web",
"DELETE /api/auth/passkeys/[id]": "web",
"DELETE /api/managed-profiles/[id]": "web",
+140 -1
View File
@@ -3,13 +3,21 @@ import {
assertAnotherAdminStays,
LastAdminError,
} from "@/lib/auth/admin-roster";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import {
apiHandler,
assertRecentCookieProof,
requireAdmin,
} from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { requestAccountDeletion } from "@/lib/account-deletion/request";
import { lockPendingAccount } from "@/lib/account-deletion/lifecycle";
import { LastManagedGuardianError } from "@/lib/managed-profiles/lifecycle";
import { adminDeleteUserSchema } from "@/lib/validations/admin";
const updateUserSchema = z
.object({
@@ -122,3 +130,134 @@ export const PUT = apiHandler(
});
},
);
/** A refusal decided under the account's row lock, inside the transaction. */
class DeletionRefusal extends Error {
constructor(readonly kind: "not_found" | "managed_profile" | "pending") {
super(`admin deletion refused: ${kind}`);
this.name = "DeletionRefusal";
}
}
function deletionRefusalResponse(kind: DeletionRefusal["kind"]): Response {
if (kind === "not_found") return apiError("User not found", 404);
if (kind === "managed_profile") {
return apiError("A managed profile is deleted by its Guardians", 409, {
errorCode: "admin.users.delete.managed_profile",
});
}
return apiError("This account is already pending deletion", 409, {
errorCode: "account.deletion.already_pending",
});
}
/**
* DELETE /api/admin/users/{id} — an admin deletes another account.
*
* The same request the account's own deletion makes
* (`requestAccountDeletion`): with a grace window the account is locked at
* once (every credential revoked, sign-in refused) and erased by the purge job
* when the window ends, and `POST /api/admin/users/{id}/undo-deletion` gives it
* back until then; with `ACCOUNT_DELETION_GRACE_DAYS=0` it is erased here.
*
* Cookie-only (`requireAdmin`) behind a fresh proof (`assertRecentCookieProof`,
* the gate in front of the other admin acts on someone's data), with the typed
* confirmation the self-deletion asks for. Refused for the caller's own
* account (that is the account settings' deletion, with its own step-up), for
* the last admin who can sign in, for a managed profile (its Guardians delete
* it), for an account already pending deletion, and for the last Guardian of
* a managed profile. Audit-logged for the admin and on the account itself.
*/
export const DELETE = apiHandler(
async (
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
const { user: admin, session } = await requireAdmin();
await assertRecentCookieProof(admin, session.id);
const { id } = await params;
annotate({
action: {
name: "admin.users.delete",
entity_type: "user",
entity_id: id,
},
});
const parsed = await parseJsonBody(request, adminDeleteUserSchema, {
maxBytes: 4 * 1024,
});
if (!parsed.ok) return parsed.error;
if (id === admin.id) {
return apiError(
"Delete your own account from your account settings",
400,
{ errorCode: "admin.users.delete.self" },
);
}
const target = await prisma.user.findUnique({
where: { id },
select: { role: true, username: true },
});
if (!target) return apiError("User not found", 404);
let purgeAt: Date | null;
try {
({ purgeAt } = await requestAccountDeletion({
userId: id,
targetIsAdmin: target.role === "ADMIN",
requestedById: admin.id,
inTransaction: async (tx, scheduledPurgeAt) => {
const state = await lockPendingAccount(tx, id);
if (!state) throw new DeletionRefusal("not_found");
if (state.managedProfile)
throw new DeletionRefusal("managed_profile");
if (state.pending) throw new DeletionRefusal("pending");
const details = {
targetUserId: id,
username: target.username,
scheduledPurgeAt: scheduledPurgeAt?.toISOString() ?? null,
};
await auditLog("admin.user.delete", {
userId: admin.id,
ipAddress: getClientIp(request),
details,
client: tx,
});
// On the account itself too, so its own activity shows who asked.
// An immediate erasure takes this row with the account's history.
await auditLog("user.account.delete", {
userId: id,
actorUserId: admin.id,
details: { ...details, requestedBy: "admin" },
client: tx,
});
},
}));
} catch (error) {
if (error instanceof DeletionRefusal) {
return deletionRefusalResponse(error.kind);
}
if (error instanceof LastAdminError) {
return apiError("The last admin account cannot be deleted", 400);
}
if (error instanceof LastManagedGuardianError) {
return apiError(
"This account is the last Guardian of a managed profile. Add another Guardian or delete the profile first",
409,
{ errorCode: "managed_profile.guardian.required" },
);
}
throw error;
}
annotate({ meta: { deletion_scheduled: purgeAt !== null } });
return apiSuccess({
deleted: true,
scheduledPurgeAt: purgeAt?.toISOString() ?? null,
});
},
);
@@ -100,6 +100,7 @@ describe("GET /api/admin/users", () => {
// BigInt in Prisma, plain number on the wire.
documentQuotaBytes: 2_147_483_648,
passkeyCount: 2,
managedProfile: false,
// Not pending deletion.
deletion: null,
});
@@ -128,6 +129,7 @@ describe("GET /api/admin/users", () => {
deletionRequestedAt: true,
deletionPurgeAt: true,
deletionRequestedById: true,
managedProfileAt: true,
_count: { select: { passkeys: true } },
});
expect(args.select).not.toHaveProperty("passwordHash");
+9 -3
View File
@@ -21,6 +21,7 @@ export const GET = apiHandler(async () => {
deletionRequestedAt: true,
deletionPurgeAt: true,
deletionRequestedById: true,
managedProfileAt: true,
_count: { select: { passkeys: true } },
},
orderBy: { createdAt: "asc" },
@@ -42,9 +43,12 @@ export const GET = apiHandler(async () => {
documentQuotaBytes:
u.documentQuotaBytes === null ? null : Number(u.documentQuotaBytes),
passkeyCount: u._count.passkeys,
// A record somebody else runs. Its Guardians delete it, so the list
// offers no admin deletion on it.
managedProfile: u.managedProfileAt != null,
// An account pending deletion: when it was asked for, when the
// purge job erases it, and whether the account asked itself or a
// Guardian deleted the managed profile. Null on every other account.
// purge job erases it, and who asked: the account itself, a Guardian
// (a managed profile), or an admin. Null on every other account.
// The list offers "Undo deletion" on these rows until `purgeAt`.
deletion: u.deletionRequestedAt
? {
@@ -53,7 +57,9 @@ export const GET = apiHandler(async () => {
requestedBy:
u.deletionRequestedById === u.id
? ("self" as const)
: ("guardian" as const),
: u.managedProfileAt != null
? ("guardian" as const)
: ("admin" as const),
}
: null,
})),
+7 -1
View File
@@ -22,19 +22,21 @@ import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { countRemainingRecoveryCodes } from "@/lib/auth/mfa/recovery-codes";
import { recentProofMethods } from "@/lib/auth/second-factor";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const [recoveryCodesRemaining, webauthn] = await Promise.all([
const [recoveryCodesRemaining, webauthn, proofMethods] = await Promise.all([
countRemainingRecoveryCodes(user.id),
prisma.webauthnMfaCredential.findMany({
where: { userId: user.id },
select: { id: true, name: true, createdAt: true, lastUsedAt: true },
orderBy: { createdAt: "desc" },
}),
recentProofMethods(user),
]);
annotate({ action: { name: "auth.mfa.status" } });
@@ -46,5 +48,9 @@ export const GET = apiHandler(async () => {
// The security hub also drives the passkey-upgrade nudge from this one
// status read, so it knows whether the user has already dismissed it.
passkeyNudgeDismissed: Boolean(user.passkeyUpgradeNudgeDismissed),
// The ways this account can confirm it is you, the list the recent-proof
// refusal names. The passkey card offers exactly these before adding one,
// so an account without a password is not handed a password field.
proofMethods,
});
});
+19 -12
View File
@@ -93,6 +93,21 @@ const SERVER_FEATURES = {
lifeCircumstances: true,
} as const;
/**
* v1.7.0 — patient-identity fields for the health-record export. The KVNR is
* stored encrypted; decrypt fail-soft so a key-rotation gap on one row never
* 500s the whole profile fetch (the field just reads null and the user
* re-enters it).
*/
function readInsuranceNumber(encrypted: string | null | undefined) {
if (!encrypted) return null;
try {
return decrypt(encrypted);
} catch {
return null;
}
}
export const GET = apiHandler(async () => {
const auth = await requireActorAuth();
const { user } = auth;
@@ -289,22 +304,14 @@ export const GET = apiHandler(async () => {
sectionsOpen(sections, "cycle") &&
isCycleEnabled(record?.gender ?? null, cycleProfile);
// v1.7.0 — patient-identity fields for the health-record export. The
// KVNR is stored encrypted; decrypt fail-soft so a key-rotation gap on
// one row never 500s the whole profile fetch (the field just reads
// null and the user re-enters it).
let insuranceNumber: string | null = null;
if (identity?.insuranceNumberEncrypted) {
try {
insuranceNumber = decrypt(identity.insuranceNumberEncrypted);
} catch {
insuranceNumber = null;
}
}
const insuranceNumber = readInsuranceNumber(
identity?.insuranceNumberEncrypted,
);
return apiSuccess({
id: user.id,
username: user.username,
createdAt: user.createdAt,
email: user.email,
role: user.role ?? "USER",
heightCm: user.heightCm,
+116 -33
View File
@@ -47,6 +47,11 @@ import {
} from "@/lib/managed-profiles/claim-sso";
import { pendingDeletionOf } from "@/lib/account-deletion/pending";
import { isHandoverUsable } from "@/lib/managed-profiles/handover";
import {
isReproofSession,
recordOidcReproof,
reproofReturnUrl,
} from "@/lib/auth/oidc-reproof";
const LOGIN_ERROR_URL = "/auth/login";
@@ -103,6 +108,20 @@ function deletionPendingRedirect(purgeAt: Date | null): NextResponse {
return response;
}
/**
* #1296 — a re-proof returns to the page it left, never to the sign-in page:
* the person is still signed in, and the session they came from is the one
* thing that leg may touch. `reason` null is the success.
*/
function reproofRedirect(next: string, reason: string | null): NextResponse {
if (reason) annotate({ meta: { reason } });
const response = NextResponse.redirect(
reproofReturnUrl(next, reason ? "failed" : "done"),
);
deleteStateCookie(response);
return response;
}
/**
* The state cookie is set with `path: "/api/auth/oidc"`; the delete must
* repeat that path (RFC 6265 keys cookies by name+domain+path — a bare
@@ -139,6 +158,10 @@ export const GET = apiHandler(async (req: NextRequest) => {
const stored = readStoredOidcState(req.cookies.get(OIDC_STATE_COOKIE)?.value);
const isNative = stored?.native === true;
const isClaim = stored?.intent === "claim";
// #1296 — a re-proof returns to the page it left (`reproofRedirect`).
const isReproof = stored?.intent === "reproof";
const reproofBack = (reason: string | null) =>
reproofRedirect(stored?.next ?? "/", reason);
// Native-aware error redirect: the custom scheme for a decrypted native flow,
// the web login page otherwise. Deletes the single-use state cookie either
@@ -158,6 +181,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
};
const failRedirect = (reason: string): NextResponse => {
if (isReproof) return reproofBack(reason);
if (isClaim) return claimRedirect(reason);
if (isNative) {
const response = NextResponse.redirect(
@@ -199,42 +223,11 @@ export const GET = apiHandler(async (req: NextRequest) => {
// resolve to the web `oidc_state` fallback: the flow is fundamentally broken
// and there is nothing safe to hand the app (spec §1 web-fallback).
if (!code || !state) {
if (isReproof) return reproofBack("oidc_state");
return isClaim ? claimRedirect("oidc_state") : errorRedirect("oidc_state");
}
if (!stored) {
// Defence-in-depth idempotency for a duplicated callback. The single-use
// state cookie is set with `path: "/api/auth/oidc"` and DELETED by the
// first callback the moment it succeeds. A second callback for the same
// sign-in (a client/proxy retry, a double navigation, or the
// service-worker double-request this release also fixes) therefore arrives
// with the state cookie already gone — `stored` is null — and, because the
// first callback set the session, it carries a live session cookie.
// Redeeming the now-consumed authorization code again fails at the IdP and
// surfaces `?error=oidc_failed` on a sign-in that actually succeeded. So
// when a VALIDATED session is present, treat this as a duplicate of a
// completed login and send the user into the app instead of erroring.
//
// Fail-closed is preserved for the real attack case: the code is NEVER
// exchanged on this branch, and the session is resolved against the
// database + its expiry (not merely "a cookie is present"). A forged,
// replayed, or expired state from a caller with no valid session gets the
// unchanged `oidc_state` error; a caller who already holds their own valid
// session gains nothing here but a redirect to a page they could already
// reach — no code redemption, no session mint, no identity change.
const existingSession = await validateSessionFromCookieValue(
readSessionCookie(req.cookies)?.value,
);
if (existingSession) {
annotate({ meta: { reason: "duplicate_callback_authenticated" } });
const response = NextResponse.redirect(oidcAppUrl("/"));
// The state cookie is already consumed; delete defensively so a stray
// blob can never outlive this response.
deleteStateCookie(response);
return response;
}
return errorRedirect("oidc_state");
}
if (!stored) return stateMissing(req);
// CSRF check — timing-safe, byte-for-byte, mirroring the Withings callback.
if (
@@ -242,9 +235,12 @@ export const GET = apiHandler(async (req: NextRequest) => {
!timingSafeEqual(Buffer.from(state), Buffer.from(stored.state))
) {
annotate({ meta: { reason: "csrf" } });
if (isReproof) return reproofBack("oidc_state");
return isClaim ? claimRedirect("oidc_state") : errorRedirect("oidc_state");
}
if (isReproof) return finishReproof(req, stored, config, ip, reproofBack);
// v1.43 (#959) — a claim is never finished over a live session (the claim
// routes' rule: a Guardian finishing their own link would otherwise be
// signed out of their account and into the person's), and is admitted only
@@ -408,3 +404,90 @@ export const GET = apiHandler(async (req: NextRequest) => {
return failRedirect("oidc_failed");
}
});
/**
* #1296 — the re-proof leg. The browser must still carry the session the
* start bound into the state, checked before the code is redeemed; then the
* provider's answer must name this account's linked identity and an
* authentication inside the gate's window. Only then is `reproofAt` stamped.
* No session is created, and every refusal lands back on the page it left.
*/
async function finishReproof(
req: NextRequest,
stored: NonNullable<ReturnType<typeof readStoredOidcState>>,
config: NonNullable<ReturnType<typeof getOidcConfig>>,
ip: string,
back: (reason: string | null) => NextResponse,
): Promise<NextResponse> {
const live = await validateSessionFromCookieValue(
readSessionCookie(req.cookies)?.value,
);
if (!isReproofSession(stored, live)) return back("session_mismatch");
try {
const metadata = await discoverOidcMetadata(config);
const tokens = await exchangeCodeForTokens({
metadata,
config,
code: req.nextUrl.searchParams.get("code") as string,
codeVerifier: stored.codeVerifier,
redirectUri: getOidcRedirectUri(),
});
if (!tokens.id_token) return back("oidc_failed");
const identity = await verifyIdToken({
metadata,
config,
idToken: tokens.id_token,
nonce: stored.nonce,
});
const outcome = await recordOidcReproof({
userId: stored.reproofUserId as string,
sessionId: stored.reproofSessionId as string,
issuer: metadata.issuer,
identity,
ipAddress: ip,
});
return back(outcome.ok ? null : outcome.reason);
} catch (err) {
getEvent()?.setError(err);
return back("exchange_or_verify_failed");
}
}
/**
* A callback that arrives without its state blob: the duplicate of a sign-in
* that already completed, or a forged, replayed or expired one.
*/
async function stateMissing(req: NextRequest): Promise<NextResponse> {
// Defence-in-depth idempotency for a duplicated callback. The single-use
// state cookie is set with `path: "/api/auth/oidc"` and DELETED by the
// first callback the moment it succeeds. A second callback for the same
// sign-in (a client/proxy retry, a double navigation, or the
// service-worker double-request this release also fixes) therefore arrives
// with the state cookie already gone — `stored` is null — and, because the
// first callback set the session, it carries a live session cookie.
// Redeeming the now-consumed authorization code again fails at the IdP and
// surfaces `?error=oidc_failed` on a sign-in that actually succeeded. So
// when a VALIDATED session is present, treat this as a duplicate of a
// completed login and send the user into the app instead of erroring.
//
// Fail-closed is preserved for the real attack case: the code is NEVER
// exchanged on this branch, and the session is resolved against the
// database + its expiry (not merely "a cookie is present"). A forged,
// replayed, or expired state from a caller with no valid session gets the
// unchanged `oidc_state` error; a caller who already holds their own valid
// session gains nothing here but a redirect to a page they could already
// reach — no code redemption, no session mint, no identity change.
const existingSession = await validateSessionFromCookieValue(
readSessionCookie(req.cookies)?.value,
);
if (existingSession) {
annotate({ meta: { reason: "duplicate_callback_authenticated" } });
const response = NextResponse.redirect(oidcAppUrl("/"));
// The state cookie is already consumed; delete defensively so a stray
// blob can never outlive this response.
deleteStateCookie(response);
return response;
}
return errorRedirect("oidc_state");
}
+118
View File
@@ -0,0 +1,118 @@
import { randomBytes } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
import { getClientIp } from "@/lib/api-response";
import { annotate, getEvent } from "@/lib/logging/context";
import { encrypt } from "@/lib/crypto";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
import { s256Challenge } from "@/lib/mcp/oauth/pkce";
import { recentProofMethods } from "@/lib/auth/second-factor";
import { throttleReproof } from "@/lib/auth/existing-factor-proof";
import {
buildAuthorizationUrl,
discoverOidcMetadata,
getOidcConfig,
getOidcRedirectUri,
sanitizeOidcNextPath,
} from "@/lib/auth/oidc";
import {
OIDC_STATE_COOKIE,
OIDC_STATE_COOKIE_PATH,
OIDC_STATE_TTL_MS,
} from "@/lib/auth/oidc-cookie";
import { OIDC_REPROOF_STAGE, reproofReturnUrl } from "@/lib/auth/oidc-reproof";
export const dynamic = "force-dynamic";
/**
* Start confirming it is you at the identity provider (#1296).
*
* A top-level navigation from the re-proof dialog, cookie session only: a
* Bearer token never re-proves this way, because a stamp on a session row
* means nothing to a token (`requireCookieAuth` reads the session cookie and
* nothing else). Offered only where `recentProofMethods` lists `oidc`, which
* is an account without a second factor whose identity is linked while a
* provider is configured; anything else goes straight back with
* `reproof=failed`.
*
* The round trip asks for a fresh authentication (`prompt=login`,
* `max_age=0`) with its own state, nonce and PKCE verifier, sealed into the
* same single-use cookie the sign-in uses, together with the account and the
* session it confirms. The callback stamps that session's `reproofAt` and
* returns to `next` (a same-origin path) with `reproof=done`. It never mints
* a session.
*
* Draws on the account's shared re-proof budget, like every other proof.
*/
export const GET = apiHandler(async (req: NextRequest) => {
annotate({ action: { name: "auth.oidc.reproof.start" } });
const { user, session } = await requireCookieAuth();
const next = sanitizeOidcNextPath(
req.nextUrl.searchParams.get("next"),
req.url,
);
const back = (reason: string): NextResponse => {
annotate({ meta: { reason } });
return NextResponse.redirect(reproofReturnUrl(next, "failed"));
};
const config = getOidcConfig();
if (!config) return back("oidc_disabled");
const methods = await recentProofMethods(user);
if (!methods.includes("oidc")) return back("not_offered");
const limited = await throttleReproof(
user.id,
getClientIp(req),
OIDC_REPROOF_STAGE,
);
if (limited) return back("rate_limited");
let metadata;
try {
metadata = await discoverOidcMetadata(config);
} catch (err) {
getEvent()?.setError(err);
return back("oidc_failed");
}
const state = randomBytes(32).toString("base64url");
const nonce = randomBytes(32).toString("base64url");
const codeVerifier = randomBytes(64).toString("base64url");
const response = NextResponse.redirect(
buildAuthorizationUrl({
metadata,
config,
state,
nonce,
codeChallenge: s256Challenge(codeVerifier),
redirectUri: getOidcRedirectUri(),
reauthenticate: true,
}),
);
response.cookies.set(
OIDC_STATE_COOKIE,
encrypt(
JSON.stringify({
state,
nonce,
codeVerifier,
next,
intent: "reproof" as const,
reproofUserId: user.id,
reproofSessionId: session.id,
}),
),
{
httpOnly: true,
secure: shouldEmitSecureCookie(),
sameSite: "lax",
maxAge: Math.floor(OIDC_STATE_TTL_MS / 1000),
path: OIDC_STATE_COOKIE_PATH,
},
);
return response;
});
@@ -5,6 +5,8 @@ import {
} from "@/lib/auth/passkey";
import { createMfaAuthenticationOptions } from "@/lib/auth/mfa/webauthn";
import {
RECENT_PROOF_MAX_AGE_MS,
REPROOF_REQUIRED_CODE,
isSecondFactorProof,
recordReproofFailure,
refundReproof,
@@ -59,6 +61,30 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiSuccess({ ...result, reauth: true });
}
// #1296 — an account whose only credential is its identity provider proves
// itself there (`GET /api/auth/oidc/reproof`), which stamps
// `Session.reproofAt`. `{ method: "oidc" }` asks this route to take that
// stamp as the proof: only where `oidc` is on the account's list (no second
// factor, a linked identity, a configured provider) and only inside the
// five-minute window. Like a password, it clears the MFA stamp rather than
// setting it.
if (Object.keys(body).length === 1 && body.method === "oidc") {
if (
!(await recentProofMethods(user)).includes("oidc") ||
!(await hasRecentReproof(session.id))
) {
annotate({
action: { name: "auth.reproof.required" },
meta: { stage: "passkey_enroll", method: "oidc" },
});
return apiError("Confirm it is you before adding a passkey", 401, {
errorCode: REPROOF_REQUIRED_CODE,
methods: await recentProofMethods(user),
});
}
return issueRegistrationOptions(user, session.id, null);
}
const parsed = stepUpMintSchema.safeParse(body);
if (!parsed.success) return proofRequired();
@@ -101,23 +127,48 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
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,
// session-bound challenge but cannot upgrade password-only authentication.
return issueRegistrationOptions(
user,
session.id,
parsed.data.method === "password" ? null : new Date(),
);
});
/** A password or provider re-proof on this session inside the window. */
async function hasRecentReproof(sessionId: string): Promise<boolean> {
const row = await prisma.session.findUnique({
where: { id: sessionId },
select: { reproofAt: true },
});
return (
row?.reproofAt != null &&
Date.now() - row.reproofAt.getTime() <= RECENT_PROOF_MAX_AGE_MS
);
}
/**
* A strong existing factor refreshes the session's MFA stamp. A password or
* provider proof deliberately clears it (`mfaVerifiedAt: null`): registration
* is authorized by this single-use, session-bound challenge but cannot upgrade
* first-factor authentication.
*/
async function issueRegistrationOptions(
user: { id: string; email: string | null; username: string },
sessionId: string,
mfaVerifiedAt: Date | null,
): Promise<Response> {
await prisma.session.update({
where: { id: session.id },
data: {
mfaVerifiedAt: parsed.data.method === "password" ? null : new Date(),
},
where: { id: sessionId },
data: { mfaVerifiedAt },
});
const { options, challengeId } = await createRegistrationOptions(
user.id,
user.email ?? user.username,
session.id,
sessionId,
);
annotate({ action: { name: "auth.passkey.register-options" } });
return apiSuccess({ options, challengeId });
});
}
+16 -58
View File
@@ -8,24 +8,10 @@ import {
MFA_STEP_UP_MAX_AGE_SECONDS,
} from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { kickOffhostPurge } from "@/lib/jobs/offhost-purge";
import {
deleteGuardianAccountWithLifecycle,
LastManagedGuardianError,
} from "@/lib/managed-profiles/lifecycle";
import {
accountDeletionGraceDays,
accountDeletionPurgeAt,
} from "@/lib/account-deletion/grace";
import {
eraseAccount,
lockAccountForDeletion,
} from "@/lib/account-deletion/lifecycle";
import { LastManagedGuardianError } from "@/lib/managed-profiles/lifecycle";
import { requestAccountDeletion } from "@/lib/account-deletion/request";
import { ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account";
import {
assertAnotherAdminStays,
LastAdminError,
} from "@/lib/auth/admin-roster";
import { LastAdminError } from "@/lib/auth/admin-roster";
export const dynamic = "force-dynamic";
@@ -104,53 +90,27 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
// the elevation would stay redeemable while the erasure was already running.
await auth.commitElevation();
const requestedAt = new Date();
const purgeAt = accountDeletionPurgeAt(
requestedAt,
accountDeletionGraceDays(),
);
let purgeAt: Date | null;
try {
await deleteGuardianAccountWithLifecycle(
({ purgeAt } = await requestAccountDeletion({
userId,
async (tx) => {
// Checked again under the roster lock: two admins deleting at the
// same moment must not each count the other.
if (user.role === "ADMIN") await assertAnotherAdminStays(tx, userId);
// The invariant has now passed, so this success event cannot survive
// a refusal. On an immediate erasure it is purged with the rest of the
// account's audit history; during a grace window it is the account's
// own record of the request.
await auditLog("user.account.delete", {
targetIsAdmin: user.role === "ADMIN",
requestedById: userId,
// The invariants have passed by the time this runs, so this success
// event cannot survive a refusal. On an immediate erasure it is purged
// with the rest of the account's audit history; during a grace window
// it is the account's own record of the request.
inTransaction: (tx, scheduledPurgeAt) =>
auditLog("user.account.delete", {
userId,
ipAddress: getClientIp(request),
details: {
username,
scheduledPurgeAt: purgeAt?.toISOString() ?? null,
scheduledPurgeAt: scheduledPurgeAt?.toISOString() ?? null,
},
client: tx,
});
if (purgeAt) {
// Locked, not erased: credentials revoked in this transaction,
// the record untouched until the purge job reaches `purgeAt`.
await lockAccountForDeletion(tx, {
userId,
requestedById: userId,
requestedAt,
purgeAt,
});
return;
}
// No window: erase now, inside the same record lifecycle transaction
// so a refusal leaves every account state intact (sessions, the
// account's audit history — GDPR Art. 17 — the off-host purge
// request, then the row with everything cascading from it).
await eraseAccount(tx, userId, "account_deleted");
},
{ erasing: purgeAt === null },
);
}),
}));
} catch (error) {
if (error instanceof LastAdminError) {
return apiError("The last admin account cannot be deleted", 400);
@@ -165,8 +125,6 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
throw error;
}
if (!purgeAt) await kickOffhostPurge();
annotate({
action: { name: "settings.account.delete" },
meta: { username, deletion_scheduled: purgeAt !== null },
@@ -30,6 +30,8 @@ const sampleUsers = [
role: "ADMIN" as const,
passkeyCount: 2,
createdAt: "2026-04-01T08:00:00Z",
managedProfile: false,
deletion: null,
},
{
id: "u2",
@@ -38,6 +40,8 @@ const sampleUsers = [
role: "USER" as const,
passkeyCount: 1,
createdAt: "2026-04-15T08:00:00Z",
managedProfile: false,
deletion: null,
},
];
@@ -53,6 +57,10 @@ vi.mock("@tanstack/react-query", () => ({
}),
}));
vi.mock("@/lib/queries/use-privacy-summary", () => ({
useAccountDeletionGrace: () => ({ days: 14, pending: false }),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
@@ -115,6 +123,15 @@ describe("UserManagementSection — mobile responsive", () => {
expect(mobileSlice).toContain("Force logout");
});
it("offers deleting every account but the admin's own", () => {
const html = render();
// Two rows, each in the desktop table and the mobile list; only the
// other account carries the control.
const deletes = html.match(/data-slot="admin-user-delete"/g) ?? [];
expect(deletes).toHaveLength(2);
expect(html).toContain('aria-label="Delete account"');
});
it("uses smaller card padding on mobile (p-4 md:p-6)", () => {
const html = render();
expect(html).toMatch(/class="[^"]*\bp-4\b[^"]*md:p-6/);
+6 -4
View File
@@ -34,15 +34,17 @@ export interface AdminUser {
// the instance-wide default applies.
documentQuotaBytes: number | null;
passkeyCount: number;
/** A record its Guardians run; they delete it, an admin does not. */
managedProfile: boolean;
/**
* Set while the account is pending deletion: when the purge job
* erases it, and whether the account asked itself or a Guardian deleted
* the managed profile. The row offers "Undo deletion" until then.
* Set while the account is pending deletion: when the purge job erases
* it, and who asked (the account itself, a Guardian of the managed
* profile, or an admin). The row offers "Undo deletion" until then.
*/
deletion: {
requestedAt: string;
purgeAt: string | null;
requestedBy: "self" | "guardian";
requestedBy: "self" | "guardian" | "admin";
} | null;
}
@@ -0,0 +1,136 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Trash2 } from "lucide-react";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { showErrorToast } from "@/lib/errors/error-toast";
import { apiDelete } from "@/lib/api/api-fetch";
import { formatDate } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { useAccountDeletionGrace } from "@/lib/queries/use-privacy-summary";
import {
recentProofErrorMessage,
useRecentProof,
} from "@/components/settings/security-section/use-recent-proof";
import type { AdminUser } from "./_shared";
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* Whether the row offers deleting the account: never the admin's own (that is
* the account settings' deletion), never a managed profile (its Guardians
* delete it), and not one already pending deletion (the row offers the undo).
*/
export function canAdminDelete(user: AdminUser, currentUserId: string | null) {
return (
user.id !== currentUserId && !user.managedProfile && user.deletion === null
);
}
/**
* The day the purge job would erase an account deleted now, or null when the
* window is 0 and the erasure happens at once. The server stamps the real
* date; this is the one the dialog names before asking.
*/
export function deletionDateFrom(graceDays: number, now: Date): Date | null {
return graceDays > 0 ? new Date(now.getTime() + graceDays * DAY_MS) : null;
}
/**
* "Delete account" on a row of the admin users list
* (`DELETE /api/admin/users/{id}`), for the rows `canAdminDelete` allows.
*/
export function DeleteUserControl({
user,
currentUserId,
}: {
user: AdminUser;
currentUserId: string | null;
}) {
if (!canAdminDelete(user, currentUserId)) return null;
return <DeleteUserButton user={user} />;
}
/**
* The dialog names the date the account is erased and that it can be undone
* here until then, or, with no grace window, that it is erased at once. It
* waits for the window before it offers anything, so the sentence is never a
* guess. The fresh proof the route asks for is collected by the shared
* re-proof dialog.
*/
function DeleteUserButton({ user }: { user: AdminUser }) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const grace = useAccountDeletionGrace();
const recentProof = useRecentProof();
const remove = useMutation({
mutationFn: () =>
recentProof.run(() =>
apiDelete<{ scheduledPurgeAt: string | null }>(
`/api/admin/users/${user.id}`,
{ confirm: "DELETE_ACCOUNT" },
),
),
onSuccess: (result) => {
void queryClient.invalidateQueries({ queryKey: queryKeys.adminUsers() });
toastWrittenOutcome(
"success",
result.scheduledPurgeAt
? t("admin.section.users.deleteUserScheduled", {
name: user.username,
date: formatDate(result.scheduledPurgeAt),
})
: t("admin.section.users.deleteUserDone", { name: user.username }),
);
},
onError: (err) => {
const message = recentProofErrorMessage(
err,
err instanceof Error && err.message
? err.message
: t("admin.section.users.deleteUserFailed"),
);
if (message) showErrorToast(message, { cause: err });
},
});
const purgeOn =
grace.days === null ? null : deletionDateFrom(grace.days, new Date());
return (
<>
{recentProof.dialog}
<ConfirmButton
slot="admin-user-delete"
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground min-h-11 min-w-11 px-2 text-xs"
icon={<Trash2 className="h-3 w-3" aria-hidden="true" />}
label=""
ariaLabel={t("admin.section.users.deleteUser")}
disabled={grace.days === null}
title={t("admin.section.users.deleteUserTitle", {
name: user.username,
})}
body={
purgeOn
? t("admin.section.users.deleteUserBodyGrace", {
name: user.username,
date: formatDate(purgeOn),
})
: t("admin.section.users.deleteUserBodyNow", {
name: user.username,
})
}
confirmLabel={t("admin.section.users.deleteUserConfirm")}
pending={remove.isPending}
onConfirm={() => remove.mutate()}
destructive
/>
</>
);
}
@@ -13,6 +13,13 @@ import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { AdminUser } from "./_shared";
/** The undo dialog's sentence, by who asked for the deletion. */
const UNDO_BODY_KEYS = {
self: "admin.section.users.undoDeletionBody",
guardian: "admin.section.users.undoDeletionBodyGuardian",
admin: "admin.section.users.undoDeletionBodyAdmin",
} as const;
/**
* An account pending deletion, in the admin users list: the date the purge
* job erases it, and the one action that stops it.
@@ -64,12 +71,9 @@ export function PendingDeletionControls({ user }: { user: AdminUser }) {
title={t("admin.section.users.undoDeletionTitle", {
name: user.username,
})}
body={t(
user.deletion.requestedBy === "guardian"
? "admin.section.users.undoDeletionBodyGuardian"
: "admin.section.users.undoDeletionBody",
{ name: user.username },
)}
body={t(UNDO_BODY_KEYS[user.deletion.requestedBy], {
name: user.username,
})}
confirmLabel={t("admin.section.users.undoDeletion")}
pending={undo.isPending}
onConfirm={() => undo.mutate()}
@@ -40,6 +40,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { type AdminUser, PasswordInput } from "./_shared";
import { PendingDeletionControls } from "./pending-deletion-controls";
import { DeleteUserControl } from "./delete-user-control";
import { apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
import {
recentProofErrorMessage,
@@ -55,6 +56,13 @@ import {
*/
type UserFilter = "all" | "admin" | "user";
/** The quota override as the edit form's gigabyte field shows it. */
function quotaGbInput(bytes: number | null): string {
if (bytes === null) return "";
// eslint-disable-next-line healthlog/no-unlocalised-decimal -- value of a type="number" input, which only takes a full stop
return String(Math.round((bytes / 1_073_741_824) * 10) / 10);
}
export function UserManagementSection() {
const { t } = useTranslations();
const currentUserId = useAuth().user?.id ?? "";
@@ -160,12 +168,7 @@ export function UserManagementSection() {
setEditingUser(u);
setEditUsername(u.username);
setEditEmail(u.email ?? "");
setEditQuotaGb(
u.documentQuotaBytes === null
? ""
: // eslint-disable-next-line healthlog/no-unlocalised-decimal -- value of a type="number" input, which only takes a full stop
String(Math.round((u.documentQuotaBytes / 1_073_741_824) * 10) / 10),
);
setEditQuotaGb(quotaGbInput(u.documentQuotaBytes));
}
function startReset(u: AdminUser) {
@@ -229,7 +232,7 @@ export function UserManagementSection() {
{u.mfaEnforced ? t("admin.mfaEnforcedOn") : t("admin.mfaEnforcedOff")}
<span className="sr-only">, {u.username}</span>
</Button>
{/* The three icon tools travel as one group: on a phone the row wraps
{/* The icon tools travel as one group: on a phone the row wraps
between the labelled toggles and the tools, never leaving the
sign-out icon alone on a line. */}
<span className="ml-auto flex items-center gap-1">
@@ -272,6 +275,7 @@ export function UserManagementSection() {
>
<LogOut className="h-3 w-3" aria-hidden="true" />
</Button>
<DeleteUserControl user={u} currentUserId={currentUserId} />
</span>
</>
);
@@ -0,0 +1,40 @@
"use client";
import { useEffect } from "react";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { useTranslations } from "@/lib/i18n/context";
import { dropParams, writeUrlState } from "@/lib/url-state";
/**
* The outcome a confirmation at the identity provider came back with
* (`?reproof=done|failed`, set by the OIDC callback). Pure, so the parsing is
* testable without a browser.
*/
export function readReproofReturn(search: string): "done" | "failed" | null {
const raw = new URLSearchParams(search).get("reproof");
return raw === "done" || raw === "failed" ? raw : null;
}
/**
* Says once how the confirmation at the identity provider went, then takes the
* marker off the address so a reload does not say it again. The browser lands
* on the page it left, which is where the person repeats what they were
* about to do.
*/
export function OidcReproofReturnBridge() {
const { t } = useTranslations();
useEffect(() => {
const outcome = readReproofReturn(window.location.search);
if (!outcome) return;
writeUrlState(dropParams("reproof"), { mode: "replace", state: "keep" });
toastWrittenOutcome(
outcome === "done" ? "success" : "failed",
outcome === "done"
? t("settings.reproof.oidcDone")
: t("settings.reproof.oidcFailed"),
);
// A later run (a locale switch) finds no marker and does nothing.
}, [t]);
return null;
}
@@ -43,7 +43,7 @@ vi.mock("@/hooks/use-auth", () => ({
}),
}));
import { DashboardHeader } from "../dashboard-header";
import { DashboardHeader, greetingLine } from "../dashboard-header";
import {
CAPTURE_KIND_ORDER,
visibleCaptureKinds,
@@ -58,12 +58,73 @@ function renderSSR(node: React.ReactElement, locale: "en" | "de" = "de") {
);
}
describe("greetingLine", () => {
const now = Date.parse("2026-10-29T09:00:00Z");
const base = { greeting: "Hello", nowMs: now };
it("welcomes an account in its first day, without a 'back'", () => {
expect(
greetingLine({
...base,
known: true,
name: "tester",
createdAt: "2026-10-29T08:50:00Z",
}),
).toEqual({
key: "dashboard.welcomeNewWithName",
values: { name: "tester" },
});
expect(
greetingLine({
...base,
known: true,
name: null,
createdAt: "2026-10-28T10:00:00Z",
}).key,
).toBe("dashboard.welcomeNew");
});
it("welcomes back an account older than a day", () => {
expect(
greetingLine({
...base,
known: true,
name: "tester",
createdAt: "2026-10-20T08:00:00Z",
}),
).toEqual({
key: "dashboard.welcomeBackWithName",
values: { greeting: "Hello", name: "tester" },
});
});
it("welcomes back when an older server does not say when the account began", () => {
expect(
greetingLine({ ...base, known: true, name: null, createdAt: undefined })
.key,
).toBe("dashboard.welcomeBack");
});
it("says only the greeting while the account is not known", () => {
expect(
greetingLine({
...base,
known: false,
name: "tester",
createdAt: "2026-10-29T08:50:00Z",
}),
).toEqual({ key: "dashboard.greetingOnly", values: { greeting: "Hello" } });
});
});
describe("<DashboardHeader> — greeting", () => {
it("renders the greeting line under the title", () => {
const html = renderSSR(<DashboardHeader onQuickEntry={() => undefined} />);
expect(html).toContain('data-slot="dashboard-header-greeting"');
// SSR pass: name-less fallback (personalises post-hydration only).
expect(html).toContain("willkommen zurück.");
// SSR pass: the time-of-day greeting alone. Nothing says "welcome
// back" before the client knows the account is not new.
expect(html).toContain("Hallo.");
expect(html).not.toContain("willkommen zurück");
// Reserved line box keeps the header height stable through the
// post-hydration personalisation swap.
expect(html).toMatch(/data-slot="dashboard-header-greeting"[^>]*>/);
+47 -10
View File
@@ -42,6 +42,43 @@ import {
} from "@/components/layout/capture-picker";
import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets";
const FIRST_DAY_MS = 24 * 60 * 60 * 1000;
/**
* The greeting line, as an i18n key and its values.
*
* Until the account is known (the SSR pass and the hydration render) it is the
* time-of-day greeting alone, so nothing claims "welcome back" before the
* client knows it is true. An account in its first day (`createdAt` from
* `/api/auth/me`) is welcomed, not welcomed back: the setup flow lands a new
* account here minutes after it was created. Any other account, and one whose
* server does not say when it was created, is welcomed back.
*/
export function greetingLine(input: {
greeting: string;
known: boolean;
name: string | null;
createdAt: string | null | undefined;
/** The clock, for tests; the moment of the call otherwise. */
nowMs?: number;
}): { key: string; values: Record<string, string> } {
const { greeting, name } = input;
const nowMs = input.nowMs ?? Date.now();
if (!input.known) {
return { key: "dashboard.greetingOnly", values: { greeting } };
}
const created = input.createdAt ? Date.parse(input.createdAt) : Number.NaN;
const isNew = Number.isFinite(created) && nowMs - created < FIRST_DAY_MS;
if (isNew) {
return name
? { key: "dashboard.welcomeNewWithName", values: { name } }
: { key: "dashboard.welcomeNew", values: {} };
}
return name
? { key: "dashboard.welcomeBackWithName", values: { greeting, name } }
: { key: "dashboard.welcomeBack", values: { greeting } };
}
export function DashboardHeader({
onQuickEntry,
}: {
@@ -103,22 +140,22 @@ export function DashboardHeader({
: hour >= 12 && hour < 18
? t("dashboard.greeting.day")
: t("dashboard.greeting.evening");
const welcomeText =
mounted && user?.username && user.username.trim().length > 0
? t("dashboard.welcomeBackWithName", {
greeting: timeGreeting,
name: user.username,
})
: t("dashboard.welcomeBack", { greeting: timeGreeting });
const greeting = greetingLine({
greeting: timeGreeting,
known: mounted && user != null,
name: user?.username?.trim() ? user.username : null,
createdAt: user?.createdAt,
});
const welcomeText = t(greeting.key, greeting.values);
return (
<PageHeader
title={t("dashboard.title")}
description={
/* `min-h-5` reserves the greeting's line box from the SSR
pass on: the text personalises (name appended) on the first
client re-render after hydration, and the reserved line
keeps the header from collapsing/growing around that swap. */
pass on: the text personalises (name and welcome appended) on
the first client re-render after hydration, and the reserved
line keeps the header from collapsing/growing around that swap. */
<span
data-slot="dashboard-header-greeting"
className="block min-h-5 truncate"
@@ -895,7 +895,7 @@ export interface SendCoachMessageParams {
conversationId?: string;
message: string;
prefill?: string;
locale?: "en" | "de";
locale?: import("@/lib/i18n/config").Locale;
/**
* v1.4.20.1 — optional snapshot scope (per-source toggles + window)
* driven by the sources-rail picker. When omitted the server falls
@@ -0,0 +1,71 @@
/**
* The language reaches the account only when there is an account.
*
* The provider used to PUT the locale on every mount, signed in or not. A
* signed-out page got a 401, the once-per-tab marker was never set, and every
* signed-out page load sent the refused write again. The write now belongs to
* `LocaleServerSyncBridge`, which waits for the account payload without
* fetching it and then sends the choice once.
*/
import { describe, expect, it } from "vitest";
import { queryKeys } from "@/lib/query-keys";
import { authUser, renderWithApp } from "../../../../tests/browser/render";
import { stubFetch } from "../../../../tests/browser/fetch-stub";
import { LocaleServerSyncBridge } from "../locale-server-sync-bridge";
const LOCALE_PUT = "/api/auth/me/locale";
async function settle(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 150));
}
describe("LocaleServerSyncBridge", () => {
it("sends nothing while nobody is signed in, and never asks who is", async () => {
const fetchStub = stubFetch().on("PUT", LOCALE_PUT, { data: null });
await renderWithApp(<LocaleServerSyncBridge />, {
user: null,
locale: "de",
});
await settle();
expect(fetchStub.calls("PUT", LOCALE_PUT)).toHaveLength(0);
expect(fetchStub.calls("GET", "/api/auth/me")).toHaveLength(0);
});
it("sends the choice made while signed out once an account appears", async () => {
const fetchStub = stubFetch().on("PUT", LOCALE_PUT, { data: null });
const { queryClient } = await renderWithApp(<LocaleServerSyncBridge />, {
user: null,
locale: "de",
});
await settle();
expect(fetchStub.calls("PUT", LOCALE_PUT)).toHaveLength(0);
queryClient.setQueryData(
queryKeys.authMe(),
authUser({ id: "account-signed-in-later" }),
);
await expect.poll(() => fetchStub.calls("PUT", LOCALE_PUT).length).toBe(1);
expect(fetchStub.calls("PUT", LOCALE_PUT)[0].body).toEqual({
locale: "de",
});
});
it("writes once per tab for the same account and language", async () => {
const fetchStub = stubFetch().on("PUT", LOCALE_PUT, { data: null });
const first = await renderWithApp(<LocaleServerSyncBridge />, {
user: { id: "account-already-synced" },
locale: "en",
});
await expect.poll(() => fetchStub.calls("PUT", LOCALE_PUT).length).toBe(1);
await first.screen.unmount();
await renderWithApp(<LocaleServerSyncBridge />, {
user: { id: "account-already-synced" },
locale: "en",
});
await settle();
expect(fetchStub.calls("PUT", LOCALE_PUT)).toHaveLength(1);
});
});
@@ -0,0 +1,39 @@
"use client";
import { useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { fetchMe } from "@/hooks/use-auth";
import { persistLocaleToServer, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
/**
* Keeps `User.locale` in step with the language on screen, for a signed-in
* account only, so the proactive Coach nudge and the other cookie-blind
* background paths write in the language the person reads.
*
* It watches the account payload without fetching it (`enabled: false`): the
* shell already reads `/api/auth/me` on every signed-in page, and a signed-out
* page has no account to write to. Fetching here would trade the refused
* locale PUT for a refused account read on every signed-out load. Same key
* and same fetcher as `useAuth`, so the cache entry is shared, not shadowed.
*
* Fires when an account appears (right after signing in, carrying the choice
* made on the sign-in page) and on every switch; `persistLocaleToServer`
* skips a value this tab already wrote for this account.
*/
export function LocaleServerSyncBridge() {
const { locale } = useTranslations();
const { data: account } = useQuery({
queryKey: queryKeys.authMe(),
queryFn: fetchMe,
enabled: false,
});
const accountId = account?.id ?? null;
useEffect(() => {
if (accountId) persistLocaleToServer(locale, accountId);
}, [locale, accountId]);
return null;
}
+4
View File
@@ -21,6 +21,8 @@ import { VersionPoller } from "@/components/version-poller";
import { ServiceWorkerRegistrar } from "@/components/service-worker-registrar";
import { SharedRecordGrantLossBridge } from "@/components/layout/shared-record-grant-loss-bridge";
import { RecordSessionFenceBridge } from "@/components/layout/record-session-fence-bridge";
import { OidcReproofReturnBridge } from "@/components/auth/oidc-reproof-return";
import { LocaleServerSyncBridge } from "@/components/layout/locale-server-sync-bridge";
import { useAuth, type AccountAccessStatus } from "@/hooks/use-auth";
import { useRecordSessionTransition } from "@/hooks/use-record-session-transition";
import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
@@ -432,6 +434,8 @@ export function Providers({
in its own account, rather than asking every call site to. */}
<SharedRecordGrantLossBridge />
<RecordSessionFenceBridge />
<OidcReproofReturnBridge />
<LocaleServerSyncBridge />
{children}
<AppToaster />
<KeyboardInsetBridge />
@@ -0,0 +1,78 @@
/**
* The web side of confirming at the identity provider (#1296): the dialogs
* offer it when the server names it, the round trip starts from and returns
* to the page the person is on, and the outcome marker is read once.
*/
import { describe, expect, it, vi } from "vitest";
import { ApiError } from "@/lib/api/api-fetch";
import { oidcReproofStartUrl } from "../existing-factor-reauth-dialog";
import { passkeyProofChoices } from "../passkey-list-section";
import { createRecentProofFlow } from "../recent-proof-flow";
import { readReproofReturn } from "@/components/auth/oidc-reproof-return";
describe("passkeyProofChoices", () => {
it("offers only what the account can give once its list is known", () => {
expect(passkeyProofChoices(["oidc"])).toEqual(["oidc"]);
expect(passkeyProofChoices(["password", "oidc", "passkey"])).toEqual([
"password",
"oidc",
"passkey",
]);
expect(passkeyProofChoices(["totp", "something-new"])).toEqual(["totp"]);
});
it("falls back to the four body proofs while the list is loading", () => {
expect(passkeyProofChoices(undefined)).toEqual([
"password",
"totp",
"passkey",
"webauthn",
]);
});
});
describe("oidcReproofStartUrl", () => {
it("names the current page as the way back, without an old outcome", () => {
const href = oidcReproofStartUrl({
pathname: "/settings/api",
search: "?tab=mcp&reproof=failed",
});
const url = new URL(href, "https://healthlog.example.test");
expect(url.pathname).toBe("/api/auth/oidc/reproof");
expect(url.searchParams.get("next")).toBe("/settings/api?tab=mcp");
});
});
describe("readReproofReturn", () => {
it("reads a known outcome off the query string", () => {
expect(readReproofReturn("?tab=mcp&reproof=done")).toBe("done");
expect(readReproofReturn("?reproof=failed")).toBe("failed");
});
it("ignores an address without a known outcome", () => {
expect(readReproofReturn("")).toBeNull();
expect(readReproofReturn("?reproof=maybe")).toBeNull();
});
});
describe("the re-proof dialog flow", () => {
it("opens with the identity provider when that is the account's only way", async () => {
const setMethods = vi.fn();
const flow = createRecentProofFlow({
view: { setMethods, setPending: vi.fn(), setError: vi.fn() },
submit: vi.fn(),
messages: () => ({ signInAgain: "sign in again", failed: "failed" }),
});
void flow
.run(async () => {
throw new ApiError("Confirm it is you", 401, {
errorCode: "auth.reproof.required",
methods: ["oidc"],
});
})
.catch(() => {});
await vi.waitFor(() => expect(setMethods).toHaveBeenCalledWith(["oidc"]));
flow.cancel();
});
});
@@ -19,7 +19,30 @@ import { useTranslations } from "@/lib/i18n/context";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
import { describePasskeyError } from "@/lib/passkey-errors";
export type ReauthMethod = "password" | "totp" | "passkey" | "webauthn";
export type ReauthMethod =
"password" | "oidc" | "totp" | "passkey" | "webauthn";
/**
* Where the browser goes to confirm at the identity provider: the re-proof
* start, told to come back to the page it is on now. A `reproof` outcome left
* on the current address from an earlier round trip is dropped, so the page
* does not come back announcing the old one.
*/
export function oidcReproofStartUrl(current: {
pathname: string;
search: string;
}): string {
const params = new URLSearchParams(current.search);
params.delete("reproof");
const query = params.toString();
const next = `${current.pathname}${query ? `?${query}` : ""}`;
return `/api/auth/oidc/reproof?${new URLSearchParams({ next }).toString()}`;
}
/** Leave for the identity provider. The page unloads; nothing resolves. */
export function startOidcReproof(): void {
window.location.assign(oidcReproofStartUrl(window.location));
}
/** The body the enrollment routes accept as a fresh existing-factor proof. */
export type ExistingFactorProof =
@@ -46,23 +69,19 @@ export function isReproofRequired(err: unknown): boolean {
const REAUTH_METHODS: readonly ReauthMethod[] = [
"password",
"oidc",
"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.
*/
/**
* The refusal says the account holds nothing it could re-prove here: an
* account that signs in through an identity provider only, with no password,
* passkey or second factor. A dialog would only offer it a password it does
* not have, so the callers say "sign in again" instead — a fresh sign-in is
* the proof such an account can give (`Session.createdAt` within five
* minutes).
* The refusal says the account holds nothing it could re-prove here: no
* password, passkey or second factor, and no identity provider to confirm at
* (an account whose provider the operator has since switched off). A dialog
* would only offer it a password it does not have, so the callers say "sign
* in again" instead — a fresh sign-in is the proof such an account can give
* (`Session.createdAt` within five minutes).
*/
export function nothingToReprove(err: unknown): boolean {
return (
@@ -72,6 +91,11 @@ export function nothingToReprove(err: unknown): boolean {
);
}
/**
* 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;
@@ -87,7 +111,9 @@ export function offeredReauthMethods(err: unknown): ReauthMethod[] | null {
* new factor is added. A passkey or security-key assertion begins at the
* cookie-only `/api/auth/passkey/register-options` re-proof arm, which issues
* an assertion challenge and nothing else; the resulting proof is handed to
* `onProof`, which retries the enrollment call with it.
* `onProof`, which retries the enrollment call with it. The identity provider
* is not a proof in the body: choosing it leaves the page for the provider and
* comes back to it, and the action is then repeated by hand.
*/
export function ExistingFactorReauthDialog({
open,
@@ -117,6 +143,11 @@ export function ExistingFactorReauthDialog({
async function submit() {
setAssertionError(null);
if (method === "oidc") {
setAsserting(true);
startOidcReproof();
return;
}
if (method === "password") {
onProof({ method, password: secret });
return;
@@ -152,6 +183,7 @@ export function ExistingFactorReauthDialog({
const labels: Record<ReauthMethod, string> = {
password: t("settings.passkeyReauth.methods.password"),
oidc: t("settings.passkeyReauth.methods.oidc"),
totp: t("settings.passkeyReauth.methods.totp"),
passkey: t("settings.passkeyReauth.methods.passkey"),
webauthn: t("settings.passkeyReauth.methods.webauthn"),
@@ -223,6 +255,10 @@ export function ExistingFactorReauthDialog({
/>
)}
{method === "oidc" && (
<p className="text-sm">{t("settings.passkeyReauth.oidcHint")}</p>
)}
{shown && (
<div
role="alert"
@@ -253,7 +289,9 @@ export function ExistingFactorReauthDialog({
{busy && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.passkeyReauth.verifyAndContinue")}
{method === "oidc"
? t("settings.passkeyReauth.continueToProvider")
: t("settings.passkeyReauth.verifyAndContinue")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -20,6 +20,7 @@ interface MfaStatus {
recoveryCodesRemaining: number;
webauthn: WebauthnKeyInfo[];
passkeyNudgeDismissed: boolean;
proofMethods: string[];
}
interface PasskeyInfo {
@@ -71,7 +72,10 @@ export function SecuritySection() {
totpEnabled={status.totp.enabled}
/>
<PasskeyListSection isAuthenticated={isAuthenticated} />
<PasskeyListSection
isAuthenticated={isAuthenticated}
proofMethods={status.proofMethods}
/>
</>
)}
@@ -45,6 +45,11 @@ import {
} from "@/lib/api/api-fetch";
import { describePasskeyError } from "@/lib/passkey-errors";
import { describeStepUp } from "./security-keys-card";
import {
isReproofRequired,
startOidcReproof,
type ReauthMethod,
} from "./existing-factor-reauth-dialog";
import { ReproofCancelledError, useRecentProof } from "./use-recent-proof";
interface PasskeyInfo {
@@ -56,17 +61,102 @@ interface PasskeyInfo {
lastUsedAt: string | null;
}
type ReauthMethod = "password" | "passkey" | "totp" | "webauthn";
type ExistingFactorProof =
| { method: "password"; password: string }
| { method: "totp"; code: string }
| { method: "oidc" }
| {
method: "passkey" | "webauthn";
challengeId: string;
credential: unknown;
};
/** What the card offers while the account's own list has not arrived. */
const FALLBACK_METHODS: readonly ReauthMethod[] = [
"password",
"totp",
"passkey",
"webauthn",
];
/**
* The proofs to offer before adding a passkey: the account's own list when the
* status read has it, so an account without a password is not handed a
* password field, and the four body proofs otherwise.
*/
export function passkeyProofChoices(
proofMethods: readonly string[] | undefined,
): ReauthMethod[] {
if (!proofMethods) return [...FALLBACK_METHODS];
const known: readonly ReauthMethod[] = [
"password",
"oidc",
"totp",
"passkey",
"webauthn",
];
return proofMethods.filter((m): m is ReauthMethod =>
known.includes(m as ReauthMethod),
);
}
/**
* Prove an existing factor, then run the registration ceremony. The proof
* finishes before the first registration challenge is requested, so a
* cancelled or failed proof leaves no enrollable challenge behind.
*/
async function addPasskey(method: ReauthMethod, secret: string): Promise<void> {
const webauthn = await import("@simplewebauthn/browser");
let freshFactor: ExistingFactorProof;
if (method === "oidc") {
// No secret: the proof is a provider re-proof already on the session.
freshFactor = { method };
} else if (method === "password") {
freshFactor = { method, password: secret };
} else if (method === "totp") {
freshFactor = { method, code: secret };
} else {
const assertionChallenge = await apiPost<{
options: Parameters<
typeof webauthn.startAuthentication
>[0]["optionsJSON"];
challengeId: string;
}>("/api/auth/passkey/register-options", { method });
const credential = await webauthn.startAuthentication({
optionsJSON: assertionChallenge.options,
});
freshFactor = {
method,
challengeId: assertionChallenge.challengeId,
credential,
};
}
let begun: {
options: Parameters<typeof webauthn.startRegistration>[0]["optionsJSON"];
challengeId: string;
};
try {
begun = await apiPost("/api/auth/passkey/register-options", freshFactor);
} catch (err) {
// No provider re-proof on the session yet: go and get one. The page
// unloads and comes back here, so the mutation stays pending rather
// than flashing an error on the way out.
if (method !== "oidc" || !isReproofRequired(err)) throw err;
startOidcReproof();
return new Promise<never>(() => {});
}
const { options, challengeId } = begun;
const credential = await webauthn.startRegistration({
optionsJSON: options,
});
await apiPost("/api/auth/passkey/register-verify", {
challengeId,
credential,
});
}
/**
* Primary-passkey management: list (name, device, backup, created, last used),
* add (registration ceremony), rename, and delete. The passwordless-primary
@@ -74,8 +164,11 @@ type ExistingFactorProof =
*/
export function PasskeyListSection({
isAuthenticated,
proofMethods,
}: {
isAuthenticated: boolean;
/** `proofMethods` from `GET /api/auth/me/mfa`. */
proofMethods?: readonly string[];
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
@@ -83,7 +176,10 @@ export function PasskeyListSection({
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const [reauthOpen, setReauthOpen] = useState(false);
const [reauthMethod, setReauthMethod] = useState<ReauthMethod>("password");
const choices = passkeyProofChoices(proofMethods);
const [reauthMethod, setReauthMethod] = useState<ReauthMethod>(
choices[0] ?? "password",
);
const [reauthSecret, setReauthSecret] = useState("");
const { data: passkeys } = useQuery({
@@ -93,56 +189,18 @@ export function PasskeyListSection({
});
const add = useMutation({
mutationFn: async ({
mutationFn: ({
method,
secret,
}: {
method: ReauthMethod;
secret: string;
}) => {
const webauthn = await import("@simplewebauthn/browser");
let freshFactor: ExistingFactorProof;
if (method === "password") {
freshFactor = { method, password: secret };
} else if (method === "totp") {
freshFactor = { method, code: secret };
} else {
const assertionChallenge = await apiPost<{
options: Parameters<
typeof webauthn.startAuthentication
>[0]["optionsJSON"];
challengeId: string;
}>("/api/auth/passkey/register-options", { method });
const credential = await webauthn.startAuthentication({
optionsJSON: assertionChallenge.options,
});
freshFactor = {
method,
challengeId: assertionChallenge.challengeId,
credential,
};
}
const { options, challengeId } = await apiPost<{
options: Parameters<
typeof webauthn.startRegistration
>[0]["optionsJSON"];
challengeId: string;
}>("/api/auth/passkey/register-options", freshFactor);
const credential = await webauthn.startRegistration({
optionsJSON: options,
});
await apiPost("/api/auth/passkey/register-verify", {
challengeId,
credential,
});
},
}) => addPasskey(method, secret),
onSuccess: () => {
setMsg(null);
setReauthOpen(false);
setReauthSecret("");
setReauthMethod("password");
setReauthMethod(choices[0] ?? "password");
queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() });
toastWrittenOutcome("success", t("settings.passkeyAddedToast"));
},
@@ -195,6 +253,14 @@ export function PasskeyListSection({
},
});
const methodLabels: Record<ReauthMethod, string> = {
password: t("settings.passkeyReauth.methods.password"),
oidc: t("settings.passkeyReauth.methods.oidc"),
totp: t("settings.passkeyReauth.methods.totp"),
passkey: t("settings.passkeyReauth.methods.passkey"),
webauthn: t("settings.passkeyReauth.methods.webauthn"),
};
const DEVICE_TYPE_LABELS: Record<string, string> = {
singleDevice: t("settings.singleDevice"),
multiDevice: t("settings.multiDevice"),
@@ -350,7 +416,14 @@ export function PasskeyListSection({
onOpenChange={(open) => {
if (add.isPending) return;
setReauthOpen(open);
if (open) setMsg(null);
if (open) {
setMsg(null);
// The choice the account's list leads with, in case the list
// arrived after the card first rendered.
if (!choices.includes(reauthMethod)) {
setReauthMethod(choices[0] ?? "password");
}
}
}}
>
<SettingsCardActions>
@@ -384,14 +457,7 @@ export function PasskeyListSection({
role="group"
aria-label={t("settings.passkeyReauth.methodLabel")}
>
{(
[
["password", t("settings.passkeyReauth.methods.password")],
["totp", t("settings.passkeyReauth.methods.totp")],
["passkey", t("settings.passkeyReauth.methods.passkey")],
["webauthn", t("settings.passkeyReauth.methods.webauthn")],
] satisfies Array<[ReauthMethod, string]>
).map(([method, label]) => (
{choices.map((method) => (
<Button
key={method}
type="button"
@@ -407,11 +473,15 @@ export function PasskeyListSection({
setMsg(null);
}}
>
{label}
{methodLabels[method]}
</Button>
))}
</div>
{reauthMethod === "oidc" && (
<p className="text-sm">{t("settings.passkeyReauth.oidcHint")}</p>
)}
{reauthMethod === "password" && (
<Input
type="password"
@@ -14,6 +14,7 @@ import {
const KNOWN_METHODS: readonly ReauthMethod[] = [
"password",
"oidc",
"totp",
"webauthn",
"passkey",
@@ -93,7 +94,9 @@ export function createRecentProofFlow(deps: {
if (!isProofRefusal(err)) throw err;
const offered = methodsFrom(err as ApiError);
// Nothing to re-prove in place: an account that signs in through an
// identity provider only. A fresh sign-in is the proof it can give.
// identity provider the instance no longer offers. A fresh sign-in is
// the proof it can give. One that can still reach its provider is
// offered `oidc` instead, which leaves the page and comes back to it.
if (offered.length === 0) {
throw new ApiError(deps.messages().signInAgain, 401, {
errorCode: "auth.reproof.sign_in_again",
+5
View File
@@ -65,6 +65,11 @@ export type AuthTourProgress = TourProgress;
export interface AuthUser {
id: string;
username: string;
/**
* When the account was created (ISO). Absent from an older server image,
* which every reader treats as an established account.
*/
createdAt?: string;
email: string | null;
role: string;
heightCm: number | null;
+81
View File
@@ -0,0 +1,81 @@
/**
* Asking for an account to be deleted: the one path the account's own
* deletion (`DELETE /api/settings/account`) and an admin's deletion of
* another account (`DELETE /api/admin/users/{id}`) both take.
*
* With a grace window (`ACCOUNT_DELETION_GRACE_DAYS`) the account is locked:
* every credential is revoked at once, the record stays exactly as it is, and
* the purge job erases it when the window ends; an admin can undo it until
* then. With the window at 0 it is erased here, in the same transaction.
*
* The Guardian invariant (an account may not leave a managed profile without
* a Guardian) and the admin roster (the last admin who can sign in stays) are
* decided inside the transaction, under their locks, and refuse by throwing
* `LastManagedGuardianError` or `LastAdminError`.
*/
import type { Prisma } from "@/generated/prisma/client";
import { kickOffhostPurge } from "@/lib/jobs/offhost-purge";
import { deleteGuardianAccountWithLifecycle } from "@/lib/managed-profiles/lifecycle";
import { assertAnotherAdminStays } from "@/lib/auth/admin-roster";
import {
accountDeletionGraceDays,
accountDeletionPurgeAt,
} from "@/lib/account-deletion/grace";
import {
eraseAccount,
lockAccountForDeletion,
} from "@/lib/account-deletion/lifecycle";
type Tx = Prisma.TransactionClient;
export async function requestAccountDeletion(input: {
userId: string;
/** Whether the account being deleted is an admin (the roster check). */
targetIsAdmin: boolean;
/** Who asked: the account itself, or the admin acting on it. */
requestedById: string;
/**
* Runs first inside the transaction, before anything is written: the place
* for a refusal that must be decided under the same locks (throw), and for
* the audit rows, which then cannot outlive a refusal.
*/
inTransaction: (tx: Tx, purgeAt: Date | null) => Promise<void>;
}): Promise<{ purgeAt: Date | null }> {
const requestedAt = new Date();
const purgeAt = accountDeletionPurgeAt(
requestedAt,
accountDeletionGraceDays(),
);
await deleteGuardianAccountWithLifecycle(
input.userId,
async (tx) => {
// Checked again under the roster lock: two admins deleting at the same
// moment must not each count the other.
if (input.targetIsAdmin) await assertAnotherAdminStays(tx, input.userId);
await input.inTransaction(tx, purgeAt);
if (purgeAt) {
// Locked, not erased: credentials revoked in this transaction, the
// record untouched until the purge job reaches `purgeAt`.
await lockAccountForDeletion(tx, {
userId: input.userId,
requestedById: input.requestedById,
requestedAt,
purgeAt,
});
return;
}
// No window: erase now, inside the same record lifecycle transaction so
// a refusal leaves every account state intact (sessions, the account's
// audit history — GDPR Art. 17 — the off-host purge request, then the
// row with everything cascading from it).
await eraseAccount(tx, input.userId, "account_deleted");
},
{ erasing: purgeAt === null },
);
if (!purgeAt) await kickOffhostPurge();
return { purgeAt };
}
@@ -0,0 +1,38 @@
/**
* The chat request bodies take every language the app ships.
*
* The Coach chat, the document chat and the fenced chat declared `locale` as
* the pair de/en, so a body naming any other app language (French, Korean,
* Brazilian Portuguese, …) was refused with 422 before the reply language was
* ever resolved, although `resolveServerLocale` and the prompts downstream
* carry every locale.
*/
import { describe, expect, it } from "vitest";
import { locales } from "@/lib/i18n/config";
import { coachChatRequestSchema } from "@/lib/ai/coach/types";
import {
documentChatRequestSchema,
fencedChatRequestSchema,
} from "@/lib/validations/inbound-documents";
const SCHEMAS = {
coach: (locale: string) =>
coachChatRequestSchema.safeParse({ message: "Hello", locale }),
document: (locale: string) =>
documentChatRequestSchema.safeParse({ message: "Hello", locale }),
fenced: (locale: string) =>
fencedChatRequestSchema.safeParse({ message: "Hello", locale }),
};
describe.each(Object.entries(SCHEMAS))("%s chat request", (_name, parse) => {
it.each([...locales])("accepts %s", (locale) => {
const parsed = parse(locale);
expect(parsed.success).toBe(true);
expect(parsed.data?.locale).toBe(locale);
});
it("refuses a language the app does not ship", () => {
expect(parse("xx").success).toBe(false);
});
});
+7 -7
View File
@@ -7,6 +7,7 @@
* can pass messages through with minimal translation.
*/
import { z } from "zod/v4";
import { locales } from "@/lib/i18n/config";
import { COACH_SCOPE_SOURCES } from "./scope-sources";
@@ -85,15 +86,14 @@ export const coachChatRequestSchema = z.object({
conversationId: z.string().min(1).max(64).optional(),
message: z.string().min(1).max(4000),
prefill: z.string().max(2000).optional(),
locale: z.enum(["en", "de"]).optional(),
locale: z.enum(locales).optional(),
scope: coachScopeSchema.optional(),
/**
* v1.16.6 — guided clarifying-questions flow: the pending question
* this message answers. The question bubble is client-side only
* (never persisted), so without this context the model would see a
* bare answer ("since 2019, with medication") and could not react
* to it. Rides the prompt as delimited user-provided context; the
* persisted user turn stays the answer alone.
* v1.16.6 — guided clarifying-questions flow: the pending question this
* message answers. The question bubble is client-side only (never persisted),
* so without this context the model would see a bare answer ("since 2019,
* with medication") and could not react to it. Rides the prompt as delimited
* user-provided context; the persisted user turn stays the answer alone.
*/
guidedQuestion: z.string().min(1).max(500).optional(),
/**
+281
View File
@@ -0,0 +1,281 @@
/**
* Confirming at the identity provider (#1296): which accounts are offered it,
* what the callback accepts as fresh, whose identity counts, and what the
* sealed state must carry.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const db = vi.hoisted(() => ({
findUser: vi.fn<(args: unknown) => Promise<unknown>>(),
updateSession: vi.fn<(args: unknown) => Promise<unknown>>(),
countKeys: vi.fn<(args: unknown) => Promise<number>>(),
countPasskeys: vi.fn<(args: unknown) => Promise<number>>(),
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: db.findUser },
session: { update: db.updateSession },
webauthnMfaCredential: { count: db.countKeys },
passkey: { count: db.countPasskeys },
},
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
rateLimitHeaders: vi.fn(() => ({})),
refundRateLimit: vi.fn(),
}));
vi.mock("@/lib/crypto", () => ({
decrypt: vi.fn((s: string) => s),
encrypt: vi.fn((s: string) => s),
}));
import { auditLog } from "@/lib/auth/audit";
import { refundRateLimit } from "@/lib/rate-limit";
import {
isFirstFactorOnlyProof,
recentProofMethods,
} from "@/lib/auth/second-factor";
import {
isFreshAuthTime,
isLinkedIdentity,
isReproofSession,
recordOidcReproof,
reproofReturnUrl,
} from "@/lib/auth/oidc-reproof";
import { readStoredOidcState } from "@/lib/auth/oidc-callback/state";
const ISSUER = "https://idp.example.test";
const OIDC_ENV = {
OIDC_ISSUER_URL: ISSUER,
OIDC_CLIENT_ID: "client",
OIDC_CLIENT_SECRET: "secret",
};
function accountRow(overrides: Record<string, unknown> = {}) {
return {
passwordHash: null,
oidcIssuer: ISSUER,
oidcSub: "sub-1",
...overrides,
};
}
beforeEach(() => {
vi.clearAllMocks();
Object.assign(process.env, OIDC_ENV);
process.env.NEXT_PUBLIC_APP_URL = "https://healthlog.example.test";
db.countKeys.mockResolvedValue(0);
db.countPasskeys.mockResolvedValue(0);
});
afterEach(() => {
for (const key of Object.keys(OIDC_ENV)) delete process.env[key];
});
describe("recentProofMethods offers the identity provider", () => {
const user = { id: "u1", totpConfirmedAt: null };
it("to an account without a second factor whose identity is linked", async () => {
db.findUser.mockResolvedValue(accountRow());
expect(await recentProofMethods(user, false)).toEqual(["oidc"]);
});
it("beside the password and passkey the account also holds", async () => {
db.countPasskeys.mockResolvedValue(1);
db.findUser.mockResolvedValue(accountRow({ passwordHash: "hash" }));
expect(await recentProofMethods(user, false)).toEqual([
"password",
"oidc",
"passkey",
]);
});
it("not to an account without a linked identity", async () => {
db.findUser.mockResolvedValue(
accountRow({ oidcIssuer: null, oidcSub: null }),
);
expect(await recentProofMethods(user, false)).toEqual([]);
});
it("not when the instance has no provider configured", async () => {
delete process.env.OIDC_CLIENT_SECRET;
db.findUser.mockResolvedValue(accountRow());
expect(await recentProofMethods(user, false)).toEqual([]);
});
it("not to an account with a second factor, where it could not clear the gate", async () => {
db.countKeys.mockResolvedValue(1);
db.findUser.mockResolvedValue(accountRow());
expect(await recentProofMethods(user, true)).toEqual(["webauthn"]);
});
it("and never counts it as a second factor", () => {
expect(isFirstFactorOnlyProof("oidc")).toBe(true);
expect(isFirstFactorOnlyProof("password")).toBe(true);
expect(isFirstFactorOnlyProof("passkey")).toBe(false);
});
});
describe("isFreshAuthTime", () => {
const now = 1_800_000_000_000;
const seconds = (ms: number) => Math.floor(ms / 1000);
it("accepts an authentication inside the five-minute window", () => {
expect(isFreshAuthTime(seconds(now - 10_000), now)).toBe(true);
expect(isFreshAuthTime(seconds(now - 300_000), now)).toBe(true);
});
it("refuses one older than the window", () => {
expect(isFreshAuthTime(seconds(now - 301_000), now)).toBe(false);
expect(isFreshAuthTime(seconds(now - 3_600_000), now)).toBe(false);
});
it("refuses a token that does not say when", () => {
expect(isFreshAuthTime(null, now)).toBe(false);
});
it("tolerates a provider clock a minute ahead, and no more", () => {
expect(isFreshAuthTime(seconds(now + 30_000), now)).toBe(true);
expect(isFreshAuthTime(seconds(now + 120_000), now)).toBe(false);
});
});
describe("isLinkedIdentity", () => {
it("needs both the issuer and the subject to match", () => {
const account = { oidcIssuer: ISSUER, oidcSub: "sub-1" };
expect(isLinkedIdentity(account, ISSUER, "sub-1")).toBe(true);
expect(isLinkedIdentity(account, ISSUER, "sub-2")).toBe(false);
expect(isLinkedIdentity(account, "https://other.test", "sub-1")).toBe(
false,
);
expect(
isLinkedIdentity({ oidcIssuer: null, oidcSub: null }, ISSUER, "sub-1"),
).toBe(false);
});
});
describe("isReproofSession", () => {
const bound = { reproofUserId: "u1", reproofSessionId: "s1" };
it("holds only for the session and account the start bound", () => {
expect(
isReproofSession(bound, { session: { id: "s1" }, user: { id: "u1" } }),
).toBe(true);
expect(
isReproofSession(bound, { session: { id: "s2" }, user: { id: "u1" } }),
).toBe(false);
expect(
isReproofSession(bound, { session: { id: "s1" }, user: { id: "u2" } }),
).toBe(false);
expect(isReproofSession(bound, null)).toBe(false);
expect(
isReproofSession({}, { session: { id: "s1" }, user: { id: "u1" } }),
).toBe(false);
});
});
describe("recordOidcReproof", () => {
const now = 1_800_000_000_000;
const args = {
userId: "u1",
sessionId: "s1",
issuer: ISSUER,
ipAddress: "203.0.113.5",
nowMs: now,
};
it("stamps reproofAt only, refunds the attempt and audits the success", async () => {
db.findUser.mockResolvedValue(accountRow());
const outcome = await recordOidcReproof({
...args,
identity: { sub: "sub-1", authTime: Math.floor(now / 1000) - 5 },
});
expect(outcome).toEqual({ ok: true });
expect(db.updateSession).toHaveBeenCalledWith({
where: { id: "s1" },
data: { reproofAt: new Date(now) },
});
expect(refundRateLimit).toHaveBeenCalledWith("auth:step-up:u1");
expect(auditLog).toHaveBeenCalledWith(
"auth.reproof.succeeded",
expect.objectContaining({ details: { method: "oidc" } }),
);
});
it("refuses a stale auth_time without stamping", async () => {
db.findUser.mockResolvedValue(accountRow());
const outcome = await recordOidcReproof({
...args,
identity: { sub: "sub-1", authTime: Math.floor(now / 1000) - 3600 },
});
expect(outcome).toEqual({ ok: false, reason: "stale_auth_time" });
expect(db.updateSession).not.toHaveBeenCalled();
expect(auditLog).toHaveBeenCalledWith(
"auth.mfa.failed",
expect.objectContaining({
details: {
stage: "oidc_reproof",
method: "oidc",
reason: "stale_auth_time",
},
}),
);
});
it("refuses a different subject without stamping", async () => {
db.findUser.mockResolvedValue(accountRow());
const outcome = await recordOidcReproof({
...args,
identity: { sub: "someone-else", authTime: Math.floor(now / 1000) },
});
expect(outcome).toEqual({ ok: false, reason: "identity_mismatch" });
expect(db.updateSession).not.toHaveBeenCalled();
});
});
describe("the re-proof return and state", () => {
it("returns to the page it left with the outcome attached", () => {
const url = reproofReturnUrl("/settings/api?tab=mcp", "done");
expect(url.origin).toBe("https://healthlog.example.test");
expect(url.pathname).toBe("/settings/api");
expect(url.searchParams.get("tab")).toBe("mcp");
expect(url.searchParams.get("reproof")).toBe("done");
});
it("reads a reproof state only with both bindings and no other intent's fields", () => {
const base = { state: "s", nonce: "n", codeVerifier: "v", next: "/" };
const ok = readStoredOidcState(
JSON.stringify({
...base,
intent: "reproof",
reproofUserId: "u1",
reproofSessionId: "s1",
}),
);
expect(ok?.intent).toBe("reproof");
expect(
readStoredOidcState(
JSON.stringify({ ...base, intent: "reproof", reproofUserId: "u1" }),
),
).toBeNull();
expect(
readStoredOidcState(
JSON.stringify({
...base,
intent: "reproof",
reproofUserId: "u1",
reproofSessionId: "s1",
native: true,
appCodeChallenge: "c",
}),
),
).toBeNull();
expect(
readStoredOidcState(JSON.stringify({ ...base, intent: "other" })),
).toBeNull();
});
});
+72 -17
View File
@@ -37,6 +37,15 @@ const ENV_KEYS = [
] as const;
const ORIGINAL_ENV: Record<string, string | undefined> = {};
/**
* A verification result carrying only what `verifyIdToken` reads: the claims
* and the header `jose` returns beside them.
*/
type VerifyResult = Awaited<ReturnType<typeof mockJwtVerify>>;
function verified(payload: Record<string, unknown>): VerifyResult {
return { payload, protectedHeader: { alg: "RS256" } } as VerifyResult;
}
function jsonResponse(body: unknown, ok = true, status = 200): Response {
return {
ok,
@@ -270,6 +279,25 @@ describe("buildAuthorizationUrl", () => {
}),
);
expect(url.searchParams.get("prompt")).toBe("login");
expect(url.searchParams.has("max_age")).toBe(false);
});
it("asks for a fresh, timed authentication for a re-proof", () => {
configureEnv();
const url = new URL(
buildAuthorizationUrl({
metadata: METADATA_DOC,
config: getOidcConfig()!,
state: "state-1",
nonce: "nonce-1",
codeChallenge: "challenge-1",
redirectUri: "https://healthlog.example.com/api/auth/oidc/callback",
reauthenticate: true,
}),
);
expect(url.searchParams.get("prompt")).toBe("login");
expect(url.searchParams.get("max_age")).toBe("0");
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
});
});
@@ -332,16 +360,15 @@ describe("verifyIdToken", () => {
beforeEach(() => configureEnv());
it("returns the identity claims on success", async () => {
// Test payloads deliberately omit the full JWTVerifyResult shape.
vi.mocked(mockJwtVerify).mockResolvedValue({
payload: {
vi.mocked(mockJwtVerify).mockResolvedValue(
verified({
sub: "user-sub-1",
email: "person@example.com",
email_verified: true,
name: "Person",
nonce: "nonce-1",
},
} as never);
}),
);
const config = getOidcConfig()!;
const identity = await verifyIdToken({
metadata: METADATA_DOC,
@@ -354,13 +381,43 @@ describe("verifyIdToken", () => {
email: "person@example.com",
emailVerified: true,
name: "Person",
authTime: null,
});
});
it("returns auth_time when the provider states it, and null otherwise", async () => {
vi.mocked(mockJwtVerify).mockResolvedValue(
verified({
sub: "user-sub-1",
nonce: "nonce-1",
auth_time: 1_700_000_000,
}),
);
const config = getOidcConfig()!;
const stated = await verifyIdToken({
metadata: METADATA_DOC,
config,
idToken: "id-token",
nonce: "nonce-1",
});
expect(stated.authTime).toBe(1_700_000_000);
vi.mocked(mockJwtVerify).mockResolvedValue(
verified({ sub: "user-sub-1", nonce: "nonce-1", auth_time: "recent" }),
);
const unstated = await verifyIdToken({
metadata: METADATA_DOC,
config,
idToken: "id-token",
nonce: "nonce-1",
});
expect(unstated.authTime).toBeNull();
});
it("rejects a nonce mismatch", async () => {
vi.mocked(mockJwtVerify).mockResolvedValue({
payload: { sub: "user-sub-1", nonce: "wrong-nonce" },
} as never);
vi.mocked(mockJwtVerify).mockResolvedValue(
verified({ sub: "user-sub-1", nonce: "wrong-nonce" }),
);
const config = getOidcConfig()!;
await expect(
verifyIdToken({
@@ -373,9 +430,7 @@ describe("verifyIdToken", () => {
});
it("rejects a token missing sub", async () => {
vi.mocked(mockJwtVerify).mockResolvedValue({
payload: { nonce: "nonce-1" },
} as never);
vi.mocked(mockJwtVerify).mockResolvedValue(verified({ nonce: "nonce-1" }));
const config = getOidcConfig()!;
await expect(
verifyIdToken({
@@ -388,9 +443,9 @@ describe("verifyIdToken", () => {
});
it("verifies with a closed asymmetric algorithm allowlist and bounded clock tolerance", async () => {
vi.mocked(mockJwtVerify).mockResolvedValue({
payload: { sub: "user-sub-1", nonce: "nonce-1" },
} as never);
vi.mocked(mockJwtVerify).mockResolvedValue(
verified({ sub: "user-sub-1", nonce: "nonce-1" }),
);
const config = getOidcConfig()!;
await verifyIdToken({
metadata: METADATA_DOC,
@@ -411,9 +466,9 @@ describe("verifyIdToken", () => {
});
it("pins the JWKS fetch to the issuer's host with a bounded timeout", async () => {
vi.mocked(mockJwtVerify).mockResolvedValue({
payload: { sub: "user-sub-1", nonce: "nonce-1" },
} as never);
vi.mocked(mockJwtVerify).mockResolvedValue(
verified({ sub: "user-sub-1", nonce: "nonce-1" }),
);
const config = getOidcConfig()!;
await verifyIdToken({
metadata: METADATA_DOC,
+20 -5
View File
@@ -56,6 +56,21 @@ beforeEach(() => {
] as never);
});
describe("applyProfileUpdate locale", () => {
it.each(["fr", "pt-BR", "ko"])(
"stores %s, a language the app ships",
async (locale) => {
const result = await applyProfileUpdate(USER_ID, { locale });
expect(result.ok).toBe(true);
expect(prisma.user.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ locale }),
}),
);
},
);
});
describe("applyProfileUpdate timezone validation", () => {
it("accepts a valid IANA zone", async () => {
const result = await applyProfileUpdate(USER_ID, {
@@ -271,7 +286,7 @@ describe("applyProfileUpdate partial-field save", () => {
it("writes nothing and reports every rejected field when the whole body is invalid", async () => {
const result = await applyProfileUpdate(USER_ID, {
gender: "male",
locale: "fr",
locale: "xx",
});
expect(result.ok).toBe(false);
if (!result.ok) {
@@ -290,15 +305,15 @@ describe("applyProfileUpdate top-level error message", () => {
// sharpest regression check available for "does the raw Zod default
// ever reach the top-level string a person reads". Zod v4's default
// enum message is shaped like:
// Invalid option: expected one of "de"|"en"
// Invalid option: expected one of "de"|"en"|"fr"|…
const RAW_ZOD_ENUM_PATTERN = /expected one of/i;
it("never lets the raw Zod enum message reach the top-level `message`", async () => {
const result = await applyProfileUpdate(USER_ID, { locale: "fr" });
const result = await applyProfileUpdate(USER_ID, { locale: "xx" });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.message).not.toMatch(RAW_ZOD_ENUM_PATTERN);
expect(result.message).not.toContain('"de"|"en"');
expect(result.message).not.toContain('"de"|"en"|"fr"');
// Relocated, not deleted: the same detail is still available for
// whoever needs it (the iOS `details.issues` contract), just not
// in the string a person reads first.
@@ -318,7 +333,7 @@ describe("applyProfileUpdate top-level error message", () => {
});
it("carries a stable errorCode so the web client can localize the sentence", async () => {
const result = await applyProfileUpdate(USER_ID, { locale: "fr" });
const result = await applyProfileUpdate(USER_ID, { locale: "xx" });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.errorCode).toBe("profile.update.nothingSaved");
+2 -1
View File
@@ -29,6 +29,7 @@ import {
import {
hasSecondFactorEnrolled,
recentProofMethods,
type RecentProofMethod,
} from "@/lib/auth/second-factor";
import { verifyPassword } from "@/lib/auth/password";
import { verifyAuthentication } from "@/lib/auth/passkey";
@@ -171,7 +172,7 @@ export async function recordReproofFailure(
userId: string,
ipAddress: string | null,
stage: string,
method: ExistingFactorProof["method"],
method: RecentProofMethod,
reason: string,
): Promise<void> {
await auditLog("auth.mfa.failed", {
+4 -2
View File
@@ -3,6 +3,7 @@ import { annotate, getEvent } from "@/lib/logging/context";
import { getSession } from "@/lib/auth/session";
import {
hasSecondFactorEnrolled,
isFirstFactorOnlyProof,
recentProofMethods,
} from "@/lib/auth/second-factor";
import { meterAccountWrite } from "@/lib/auth/write-meter";
@@ -80,9 +81,10 @@ export async function requireFreshMfa(
// does, so the browser can open the re-proof dialog instead of printing a
// sentence with nowhere to act on it. Only the possession proofs: a
// `POST /api/auth/reproof` with one of them stamps `mfaVerifiedAt`, a
// password stamps `reproofAt` only and would leave this gate shut.
// password or a provider sign-in stamps `reproofAt` only and would leave
// this gate shut.
const methods = (await recentProofMethods(sessionData.user)).filter(
(method) => method !== "password",
(method) => !isFirstFactorOnlyProof(method),
);
annotate({ meta: { step_up_methods: methods.join(",") } });
throw new StepUpRequiredError(
+26 -4
View File
@@ -27,8 +27,17 @@ export interface StoredOidcState {
* names instead of signing in, and only when the cookie still carries this
* exact binding. Web only: never together with `native`.
*/
intent?: "claim";
intent?: "claim" | "reproof";
claimBinding?: string;
/**
* Set only for `intent: "reproof"` (`GET /api/auth/oidc/reproof`): the
* signed-in account and the session row the round trip confirms. The
* callback stamps nothing unless the browser still carries exactly this
* session and the provider names exactly this account's linked identity.
* Web only, like the claim.
*/
reproofUserId?: string;
reproofSessionId?: string;
}
function isStoredOidcState(value: unknown): value is StoredOidcState {
@@ -50,11 +59,24 @@ function isStoredOidcState(value: unknown): value is StoredOidcState {
if (typeof v.appCodeChallenge !== "string") return false;
}
if (v.intent !== undefined) {
if (v.intent !== "claim") return false;
if (typeof v.claimBinding !== "string" || v.claimBinding.length === 0) {
if (v.native !== undefined) return false;
if (v.intent === "claim") {
if (typeof v.claimBinding !== "string" || v.claimBinding.length === 0) {
return false;
}
} else if (v.intent === "reproof") {
if (
typeof v.reproofUserId !== "string" ||
v.reproofUserId.length === 0 ||
typeof v.reproofSessionId !== "string" ||
v.reproofSessionId.length === 0 ||
v.claimBinding !== undefined
) {
return false;
}
} else {
return false;
}
if (v.native !== undefined) return false;
}
return true;
}
+158
View File
@@ -0,0 +1,158 @@
/**
* Confirming it is you through the identity provider.
*
* An account created by single sign-on holds no password, passkey or second
* factor, so the recent-proof gate (token minting, exports, share links, the
* admin data actions) had nothing to ask it for. This is the proof such an
* account can give: the browser goes back to the provider with
* `prompt=login` and `max_age=0`, and the callback counts the round trip only
* when the ID token says the provider authenticated the person inside the
* gate's own window and names the identity linked to the signed-in account.
*
* It records the proof exactly as a password re-proof does, `Session.reproofAt`
* and nothing else, so it never stands in for a second factor. It creates no
* session and switches none: the callback stamps the session row the start
* bound into the sealed state, and only while the browser still carries it.
*
* Start: `GET /api/auth/oidc/reproof?next=<path>`. Finish: the shared
* `GET /api/auth/oidc/callback` with `intent: "reproof"` in the state blob.
*/
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import { oidcAppUrl, type OidcIdentity } from "@/lib/auth/oidc";
import {
RECENT_PROOF_MAX_AGE_MS,
recordReproofFailure,
refundReproof,
} from "@/lib/auth/existing-factor-proof";
/** The query parameter the browser comes back with, and its two values. */
export const OIDC_REPROOF_RESULT_PARAM = "reproof";
export type OidcReproofResult = "done" | "failed";
/** The stage name the shared re-proof bucket and the audit rows carry. */
export const OIDC_REPROOF_STAGE = "oidc_reproof";
/**
* How far in the future `auth_time` may sit and still count: the clock
* tolerance `verifyIdToken` already grants `iat` and `exp`, for an identity
* provider on another box whose clock runs a little ahead.
*/
const AUTH_TIME_FUTURE_TOLERANCE_MS = 60_000;
/**
* Did the provider authenticate the person inside the gate's window (the five
* minutes every recent-proof surface uses)? A token
* without `auth_time` is refused: with `max_age` in the request the provider
* owes the claim (OpenID Connect Core 2), and without it nothing says the
* person signed in now rather than an hour ago.
*/
export function isFreshAuthTime(
authTime: number | null,
nowMs: number,
maxAgeMs: number = RECENT_PROOF_MAX_AGE_MS,
): boolean {
if (authTime === null) return false;
const at = authTime * 1000;
return nowMs - at <= maxAgeMs && at - nowMs <= AUTH_TIME_FUTURE_TOLERANCE_MS;
}
/**
* Is the identity the provider named the one linked to this account? Both
* halves, compared exactly: the subject is only unique per issuer.
*/
export function isLinkedIdentity(
account: { oidcIssuer: string | null; oidcSub: string | null },
issuer: string,
sub: string,
): boolean {
return (
account.oidcIssuer !== null &&
account.oidcSub !== null &&
account.oidcIssuer === issuer &&
account.oidcSub === sub
);
}
/**
* Does the browser still carry the session the round trip was started from?
* Checked before the code is redeemed, so a mismatch spends nothing at the
* provider.
*/
export function isReproofSession(
bound: { reproofUserId?: string; reproofSessionId?: string },
live: { session: { id: string }; user: { id: string } } | null,
): boolean {
return (
live !== null &&
typeof bound.reproofSessionId === "string" &&
typeof bound.reproofUserId === "string" &&
live.session.id === bound.reproofSessionId &&
live.user.id === bound.reproofUserId
);
}
/** Where the browser lands: the page it left, with the outcome attached. */
export function reproofReturnUrl(next: string, result: OidcReproofResult): URL {
const url = oidcAppUrl(next);
url.searchParams.set(OIDC_REPROOF_RESULT_PARAM, result);
return url;
}
export type OidcReproofOutcome =
{ ok: true } | { ok: false; reason: "identity_mismatch" | "stale_auth_time" };
/**
* Check the verified ID token against the account and stamp the session.
* Every refusal is audited like a failed password re-proof; a success gives
* back the attempt the start charged to the shared bucket.
*/
export async function recordOidcReproof(args: {
userId: string;
sessionId: string;
issuer: string;
identity: Pick<OidcIdentity, "sub" | "authTime">;
ipAddress: string | null;
nowMs?: number;
}): Promise<OidcReproofOutcome> {
const { userId, sessionId, issuer, identity, ipAddress } = args;
const nowMs = args.nowMs ?? Date.now();
const account = await prisma.user.findUnique({
where: { id: userId },
select: { oidcIssuer: true, oidcSub: true },
});
let refusal: "identity_mismatch" | "stale_auth_time" | null = null;
if (!account || !isLinkedIdentity(account, issuer, identity.sub)) {
refusal = "identity_mismatch";
} else if (!isFreshAuthTime(identity.authTime, nowMs)) {
refusal = "stale_auth_time";
}
if (refusal) {
await recordReproofFailure(
userId,
ipAddress,
OIDC_REPROOF_STAGE,
"oidc",
refusal,
);
return { ok: false, reason: refusal };
}
await prisma.session.update({
where: { id: sessionId },
data: { reproofAt: new Date(nowMs) },
});
await refundReproof(userId);
await auditLog("auth.reproof.succeeded", {
userId,
ipAddress,
details: { method: "oidc" },
});
annotate({
action: { name: "auth.reproof.succeeded" },
meta: { method: "oidc" },
});
return { ok: true };
}
+20 -1
View File
@@ -244,6 +244,13 @@ export function buildAuthorizationUrl(params: {
* keyboard now, not a session someone else left open in this browser.
*/
forceLogin?: boolean;
/**
* Ask the provider to authenticate afresh AND to say when it did: `prompt=login`
* plus `max_age=0` (OpenID Connect Core 3.1.2.1). With `max_age` in the
* request the ID token must carry `auth_time`, which is what the re-proof
* leg checks before it counts the round trip as a proof.
*/
reauthenticate?: boolean;
}): string {
const url = new URL(params.metadata.authorization_endpoint);
url.search = new URLSearchParams({
@@ -255,7 +262,8 @@ export function buildAuthorizationUrl(params: {
nonce: params.nonce,
code_challenge: params.codeChallenge,
code_challenge_method: "S256",
...(params.forceLogin ? { prompt: "login" } : {}),
...(params.forceLogin || params.reauthenticate ? { prompt: "login" } : {}),
...(params.reauthenticate ? { max_age: "0" } : {}),
}).toString();
return url.toString();
}
@@ -304,6 +312,12 @@ export interface OidcIdentity {
email: string | null;
emailVerified: boolean | undefined;
name: string | null;
/**
* When the provider last authenticated the person (`auth_time`, seconds
* since the epoch), or null when the token does not say. Only the re-proof
* leg reads it, and it refuses a null.
*/
authTime: number | null;
}
/**
@@ -350,6 +364,11 @@ export async function verifyIdToken(params: {
? payload.email_verified
: undefined,
name: typeof payload.name === "string" ? payload.name : null,
authTime:
typeof payload.auth_time === "number" &&
Number.isFinite(payload.auth_time)
? payload.auth_time
: null,
};
}
+5 -2
View File
@@ -17,6 +17,7 @@ import { sanitiseZodIssues, type SanitisedZodIssue } from "@/lib/api-response";
import { checkProfileEmailRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { z } from "zod/v4";
import { locales } from "@/lib/i18n/config";
import {
REPROOF_FAILED_CODE,
REPROOF_REQUIRED_CODE,
@@ -26,9 +27,11 @@ import {
const extendedProfileSchema = profileSchema.extend({
// Trimmed first; "" (or only spaces) clears the name to null below.
displayName: optionalDisplayNameSchema.nullable().optional(),
// Every language the app ships: the stored language is what background
// work (the Coach nudge, the notification texts) writes in.
locale: z
.enum(["de", "en"], {
error: "Locale must be de or en.",
.enum(locales, {
error: "Locale must be one of the languages the app ships.",
})
.nullable()
.optional(),
+28 -7
View File
@@ -1,4 +1,5 @@
import { prisma } from "@/lib/db";
import { isOidcConfigured } from "@/lib/auth/oidc";
/**
* Does this account have a SECOND FACTOR at all?
@@ -40,11 +41,25 @@ export async function hasSecondFactorEnrolled(user: {
}
/**
* 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.
* A proof the recent-proof gate can ask for. `password`, `oidc` 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.
*
* `oidc` is a fresh sign-in at the account's linked identity provider
* (`GET /api/auth/oidc/reproof`), not a body proof: it stamps
* `Session.reproofAt` exactly as a password does, and like a password it never
* stands in for a second factor.
*/
export type RecentProofMethod = "password" | "totp" | "webauthn" | "passkey";
export type RecentProofMethod =
"password" | "oidc" | "totp" | "webauthn" | "passkey";
/**
* The proofs that stamp only `Session.reproofAt` and so never clear a gate that
* asks for a second factor (`requireFreshMfa`).
*/
export function isFirstFactorOnlyProof(method: RecentProofMethod): boolean {
return method === "password" || method === "oidc";
}
/**
* The proofs that would satisfy `assertRecentCookieProof` for this
@@ -63,15 +78,21 @@ export async function recentProofMethods(
prisma.passkey.count({ where: { userId: user.id } }),
prisma.user.findUnique({
where: { id: user.id },
select: { passwordHash: true },
select: { passwordHash: true, oidcIssuer: true, oidcSub: true },
}),
]);
const methods: RecentProofMethod[] = [];
if (hasSecond) {
if (user.totpConfirmedAt) methods.push("totp");
if (keys > 0) methods.push("webauthn");
} else if (credential?.passwordHash) {
methods.push("password");
} else {
if (credential?.passwordHash) methods.push("password");
// A linked identity is only a way to confirm while the instance still
// talks to a provider: with the provider group unset the round trip has
// nowhere to go.
if (credential?.oidcIssuer && credential.oidcSub && isOidcConfigured()) {
methods.push("oidc");
}
}
if (passkeys > 0) methods.push("passkey");
return methods;
@@ -63,8 +63,11 @@ describe("first paint carries the resolved locale, not a default", () => {
/prev\.locale === locale \? \{ locale, messages: loaded \} : prev/,
);
// And the persistence effect keys on the locale alone, so one mount
// with one locale means one write.
expect(src).toMatch(/persistLocaleToServer\(locale\);\s*\}, \[locale\]\);/);
// And the provider writes nothing to the server itself: the mirror lives
// in `LocaleServerSyncBridge`, keyed on the locale and the signed-in
// account, so a signed-out mount sends nothing and one account with one
// locale means one write (its browser test pins both).
expect(src).toMatch(/export function persistLocaleToServer\(/);
expect(src).not.toMatch(/persistLocaleToServer\(locale/);
});
});
+12 -14
View File
@@ -125,16 +125,23 @@ function persistLocale(newLocale: Locale) {
// the server-set cookie the route emits in reply: Safari's ITP caps the
// script-written copy at 7 days, and a Set-Cookie is exempt.
//
// Once per tab session and per locale, not on every mount: every page load
// mounts the provider, and every authenticated write is charged to the
// Once per tab session, account and locale, not on every mount: every page
// load mounts the provider, and every authenticated write is charged to the
// account's shared write bucket, so a mount-time PUT spent one write per page
// view on a value that had not changed. A switch still writes at once (the
// locale differs from the one recorded), and a new tab writes once.
//
// Only for a signed-in account. The route answers 401 to nobody, the marker
// is then never set, and every signed-out page load used to repeat the
// refused PUT. Signed out, the choice stays in localStorage and the cookie;
// `LocaleServerSyncBridge` calls this once an account is known, so the
// choice made before signing in reaches the column right after.
const LOCALE_SYNCED_KEY = "healthlog-locale-synced";
function persistLocaleToServer(newLocale: Locale) {
export function persistLocaleToServer(newLocale: Locale, accountId: string) {
const marker = `${accountId}:${newLocale}`;
try {
if (sessionStorage.getItem(LOCALE_SYNCED_KEY) === newLocale) return;
if (sessionStorage.getItem(LOCALE_SYNCED_KEY) === marker) return;
} catch {
// Storage unavailable: write as before.
}
@@ -148,7 +155,7 @@ function persistLocaleToServer(newLocale: Locale) {
.then((res) => {
if (!res.ok) return;
try {
sessionStorage.setItem(LOCALE_SYNCED_KEY, newLocale);
sessionStorage.setItem(LOCALE_SYNCED_KEY, marker);
} catch {
// Storage unavailable: the next mount writes again.
}
@@ -238,15 +245,6 @@ export function I18nProvider({
};
}, [locale]);
// v1.25.0 — keep `User.locale` in step with the committed UI locale. Runs on
// the first mount of a tab session (backfilling the column for users who set
// their language before this landed) and on every switch, so the proactive
// Coach nudge and other cookie-blind background paths render in the language
// the user reads.
useEffect(() => {
persistLocaleToServer(locale);
}, [locale]);
const setLocale = useCallback((newLocale: Locale) => {
if (!(locales as readonly string[]).includes(newLocale)) return;
+3 -3
View File
@@ -8,7 +8,7 @@
* to the rounding precision, the chart-token scrub, or the cache-row
* shape lands once rather than seven times.
*
* `SupportedLocale` is the full six-locale UI union. The status prompts
* `SupportedLocale` is the full UI locale union. The status prompts
* compose one of two reviewed instruction bodies (de / en) but name the
* reader's own language in an explicit output directive, so the reader's
* locale must survive the whole pipeline rather than being collapsed to a
@@ -29,7 +29,7 @@ import {
} from "@/lib/ai/safety/outbound-screen";
/**
* The locales the assessment pipeline carries end-to-end — all six the UI
* The locales the assessment pipeline carries end-to-end — every one the UI
* ships. Aliased to the i18n `Locale` so adding a UI locale widens the
* pipeline in one place rather than needing a second edit here.
*/
@@ -108,7 +108,7 @@ export function normalizeSummaryText(value: string): string {
}
/**
* Validate an arbitrary locale string against the six the UI ships.
* Validate an arbitrary locale string against every locale the UI ships.
*
* A recognised locale passes through UNCHANGED — that is the whole point:
* the former `value === "de" ? "de" : "en"` binary erased a French reader's
@@ -53,13 +53,54 @@ describe("crisisResourcesForLocale", () => {
}
});
it("gives a Dutch reader with no region 112, then the Dutch and Belgian lines by name", () => {
for (const locale of ["nl", "nl-SR"]) {
const set = crisisResourcesForLocale(locale);
expect(set.emergencyNumber, locale).toBe("112");
expect(set.resources).toEqual([
{
id: "zelfmoordpreventie113",
contacts: ["113", "0800-0113", "113.nl"],
},
{ id: "zelfmoordlijn1813", contacts: ["1813", "zelfmoord1813.be"] },
{ id: "findahelpline", contacts: ["findahelpline.com"] },
]);
}
const { t } = getServerTranslator("nl");
expect(
t("mentalHealth.crisisResource.zelfmoordpreventie113.name"),
).toContain("Nederland");
expect(t("mentalHealth.crisisResource.zelfmoordlijn1813.name")).toContain(
"België",
);
});
it("gives an explicit Dutch region 113 and an explicit Belgian one 1813", () => {
for (const locale of ["nl-NL", "nl_NL"]) {
const ids = crisisResourcesForLocale(locale).resources.map((r) => r.id);
expect(ids, locale).toEqual(["zelfmoordpreventie113", "findahelpline"]);
}
for (const locale of ["nl-BE", "nl_BE", "be"]) {
const ids = crisisResourcesForLocale(locale).resources.map((r) => r.id);
expect(ids, locale).toEqual(["zelfmoordlijn1813", "findahelpline"]);
}
});
it("names every crisis resource in every language", () => {
// History re-derives the set from the row's stored locale and renders it
// in whatever language the reader uses now.
const ids = new Set(
["pt-BR", "pt-PT", "br", "de", "de-AT", "de-CH", "en-US", "fr"].flatMap(
(l) => crisisResourcesForLocale(l).resources.map((r) => r.id),
),
[
"pt-BR",
"pt-PT",
"br",
"de",
"de-AT",
"de-CH",
"en-US",
"fr",
"nl",
].flatMap((l) => crisisResourcesForLocale(l).resources.map((r) => r.id)),
);
for (const locale of locales) {
const { t } = getServerTranslator(locale);
+52 -2
View File
@@ -149,6 +149,51 @@ const PT_BR_AMBIGUOUS: CrisisResourceSet = {
],
};
/**
* The Netherlands (explicit "nl-NL" / "nl_NL"). 112 is the emergency number.
* 113 Zelfmoordpreventie answers by phone on 113 or the free 0800-0113 and by
* chat on 113.nl, anonymously.
*/
const NL: CrisisResourceSet = {
emergencyNumber: "112",
resources: [
{
id: "zelfmoordpreventie113",
contacts: ["113", "0800-0113", "113.nl"],
},
{ id: "findahelpline", contacts: ["findahelpline.com"] },
],
};
/**
* Belgium (explicit "nl-BE" / "nl_BE", or the bare region "be"). 112 is the
* emergency number. The Zelfmoordlijn answers 1813 around the clock and runs
* a chat in the evenings (zelfmoord1813.be).
*/
const BE: CrisisResourceSet = {
emergencyNumber: "112",
resources: [
{ id: "zelfmoordlijn1813", contacts: ["1813", "zelfmoord1813.be"] },
{ id: "findahelpline", contacts: ["findahelpline.com"] },
],
};
/**
* Dutch with no region signal. The app ships one Dutch bundle, `nl`, read in
* the Netherlands and in Flanders alike, and the account keeps no country to
* tell them apart. Following DE_AT_CH, the set leads with 112 (the emergency
* number in both countries) and names each country's line with the country
* in its label, so nothing reads as if it reached everyone.
*/
const NL_BE: CrisisResourceSet = {
emergencyNumber: "112",
resources: [
...NL.resources.filter((r) => r.id !== "findahelpline"),
...BE.resources.filter((r) => r.id !== "findahelpline"),
{ id: "findahelpline", contacts: ["findahelpline.com"] },
],
};
/**
* Resolve a crisis-resource set from the user's locale. Region detection is
* coarse (locale prefix) and defaults to the international set — the goal is to
@@ -156,8 +201,9 @@ const PT_BR_AMBIGUOUS: CrisisResourceSet = {
*
* A region-qualified tag ("de-AT", "de_CH", "pt-PT", …) resolves to the exact
* country set when the caller has one to give us. The web stores the app
* locale, which is a bare "de" or the folded "pt-BR", so it lands on the
* combined sets: never assume Germany or Brazil when the region is unknown.
* locale, which is a bare "de", "nl" or the folded "pt-BR", so it lands on the
* combined sets: never assume Germany, the Netherlands or Brazil when the
* region is unknown.
*/
export function crisisResourcesForLocale(
locale: string | null | undefined,
@@ -173,5 +219,9 @@ export function crisisResourcesForLocale(
// PT_BR_AMBIGUOUS. Any other Portuguese tag (pt, pt-AO, pt-MZ) lands here
// too, and still gets 112 and the worldwide directory first.
if (lc === "pt" || lc.startsWith("pt-")) return PT_BR_AMBIGUOUS;
if (lc === "nl-nl") return NL;
if (lc === "nl-be" || lc === "be") return BE;
// The bare "nl" the web stores, and any other Dutch tag: both countries.
if (lc === "nl" || lc.startsWith("nl-")) return NL_BE;
return INTERNATIONAL;
}
+6 -1
View File
@@ -40,8 +40,13 @@ import { DEFAULT_ERROR_CODES } from "@/lib/api/default-error-codes";
*/
export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
{
account: ["account.deletion.no_guardian", "account.deletion.not_pending"],
account: [
"account.deletion.already_pending",
"account.deletion.no_guardian",
"account.deletion.not_pending",
],
activity: ["activity.calendar.future_date", "activity.calendar.too_old"],
admin: ["admin.users.delete.managed_profile", "admin.users.delete.self"],
ai: [
"ai.budget.exceeded",
"ai.provider.none",
@@ -508,6 +508,9 @@ const accountPayload = z
.looseObject({
id: z.string(),
username: z.string(),
createdAt: isoDateTime().describe(
"When the signed-in account was created. The web greets an account in its first day as new rather than welcoming it back.",
),
// Enumerated because clients cut days and convert glucose by them: the
// day key and the unit must be the server's, not the device's.
timezone: z
+20 -3
View File
@@ -342,6 +342,11 @@ const mfaStatusResponse = z
recoveryCodesRemaining: z.number().int(),
webauthn: z.array(webauthnCredentialInfo),
passkeyNudgeDismissed: z.boolean(),
proofMethods: z
.array(z.enum(["password", "oidc", "totp", "webauthn", "passkey"]))
.describe(
"The ways this account can confirm it is you, the same list a recent-proof refusal names in `meta.methods`. `password` and `oidc` only on an account without a second factor; `oidc` is a browser round trip to the identity provider (`GET /api/auth/oidc/reproof`) and is never offered to a token.",
),
})
.meta({ id: "MfaStatusResponse" });
@@ -599,6 +604,14 @@ const changePasswordRequest = changePasswordSchema.meta({
"Password rotation. `newPassword` and `confirmPassword` must match, and `newPassword` must differ from the current one. The strength floor is 12 characters plus a scored check against the account's own username and email.",
});
const passkeyProviderProofRequest = z
.object({ method: z.literal("oidc") })
.meta({
id: "PasskeyProviderProofRequest",
description:
"Take the session's recent identity-provider re-proof (GET /api/auth/oidc/reproof) as the proof. Accepted only for an account whose `proofMethods` list `oidc` and only within five minutes of that re-proof; otherwise 401 `auth.reproof.required` with `meta.methods`. Web only.",
});
const passkeyReauthRequest = z
.object({
method: z
@@ -1130,7 +1143,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
summary: "Re-prove a credential on a browser session (cookie only)",
description:
"Stamps the calling browser session as recently re-proved, so for the next five minutes it may run the actions that ask for a fresh proof: GET /api/export/full-backup, POST /api/export/encrypted, POST /api/share-links, POST /api/mcp/tokens, POST /api/tokens/measurements, POST /api/tokens/workouts, POST /api/tokens/documents, and the admin backup download, upload and restore, the admin data wipe and the admin password reset. Those answer 401 `auth.reproof.required` with `meta.methods` (the proofs this account can give) when the session signed in or re-proved more than five minutes ago. A `totp`, `webauthn` or `passkey` proof here also clears the routes that ask for a fresh second factor (the managed-profile acts, the second-factor and passkey changes, the password change, account deletion, the data wipe and key rotation): on a browser session those answer 401 `auth.stepup.required` with `meta.methods` naming the possession proofs the account holds, and the web re-proves here and retries.\n\n" +
'The body takes the same shapes as POST /api/auth/step-up. On an account with a second factor only `totp`, `webauthn` (a security key) or `passkey` are accepted, because a password has never stood in for the second factor; on one without, `password` or `passkey`. A method the account cannot use here is refused with 422 `auth.reproof.too_weak` and `meta.methods`, before any guess is spent. A second-factor or passkey proof also refreshes the session\'s second-factor stamp, exactly as signing in again would. Assertions begin at POST /api/auth/passkey/register-options with `{ method: "passkey" | "webauthn" }`.\n\n' +
'The body takes the same shapes as POST /api/auth/step-up. On an account with a second factor only `totp`, `webauthn` (a security key) or `passkey` are accepted, because a password has never stood in for the second factor; on one without, `password` or `passkey`. An account without a second factor whose identity is linked to the configured identity provider is also offered `oidc` in `meta.methods`; that proof is not posted here but given by the browser round trip GET /api/auth/oidc/reproof, which stamps the session exactly as a password proof does. A method the account cannot use here is refused with 422 `auth.reproof.too_weak` and `meta.methods`, before any guess is spent. A second-factor or passkey proof also refreshes the session\'s second-factor stamp, exactly as signing in again would. Assertions begin at POST /api/auth/passkey/register-options with `{ method: "passkey" | "webauthn" }`.\n\n' +
"Proofs draw on the account's shared re-proof budget (five per fifteen minutes, 429 once spent). A proof that does not verify is 401 `auth.reproof.failed` and is audited. Bearer callers use POST /api/auth/step-up instead.",
requestBody: {
required: true,
@@ -2454,7 +2467,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
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. 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" +
'**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. An account confirmed at its identity provider (GET /api/auth/oidc/reproof, within five minutes) sends `{ method: "oidc" }` instead, which takes that re-proof as the proof and, like a password, clears the second-factor stamp. 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.",
@@ -2462,7 +2475,11 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
required: true,
content: {
"application/json": {
schema: z.union([passkeyReauthRequest, stepUpMintSchema]),
schema: z.union([
passkeyReauthRequest,
passkeyProviderProofRequest,
stepUpMintSchema,
]),
},
},
},
+13 -6
View File
@@ -8,6 +8,7 @@
import { recoveryEmptySchema } from "../recovery-empty";
import { trendSentiment, trendVerdict } from "../trend-verdict";
import { z } from "zod/v4";
import { locales } from "@/lib/i18n/config";
import { measurementSourceEnum, measurementTypeEnum } from "../shared";
import { METRIC_STATUS_IDS } from "@/lib/insights/metric-status-registry";
import {
@@ -45,9 +46,11 @@ export const metricStatusQuery = z
"HealthKit metric id to assess (e.g. RESTING_HEART_RATE, SLEEP_DURATION). Closed enum: an unknown id 422s. The seven specialised metrics are served by their own routes and are not accepted here.",
),
locale: z
.enum(["de", "en"])
.enum(locales)
.optional()
.describe("Optional UI-locale override; defaults to the session locale."),
.describe(
"Optional UI-locale override, any language the app ships; defaults to the session locale.",
),
})
.meta({ id: "MetricStatusQuery" });
@@ -792,9 +795,11 @@ export const glp1PlateauResponse = z
export const insightStatusQuery = z
.object({
locale: z
.enum(["de", "en"])
.enum(locales)
.optional()
.describe("Optional UI-locale override; defaults to the session locale."),
.describe(
"Optional UI-locale override, any language the app ships; defaults to the session locale.",
),
})
.meta({ id: "InsightStatusQuery" });
@@ -856,9 +861,11 @@ export const biomarkerAssessmentQuery = z
"User-scoped biomarker id to assess. A cross-user or unknown id returns an `insufficient` envelope, not a 404 (existence sealed).",
),
locale: z
.enum(["de", "en"])
.enum(locales)
.optional()
.describe("Optional UI-locale override; defaults to the session locale."),
.describe(
"Optional UI-locale override, any language the app ships; defaults to the session locale.",
),
})
.meta({ id: "BiomarkerAssessmentQuery" });
+1 -1
View File
@@ -554,7 +554,7 @@ export const profileUpdateRequest = z
.describe(
"Trimmed before its bound is checked. Null, an empty string or only spaces clears it; an omitted field is left untouched.",
),
locale: z.enum(["de", "en"]).nullable().optional(),
locale: z.enum(locales).nullable().optional(),
timezone: z.string().min(1).max(64).optional(),
timeFormat: z
.enum(["AUTO", "H12", "H24"])
+8
View File
@@ -106,3 +106,11 @@ export const adminSettingsSchema = z
assistantDocumentAiEnabled: z.boolean().optional(),
})
.strict();
/**
* Body of DELETE /api/admin/users/{id}: the typed confirmation the account's
* own deletion asks for, so a stray request cannot lock an account.
*/
export const adminDeleteUserSchema = z
.object({ confirm: z.literal("DELETE_ACCOUNT") })
.strict();
+4 -4
View File
@@ -24,6 +24,7 @@
* it is always narrowed from the session.
*/
import { z } from "zod/v4";
import { locales } from "@/lib/i18n/config";
import {
DOCUMENT_BULK_MAX_IDS,
DOCUMENT_CONTENT_INDEX_SOURCES,
@@ -944,14 +945,13 @@ export const DOCUMENT_CHAT_MESSAGE_MAX = 4000;
* Request body for `POST /api/documents/inbound/{id}/chat`. No `userId` and no
* `documentId` field — the user is narrowed from the session and the document id
* comes from the path. `conversationId` continues an existing thread scoped to
* this document; omitting it starts a new one. `locale` is the reply language
* (the document chat, like the Coach, replies in de/en).
* this document; omitting it starts a new one. `locale` is the reply language.
*/
export const documentChatRequestSchema = z
.object({
conversationId: z.string().trim().min(1).max(64).optional(),
message: z.string().trim().min(1).max(DOCUMENT_CHAT_MESSAGE_MAX),
locale: z.enum(["en", "de"]).optional(),
locale: z.enum(locales).optional(),
})
.meta({ id: "DocumentChatRequest" });
@@ -990,7 +990,7 @@ export const fencedChatRequestSchema = z
.object({
conversationId: z.string().trim().min(1).max(64).optional(),
message: z.string().trim().min(1).max(DOCUMENT_CHAT_MESSAGE_MAX),
locale: z.enum(["en", "de"]).optional(),
locale: z.enum(locales).optional(),
attachmentIds: z
.array(z.string().trim().min(1).max(64))
.min(1)