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

+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"])