diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c2f3e4b0..e80683559 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ ### Fixed +- **An account with two-factor can be deleted from the app.** Deleting + the account, and erasing the record while keeping the account, both demand a + fresh second-factor proof once a factor is enrolled. That proof lived on the + session row, which a native client does not have, so anyone who had turned on + TOTP or registered a security key was refused on both endpoints from the app + no matter what they sent — the only way through was a browser. The app can + now re-prove a factor the same way it already does for second-factor + management: mint a single-use elevation at `POST /api/auth/step-up` with a + TOTP code, security key, or passkey, and send it back in the `X-Step-Up` + header alongside the deletion. The web path is untouched, a token on its own + still gets nowhere, a password-proved elevation is refused for erasure just as + a password login never satisfied the gate on the web, and the proof is spent + only when the erasure is about to run, so a mistyped confirmation does not + burn it. One thing to know when erasing the record over a token: API tokens + are part of what that request deletes, so the app signs itself out and has to + sign in again. - **Anthropic keys work again on current Claude models.** Every JSON surface on the Anthropic provider (the daily briefing, the status cards, lab and document extraction, anything that asks the model for a JSON object) had diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 8889ef682..a1d92ae87 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1989,8 +1989,15 @@ paths: Accepting routes (the complete set): POST /api/auth/me/mfa/totp/setup; POST /api/auth/me/mfa/totp/confirm; POST /api/auth/me/mfa/disable; POST /api/auth/me/mfa/recovery-codes/regenerate; POST /api/auth/me/mfa/webauthn/register/options; POST /api/auth/me/mfa/webauthn/register/verify; PATCH and DELETE - /api/auth/me/mfa/webauthn/{id}. The last three of those and disable require a fresh-factor proof. GET - /api/auth/me/mfa needs no elevation at all. Nothing else accepts one — admin endpoints stay cookie-only. + /api/auth/me/mfa/webauthn/{id}; DELETE /api/settings/account; DELETE /api/settings/data. The last three MFA + routes, disable, and both erasure routes require a fresh-factor proof. GET /api/auth/me/mfa needs no elevation + at all. Nothing else accepts one — admin endpoints stay cookie-only. + + + The two erasure routes joined the set so an account with a second factor can be deleted from a native app: their + gate was cookie-only, which left an enrolled user unable to delete their own account from the app at all. They + take the elevation the same way as the rest — fresh factor required, spent only when the erasure is about to + run. requestBody: required: true content: @@ -11965,10 +11972,19 @@ paths: Two gates, and they compose. The body must carry the typed confirmation. And for an account with a second factor - enrolled — a confirmed TOTP secret OR a registered security key — the session must additionally carry a fresh - factor proof, which is cookie-only by construction: a Bearer transport carries no `mfaVerifiedAt` and therefore - cannot satisfy it at any scope. An account with NO second factor keeps the typed-confirmation-only contract on - either transport, deliberately, because a user who never enrolled has no ceremony to complete. + enrolled — a confirmed TOTP secret OR a registered security key — the caller must additionally present a fresh + factor proof. An account with NO second factor keeps the typed-confirmation-only contract on either transport, + deliberately, because a user who never enrolled has no ceremony to complete. + + + How the fresh proof is presented depends on the transport. A cookie session carries it on the session row: + complete the second factor within the last five minutes and retry. A Bearer caller presents a single-use + elevation from `POST /api/auth/step-up` in the `X-Step-Up` header, minted against a `totp`, `webauthn`, or + `passkey` proof — a `password`-proved elevation is refused here exactly as a password login never satisfies + step-up on the web. The elevation is bound to the token that minted it, valid five minutes, and spent only when + the deletion is about to run, so a rejected confirmation does not burn it. A recovery code is not accepted on + the Bearer path: the mint does not take one, so an account that has lost its authenticator deletes itself from + the web. Two refusals are not about the caller's credentials at all. The last remaining admin cannot delete themselves, @@ -11993,9 +12009,20 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": - description: Not authenticated — or the account has a second factor and the session carries no proof of it fresh enough - for a destructive action (`meta.errorCode` = `auth.stepup.required`, or `auth.stepup.mfa_not_enrolled`). - Re-verify the factor on the web and retry; a Bearer token cannot clear this arm. + description: Not authenticated — or the account has a second factor and the caller carries no proof of it fresh enough + for a destructive action (`meta.errorCode` = `auth.stepup.required`, or `auth.stepup.mfa_not_enrolled`). On + a cookie session, re-verify the factor and retry; on a Bearer token, mint an elevation at `POST + /api/auth/step-up` with a `totp`, `webauthn`, or `passkey` proof and resend with `X-Step-Up`. The same code + covers an elevation that is unknown, expired, already spent, minted for a different token, or minted from + too weak a factor — the refusals are deliberately indistinguishable on the wire, and the real reason is + audited. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "403": + description: The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal + happens before any second-factor proof is looked at. content: application/json: schema: @@ -12181,13 +12208,19 @@ paths: data. - Gated like account deletion: the typed confirmation, plus a fresh factor proof for an MFA-enrolled account, - which is cookie-only by construction. The confirmation string is `DELETE`, NOT the `DELETE_ACCOUNT` that route - wants. + Gated like account deletion: the typed confirmation, plus a fresh factor proof for an MFA-enrolled account — on + a cookie session the second factor completed within the last five minutes, on a Bearer token a single-use + `X-Step-Up` elevation minted at `POST /api/auth/step-up` against a `totp`, `webauthn`, or `passkey` proof. The + confirmation string is `DELETE`, NOT the `DELETE_ACCOUNT` that route wants. One audit row survives the wipe that clears the rest of the audit history: it is written after the transaction commits, as the receipt for the erasure the person asked for. + + + A Bearer client should expect to be signed out by its own request: API tokens are on the wipe list, so the token + that called this — and any step-up elevation minted from it — is removed along with the record. Sign in again + rather than retrying with the same credential. requestBody: required: true content: @@ -12202,9 +12235,17 @@ paths: schema: $ref: "#/components/schemas/DataWipeEnvelope" "401": - description: Not authenticated — or the account has a second factor and the session carries no proof of it fresh enough + description: Not authenticated — or the account has a second factor and the caller carries no proof of it fresh enough for a destructive action (`meta.errorCode` = `auth.stepup.required`, or `auth.stepup.mfa_not_enrolled`). A - Bearer token cannot clear this arm. + Bearer caller clears it with an `X-Step-Up` elevation from `POST /api/auth/step-up`; the same code covers an + elevation that is unknown, expired, already spent, bound to another token, or minted from too weak a factor. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "403": + description: The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal + happens before any second-factor proof is looked at. content: application/json: schema: diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index 265690f28..96d5efdd9 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -840,7 +840,8 @@ export const authPaths: NonNullable = { "WHICH factor you re-prove decides WHAT the elevation reaches. `password` reaches the same routes a plain cookie session reaches. `totp`, `webauthn`, and `passkey` additionally satisfy the fresh-factor routes — MFA disable, recovery-code regeneration, security-key removal — which is precisely the set of ceremonies for which the web marks a session second-factor-verified. The response carries `satisfiesFreshFactor` so a client can choose the right ceremony up front rather than discovering the refusal after spending a proof. A recovery code is NOT accepted here; an account that has lost its authenticator manages its second factor on the web.\n\n" + "The elevation is bound to the exact token that minted it (another token, including the same account's, cannot redeem it), single-use, and valid for five minutes — the same window the cookie path uses. Present it as `X-Step-Up: hle_…` alongside the normal `Authorization: Bearer` header. It is spent only when the target route is about to act, so a 429, a 422, or a wrong code does not burn it.\n\n" + "Presenting the token alone mints nothing: the body must carry a fresh factor proof. Every failure — wrong password, no password set on an SSO-provisioned account, an assertion for another account, a stale challenge, a replayed TOTP step — returns the same 401 with the same prose, and is audited server-side. Rate-limited per account (5 / 15 min) and per source address.\n\n" + - "Accepting routes (the complete set): POST /api/auth/me/mfa/totp/setup; POST /api/auth/me/mfa/totp/confirm; POST /api/auth/me/mfa/disable; POST /api/auth/me/mfa/recovery-codes/regenerate; POST /api/auth/me/mfa/webauthn/register/options; POST /api/auth/me/mfa/webauthn/register/verify; PATCH and DELETE /api/auth/me/mfa/webauthn/{id}. The last three of those and disable require a fresh-factor proof. GET /api/auth/me/mfa needs no elevation at all. Nothing else accepts one — admin endpoints stay cookie-only.", + "Accepting routes (the complete set): POST /api/auth/me/mfa/totp/setup; POST /api/auth/me/mfa/totp/confirm; POST /api/auth/me/mfa/disable; POST /api/auth/me/mfa/recovery-codes/regenerate; POST /api/auth/me/mfa/webauthn/register/options; POST /api/auth/me/mfa/webauthn/register/verify; PATCH and DELETE /api/auth/me/mfa/webauthn/{id}; DELETE /api/settings/account; DELETE /api/settings/data. The last three MFA routes, disable, and both erasure routes require a fresh-factor proof. GET /api/auth/me/mfa needs no elevation at all. Nothing else accepts one — admin endpoints stay cookie-only.\n\n" + + "The two erasure routes joined the set so an account with a second factor can be deleted from a native app: their gate was cookie-only, which left an enrolled user unable to delete their own account from the app at all. They take the elevation the same way as the rest — fresh factor required, spent only when the erasure is about to run.", requestBody: { required: true, content: { "application/json": { schema: stepUpMintSchema } }, diff --git a/src/lib/openapi/routes/settings.ts b/src/lib/openapi/routes/settings.ts index 600af5e49..085fee9ab 100644 --- a/src/lib/openapi/routes/settings.ts +++ b/src/lib/openapi/routes/settings.ts @@ -562,7 +562,8 @@ export const settingsPaths: NonNullable = { summary: "Permanently delete the account and everything in it", description: "Erasure, not deactivation: the user row goes, every related row cascades with it, and the account's own audit history — IP addresses, login geo — is purged inside the same transaction rather than being left orphaned. There is no undo and no grace period.\n\n" + - "Two gates, and they compose. The body must carry the typed confirmation. And for an account with a second factor enrolled — a confirmed TOTP secret OR a registered security key — the session must additionally carry a fresh factor proof, which is cookie-only by construction: a Bearer transport carries no `mfaVerifiedAt` and therefore cannot satisfy it at any scope. An account with NO second factor keeps the typed-confirmation-only contract on either transport, deliberately, because a user who never enrolled has no ceremony to complete.\n\n" + + "Two gates, and they compose. The body must carry the typed confirmation. And for an account with a second factor enrolled — a confirmed TOTP secret OR a registered security key — the caller must additionally present a fresh factor proof. An account with NO second factor keeps the typed-confirmation-only contract on either transport, deliberately, because a user who never enrolled has no ceremony to complete.\n\n" + + "How the fresh proof is presented depends on the transport. A cookie session carries it on the session row: complete the second factor within the last five minutes and retry. A Bearer caller presents a single-use elevation from `POST /api/auth/step-up` in the `X-Step-Up` header, minted against a `totp`, `webauthn`, or `passkey` proof — a `password`-proved elevation is refused here exactly as a password login never satisfies step-up on the web. The elevation is bound to the token that minted it, valid five minutes, and spent only when the deletion is about to run, so a rejected confirmation does not burn it. A recovery code is not accepted on the Bearer path: the mint does not take one, so an account that has lost its authenticator deletes itself from the web.\n\n" + "Two refusals are not about the caller's credentials at all. The last remaining admin cannot delete themselves, and the last Guardian of a managed profile cannot leave that profile unattended.", requestBody: { required: true, @@ -597,7 +598,12 @@ export const settingsPaths: NonNullable = { ...stdResponses, "401": { description: - "Not authenticated — or the account has a second factor and the session carries no proof of it fresh enough for a destructive action (`meta.errorCode` = `auth.stepup.required`, or `auth.stepup.mfa_not_enrolled`). Re-verify the factor on the web and retry; a Bearer token cannot clear this arm.", + "Not authenticated — or the account has a second factor and the caller carries no proof of it fresh enough for a destructive action (`meta.errorCode` = `auth.stepup.required`, or `auth.stepup.mfa_not_enrolled`). On a cookie session, re-verify the factor and retry; on a Bearer token, mint an elevation at `POST /api/auth/step-up` with a `totp`, `webauthn`, or `passkey` proof and resend with `X-Step-Up`. The same code covers an elevation that is unknown, expired, already spent, minted for a different token, or minted from too weak a factor — the refusals are deliberately indistinguishable on the wire, and the real reason is audited.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "403": { + description: + "The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at.", content: { "application/json": { schema: errorEnvelope } }, }, }, @@ -749,8 +755,9 @@ export const settingsPaths: NonNullable = { "The other half of account deletion: this removes what the account HAS and keeps what signs into it. Ninety-nine models are hard-deleted for this user — measurements and every derived rollup, medications and their intake history, mood, labs, documents, cycle, workouts, nutrients, notification channels, Coach conversations, consent receipts, audit history — with no `deletedAt` filter, so soft-deleted tombstones go too. The account row survives with its personal columns reset: body and identity fields, home location, cached AI output. What is deliberately kept is the credential set (password hash, SSO binding, second-factor secret and enrolment, trusted-device state) and the interface preferences (timezone, locale, units, time and date format, glucose unit), because those are about the account rather than the record.\n\n" + "The model list is not written at the route. It is declared once in the wipe plan and held against the Prisma schema by a completeness test, which exists because this endpoint once deleted thirteen tables out of eighty-odd while telling people it had deleted everything.\n\n" + "One transaction, with a two-minute budget: a half-applied wipe is worse than none, because the person is told it succeeded either way. Every cached payload for the account is evicted afterwards so no surface serves pre-wipe data.\n\n" + - "Gated like account deletion: the typed confirmation, plus a fresh factor proof for an MFA-enrolled account, which is cookie-only by construction. The confirmation string is `DELETE`, NOT the `DELETE_ACCOUNT` that route wants.\n\n" + - "One audit row survives the wipe that clears the rest of the audit history: it is written after the transaction commits, as the receipt for the erasure the person asked for.", + "Gated like account deletion: the typed confirmation, plus a fresh factor proof for an MFA-enrolled account — on a cookie session the second factor completed within the last five minutes, on a Bearer token a single-use `X-Step-Up` elevation minted at `POST /api/auth/step-up` against a `totp`, `webauthn`, or `passkey` proof. The confirmation string is `DELETE`, NOT the `DELETE_ACCOUNT` that route wants.\n\n" + + "One audit row survives the wipe that clears the rest of the audit history: it is written after the transaction commits, as the receipt for the erasure the person asked for.\n\n" + + "A Bearer client should expect to be signed out by its own request: API tokens are on the wipe list, so the token that called this — and any step-up elevation minted from it — is removed along with the record. Sign in again rather than retrying with the same credential.", requestBody: { required: true, content: { "application/json": { schema: dataWipeRequest } }, @@ -777,7 +784,12 @@ export const settingsPaths: NonNullable = { ...stdResponses, "401": { description: - "Not authenticated — or the account has a second factor and the session carries no proof of it fresh enough for a destructive action (`meta.errorCode` = `auth.stepup.required`, or `auth.stepup.mfa_not_enrolled`). A Bearer token cannot clear this arm.", + "Not authenticated — or the account has a second factor and the caller carries no proof of it fresh enough for a destructive action (`meta.errorCode` = `auth.stepup.required`, or `auth.stepup.mfa_not_enrolled`). A Bearer caller clears it with an `X-Step-Up` elevation from `POST /api/auth/step-up`; the same code covers an elevation that is unknown, expired, already spent, bound to another token, or minted from too weak a factor.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "403": { + description: + "The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at.", content: { "application/json": { schema: errorEnvelope } }, }, },