From baa7dbc4c14b584ab34b8812d807fa7b63e47671 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Thu, 22 Oct 2026 15:32:56 -0400 Subject: [PATCH 1/4] feat: self-service account recovery with a delay and notices Recovery through a verified email or phone that cannot complete quietly. POST /recovery/start sends a code without saying which channels exist, /recovery/verify opens a request that completes only after account_recovery.delay and tells the verified email and phone how to cancel it, and /recovery/complete revokes every session and issues a restricted recovery session. /recovery/cancel takes the notice's token and answers uniformly. A user returning after the delay resumes the request on a new attempt by verifying a fresh code on the same channel. Only a verified code opens, resumes or cancels a request, so starting one disturbs nothing. Using a recovery code now notifies the account's verified addresses. Closes #399. --- .changeset/account-recovery.md | 14 + adapter-manifest.json | 10 + docs/account-recovery.md | 78 +- docs/configuration.md | 2 +- openapi.json | 693 ++++++++++++++ resources/coverage-badge.svg | 14 +- src/controllers/accountRecovery.ts | 320 +++++++ src/controllers/decoyResponders.ts | 50 ++ src/controllers/recoveryCodes.ts | 15 +- src/controllers/systemConfig.ts | 8 +- src/generated/api.ts | 844 ++++++++++++++++++ src/lib/authEventCategories.ts | 1 + ...22160000-add-account-recovery-requests.cjs | 53 ++ src/models/accountRecoveryRequests.ts | 145 +++ src/routes/accountRecovery.routes.ts | 154 ++++ src/schemas/accountRecovery.ts | 13 + src/schemas/authEvent.types.ts | 5 + src/services/accountNotices.ts | 83 ++ src/services/accountRecoveryService.ts | 257 ++++++ src/services/sessionIssuance.ts | 9 +- src/utils/otp.ts | 5 + tests/factories/accountRecoveryStore.ts | 85 ++ .../recovery/accountRecovery.spec.ts | 470 ++++++++++ .../recovery/recoveryCodes.spec.ts | 24 + .../systemConfig/systemConfig.spec.ts | 12 +- tests/postgres/accountRecovery.spec.ts | 72 ++ tests/setup/mocks.ts | 14 +- tests/unit/models/models.spec.ts | 1 + .../routes/adapterManifest.routes.spec.ts | 2 + tests/unit/services/accountNotices.spec.ts | 63 ++ .../services/accountRecoveryService.spec.ts | 221 +++++ 31 files changed, 3721 insertions(+), 16 deletions(-) create mode 100644 .changeset/account-recovery.md create mode 100644 src/controllers/accountRecovery.ts create mode 100644 src/migrations/20261022160000-add-account-recovery-requests.cjs create mode 100644 src/models/accountRecoveryRequests.ts create mode 100644 src/routes/accountRecovery.routes.ts create mode 100644 src/schemas/accountRecovery.ts create mode 100644 src/services/accountNotices.ts create mode 100644 src/services/accountRecoveryService.ts create mode 100644 tests/factories/accountRecoveryStore.ts create mode 100644 tests/integration/recovery/accountRecovery.spec.ts create mode 100644 tests/postgres/accountRecovery.spec.ts create mode 100644 tests/unit/services/accountNotices.spec.ts create mode 100644 tests/unit/services/accountRecoveryService.spec.ts diff --git a/.changeset/account-recovery.md b/.changeset/account-recovery.md new file mode 100644 index 00000000..cf389b9e --- /dev/null +++ b/.changeset/account-recovery.md @@ -0,0 +1,14 @@ +--- +'seamless-auth-api': minor +--- + +Self-service account recovery through a verified email or phone, with a delay and notices (#399). + +- `POST /recovery/start` sends a six-digit code to the verified address on the chosen channel, answering the same whether or not the account has one. `POST /recovery/verify` opens the request, which completes only after `account_recovery.delay`, and tells the account's verified email and phone how to cancel it. `GET /recovery/status` reports `pending`, `ready`, `cancelled` or `completed`. +- `POST /recovery/cancel` takes the token from the notice's link and needs no session. `POST /recovery/complete` on a ready request signs out every session and issues a restricted recovery session. +- A user returning after the delay resumes the request on a new attempt by verifying a fresh code on the same channel. +- Off by default (`ACCOUNT_RECOVERY`). Not blocked by account lockout; five wrong codes void a request. `GET /system-config/public` reports `accountRecovery`. +- Using a recovery code now notifies the account's verified email and phone. +- Migration: new `account_recovery_requests` table. + +See docs/account-recovery.md. diff --git a/adapter-manifest.json b/adapter-manifest.json index c1168a42..65965dda 100644 --- a/adapter-manifest.json +++ b/adapter-manifest.json @@ -185,6 +185,16 @@ "credential": "preAuth", "issues": "session" }, + { "method": "POST", "path": "/recovery/cancel", "credential": "none" }, + { + "method": "POST", + "path": "/recovery/complete", + "credential": "preAuth", + "issues": "session" + }, + { "method": "POST", "path": "/recovery/start", "credential": "preAuth", "delivery": true }, + { "method": "GET", "path": "/recovery/status", "credential": "preAuth" }, + { "method": "POST", "path": "/recovery/verify", "credential": "preAuth" }, { "method": "POST", "path": "/refresh", "credential": "refresh", "issues": "session" }, { "method": "POST", "path": "/registration/phone", "credential": "access", "delivery": true }, { "method": "POST", "path": "/registration/phone/verify", "credential": "access" }, diff --git a/docs/account-recovery.md b/docs/account-recovery.md index 695a095c..6978ddb5 100644 --- a/docs/account-recovery.md +++ b/docs/account-recovery.md @@ -26,8 +26,10 @@ A user can generate a set of ten single-use codes, shown once as `XXXXX-XXXXX`. are turned off only by `recovery_codes.enabled` (`RECOVERY_CODES`), and `GET /system-config/public` reports `recoveryCodes` so sign-in screens offer them only where they work. -- Audit events: `recovery_codes_generated`, `recovery_code_used` (with the number left) and - `recovery_code_failed`. +- Using a code tells the account's verified email and phone, so a code used by someone else + does not go unnoticed. +- Audit events: `recovery_codes_generated`, `recovery_code_used` (with the number left and + the outcome of each notice) and `recovery_code_failed`. ## Recovery sessions @@ -51,3 +53,75 @@ half-recovered account as fully signed in. A recovery session is allowed in `phishing_resistant_only` mode and when passkey fallback is off, because enrolling a passkey is all it can do. + +## Self-service recovery + +For a user with no codes, recovery through a verified email or phone. It cannot complete +quietly: it waits out a delay, and every verified address on the account is told when it +opens, with a link that cancels it. + +Off by default. Turn it on with `ACCOUNT_RECOVERY` (`account_recovery`), whose `delay` is +`24h` unless set (`1h` to `30d`). While it is off, every route below answers +`403 { "error": "account_recovery_disabled" }`, and `GET /system-config/public` reports +`accountRecovery` so sign-in screens offer it only where it works. + +| Route | Auth | What it does | +| ------------------------- | ---------------------------- | --------------------------------------------------------- | +| `POST /recovery/start` | pre-auth token from `/login` | `{ channel: "email" \| "phone" }`, sends a six-digit code | +| `POST /recovery/verify` | the same attempt | `{ code }`, opens the request and starts the delay | +| `GET /recovery/status` | the same attempt | `{ state, requestedAt, completesAt }` | +| `POST /recovery/cancel` | none | `{ cancelToken }` from the notice | +| `POST /recovery/complete` | the same attempt, once ready | Signs out every session and issues a recovery session | + +### The flow + +1. **Start.** The code goes to the verified address on file for the channel, through the + same delivery as OTP codes (including external delivery, where the response carries + `delivery`). The answer is the same whether or not the account has a verified address + there, so it does not say which channels exist. The code lasts ten minutes and is stored + hashed. +2. **Verify.** Five wrong codes void the request. The right one opens it: `state` is + `pending`, `completesAt` is now plus the delay, and a notice goes to the account's + verified email **and** phone saying recovery was requested, when it completes and how to + cancel it. The cancel link is `${FRONTEND_URL}/recovery/cancel#`; the token is in + the fragment so it never reaches a server log, and the page posts it to + `/recovery/cancel`. Opening a request cancels any other open request for the account. +3. **Wait.** `GET /recovery/status` reports `pending` until `completesAt`, then `ready`, or + `cancelled` or `completed`. +4. **Complete.** On a `ready` request this revokes every session on the account, tells + both verified addresses, and issues a [recovery session](#recovery-sessions), so the + user's next step is enrolling a new passkey or authenticator app. + +### Coming back after the delay + +A pre-auth token lasts minutes and the delay lasts hours, so the user returns on a new +attempt: they sign in with `/login` again, call `/recovery/start` on the **same channel**, +and verify the new code. That resumes the open request on the new attempt with its clock +unchanged and sends no new notice; status and complete then work on that attempt. A code +for a different channel opens a new request instead, with its own delay and notices. + +Starting is not enough to disturb anything: anyone who knows an address can start, so only +a verified code opens, resumes or cancels a request. Five wrong codes on a resume void +that code and leave the open request as it was. + +### Cancelling + +`POST /recovery/cancel` with the token from the notice cancels the request whether it is +`pending` or `ready`. It needs no session, because the owner may be signed out everywhere, +and it answers the same `200` whether or not the token matched, so it is not an oracle. +Cancelling from a signed-in session without the token is a follow-up. + +### Notes + +- Account lockout does not block recovery, since lockout is what someone recovering may be + locked out by. Wrong codes are limited per request as above, and every route sits behind + the OTP flow rate limits. +- A pre-auth token for an identifier with no account gets the answers a real account with + no verified address gets, and nothing is stored or sent for it. +- Notices are only skipped for a credentialed caller that owns delivery (external delivery + with a service token): the delivery contract has no kind for account notices yet, so the + audit event records `not_sent_external_delivery` instead. The delivery header alone does + not suppress them, so a stranger cannot recover an account silently. +- Audit events: `account_recovery_started`, `account_recovery_verified` (with each notice's + outcome), `account_recovery_cancelled`, `account_recovery_completed` (with the number of + sessions revoked) and `account_recovery_failed` (a wrong code or a failed delivery). diff --git a/docs/configuration.md b/docs/configuration.md index ad978387..f8098644 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -86,7 +86,7 @@ first boot. | `MAX_CONCURRENT_SESSIONS` | No | unlimited | `max_concurrent_sessions` | How many sessions one user may hold at once. Unset, empty, `null`, `none` or `unlimited` all mean no cap, which is the default. When a signed-in user is at the limit, the oldest session is revoked to make room and a `session_evicted` auth event is recorded; the sign-in itself always succeeds. Lowering the limit converges on each user's next sign-in rather than one session per login. NIST 800-53 AC-10. | | `AUTHENTICATOR_POLICY` | No | `{"attachment":"any","userVerification":"required","attestation":"none","requireKnownAuthenticator":false,"syncedPasskeys":"allow","aaguidAllowList":[],"aaguidDenyList":[]}` | `authenticator_policy` | JSON. `attachment` is `any`, `platform` or `cross-platform`; `any` offers both built-in authenticators and roaming security keys, and naming one narrows the browser picker and rejects a request asking for the other. `userVerification` is `required`, `preferred` or `discouraged` and drives both what the browser is asked for and what the server enforces. `attestation` is `none` or `direct`; `direct` asks the authenticator to identify itself, which is what enables validation against the FIDO Metadata Service, and carries a privacy cost so it is off unless needed. `requireKnownAuthenticator` refuses an authenticator the metadata service does not list, and only applies under `direct`. It also refuses a credential that self attests or presents no attestation, since neither can be looked up, so it rules out authenticators that ship no attestation certificate. Changing `attestation` needs a restart, because the metadata service is prepared at startup. `syncedPasskeys` is `allow` or `block`; `allow`, the default, enrols a credential that can leave the device it was created on, which includes every iCloud Keychain and Google Password Manager passkey, so a stock instance accepts the passkey a normal device offers. `block` refuses those, which is what a deployment issuing its own authenticators wants. `aaguidAllowList` and `aaguidDenyList` restrict which authenticator models may register, and need `attestation` set to `direct` to mean anything. | | `RECOVERY_CODES` | No | `{"enabled":true,"requireAtEnrollment":false}` | `recovery_codes` | JSON. Single-use recovery codes. A user has codes only after generating a set, so `enabled` matters once someone has. `requireAtEnrollment` asks for a set during first passkey enrollment. Recovery codes are not a login method, so `LOGIN_METHODS` cannot turn them off. | -| `ACCOUNT_RECOVERY` | No | `{"enabled":false,"delay":"24h"}` | `account_recovery` | JSON. Self-service recovery through a verified email or phone. Off by default. It completes only after `delay` (`1h` to `30d`), and every other channel and session is told so the owner can cancel. | +| `ACCOUNT_RECOVERY` | No | `{"enabled":false,"delay":"24h"}` | `account_recovery` | JSON. Self-service recovery through a verified email or phone (`/recovery/*`, see [account-recovery.md](account-recovery.md)). Off by default; while off every `/recovery/*` route answers `403 account_recovery_disabled` and `GET /system-config/public` reports `accountRecovery: false`. A request completes only after `delay` (`1h` to `30d`). When it opens, and again when it completes, the account's verified email and phone are both told, the first notice with a cancel link built on `FRONTEND_URL` (or the first origin). Completing signs out every session and issues a restricted recovery session. A change to `delay` applies to requests opened after it. | #### `/login` response timing diff --git a/openapi.json b/openapi.json index 99070bb5..f4c2f06e 100644 --- a/openapi.json +++ b/openapi.json @@ -9,6 +9,689 @@ } }, "paths": { + "/recovery/start": { + "post": { + "summary": "Start account recovery", + "description": "Sends a code to the verified address on file for the channel. Answers the same whether or not the account has one. Not subject to account lockout.", + "tags": ["Recovery"], + "security": [{ "bearerAuth": [] }], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "channel": { "type": "string", "enum": ["email", "phone"], "default": "email" } + } + } + } + } + }, + "responses": { + "200": { + "description": "HTTP 200", + "content": { + "application/json": { + "example": { "message": "string", "delivery": null }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "delivery": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["otp_email"] }, + "to": { "type": "string" }, + "token": { "type": "string" } + }, + "required": ["kind", "to", "token"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["otp_sms"] }, + "to": { "type": "string" }, + "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + }, + "required": ["kind", "to", "token"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["magic_link_email"] }, + "to": { "type": "string" }, + "token": { "type": "string" }, + "magicLinkUrl": { "type": "string" } + }, + "required": ["kind", "to", "magicLinkUrl"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["enrollment_invite_email"] }, + "to": { "type": "string" }, + "signInUrl": { "type": "string" } + }, + "required": ["kind", "to", "signInUrl"] + } + ] + } + }, + "required": ["message"] + } + } + } + }, + "400": { + "description": "HTTP 400", + "content": { + "application/json": { + "example": { + "error": "string", + "message": "string", + "details": { "issues": [null] } + }, + "schema": { + "type": "object", + "properties": { + "error": { "type": "string" }, + "message": { "type": "string" }, + "details": { + "type": "object", + "properties": { + "issues": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "array", + "items": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + }, + "code": { "type": "string" }, + "message": { "type": "string" } + }, + "required": ["path", "code", "message"] + } + } + }, + "required": ["issues"] + } + }, + "required": ["error"] + } + } + } + }, + "401": { + "description": "HTTP 401", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "429": { + "description": "HTTP 429", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "500": { + "description": "HTTP 500", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + } + } + } + }, + "/recovery/verify": { + "post": { + "summary": "Verify an account recovery code", + "description": "Opens the request started on this attempt, which completes after the configured delay, and tells every verified address how to cancel it. A code for a request that is already open resumes it on this attempt. Five wrong codes void the code.", + "tags": ["Recovery"], + "security": [{ "bearerAuth": [] }], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { "code": { "type": "string", "minLength": 1 } }, + "required": ["code"] + } + } + } + }, + "responses": { + "200": { + "description": "HTTP 200", + "content": { + "application/json": { + "example": { "state": null, "requestedAt": null, "completesAt": null }, + "schema": { + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": ["pending", "ready", "cancelled", "completed"] + }, + "requestedAt": { "type": "string", "nullable": true, "format": "date-time" }, + "completesAt": { "type": "string", "nullable": true, "format": "date-time" } + }, + "required": ["state", "requestedAt", "completesAt"] + } + } + } + }, + "400": { + "description": "HTTP 400", + "content": { + "application/json": { + "example": { + "error": "string", + "message": "string", + "details": { "issues": [null] } + }, + "schema": { + "type": "object", + "properties": { + "error": { "type": "string" }, + "message": { "type": "string" }, + "details": { + "type": "object", + "properties": { + "issues": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "array", + "items": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + }, + "code": { "type": "string" }, + "message": { "type": "string" } + }, + "required": ["path", "code", "message"] + } + } + }, + "required": ["issues"] + } + }, + "required": ["error"] + } + } + } + }, + "401": { + "description": "HTTP 401", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "429": { + "description": "HTTP 429", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "500": { + "description": "HTTP 500", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + } + } + } + }, + "/recovery/status": { + "get": { + "summary": "Account recovery status", + "description": "The request this attempt opened: `pending` until `completesAt`, then `ready`.", + "tags": ["Recovery"], + "security": [{ "bearerAuth": [] }], + "responses": { + "200": { + "description": "HTTP 200", + "content": { + "application/json": { + "example": { "state": null, "requestedAt": null, "completesAt": null }, + "schema": { + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": ["pending", "ready", "cancelled", "completed"] + }, + "requestedAt": { "type": "string", "nullable": true, "format": "date-time" }, + "completesAt": { "type": "string", "nullable": true, "format": "date-time" } + }, + "required": ["state", "requestedAt", "completesAt"] + } + } + } + }, + "401": { + "description": "HTTP 401", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "404": { + "description": "HTTP 404", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "429": { + "description": "HTTP 429", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "500": { + "description": "HTTP 500", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + } + } + } + }, + "/recovery/cancel": { + "post": { + "summary": "Cancel account recovery", + "description": "Cancels the open request the token from a recovery notice belongs to. Answers the same whether or not it matched.", + "tags": ["Recovery"], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { "cancelToken": { "type": "string", "minLength": 1 } } + } + } + } + }, + "responses": { + "200": { + "description": "HTTP 200", + "content": { + "application/json": { + "example": { "message": "string", "token": "string", "delivery": null }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "token": { "type": "string" }, + "delivery": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["otp_email"] }, + "to": { "type": "string" }, + "token": { "type": "string" } + }, + "required": ["kind", "to", "token"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["otp_sms"] }, + "to": { "type": "string" }, + "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + }, + "required": ["kind", "to", "token"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["magic_link_email"] }, + "to": { "type": "string" }, + "token": { "type": "string" }, + "magicLinkUrl": { "type": "string" } + }, + "required": ["kind", "to", "magicLinkUrl"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["enrollment_invite_email"] }, + "to": { "type": "string" }, + "signInUrl": { "type": "string" } + }, + "required": ["kind", "to", "signInUrl"] + } + ] + } + }, + "required": ["message"] + } + } + } + }, + "400": { + "description": "HTTP 400", + "content": { + "application/json": { + "example": { + "error": "string", + "message": "string", + "details": { "issues": [null] } + }, + "schema": { + "type": "object", + "properties": { + "error": { "type": "string" }, + "message": { "type": "string" }, + "details": { + "type": "object", + "properties": { + "issues": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "array", + "items": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + }, + "code": { "type": "string" }, + "message": { "type": "string" } + }, + "required": ["path", "code", "message"] + } + } + }, + "required": ["issues"] + } + }, + "required": ["error"] + } + } + } + }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "429": { + "description": "HTTP 429", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "500": { + "description": "HTTP 500", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + } + } + } + }, + "/recovery/complete": { + "post": { + "summary": "Complete account recovery", + "description": "Once the delay has passed, signs out every session on the account and issues a recovery session (`recovery: true`) that can only enroll a new passkey or authenticator app until one is enrolled.", + "tags": ["Recovery"], + "security": [{ "bearerAuth": [] }], + "responses": { + "200": { + "description": "HTTP 200", + "content": { + "application/json": { + "example": { + "message": "string", + "token": "string", + "refreshToken": "string", + "sub": "string", + "organizationId": "string", + "roles": [null], + "email": "string", + "phone": "string", + "ttl": 0, + "refreshTtl": 0, + "recovery": null, + "nextStep": null + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "token": { "type": "string" }, + "refreshToken": { "type": "string" }, + "sub": { "type": "string" }, + "organizationId": { "type": "string", "nullable": true }, + "roles": { "type": "array", "items": { "type": "string" } }, + "email": { "type": "string" }, + "phone": { "type": "string", "nullable": true }, + "ttl": { "type": "number" }, + "refreshTtl": { "type": "number" }, + "recovery": { "type": "boolean", "enum": [true] }, + "nextStep": { "type": "string", "enum": ["enroll_passkey"] } + }, + "required": ["message", "recovery", "nextStep"] + } + } + } + }, + "401": { + "description": "HTTP 401", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "404": { + "description": "HTTP 404", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "409": { + "description": "HTTP 409", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "429": { + "description": "HTTP 429", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "500": { + "description": "HTTP 500", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + } + } + } + }, "/.well-known/seamless-adapter.json": { "get": { "summary": "Routes a server adapter exposes and what it does with each", @@ -3434,6 +4117,11 @@ { "type": "string", "enum": [ + "account_recovery_cancelled", + "account_recovery_completed", + "account_recovery_failed", + "account_recovery_started", + "account_recovery_verified", "auth_action_incremented", "admin_device_replacement_recovery", "admin_enrollment_invite_sent", @@ -3516,6 +4204,11 @@ { "type": "string", "enum": [ + "account_recovery_cancelled", + "account_recovery_completed", + "account_recovery_failed", + "account_recovery_started", + "account_recovery_verified", "auth_action_incremented", "admin_device_replacement_recovery", "admin_enrollment_invite_sent", diff --git a/resources/coverage-badge.svg b/resources/coverage-badge.svg index 8bd6c88a..322cf29d 100644 --- a/resources/coverage-badge.svg +++ b/resources/coverage-badge.svg @@ -1,5 +1,5 @@ - - coverage: 99% + + coverage: 99.1% @@ -7,17 +7,17 @@ - + - - + + coverage coverage - 99% - 99% + 99.1% + 99.1% diff --git a/src/controllers/accountRecovery.ts b/src/controllers/accountRecovery.ts new file mode 100644 index 00000000..05f5a052 --- /dev/null +++ b/src/controllers/accountRecovery.ts @@ -0,0 +1,320 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import { Request, Response } from 'express'; + +import { getSystemConfig } from '../config/getSystemConfig.js'; +import { canReturnExternalDelivery } from '../lib/externalDelivery.js'; +import { RecoveryChannel } from '../models/accountRecoveryRequests.js'; +import { sendAccountNotice, verifiedAddresses } from '../services/accountNotices.js'; +import { + cancelRecoveryByToken, + completeRecovery, + findRecoveryForAttempt, + RecoveryStatus, + recoveryStatus, + startRecovery, + verifyRecovery, +} from '../services/accountRecoveryService.js'; +import { AuthEventService } from '../services/authEventService.js'; +import { DeliveryError } from '../services/deliveryError.js'; +import { sendOTPEmail, sendOTPSMS } from '../services/messagingService.js'; +import { issueSessionAndRespond } from '../services/sessionIssuance.js'; +import { AuthenticatedRequest } from '../types/types.js'; +import getLogger from '../utils/logger.js'; +import { normalizePhoneNumber, parseDurationToSeconds } from '../utils/utils.js'; + +const logger = getLogger('accountRecovery'); + +export const RECOVERY_STARTED_MESSAGE = + 'If the account has a verified address for that channel, a recovery code was sent.'; +export const RECOVERY_CANCEL_MESSAGE = + 'If the link was for an open recovery request, it has been cancelled.'; + +export async function accountRecoveryPolicy() { + const { account_recovery } = await getSystemConfig(); + + return { + enabled: account_recovery?.enabled === true, + delaySeconds: parseDurationToSeconds(account_recovery?.delay ?? '24h'), + }; +} + +export function refuseAccountRecoveryDisabled(res: Response) { + return res.status(403).json({ error: 'account_recovery_disabled' }); +} + +export function serializeRecoveryStatus(status: RecoveryStatus) { + return { + state: status.state, + requestedAt: status.requestedAt.toISOString(), + completesAt: status.completesAt.toISOString(), + }; +} + +function recoveryAddress(user: AuthenticatedRequest['user'], channel: RecoveryChannel) { + const { email, phone } = verifiedAddresses(user); + + return channel === 'email' ? email : phone ? normalizePhoneNumber(phone) : null; +} + +async function appName() { + return (await getSystemConfig())?.app_name ?? 'Seamless Auth'; +} + +async function cancelUrl(cancelToken: string) { + const { frontend_url, origins } = await getSystemConfig(); + const base = (frontend_url ?? origins?.[0] ?? '').replace(/\/+$/, ''); + + // A fragment, so the token never reaches a server log on the way to the page. + return `${base}/recovery/cancel#${cancelToken}`; +} + +/** + * Sends a code to the verified address on the chosen channel. Answers the same whether + * or not the account has one, so it does not say which channels an account has. + * Account lockout does not apply: lockout is what someone recovering may be locked out by. + */ +export const startAccountRecovery = async (req: Request, res: Response) => { + if (!(await accountRecoveryPolicy()).enabled) { + return refuseAccountRecoveryDisabled(res); + } + + const { user, attemptId } = req as AuthenticatedRequest; + const channel: RecoveryChannel = req.body?.channel ?? 'email'; + const to = recoveryAddress(user, channel); + + if (!to || !attemptId) { + await AuthEventService.log({ + userId: user.id, + type: 'account_recovery_started', + req, + metadata: { channel, sent: false, reason: 'No verified address' }, + }); + return res.status(200).json({ message: RECOVERY_STARTED_MESSAGE }); + } + + const external = await canReturnExternalDelivery(req); + + try { + const code = await startRecovery(user.id, channel, attemptId); + + if (!external) { + if (channel === 'email') { + await sendOTPEmail(to, code); + } else { + await sendOTPSMS(to, Number(code)); + } + } + + await AuthEventService.log({ + userId: user.id, + type: 'account_recovery_started', + req, + metadata: { channel, sent: true }, + }); + + return res.status(200).json({ + message: RECOVERY_STARTED_MESSAGE, + ...(external + ? { + delivery: + channel === 'email' + ? { kind: 'otp_email', to, token: code } + : { kind: 'otp_sms', to, token: code }, + } + : {}), + }); + } catch (error) { + if (!(error instanceof DeliveryError)) throw error; + + logger.error(`Account recovery code delivery failed on ${channel}`); + await AuthEventService.log({ + userId: user.id, + type: 'account_recovery_failed', + req, + metadata: { channel, reason: 'Delivery failed' }, + }); + return res.status(500).json({ error: 'Internal server error' }); + } +}; + +async function openedNotice(channel: RecoveryChannel, completesAt: Date, cancelToken: string) { + const name = await appName(); + const link = await cancelUrl(cancelToken); + const via = channel === 'email' ? 'email address' : 'phone number'; + const text = + `Someone asked to recover your ${name} account through its ${via}. Unless it is ` + + `cancelled, recovery completes at ${completesAt.toISOString()}, signs out every ` + + `session and lets them enroll a new passkey. If this was not you, cancel it: ${link}`; + + return { subject: `Account recovery requested for your ${name} account`, text }; +} + +/** + * Opens the request with the code from `/recovery/start`, starting its delay and telling + * every verified address how to cancel it. A code for an open request resumes it on this + * attempt instead, with its clock unchanged. + */ +export const verifyAccountRecovery = async (req: Request, res: Response) => { + const { enabled, delaySeconds } = await accountRecoveryPolicy(); + + if (!enabled) { + return refuseAccountRecoveryDisabled(res); + } + + const { user, attemptId } = req as AuthenticatedRequest; + const result = attemptId + ? await verifyRecovery(user.id, attemptId, req.body.code, delaySeconds) + : ({ outcome: 'invalid' } as const); + + if (result.outcome === 'invalid') { + await AuthEventService.log({ + userId: user.id, + type: 'account_recovery_failed', + req, + metadata: { reason: 'Invalid code' }, + }); + return res.status(401).json({ error: 'invalid_recovery_code' }); + } + + const { request } = result; + const status = recoveryStatus(request); + + if (result.outcome === 'resumed') { + await AuthEventService.log({ + userId: user.id, + type: 'account_recovery_verified', + req, + metadata: { channel: request.channel, resumed: true }, + }); + return res.status(200).json(serializeRecoveryStatus(status)); + } + + const notices = await sendAccountNotice( + user, + await openedNotice(request.channel, status.completesAt, result.cancelToken), + { external: await canReturnExternalDelivery(req) }, + ); + + await AuthEventService.log({ + userId: user.id, + type: 'account_recovery_verified', + req, + metadata: { + channel: request.channel, + completesAt: status.completesAt.toISOString(), + supersededRequests: result.cancelled, + notices, + }, + }); + + return res.status(200).json(serializeRecoveryStatus(status)); +}; + +export const getAccountRecoveryStatus = async (req: Request, res: Response) => { + if (!(await accountRecoveryPolicy()).enabled) { + return refuseAccountRecoveryDisabled(res); + } + + const { user, attemptId } = req as AuthenticatedRequest; + const request = attemptId ? await findRecoveryForAttempt(user.id, attemptId) : null; + + if (!request) { + return res.status(404).json({ error: 'recovery_not_found' }); + } + + return res.status(200).json(serializeRecoveryStatus(recoveryStatus(request))); +}; + +/** + * Public on purpose: the token is the credential, and the owner may be signed out + * everywhere. Answers the same whether or not it matched, so it is not an oracle. + */ +export const cancelAccountRecovery = async (req: Request, res: Response) => { + if (!(await accountRecoveryPolicy()).enabled) { + return refuseAccountRecoveryDisabled(res); + } + + const cancelToken = req.body?.cancelToken; + + if (typeof cancelToken !== 'string' || cancelToken.length === 0) { + return res.status(400).json({ error: 'cancel_token_required' }); + } + + const request = await cancelRecoveryByToken(cancelToken); + + if (request) { + await AuthEventService.log({ + userId: request.userId, + type: 'account_recovery_cancelled', + req, + metadata: { channel: request.channel, via: 'cancel_token' }, + }); + } + + return res.status(200).json({ message: RECOVERY_CANCEL_MESSAGE }); +}; + +/** + * Completes a request whose delay has passed: signs out every session, tells every + * verified address, and issues a recovery session that can only enroll a new factor. + */ +export const completeAccountRecovery = async (req: Request, res: Response) => { + if (!(await accountRecoveryPolicy()).enabled) { + return refuseAccountRecoveryDisabled(res); + } + + const { user, attemptId } = req as AuthenticatedRequest; + const result = attemptId ? await completeRecovery(user.id, attemptId) : null; + + if (!result || result.outcome === 'not_found') { + return res.status(404).json({ error: 'recovery_not_found' }); + } + + if (result.outcome === 'not_ready') { + return res.status(409).json({ + error: 'recovery_not_ready', + message: `Recovery completes at ${(result.request.completesAt as Date).toISOString()}`, + }); + } + + const name = await appName(); + const notices = await sendAccountNotice( + user, + { + subject: `Your ${name} account was recovered`, + text: + `Your ${name} account was recovered and every session on it was signed out. ` + + 'If this was not you, contact your administrator.', + }, + { external: await canReturnExternalDelivery(req) }, + ); + + await AuthEventService.log({ + userId: user.id, + type: 'account_recovery_completed', + req, + metadata: { + channel: result.request.channel, + revokedSessions: result.revokedSessions, + notices, + }, + }); + + await issueSessionAndRespond({ + method: 'account_recovery', + recovery: true, + user: { + id: user.id, + email: user.email, + phone: user.phone, + roles: user.roles ?? [], + }, + req, + res, + }); +}; diff --git a/src/controllers/decoyResponders.ts b/src/controllers/decoyResponders.ts index 13acf7a5..b56f7fdb 100644 --- a/src/controllers/decoyResponders.ts +++ b/src/controllers/decoyResponders.ts @@ -24,6 +24,7 @@ import { } from '../services/magicLinkRedirect.js'; import { AuthenticatedRequest } from '../types/types.js'; import { hashDeviceFingerprint } from '../utils/utils.js'; +import { RECOVERY_STARTED_MESSAGE } from './accountRecovery.js'; /** * How the thirteen ephemeral endpoints answer when the pre-auth subject is a decoy. @@ -326,3 +327,52 @@ export const decoyRedeemRecoveryCode = async (req: Request, res: Response) => { return res.status(401).json({ error: 'invalid_recovery_code' }); }; + +async function rejectAccountRecoveryDisabled(res: Response) { + const { account_recovery } = await getSystemConfig(); + + if (account_recovery?.enabled === true) { + return false; + } + + res.status(403).json({ error: 'account_recovery_disabled' }); + return true; +} + +/** A real account without a verified address on the channel answers exactly this. */ +export const decoyStartAccountRecovery = async (req: Request, res: Response) => { + if (await rejectAccountRecoveryDisabled(res)) { + return; + } + + await logDecoy(req, 'recovery:start'); + + return res.status(200).json({ message: RECOVERY_STARTED_MESSAGE }); +}; + +export const decoyVerifyAccountRecovery = async (req: Request, res: Response) => { + if (await rejectAccountRecoveryDisabled(res)) { + return; + } + + await logDecoy(req, 'recovery:verify'); + + return res.status(401).json({ error: 'invalid_recovery_code' }); +}; + +/** What an attempt that has opened no request gets, which a decoy's never has. */ +async function respondNoRecovery(req: Request, res: Response, endpoint: string) { + if (await rejectAccountRecoveryDisabled(res)) { + return; + } + + await logDecoy(req, endpoint); + + return res.status(404).json({ error: 'recovery_not_found' }); +} + +export const decoyAccountRecoveryStatus = (req: Request, res: Response) => + respondNoRecovery(req, res, 'recovery:status'); + +export const decoyCompleteAccountRecovery = (req: Request, res: Response) => + respondNoRecovery(req, res, 'recovery:complete'); diff --git a/src/controllers/recoveryCodes.ts b/src/controllers/recoveryCodes.ts index dcd197c1..91a008c8 100644 --- a/src/controllers/recoveryCodes.ts +++ b/src/controllers/recoveryCodes.ts @@ -7,7 +7,9 @@ import { Request, Response } from 'express'; import { getSystemConfig } from '../config/getSystemConfig.js'; +import { canReturnExternalDelivery } from '../lib/externalDelivery.js'; import { requireStepUp } from '../middleware/requireStepUp.js'; +import { sendAccountNotice } from '../services/accountNotices.js'; import { AuthEventService } from '../services/authEventService.js'; import { generateRecoveryCodes, @@ -90,12 +92,23 @@ export const redeemRecoveryCodeLogin = async (req: Request, res: Response) => { } const { remaining } = await getRecoveryCodeStatus(user.id); + const { app_name: name } = await getSystemConfig(); + const notices = await sendAccountNotice( + user, + { + subject: `A recovery code was used on your ${name} account`, + text: + `A recovery code was used to sign in to your ${name} account (${remaining} left). ` + + 'If this was not you, contact your administrator.', + }, + { external: await canReturnExternalDelivery(req) }, + ); await AuthEventService.log({ userId: user.id, type: 'recovery_code_used', req, - metadata: { remaining }, + metadata: { remaining, notices }, }); await issueSessionAndRespond({ diff --git a/src/controllers/systemConfig.ts b/src/controllers/systemConfig.ts index 8977a029..879f3417 100644 --- a/src/controllers/systemConfig.ts +++ b/src/controllers/systemConfig.ts @@ -171,7 +171,11 @@ export const getAvailableRoles = async (_req: Request, res: Response) => { */ export const getPublicSystemConfig = async (_req: Request, res: Response) => { const { loginMethods } = await getLoginPolicy(); - const { recovery_codes } = await getSystemConfig(); + const { recovery_codes, account_recovery } = await getSystemConfig(); - return res.json({ loginMethods, recoveryCodes: recovery_codes?.enabled !== false }); + return res.json({ + loginMethods, + recoveryCodes: recovery_codes?.enabled !== false, + accountRecovery: account_recovery?.enabled === true, + }); }; diff --git a/src/generated/api.ts b/src/generated/api.ts index be7a58aa..fc19be00 100644 --- a/src/generated/api.ts +++ b/src/generated/api.ts @@ -12,6 +12,840 @@ */ export interface paths { + '/recovery/start': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Start account recovery + * @description Sends a code to the verified address on file for the channel. Answers the same whether or not the account has one. Not subject to account lockout. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + 'application/json': { + /** + * @default email + * @enum {string} + */ + channel?: 'email' | 'phone'; + }; + }; + }; + responses: { + /** @description HTTP 200 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "delivery": null + * } + */ + 'application/json': { + message: string; + delivery?: + | { + /** @enum {string} */ + kind: 'otp_email'; + to: string; + token: string; + } + | { + /** @enum {string} */ + kind: 'otp_sms'; + to: string; + token: string | number; + } + | { + /** @enum {string} */ + kind: 'magic_link_email'; + to: string; + token?: string; + magicLinkUrl: string; + } + | { + /** @enum {string} */ + kind: 'enrollment_invite_email'; + to: string; + signInUrl: string; + }; + }; + }; + }; + /** @description HTTP 400 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "error": "string", + * "message": "string", + * "details": { + * "issues": [ + * null + * ] + * } + * } + */ + 'application/json': { + error: string; + message?: string; + details?: { + issues: { + path: (string | number)[]; + code: string; + message: string; + }[]; + }; + }; + }; + }; + /** @description HTTP 401 */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 500 */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/recovery/verify': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Verify an account recovery code + * @description Opens the request started on this attempt, which completes after the configured delay, and tells every verified address how to cancel it. A code for a request that is already open resumes it on this attempt. Five wrong codes void the code. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + 'application/json': { + code: string; + }; + }; + }; + responses: { + /** @description HTTP 200 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "state": null, + * "requestedAt": null, + * "completesAt": null + * } + */ + 'application/json': { + /** @enum {string} */ + state: 'pending' | 'ready' | 'cancelled' | 'completed'; + /** Format: date-time */ + requestedAt: string | null; + /** Format: date-time */ + completesAt: string | null; + }; + }; + }; + /** @description HTTP 400 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "error": "string", + * "message": "string", + * "details": { + * "issues": [ + * null + * ] + * } + * } + */ + 'application/json': { + error: string; + message?: string; + details?: { + issues: { + path: (string | number)[]; + code: string; + message: string; + }[]; + }; + }; + }; + }; + /** @description HTTP 401 */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 500 */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/recovery/status': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Account recovery status + * @description The request this attempt opened: `pending` until `completesAt`, then `ready`. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description HTTP 200 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "state": null, + * "requestedAt": null, + * "completesAt": null + * } + */ + 'application/json': { + /** @enum {string} */ + state: 'pending' | 'ready' | 'cancelled' | 'completed'; + /** Format: date-time */ + requestedAt: string | null; + /** Format: date-time */ + completesAt: string | null; + }; + }; + }; + /** @description HTTP 401 */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 404 */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 500 */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/recovery/cancel': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Cancel account recovery + * @description Cancels the open request the token from a recovery notice belongs to. Answers the same whether or not it matched. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + 'application/json': { + cancelToken?: string; + }; + }; + }; + responses: { + /** @description HTTP 200 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "token": "string", + * "delivery": null + * } + */ + 'application/json': { + message: string; + token?: string; + delivery?: + | { + /** @enum {string} */ + kind: 'otp_email'; + to: string; + token: string; + } + | { + /** @enum {string} */ + kind: 'otp_sms'; + to: string; + token: string | number; + } + | { + /** @enum {string} */ + kind: 'magic_link_email'; + to: string; + token?: string; + magicLinkUrl: string; + } + | { + /** @enum {string} */ + kind: 'enrollment_invite_email'; + to: string; + signInUrl: string; + }; + }; + }; + }; + /** @description HTTP 400 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "error": "string", + * "message": "string", + * "details": { + * "issues": [ + * null + * ] + * } + * } + */ + 'application/json': { + error: string; + message?: string; + details?: { + issues: { + path: (string | number)[]; + code: string; + message: string; + }[]; + }; + }; + }; + }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 500 */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/recovery/complete': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Complete account recovery + * @description Once the delay has passed, signs out every session on the account and issues a recovery session (`recovery: true`) that can only enroll a new passkey or authenticator app until one is enrolled. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description HTTP 200 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "token": "string", + * "refreshToken": "string", + * "sub": "string", + * "organizationId": "string", + * "roles": [ + * null + * ], + * "email": "string", + * "phone": "string", + * "ttl": 0, + * "refreshTtl": 0, + * "recovery": null, + * "nextStep": null + * } + */ + 'application/json': { + message: string; + token?: string; + refreshToken?: string; + sub?: string; + organizationId?: string | null; + roles?: string[]; + email?: string; + phone?: string | null; + ttl?: number; + refreshTtl?: number; + /** @enum {boolean} */ + recovery: true; + /** @enum {string} */ + nextStep: 'enroll_passkey'; + }; + }; + }; + /** @description HTTP 401 */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 404 */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 409 */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 500 */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/.well-known/seamless-adapter.json': { parameters: { query?: never; @@ -3126,6 +3960,11 @@ export interface paths { sessionId?: string; type?: | ( + | 'account_recovery_cancelled' + | 'account_recovery_completed' + | 'account_recovery_failed' + | 'account_recovery_started' + | 'account_recovery_verified' | 'auth_action_incremented' | 'admin_device_replacement_recovery' | 'admin_enrollment_invite_sent' @@ -3202,6 +4041,11 @@ export interface paths { | string | ( | ( + | 'account_recovery_cancelled' + | 'account_recovery_completed' + | 'account_recovery_failed' + | 'account_recovery_started' + | 'account_recovery_verified' | 'auth_action_incremented' | 'admin_device_replacement_recovery' | 'admin_enrollment_invite_sent' diff --git a/src/lib/authEventCategories.ts b/src/lib/authEventCategories.ts index 2c744e07..55e4886d 100644 --- a/src/lib/authEventCategories.ts +++ b/src/lib/authEventCategories.ts @@ -40,6 +40,7 @@ const PREFIX_CATEGORIES: ReadonlyArray<[string, AuthEventCategory]> = [ ['logout_', 'login'], // Signing in with a recovery code, and managing the set, are sign-in events. ['recovery_code', 'login'], + ['account_recovery_', 'login'], ['refresh_token_', 'token'], ['bearer_token_', 'token'], ['service_token_', 'token'], diff --git a/src/migrations/20261022160000-add-account-recovery-requests.cjs b/src/migrations/20261022160000-add-account-recovery-requests.cjs new file mode 100644 index 00000000..b80eeda8 --- /dev/null +++ b/src/migrations/20261022160000-add-account-recovery-requests.cjs @@ -0,0 +1,53 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +'use strict'; + +/** + * Self-service account recovery: a request proven through a verified email or phone, + * which completes only after the configured delay and can be cancelled until then. + * + * @type {import('sequelize-cli').Migration} + */ +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query(` + CREATE TABLE IF NOT EXISTS public.account_recovery_requests ( + id uuid NOT NULL, + "userId" uuid NOT NULL, + channel character varying(16) NOT NULL, + state character varying(16) NOT NULL, + "attemptId" character varying(255), + "codeAttemptId" character varying(255), + "codeHash" character varying(255), + "codeExpiresAt" timestamp with time zone, + "failedAttempts" integer DEFAULT 0 NOT NULL, + "cancelTokenHash" character varying(64), + "requestedAt" timestamp with time zone, + "completesAt" timestamp with time zone, + "cancelledAt" timestamp with time zone, + "completedAt" timestamp with time zone, + "createdAt" timestamp with time zone NOT NULL, + "updatedAt" timestamp with time zone NOT NULL, + CONSTRAINT account_recovery_requests_pkey PRIMARY KEY (id), + CONSTRAINT "account_recovery_requests_userId_fkey" + FOREIGN KEY ("userId") REFERENCES public.users(id) ON UPDATE CASCADE ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_account_recovery_requests_user + ON public.account_recovery_requests USING btree ("userId"); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_account_recovery_requests_cancel_token + ON public.account_recovery_requests USING btree ("cancelTokenHash"); + `); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query(` + DROP TABLE IF EXISTS public.account_recovery_requests; + `); + }, +}; diff --git a/src/models/accountRecoveryRequests.ts b/src/models/accountRecoveryRequests.ts new file mode 100644 index 00000000..f54021ff --- /dev/null +++ b/src/models/accountRecoveryRequests.ts @@ -0,0 +1,145 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import { DataTypes, Model, Optional, Sequelize } from 'sequelize'; + +export type RecoveryChannel = 'email' | 'phone'; + +/** + * What is stored. `ready` is not one of them: a `pending` request is ready once + * `completesAt` has passed, so nothing has to run to advance it. + */ +export type StoredRecoveryState = 'awaiting_code' | 'pending' | 'cancelled' | 'completed' | 'void'; + +export interface AccountRecoveryRequestAttributes { + id: string; + userId: string; + channel: RecoveryChannel; + state: StoredRecoveryState; + attemptId?: string | null; + codeAttemptId?: string | null; + codeHash?: string | null; + codeExpiresAt?: Date | null; + failedAttempts?: number; + cancelTokenHash?: string | null; + requestedAt?: Date | null; + completesAt?: Date | null; + cancelledAt?: Date | null; + completedAt?: Date | null; + createdAt?: Date; + updatedAt?: Date; +} + +type AccountRecoveryRequestCreationAttributes = Optional< + AccountRecoveryRequestAttributes, + | 'id' + | 'attemptId' + | 'codeAttemptId' + | 'codeHash' + | 'codeExpiresAt' + | 'failedAttempts' + | 'cancelTokenHash' + | 'requestedAt' + | 'completesAt' + | 'cancelledAt' + | 'completedAt' +>; + +export class AccountRecoveryRequest + extends Model + implements AccountRecoveryRequestAttributes +{ + declare id: string; + declare userId: string; + declare channel: RecoveryChannel; + declare state: StoredRecoveryState; + declare attemptId: string | null; + declare codeAttemptId: string | null; + declare codeHash: string | null; + declare codeExpiresAt: Date | null; + declare failedAttempts: number; + declare cancelTokenHash: string | null; + declare requestedAt: Date | null; + declare completesAt: Date | null; + declare cancelledAt: Date | null; + declare completedAt: Date | null; + declare readonly createdAt: Date; + declare readonly updatedAt: Date; +} + +const initializeAccountRecoveryRequestModel = (sequelize: Sequelize) => { + AccountRecoveryRequest.init( + { + id: { + type: DataTypes.UUID, + primaryKey: true, + defaultValue: DataTypes.UUIDV4, + }, + userId: { + type: DataTypes.UUID, + allowNull: false, + }, + channel: { + type: DataTypes.STRING(16), + allowNull: false, + }, + state: { + type: DataTypes.STRING(16), + allowNull: false, + }, + attemptId: { + type: DataTypes.STRING, + allowNull: true, + }, + codeAttemptId: { + type: DataTypes.STRING, + allowNull: true, + }, + codeHash: { + type: DataTypes.STRING, + allowNull: true, + }, + codeExpiresAt: { + type: DataTypes.DATE, + allowNull: true, + }, + failedAttempts: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0, + }, + cancelTokenHash: { + type: DataTypes.STRING(64), + allowNull: true, + }, + requestedAt: { + type: DataTypes.DATE, + allowNull: true, + }, + completesAt: { + type: DataTypes.DATE, + allowNull: true, + }, + cancelledAt: { + type: DataTypes.DATE, + allowNull: true, + }, + completedAt: { + type: DataTypes.DATE, + allowNull: true, + }, + }, + { + sequelize, + tableName: 'account_recovery_requests', + modelName: 'AccountRecoveryRequest', + }, + ); + + return AccountRecoveryRequest; +}; + +export default initializeAccountRecoveryRequestModel; diff --git a/src/routes/accountRecovery.routes.ts b/src/routes/accountRecovery.routes.ts new file mode 100644 index 00000000..78f123a8 --- /dev/null +++ b/src/routes/accountRecovery.routes.ts @@ -0,0 +1,154 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import { + cancelAccountRecovery, + completeAccountRecovery, + getAccountRecoveryStatus, + startAccountRecovery, + verifyAccountRecovery, +} from '../controllers/accountRecovery.js'; +import { + decoyAccountRecoveryStatus, + decoyCompleteAccountRecovery, + decoyStartAccountRecovery, + decoyVerifyAccountRecovery, +} from '../controllers/decoyResponders.js'; +import { createRouter } from '../lib/createRouter.js'; +import { otpIdentityLimiter, otpIpLimiter } from '../middleware/rateLimit.js'; +import { + AccountRecoveryCancelRequestSchema, + AccountRecoveryStartRequestSchema, + AccountRecoveryStartResponseSchema, + AccountRecoveryStatusSchema, + AccountRecoveryVerifyRequestSchema, +} from '../schemas/accountRecovery.js'; +import { ErrorSchema, MessageSchema } from '../schemas/generic.responses.js'; +import { RecoverySessionResponseSchema } from '../schemas/recoveryCodes.js'; + +const accountRecoveryRouter = createRouter('/recovery'); + +accountRecoveryRouter.post( + '/start', + { + adapter: { credential: 'preAuth', delivery: true }, + auth: 'ephemeral', + decoy: decoyStartAccountRecovery, + middleware: [otpIpLimiter, otpIdentityLimiter], + summary: 'Start account recovery', + description: + 'Sends a code to the verified address on file for the channel. Answers the same ' + + 'whether or not the account has one. Not subject to account lockout.', + tags: ['Recovery'], + schemas: { + body: AccountRecoveryStartRequestSchema, + response: { + 200: AccountRecoveryStartResponseSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, + }, + startAccountRecovery, +); + +accountRecoveryRouter.post( + '/verify', + { + adapter: { credential: 'preAuth' }, + auth: 'ephemeral', + decoy: decoyVerifyAccountRecovery, + middleware: [otpIpLimiter, otpIdentityLimiter], + summary: 'Verify an account recovery code', + description: + 'Opens the request started on this attempt, which completes after the configured ' + + 'delay, and tells every verified address how to cancel it. A code for a request ' + + 'that is already open resumes it on this attempt. Five wrong codes void the code.', + tags: ['Recovery'], + schemas: { + body: AccountRecoveryVerifyRequestSchema, + response: { + 200: AccountRecoveryStatusSchema, + 401: ErrorSchema, + 403: ErrorSchema, + }, + }, + }, + verifyAccountRecovery, +); + +accountRecoveryRouter.get( + '/status', + { + adapter: { credential: 'preAuth' }, + auth: 'ephemeral', + decoy: decoyAccountRecoveryStatus, + middleware: [otpIpLimiter, otpIdentityLimiter], + summary: 'Account recovery status', + description: 'The request this attempt opened: `pending` until `completesAt`, then `ready`.', + tags: ['Recovery'], + schemas: { + response: { + 200: AccountRecoveryStatusSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + }, + getAccountRecoveryStatus, +); + +accountRecoveryRouter.post( + '/cancel', + { + adapter: {}, + middleware: [otpIpLimiter], + summary: 'Cancel account recovery', + description: + 'Cancels the open request the token from a recovery notice belongs to. Answers the ' + + 'same whether or not it matched.', + tags: ['Recovery'], + schemas: { + body: AccountRecoveryCancelRequestSchema, + response: { + 200: MessageSchema, + 400: ErrorSchema, + 403: ErrorSchema, + }, + }, + }, + cancelAccountRecovery, +); + +accountRecoveryRouter.post( + '/complete', + { + adapter: { credential: 'preAuth', issues: 'session' }, + auth: 'ephemeral', + decoy: decoyCompleteAccountRecovery, + middleware: [otpIpLimiter, otpIdentityLimiter], + summary: 'Complete account recovery', + description: + 'Once the delay has passed, signs out every session on the account and issues a ' + + 'recovery session (`recovery: true`) that can only enroll a new passkey or ' + + 'authenticator app until one is enrolled.', + tags: ['Recovery'], + schemas: { + response: { + 200: RecoverySessionResponseSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 409: ErrorSchema, + }, + }, + }, + completeAccountRecovery, +); + +export default accountRecoveryRouter.router; diff --git a/src/schemas/accountRecovery.ts b/src/schemas/accountRecovery.ts new file mode 100644 index 00000000..fbf2a677 --- /dev/null +++ b/src/schemas/accountRecovery.ts @@ -0,0 +1,13 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +export { + AccountRecoveryCancelRequestSchema, + AccountRecoveryStartRequestSchema, + AccountRecoveryStartResponseSchema, + AccountRecoveryStatusSchema, + AccountRecoveryVerifyRequestSchema, +} from '@seamless-auth/types'; diff --git a/src/schemas/authEvent.types.ts b/src/schemas/authEvent.types.ts index 6bf5c30e..aec39368 100644 --- a/src/schemas/authEvent.types.ts +++ b/src/schemas/authEvent.types.ts @@ -8,6 +8,11 @@ import { z } from 'zod'; export const AUTH_EVENT_TYPES = [ + 'account_recovery_cancelled', + 'account_recovery_completed', + 'account_recovery_failed', + 'account_recovery_started', + 'account_recovery_verified', 'auth_action_incremented', 'admin_device_replacement_recovery', 'admin_enrollment_invite_sent', diff --git a/src/services/accountNotices.ts b/src/services/accountNotices.ts new file mode 100644 index 00000000..fee3dc7a --- /dev/null +++ b/src/services/accountNotices.ts @@ -0,0 +1,83 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import getLogger from '../utils/logger.js'; +import { DeliveryError } from './deliveryError.js'; +import type { IdentityNoticeStatus } from './identityChangeService.js'; +import { sendNoticeEmail, sendNoticeSms } from './messagingService.js'; + +const logger = getLogger('accountNotices'); + +export interface NoticeRecipient { + email: string | null; + emailVerified?: boolean | null; + phone: string | null; + phoneVerified?: boolean | null; +} + +export interface AccountNotice { + subject: string; + text: string; +} + +export interface AccountNoticeResult { + channel: 'email' | 'phone'; + status: IdentityNoticeStatus; +} + +/** The account's verified addresses only: an unverified one may not be the owner's. */ +export function verifiedAddresses(user: NoticeRecipient) { + return { + email: user.emailVerified && user.email ? user.email : null, + phone: user.phoneVerified && user.phone ? user.phone : null, + }; +} + +/** + * Tells the owner, on every verified address, about something that happened to the + * account. A notice that fails to send does not fail the request it reports on; the + * outcome belongs in that request's audit event. + * + * `external` is for a credentialed caller that owns delivery. The delivery contract has + * no kind for account notices, so such a caller cannot be handed one, and sending + * directly would bypass it. + */ +export async function sendAccountNotice( + user: NoticeRecipient, + notice: AccountNotice, + { external }: { external: boolean }, +): Promise { + const { email, phone } = verifiedAddresses(user); + const targets: Array<['email' | 'phone', string | null]> = [ + ['email', email], + ['phone', phone], + ]; + const results: AccountNoticeResult[] = []; + + for (const [channel, to] of targets) { + if (!to) continue; + + if (external) { + results.push({ channel, status: 'not_sent_external_delivery' }); + continue; + } + + try { + if (channel === 'email') { + await sendNoticeEmail(to, notice.subject, notice.text); + } else { + await sendNoticeSms(to, notice.text); + } + results.push({ channel, status: 'sent' }); + } catch (error) { + if (!(error instanceof DeliveryError)) throw error; + logger.error(`Account notice failed on ${channel}`); + results.push({ channel, status: 'failed' }); + } + } + + return results; +} diff --git a/src/services/accountRecoveryService.ts b/src/services/accountRecoveryService.ts new file mode 100644 index 00000000..47c03979 --- /dev/null +++ b/src/services/accountRecoveryService.ts @@ -0,0 +1,257 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import { randomBytes } from 'crypto'; +import { Op } from 'sequelize'; + +import { + AccountRecoveryRequest, + RecoveryChannel, + StoredRecoveryState, +} from '../models/accountRecoveryRequests.js'; +import { Session } from '../models/sessions.js'; +import { generateRandomPhoneOTP, hashOtpToken, otpHashMatches } from '../utils/otp.js'; +import { hashSha256 } from '../utils/utils.js'; + +export const RECOVERY_CODE_TTL_MS = 10 * 60 * 1000; +export const MAX_RECOVERY_CODE_ATTEMPTS = 5; + +export type RecoveryState = 'pending' | 'ready' | 'cancelled' | 'completed'; + +export interface RecoveryStatus { + state: RecoveryState; + requestedAt: Date; + completesAt: Date; +} + +export function recoveryStatus(request: AccountRecoveryRequest, now = new Date()): RecoveryStatus { + const completesAt = request.completesAt as Date; + const stored = request.state as Exclude; + + return { + state: stored === 'pending' && now >= completesAt ? 'ready' : stored, + requestedAt: request.requestedAt as Date, + completesAt, + }; +} + +function hashCancelToken(token: string) { + return hashSha256(`account-recovery-cancel:${token}`); +} + +/** + * Issues a code for `attemptId`. When the user already has an open request on this + * channel, the code goes on that request, so verifying it resumes the request on the new + * attempt with its clock unchanged. Otherwise it starts a new one. Ephemeral tokens live + * for minutes and the delay is hours, so coming back means a new attempt, and the same + * channel is how the requester proves it is them again. + * + * Nothing earlier is cancelled here. A stranger who knows the address can call this, and + * only a verified code may cancel the owner's request. + */ +export async function startRecovery(userId: string, channel: RecoveryChannel, attemptId: string) { + const code = String(generateRandomPhoneOTP()); + const codeFields = { + codeAttemptId: attemptId, + codeHash: hashOtpToken(code), + codeExpiresAt: new Date(Date.now() + RECOVERY_CODE_TTL_MS), + failedAttempts: 0, + }; + + await AccountRecoveryRequest.update( + { state: 'void', codeHash: null, codeAttemptId: null }, + { where: { userId, codeAttemptId: attemptId, state: 'awaiting_code' } }, + ); + + const open = await AccountRecoveryRequest.findOne({ + where: { userId, channel, state: 'pending' }, + order: [['requestedAt', 'DESC']], + }); + + if (open) { + await open.update(codeFields); + } else { + await AccountRecoveryRequest.create({ userId, channel, state: 'awaiting_code', ...codeFields }); + } + + return code; +} + +export type VerifyRecoveryResult = + | { outcome: 'invalid' } + | { outcome: 'opened'; request: AccountRecoveryRequest; cancelToken: string; cancelled: number } + | { outcome: 'resumed'; request: AccountRecoveryRequest }; + +function clearCode(request: AccountRecoveryRequest) { + return { + codeHash: null, + codeAttemptId: null, + codeExpiresAt: null, + // A resumed request survives a spent or exhausted code; a new one does not. + ...(request.state === 'awaiting_code' ? { state: 'void' as const } : {}), + }; +} + +/** + * Checks the code issued to this attempt. Each try is claimed with a conditional update + * before the code is compared, so parallel requests cannot spend more than + * {@link MAX_RECOVERY_CODE_ATTEMPTS} between them. + */ +export async function verifyRecovery( + userId: string, + attemptId: string, + code: string, + delaySeconds: number, +): Promise { + const request = await AccountRecoveryRequest.findOne({ + where: { + userId, + codeAttemptId: attemptId, + state: { [Op.in]: ['awaiting_code', 'pending'] }, + codeHash: { [Op.ne]: null }, + }, + order: [['createdAt', 'DESC']], + }); + + if (!request?.codeHash || !request.codeExpiresAt) { + return { outcome: 'invalid' }; + } + + const tries = request.failedAttempts ?? 0; + + if (request.codeExpiresAt.getTime() <= Date.now() || tries >= MAX_RECOVERY_CODE_ATTEMPTS) { + await request.update(clearCode(request)); + return { outcome: 'invalid' }; + } + + const [claimed] = await AccountRecoveryRequest.update( + { failedAttempts: tries + 1 }, + { where: { id: request.id, failedAttempts: tries } }, + ); + + if (claimed !== 1) { + return { outcome: 'invalid' }; + } + + if (!otpHashMatches(request.codeHash, code)) { + if (tries + 1 >= MAX_RECOVERY_CODE_ATTEMPTS) { + await request.update(clearCode(request)); + } + return { outcome: 'invalid' }; + } + + if (request.state === 'pending') { + await request.update({ ...clearCode(request), attemptId, failedAttempts: 0 }); + return { outcome: 'resumed', request }; + } + + const cancelToken = randomBytes(32).toString('base64url'); + const requestedAt = new Date(); + const [opened] = await AccountRecoveryRequest.update( + { + ...clearCode(request), + state: 'pending', + attemptId, + failedAttempts: 0, + cancelTokenHash: hashCancelToken(cancelToken), + requestedAt, + completesAt: new Date(requestedAt.getTime() + delaySeconds * 1000), + }, + { where: { id: request.id, state: 'awaiting_code' } }, + ); + + if (opened !== 1) { + return { outcome: 'invalid' }; + } + + const [cancelled] = await AccountRecoveryRequest.update( + { state: 'cancelled', cancelledAt: requestedAt, codeHash: null, codeAttemptId: null }, + { where: { userId, id: { [Op.ne]: request.id }, state: 'pending' } }, + ); + + await request.reload(); + + return { outcome: 'opened', request, cancelToken, cancelled }; +} + +/** The request this attempt opened or resumed, if any. */ +export async function findRecoveryForAttempt(userId: string, attemptId: string) { + return AccountRecoveryRequest.findOne({ + where: { + userId, + attemptId, + state: { [Op.in]: ['pending', 'cancelled', 'completed'] }, + }, + order: [['requestedAt', 'DESC']], + }); +} + +export type CompleteRecoveryResult = + | { outcome: 'not_found' } + | { outcome: 'not_ready'; request: AccountRecoveryRequest } + | { outcome: 'completed'; request: AccountRecoveryRequest; revokedSessions: number }; + +/** + * Completes a ready request and signs out every session on the account. The state + * change is conditional, so only one caller can complete a request. + */ +export async function completeRecovery( + userId: string, + attemptId: string, +): Promise { + const request = await findRecoveryForAttempt(userId, attemptId); + + if (!request || request.state !== 'pending') { + return { outcome: 'not_found' }; + } + + const now = new Date(); + + if (recoveryStatus(request, now).state !== 'ready') { + return { outcome: 'not_ready', request }; + } + + const [completed] = await AccountRecoveryRequest.update( + { state: 'completed', completedAt: now }, + { where: { id: request.id, state: 'pending', completesAt: { [Op.lte]: now } } }, + ); + + if (completed !== 1) { + return { outcome: 'not_found' }; + } + + // A bulk update rather than a per-session revoke, as the identity change path does. + // It becomes revokeAllSessionsForUsers once #488 lands. + const [revokedSessions] = await Session.update( + { revokedAt: now, revokedReason: 'account_recovery' }, + { where: { userId, revokedAt: null } }, + ); + + await request.reload(); + + return { outcome: 'completed', request, revokedSessions }; +} + +/** + * Cancels the open request a notice's token belongs to. The token is 32 random bytes, + * so looking it up by its hash is not a timing oracle on the token itself. + */ +export async function cancelRecoveryByToken(cancelToken: string) { + const request = await AccountRecoveryRequest.findOne({ + where: { cancelTokenHash: hashCancelToken(cancelToken), state: 'pending' }, + }); + + if (!request) { + return null; + } + + const [cancelled] = await AccountRecoveryRequest.update( + { state: 'cancelled', cancelledAt: new Date(), codeHash: null, codeAttemptId: null }, + { where: { id: request.id, state: 'pending' } }, + ); + + return cancelled === 1 ? request : null; +} diff --git a/src/services/sessionIssuance.ts b/src/services/sessionIssuance.ts index bfdaf837..561a774d 100644 --- a/src/services/sessionIssuance.ts +++ b/src/services/sessionIssuance.ts @@ -16,7 +16,14 @@ import { getDefaultOrganizationIdForUser } from './organizationService.js'; /** The factor that proved the user, as far as session issuance needs to know it. */ export type SessionMethod = - 'passkey' | 'email_otp' | 'phone_otp' | 'magic_link' | 'totp' | 'oauth' | 'recovery_code'; + | 'passkey' + | 'email_otp' + | 'phone_otp' + | 'magic_link' + | 'totp' + | 'oauth' + | 'recovery_code' + | 'account_recovery'; export class PasskeyRequiredError extends Error { constructor(method: SessionMethod) { diff --git a/src/utils/otp.ts b/src/utils/otp.ts index 26016859..7cd6137b 100644 --- a/src/utils/otp.ts +++ b/src/utils/otp.ts @@ -44,6 +44,11 @@ export function hashOtpToken(token: string) { return `${OTP_HASH_PREFIX}${hashSha256(token)}`; } +/** Compares a submitted code with a stored `hashOtpToken` value in constant time. */ +export function otpHashMatches(storedHash: string, code: string) { + return safeStringEqual(storedHash, hashOtpToken(code.trim())); +} + function otpMatchesStoredValue( storedToken: string, verificationToken: string, diff --git a/tests/factories/accountRecoveryStore.ts b/tests/factories/accountRecoveryStore.ts new file mode 100644 index 00000000..227296a1 --- /dev/null +++ b/tests/factories/accountRecoveryStore.ts @@ -0,0 +1,85 @@ +import { randomUUID } from 'crypto'; +import { Op } from 'sequelize'; + +import { AccountRecoveryRequest } from '../../src/models/accountRecoveryRequests.js'; + +type Row = Record; +type Where = Record; + +function matchesCondition(value: any, condition: any): boolean { + if (condition === null) return value === null || value === undefined; + if (condition instanceof Date) return value instanceof Date && +value === +condition; + + if (typeof condition === 'object') { + return Object.getOwnPropertySymbols(condition).every((op) => { + const operand = condition[op]; + if (op === Op.in) return operand.includes(value); + if (op === Op.ne) return operand === null ? value != null : value !== operand; + if (op === Op.lte) return value != null && +value <= +operand; + throw new Error(`Unsupported operator ${String(op)}`); + }); + } + + return value === condition; +} + +function matches(row: Row, where: Where) { + return Object.keys(where).every((key) => matchesCondition(row[key], where[key])); +} + +/** + * An in-memory stand-in for the requests table that honours the where clauses the + * service writes, so its conditional updates are exercised rather than assumed. + */ +export function installAccountRecoveryStore() { + const rows: Row[] = []; + let clock = 0; + + const instance = (row: Row) => { + Object.defineProperties(row, { + update: { value: async (fields: Row) => Object.assign(row, fields), enumerable: false }, + reload: { value: async () => row, enumerable: false }, + }); + return row; + }; + + (AccountRecoveryRequest.create as any).mockImplementation(async (data: Row) => { + clock += 1; + const row = instance({ + id: randomUUID(), + attemptId: null, + codeAttemptId: null, + codeHash: null, + codeExpiresAt: null, + failedAttempts: 0, + cancelTokenHash: null, + requestedAt: null, + completesAt: null, + cancelledAt: null, + completedAt: null, + createdAt: new Date(clock), + ...data, + }); + rows.push(row); + return row; + }); + + (AccountRecoveryRequest.findOne as any).mockImplementation( + async ({ where, order }: { where: Where; order?: [string, string][] }) => { + const found = rows.filter((row) => matches(row, where)); + const key = order?.[0]?.[0]; + if (key) found.sort((a, b) => +(b[key] ?? 0) - +(a[key] ?? 0)); + return found[0] ?? null; + }, + ); + + (AccountRecoveryRequest.update as any).mockImplementation( + async (fields: Row, { where }: { where: Where }) => { + const found = rows.filter((row) => matches(row, where)); + for (const row of found) Object.assign(row, fields); + return [found.length]; + }, + ); + + return rows; +} diff --git a/tests/integration/recovery/accountRecovery.spec.ts b/tests/integration/recovery/accountRecovery.spec.ts new file mode 100644 index 00000000..ff45db53 --- /dev/null +++ b/tests/integration/recovery/accountRecovery.spec.ts @@ -0,0 +1,470 @@ +import { Application } from 'express'; +import request from 'supertest'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const issueSessionAndRespondMock = vi.fn(async ({ res }) => { + res.status(200).json({ message: 'Success', recovery: true, nextStep: 'enroll_passkey' }); +}); + +vi.mock('../../../src/services/sessionIssuance.js', () => ({ + issueSessionAndRespond: issueSessionAndRespondMock, +})); + +let decoy = false; +let attemptId = 'attempt-1'; +let principal: Record; + +vi.mock('../../../src/middleware/attachAuthMiddleware.js', async (importOriginal) => { + const actual = + await importOriginal(); + + return { + ...actual, + attachAuthMiddleware: () => (req: any, _res: any, next: any) => { + req.user = principal; + req.attemptId = attemptId; + if (decoy) req.decoy = true; + next(); + }, + }; +}); + +import { createApp } from '../../../src/app.js'; +import { getSystemConfig } from '../../../src/config/getSystemConfig.js'; +import { Session } from '../../../src/models/sessions.js'; +import { AuthEventService } from '../../../src/services/authEventService.js'; +import { DeliveryError } from '../../../src/services/deliveryError.js'; +import { + sendNoticeEmail, + sendNoticeSms, + sendOTPEmail, + sendOTPSMS, +} from '../../../src/services/messagingService.js'; +import { decoyPrincipalForSubject, decoySubjectFor } from '../../../src/services/decoyPrincipal.js'; +import { installAccountRecoveryStore } from '../../factories/accountRecoveryStore.js'; +import { mintInternalServiceToken } from '../../factories/serviceTokenFactory.js'; +import { buildSystemConfig } from '../../factories/systemConfigFactory.js'; + +const HOUR_MS = 3600 * 1000; +let app: Application; +let rows: ReturnType; + +function realUser(overrides: Record = {}) { + return { + id: 'user-1', + email: 'owner@example.com', + phone: '+14155552671', + emailVerified: true, + phoneVerified: true, + roles: ['user'], + ...overrides, + }; +} + +function enabled(overrides: Record = {}) { + (getSystemConfig as any).mockResolvedValue( + buildSystemConfig({ + account_recovery: { enabled: true, delay: '1h' }, + frontend_url: 'https://app.example.com/', + ...overrides, + }), + ); +} + +function sentCode() { + const email = (sendOTPEmail as any).mock.calls.at(-1); + if (email) return email[1] as string; + return String((sendOTPSMS as any).mock.calls.at(-1)[1]); +} + +async function startAndVerify(channel: 'email' | 'phone' = 'email') { + await request(app).post('/recovery/start').send({ channel }); + return request(app).post('/recovery/verify').send({ code: sentCode() }); +} + +function afterDelay() { + vi.useFakeTimers({ now: Date.now() + HOUR_MS + 1, toFake: ['Date'] }); +} + +beforeAll(async () => { + app = await createApp(); +}); + +beforeEach(() => { + vi.clearAllMocks(); + decoy = false; + attemptId = 'attempt-1'; + principal = realUser(); + rows = installAccountRecoveryStore(); + (Session.update as any).mockResolvedValue([2]); + enabled(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('while account recovery is disabled', () => { + it.each([ + ['post', '/recovery/start', { channel: 'email' }], + ['post', '/recovery/verify', { code: '123456' }], + ['get', '/recovery/status', undefined], + ['post', '/recovery/cancel', { cancelToken: 'token' }], + ['post', '/recovery/complete', undefined], + ] as const)('refuses %s %s, for a real account and a decoy alike', async (method, path, body) => { + (getSystemConfig as any).mockResolvedValue(buildSystemConfig()); + + for (const asDecoy of [false, true]) { + decoy = asDecoy; + const res = await request(app)[method](path).send(body); + + expect(res.status).toBe(403); + expect(res.body).toEqual({ error: 'account_recovery_disabled' }); + } + }); +}); + +describe('POST /recovery/start', () => { + it('sends a code to the verified address on the channel', async () => { + const res = await request(app).post('/recovery/start').send({ channel: 'phone' }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ message: expect.any(String) }); + expect(sendOTPSMS).toHaveBeenCalledWith('+14155552671', expect.any(Number)); + expect(rows).toHaveLength(1); + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ type: 'account_recovery_started' }), + ); + }); + + it('defaults to email', async () => { + await request(app).post('/recovery/start').send({}); + + expect(sendOTPEmail).toHaveBeenCalledWith( + 'owner@example.com', + expect.stringMatching(/^\d{6}$/), + ); + }); + + it('answers the same for a channel the account has no verified address on', async () => { + const real = await request(app).post('/recovery/start').send({ channel: 'email' }); + principal = realUser({ phone: null, phoneVerified: false }); + const missing = await request(app).post('/recovery/start').send({ channel: 'phone' }); + principal = realUser({ emailVerified: false }); + const unverified = await request(app).post('/recovery/start').send({ channel: 'email' }); + + expect(missing.status).toBe(real.status); + expect(missing.body).toEqual(real.body); + expect(unverified.body).toEqual(real.body); + expect(sendOTPSMS).not.toHaveBeenCalled(); + expect(sendOTPEmail).toHaveBeenCalledTimes(1); + }); + + it('refuses an unknown channel at validation', async () => { + const res = await request(app).post('/recovery/start').send({ channel: 'carrier-pigeon' }); + + expect(res.status).toBe(400); + }); + + it('hands the code to a credentialed caller that owns delivery instead of sending it', async () => { + const res = await request(app) + .post('/recovery/start') + .set('x-seamless-auth-delivery-mode', 'external') + .set('x-seamless-service-token', await mintInternalServiceToken()) + .send({ channel: 'email' }); + + expect(res.status).toBe(200); + expect(res.body.delivery).toEqual({ + kind: 'otp_email', + to: 'owner@example.com', + token: expect.stringMatching(/^\d{6}$/), + }); + expect(sendOTPEmail).not.toHaveBeenCalled(); + }); + + it('hands over an SMS code the same way', async () => { + const res = await request(app) + .post('/recovery/start') + .set('x-seamless-auth-delivery-mode', 'external') + .set('x-seamless-service-token', await mintInternalServiceToken()) + .send({ channel: 'phone' }); + + expect(res.body.delivery).toMatchObject({ kind: 'otp_sms', to: '+14155552671' }); + }); + + it('answers 500 when the code cannot be delivered', async () => { + (sendOTPEmail as any).mockRejectedValueOnce(new DeliveryError('down', new Error('down'))); + + const res = await request(app).post('/recovery/start').send({ channel: 'email' }); + + expect(res.status).toBe(500); + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ type: 'account_recovery_failed' }), + ); + }); + + it('ignores an existing lockout, which is what recovery is for', async () => { + const { AuthFailure } = await import('../../../src/models/authFailures.js'); + (AuthFailure.count as any).mockResolvedValue(1000); + + const res = await request(app).post('/recovery/start').send({ channel: 'email' }); + + expect(res.status).toBe(200); + expect(sendOTPEmail).toHaveBeenCalled(); + }); +}); + +describe('POST /recovery/verify', () => { + it('opens the request and notifies both verified addresses with a cancel link', async () => { + const res = await startAndVerify('email'); + + expect(res.status).toBe(200); + expect(res.body.state).toBe('pending'); + expect(Date.parse(res.body.completesAt) - Date.parse(res.body.requestedAt)).toBe(HOUR_MS); + + const [to, , text] = (sendNoticeEmail as any).mock.calls[0]; + expect(to).toBe('owner@example.com'); + expect(text).toMatch(/https:\/\/app\.example\.com\/recovery\/cancel#\S+/); + expect(text).toContain(res.body.completesAt); + expect(sendNoticeSms).toHaveBeenCalledWith('+14155552671', expect.stringContaining('#')); + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'account_recovery_verified', + metadata: expect.objectContaining({ + notices: [ + { channel: 'email', status: 'sent' }, + { channel: 'phone', status: 'sent' }, + ], + }), + }), + ); + }); + + it('records the notices as not sent when a credentialed caller owns delivery', async () => { + const token = await mintInternalServiceToken(); + const started = await request(app) + .post('/recovery/start') + .set('x-seamless-auth-delivery-mode', 'external') + .set('x-seamless-service-token', token) + .send({ channel: 'email' }); + + const res = await request(app) + .post('/recovery/verify') + .set('x-seamless-auth-delivery-mode', 'external') + .set('x-seamless-service-token', token) + .send({ code: started.body.delivery.token }); + + expect(res.status).toBe(200); + expect(sendNoticeEmail).not.toHaveBeenCalled(); + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'account_recovery_verified', + metadata: expect.objectContaining({ + notices: [ + { channel: 'email', status: 'not_sent_external_delivery' }, + { channel: 'phone', status: 'not_sent_external_delivery' }, + ], + }), + }), + ); + }); + + it('does not let a stranger suppress the notices with the delivery header alone', async () => { + await request(app).post('/recovery/start').send({ channel: 'email' }); + + const res = await request(app) + .post('/recovery/verify') + .set('x-seamless-auth-delivery-mode', 'external') + .send({ code: sentCode() }); + + expect(res.status).toBe(200); + expect(sendNoticeEmail).toHaveBeenCalled(); + expect(sendNoticeSms).toHaveBeenCalled(); + }); + + it('refuses a wrong code, and voids the request after five', async () => { + await request(app).post('/recovery/start').send({ channel: 'email' }); + const code = sentCode(); + const other = code === '123456' ? '654321' : '123456'; + + for (let i = 0; i < 5; i += 1) { + const res = await request(app).post('/recovery/verify').send({ code: other }); + expect(res.status).toBe(401); + expect(res.body).toEqual({ error: 'invalid_recovery_code' }); + } + + const res = await request(app).post('/recovery/verify').send({ code }); + + expect(res.status).toBe(401); + expect(rows[0].state).toBe('void'); + expect(sendNoticeEmail).not.toHaveBeenCalled(); + }); + + it('resumes an open request on a new attempt without notifying again', async () => { + const opened = await startAndVerify('email'); + vi.clearAllMocks(); + attemptId = 'attempt-2'; + + const resumed = await startAndVerify('email'); + + expect(resumed.status).toBe(200); + expect(resumed.body.completesAt).toBe(opened.body.completesAt); + expect(sendNoticeEmail).not.toHaveBeenCalled(); + }); +}); + +describe('GET /recovery/status', () => { + it('is pending until the delay passes, then ready', async () => { + await startAndVerify(); + + expect((await request(app).get('/recovery/status')).body.state).toBe('pending'); + + afterDelay(); + + expect((await request(app).get('/recovery/status')).body.state).toBe('ready'); + }); + + it('answers 404 for an attempt that opened no request', async () => { + await startAndVerify(); + attemptId = 'attempt-2'; + + const res = await request(app).get('/recovery/status'); + + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'recovery_not_found' }); + }); +}); + +describe('POST /recovery/complete', () => { + it('is refused until the delay has passed', async () => { + await startAndVerify(); + + const res = await request(app).post('/recovery/complete'); + + expect(res.status).toBe(409); + expect(res.body.error).toBe('recovery_not_ready'); + expect(Session.update).not.toHaveBeenCalled(); + expect(issueSessionAndRespondMock).not.toHaveBeenCalled(); + }); + + it('revokes every session, notifies, and issues a recovery session once ready', async () => { + await startAndVerify(); + vi.mocked(sendNoticeEmail).mockClear(); + afterDelay(); + + const res = await request(app).post('/recovery/complete'); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ recovery: true, nextStep: 'enroll_passkey' }); + expect(Session.update).toHaveBeenCalledWith( + expect.objectContaining({ revokedReason: 'account_recovery' }), + { where: { userId: 'user-1', revokedAt: null } }, + ); + expect(issueSessionAndRespondMock).toHaveBeenCalledWith( + expect.objectContaining({ method: 'account_recovery', recovery: true }), + ); + expect(sendNoticeEmail).toHaveBeenCalledWith( + 'owner@example.com', + expect.stringContaining('recovered'), + expect.any(String), + ); + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'account_recovery_completed', + metadata: expect.objectContaining({ revokedSessions: 2 }), + }), + ); + expect((await request(app).get('/recovery/status')).body.state).toBe('completed'); + expect((await request(app).post('/recovery/complete')).status).toBe(404); + }); + + it('answers 404 for an attempt that opened no request', async () => { + const res = await request(app).post('/recovery/complete'); + + expect(res.status).toBe(404); + }); +}); + +describe('POST /recovery/cancel', () => { + function cancelTokenFromNotice() { + const text = (sendNoticeEmail as any).mock.calls[0][2] as string; + return text.match(/#(\S+)/)![1]; + } + + it('cancels the open request from the link in the notice', async () => { + await startAndVerify(); + + const res = await request(app) + .post('/recovery/cancel') + .send({ cancelToken: cancelTokenFromNotice() }); + + expect(res.status).toBe(200); + expect(rows[0].state).toBe('cancelled'); + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ type: 'account_recovery_cancelled', userId: 'user-1' }), + ); + + expect((await request(app).get('/recovery/status')).body.state).toBe('cancelled'); + afterDelay(); + expect((await request(app).post('/recovery/complete')).status).toBe(404); + }); + + it('answers the same whether or not the token matched', async () => { + await startAndVerify(); + const token = cancelTokenFromNotice(); + + const unknown = await request(app).post('/recovery/cancel').send({ cancelToken: 'nope' }); + const matched = await request(app).post('/recovery/cancel').send({ cancelToken: token }); + const again = await request(app).post('/recovery/cancel').send({ cancelToken: token }); + + expect(unknown.status).toBe(200); + expect(unknown.body).toEqual(matched.body); + expect(again.body).toEqual(matched.body); + }); + + it('needs a token', async () => { + const res = await request(app).post('/recovery/cancel').send({}); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: 'cancel_token_required' }); + }); +}); + +describe('decoys', () => { + const DECOY = decoyPrincipalForSubject(decoySubjectFor('nobody@example.com', 'email')); + + async function answers() { + return { + start: await request(app).post('/recovery/start').send({ channel: 'phone' }), + verify: await request(app).post('/recovery/verify').send({ code: '123456' }), + status: await request(app).get('/recovery/status'), + complete: await request(app).post('/recovery/complete'), + }; + } + + it('answers every ephemeral route the way a real account without a request is answered', async () => { + principal = realUser({ phone: null, phoneVerified: false }); + const real = await answers(); + + decoy = true; + principal = DECOY as unknown as Record; + const fake = await answers(); + + for (const key of Object.keys(real) as Array) { + expect(fake[key].status).toBe(real[key].status); + expect(fake[key].body).toEqual(real[key].body); + } + expect(rows).toHaveLength(0); + }); + + it('never sends a code or a notice for a decoy', async () => { + decoy = true; + principal = DECOY as unknown as Record; + + await request(app).post('/recovery/start').send({ channel: 'email' }); + await request(app).post('/recovery/verify').send({ code: '123456' }); + + expect(sendOTPEmail).not.toHaveBeenCalled(); + expect(sendNoticeEmail).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/integration/recovery/recoveryCodes.spec.ts b/tests/integration/recovery/recoveryCodes.spec.ts index 62bea3f7..41646b08 100644 --- a/tests/integration/recovery/recoveryCodes.spec.ts +++ b/tests/integration/recovery/recoveryCodes.spec.ts @@ -15,6 +15,7 @@ import { getSystemConfig } from '../../../src/config/getSystemConfig.js'; import { RecoveryCode } from '../../../src/models/recoveryCodes.js'; import { Session } from '../../../src/models/sessions.js'; import { AuthEventService } from '../../../src/services/authEventService.js'; +import { sendNoticeEmail, sendNoticeSms } from '../../../src/services/messagingService.js'; import { buildSystemConfig } from '../../factories/systemConfigFactory.js'; let app: Application; @@ -98,6 +99,29 @@ describe('POST /recovery-codes/redeem', () => { ); }); + it('tells the verified addresses that a code was used', async () => { + await request(app).post('/recovery-codes/redeem').send({ code: 'ABCDE-FGH23' }); + + // The shared test principal has both addresses verified. + expect(sendNoticeEmail).toHaveBeenCalledWith( + 'test@example.com', + expect.stringContaining('recovery code was used'), + expect.any(String), + ); + expect(sendNoticeSms).toHaveBeenCalledWith('+14155552671', expect.any(String)); + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'recovery_code_used', + metadata: expect.objectContaining({ + notices: [ + { channel: 'email', status: 'sent' }, + { channel: 'phone', status: 'sent' }, + ], + }), + }), + ); + }); + it('refuses a wrong or used code without issuing a session', async () => { (RecoveryCode.update as any).mockResolvedValueOnce([0]); diff --git a/tests/integration/systemConfig/systemConfig.spec.ts b/tests/integration/systemConfig/systemConfig.spec.ts index 0ddd2f39..ad87376a 100644 --- a/tests/integration/systemConfig/systemConfig.spec.ts +++ b/tests/integration/systemConfig/systemConfig.spec.ts @@ -260,7 +260,7 @@ describe('GET /system-config/public', () => { const res = await request(app).get('/system-config/public'); expect(res.status).toBe(200); - expect(Object.keys(res.body)).toEqual(['loginMethods', 'recoveryCodes']); + expect(Object.keys(res.body)).toEqual(['loginMethods', 'recoveryCodes', 'accountRecovery']); }); it('says whether recovery codes can be used', async () => { @@ -272,4 +272,14 @@ describe('GET /system-config/public', () => { expect(res.body.recoveryCodes).toBe(false); }); + + it('says whether self-service account recovery is on, which it is not by default', async () => { + (getSystemConfig as any).mockResolvedValue(buildSystemConfig()); + expect((await request(app).get('/system-config/public')).body.accountRecovery).toBe(false); + + (getSystemConfig as any).mockResolvedValue( + buildSystemConfig({ account_recovery: { enabled: true, delay: '24h' } }), + ); + expect((await request(app).get('/system-config/public')).body.accountRecovery).toBe(true); + }); }); diff --git a/tests/postgres/accountRecovery.spec.ts b/tests/postgres/accountRecovery.spec.ts new file mode 100644 index 00000000..4bfc89f6 --- /dev/null +++ b/tests/postgres/accountRecovery.spec.ts @@ -0,0 +1,72 @@ +import { randomUUID } from 'crypto'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { AccountRecoveryRequest } from '../../src/models/accountRecoveryRequests.js'; +import { getSequelize, initializeModels } from '../../src/models/index.js'; +import { + cancelRecoveryByToken, + completeRecovery, + findRecoveryForAttempt, + startRecovery, + verifyRecovery, +} from '../../src/services/accountRecoveryService.js'; + +const userId = randomUUID(); + +beforeAll(async () => { + await initializeModels(); + await getSequelize().query( + `INSERT INTO users (id, email, roles, created_at, updated_at) + VALUES (:id, :email, '{user}', now(), now())`, + { replacements: { id: userId, email: `recovery-${userId}@example.com` } }, + ); +}); + +afterAll(async () => { + await getSequelize().close(); +}); + +// The service's where clauses (null checks, IN lists, conditional updates) as Postgres +// evaluates them, rather than as the in-memory double does. +describe('account recovery against Postgres', () => { + it('opens, refuses early completion, cancels and cascades with the user', async () => { + const code = await startRecovery(userId, 'email', 'attempt-1'); + const opened = await verifyRecovery(userId, 'attempt-1', code, 3600); + + expect(opened.outcome).toBe('opened'); + expect((await completeRecovery(userId, 'attempt-1')).outcome).toBe('not_ready'); + + const resumeCode = await startRecovery(userId, 'email', 'attempt-2'); + expect((await verifyRecovery(userId, 'attempt-2', resumeCode, 3600)).outcome).toBe('resumed'); + expect((await findRecoveryForAttempt(userId, 'attempt-2'))?.state).toBe('pending'); + + if (opened.outcome !== 'opened') throw new Error('not opened'); + expect((await cancelRecoveryByToken(opened.cancelToken))?.userId).toBe(userId); + expect((await findRecoveryForAttempt(userId, 'attempt-2'))?.state).toBe('cancelled'); + + await getSequelize().query('DELETE FROM users WHERE id = :id', { + replacements: { id: userId }, + }); + expect(await AccountRecoveryRequest.count({ where: { userId } })).toBe(0); + }); + + it('completes once the delay has passed and revokes sessions', async () => { + const owner = randomUUID(); + await getSequelize().query( + `INSERT INTO users (id, email, roles, created_at, updated_at) + VALUES (:id, :email, '{user}', now(), now())`, + { replacements: { id: owner, email: `recovery-${owner}@example.com` } }, + ); + const code = await startRecovery(owner, 'phone', 'attempt-3'); + await verifyRecovery(owner, 'attempt-3', code, 3600); + await AccountRecoveryRequest.update( + { completesAt: new Date(Date.now() - 1000) }, + { where: { userId: owner } }, + ); + + const result = await completeRecovery(owner, 'attempt-3'); + + expect(result.outcome).toBe('completed'); + expect((await findRecoveryForAttempt(owner, 'attempt-3'))?.state).toBe('completed'); + }); +}); diff --git a/tests/setup/mocks.ts b/tests/setup/mocks.ts index 6fe20a48..8b0046a8 100644 --- a/tests/setup/mocks.ts +++ b/tests/setup/mocks.ts @@ -89,6 +89,14 @@ vi.mock('../../src/models/recoveryCodes.js', () => ({ }, })); +vi.mock('../../src/models/accountRecoveryRequests.js', () => ({ + AccountRecoveryRequest: { + create: vi.fn(), + findOne: vi.fn(async () => null), + update: vi.fn(async () => [0]), + }, +})); + vi.mock('../../src/models/oauthIdentities.js', () => ({ OAuthIdentity: { create: vi.fn(), @@ -234,7 +242,9 @@ vi.mock('../../src/middleware/rateLimit.js', () => ({ dynamicSlowDown: (_req: any, _res: any, next: any) => next(), })); -vi.mock('../../src/utils/otp.js', () => ({ +vi.mock('../../src/utils/otp.js', async (importOriginal) => ({ + // The pure code helpers stay real; only the functions that store or send are doubles. + ...(await importOriginal()), generatePhoneOTP: vi.fn(), generateEmailOTP: vi.fn(), verifyPhoneOTP: vi.fn(), @@ -278,6 +288,8 @@ vi.mock('../../src/models/magicLinks.js', () => ({ vi.mock('../../src/services/messagingService.js', () => ({ sendMagicLinkEmail: vi.fn(), sendEnrollmentInviteEmail: vi.fn(), + sendOTPEmail: vi.fn(), + sendOTPSMS: vi.fn(), sendNoticeEmail: vi.fn(), sendNoticeSms: vi.fn(), })); diff --git a/tests/unit/models/models.spec.ts b/tests/unit/models/models.spec.ts index a7df3f0f..5c66e938 100644 --- a/tests/unit/models/models.spec.ts +++ b/tests/unit/models/models.spec.ts @@ -8,6 +8,7 @@ vi.unmock('../../../src/models/systemConfig.js'); vi.unmock('../../../src/models/credentials.js'); vi.unmock('../../../src/models/totpCredentials.js'); vi.unmock('../../../src/models/recoveryCodes.js'); +vi.unmock('../../../src/models/accountRecoveryRequests.js'); vi.unmock('../../../src/models/magicLinks.js'); vi.unmock('../../../src/models/oauthIdentities.js'); vi.unmock('../../../src/models/organizations.js'); diff --git a/tests/unit/routes/adapterManifest.routes.spec.ts b/tests/unit/routes/adapterManifest.routes.spec.ts index 6118267c..6404597b 100644 --- a/tests/unit/routes/adapterManifest.routes.spec.ts +++ b/tests/unit/routes/adapterManifest.routes.spec.ts @@ -59,6 +59,7 @@ describe('adapter manifest for the live routes', () => { 'POST /otp/generate-login-email-otp', 'POST /otp/generate-login-phone-otp', 'POST /otp/generate-phone-otp', + 'POST /recovery/start', 'POST /registration/phone', 'POST /registration/register', ]); @@ -80,6 +81,7 @@ describe('adapter manifest for the live routes', () => { 'POST /otp/verify-login-phone-otp preAuth -> session', 'POST /otp/verify-phone-otp registration -> session', 'POST /recovery-codes/redeem preAuth -> session', + 'POST /recovery/complete preAuth -> session', 'POST /refresh refresh -> session', 'POST /registration/register none -> registration', 'POST /totp/verify-login preAuth -> session', diff --git a/tests/unit/services/accountNotices.spec.ts b/tests/unit/services/accountNotices.spec.ts new file mode 100644 index 00000000..47db164d --- /dev/null +++ b/tests/unit/services/accountNotices.spec.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; + +import { sendAccountNotice, verifiedAddresses } from '../../../src/services/accountNotices.js'; +import { DeliveryError } from '../../../src/services/deliveryError.js'; +import { sendNoticeEmail, sendNoticeSms } from '../../../src/services/messagingService.js'; + +const notice = { subject: 'Subject', text: 'Body' }; +const owner = { + email: 'owner@example.com', + emailVerified: true, + phone: '+14155552671', + phoneVerified: true, +}; + +describe('verifiedAddresses', () => { + it('leaves out an address that is not verified', () => { + expect(verifiedAddresses({ ...owner, phoneVerified: false })).toEqual({ + email: 'owner@example.com', + phone: null, + }); + expect(verifiedAddresses({ email: null, phone: null })).toEqual({ email: null, phone: null }); + }); +}); + +describe('sendAccountNotice', () => { + it('sends to every verified address', async () => { + const results = await sendAccountNotice(owner, notice, { external: false }); + + expect(sendNoticeEmail).toHaveBeenCalledWith('owner@example.com', 'Subject', 'Body'); + expect(sendNoticeSms).toHaveBeenCalledWith('+14155552671', 'Body'); + expect(results).toEqual([ + { channel: 'email', status: 'sent' }, + { channel: 'phone', status: 'sent' }, + ]); + }); + + it('records a delivery failure without failing', async () => { + (sendNoticeSms as any).mockRejectedValueOnce(new DeliveryError('down', new Error('down'))); + + const results = await sendAccountNotice(owner, notice, { external: false }); + + expect(results).toEqual([ + { channel: 'email', status: 'sent' }, + { channel: 'phone', status: 'failed' }, + ]); + }); + + it('rethrows anything that is not a delivery failure', async () => { + (sendNoticeEmail as any).mockRejectedValueOnce(new Error('bug')); + + await expect(sendAccountNotice(owner, notice, { external: false })).rejects.toThrow('bug'); + }); + + it('sends nothing to a caller that owns delivery, and says so', async () => { + const results = await sendAccountNotice(owner, notice, { external: true }); + + expect(sendNoticeEmail).not.toHaveBeenCalled(); + expect(results).toEqual([ + { channel: 'email', status: 'not_sent_external_delivery' }, + { channel: 'phone', status: 'not_sent_external_delivery' }, + ]); + }); +}); diff --git a/tests/unit/services/accountRecoveryService.spec.ts b/tests/unit/services/accountRecoveryService.spec.ts new file mode 100644 index 00000000..e163726c --- /dev/null +++ b/tests/unit/services/accountRecoveryService.spec.ts @@ -0,0 +1,221 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { AccountRecoveryRequest } from '../../../src/models/accountRecoveryRequests.js'; +import { Session } from '../../../src/models/sessions.js'; +import { + cancelRecoveryByToken, + completeRecovery, + findRecoveryForAttempt, + MAX_RECOVERY_CODE_ATTEMPTS, + recoveryStatus, + startRecovery, + verifyRecovery, +} from '../../../src/services/accountRecoveryService.js'; +import { installAccountRecoveryStore } from '../../factories/accountRecoveryStore.js'; + +const HOUR = 3600; +let rows: ReturnType; + +beforeEach(() => { + rows = installAccountRecoveryStore(); + (Session.update as any).mockResolvedValue([3]); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +function wrong(code: string) { + return code === '000000' ? '111111' : '000000'; +} + +async function open(attemptId = 'attempt-1', channel: 'email' | 'phone' = 'email') { + const code = await startRecovery('user-1', channel, attemptId); + const result = await verifyRecovery('user-1', attemptId, code, HOUR); + if (result.outcome !== 'opened') throw new Error(`expected opened, got ${result.outcome}`); + return result; +} + +describe('startRecovery', () => { + it('stores a six-digit code only as a hash, bound to the attempt', async () => { + const code = await startRecovery('user-1', 'email', 'attempt-1'); + + expect(code).toMatch(/^\d{6}$/); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ state: 'awaiting_code', codeAttemptId: 'attempt-1' }); + expect(rows[0].codeHash).toMatch(/^sha256:/); + expect(rows[0].codeHash).not.toContain(code); + expect(+rows[0].codeExpiresAt - Date.now()).toBeGreaterThan(9 * 60 * 1000); + }); + + it('voids an earlier unverified code on the same attempt', async () => { + await startRecovery('user-1', 'email', 'attempt-1'); + await startRecovery('user-1', 'phone', 'attempt-1'); + + expect(rows.map((row) => row.state)).toEqual(['void', 'awaiting_code']); + }); + + it('does not cancel an open request, since anyone who knows the address can start one', async () => { + await open('attempt-1', 'email'); + await startRecovery('user-1', 'phone', 'attempt-2'); + + expect(rows[0].state).toBe('pending'); + }); +}); + +describe('verifyRecovery', () => { + it('opens the request with the configured delay and a hashed cancel token', async () => { + const before = Date.now(); + const { request, cancelToken } = await open(); + + expect(request.state).toBe('pending'); + expect(request.attemptId).toBe('attempt-1'); + expect(request.codeHash).toBeNull(); + expect(+request.completesAt! - +request.requestedAt!).toBe(HOUR * 1000); + expect(+request.requestedAt!).toBeGreaterThanOrEqual(before); + expect(cancelToken.length).toBeGreaterThan(20); + expect(request.cancelTokenHash).toMatch(/^[0-9a-f]{64}$/); + expect(request.cancelTokenHash).not.toContain(cancelToken); + expect(recoveryStatus(request).state).toBe('pending'); + }); + + it('refuses a code from another attempt', async () => { + const code = await startRecovery('user-1', 'email', 'attempt-1'); + + expect(await verifyRecovery('user-1', 'attempt-2', code, HOUR)).toEqual({ + outcome: 'invalid', + }); + }); + + it(`voids the request after ${MAX_RECOVERY_CODE_ATTEMPTS} wrong codes`, async () => { + const code = await startRecovery('user-1', 'email', 'attempt-1'); + + for (let i = 0; i < MAX_RECOVERY_CODE_ATTEMPTS; i += 1) { + expect((await verifyRecovery('user-1', 'attempt-1', wrong(code), HOUR)).outcome).toBe( + 'invalid', + ); + } + + expect(rows[0].state).toBe('void'); + expect((await verifyRecovery('user-1', 'attempt-1', code, HOUR)).outcome).toBe('invalid'); + }); + + it('refuses an expired code and voids it', async () => { + const code = await startRecovery('user-1', 'email', 'attempt-1'); + rows[0].codeExpiresAt = new Date(Date.now() - 1); + + expect((await verifyRecovery('user-1', 'attempt-1', code, HOUR)).outcome).toBe('invalid'); + expect(rows[0].state).toBe('void'); + }); + + it('refuses a try that lost the race to claim it', async () => { + const code = await startRecovery('user-1', 'email', 'attempt-1'); + (AccountRecoveryRequest.update as any).mockResolvedValueOnce([0]); + + expect((await verifyRecovery('user-1', 'attempt-1', code, HOUR)).outcome).toBe('invalid'); + expect(rows[0].state).toBe('awaiting_code'); + }); + + it('cancels an earlier open request once a new one is verified', async () => { + await open('attempt-1', 'email'); + const second = await open('attempt-2', 'phone'); + + expect(rows[0].state).toBe('cancelled'); + expect(second.cancelled).toBe(1); + expect(second.request.state).toBe('pending'); + }); + + it('resumes an open request on a new attempt through the same channel, keeping its clock', async () => { + const { request } = await open('attempt-1', 'email'); + const completesAt = request.completesAt; + const code = await startRecovery('user-1', 'email', 'attempt-2'); + + const result = await verifyRecovery('user-1', 'attempt-2', code, HOUR * 48); + + expect(result.outcome).toBe('resumed'); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ state: 'pending', attemptId: 'attempt-2', completesAt }); + }); + + it('leaves an open request intact when a resume code is guessed wrong too often', async () => { + await open('attempt-1', 'email'); + const code = await startRecovery('user-1', 'email', 'attempt-2'); + + for (let i = 0; i < MAX_RECOVERY_CODE_ATTEMPTS; i += 1) { + await verifyRecovery('user-1', 'attempt-2', wrong(code), HOUR); + } + + expect(rows[0]).toMatchObject({ state: 'pending', attemptId: 'attempt-1', codeHash: null }); + }); +}); + +describe('completeRecovery', () => { + it('refuses before the delay has passed', async () => { + await open(); + + const result = await completeRecovery('user-1', 'attempt-1'); + + expect(result.outcome).toBe('not_ready'); + expect(rows[0].state).toBe('pending'); + expect(Session.update).not.toHaveBeenCalled(); + }); + + it('completes a ready request and revokes every session', async () => { + await open(); + vi.useFakeTimers({ now: Date.now() + HOUR * 1000 + 1, toFake: ['Date'] }); + + const result = await completeRecovery('user-1', 'attempt-1'); + + expect(result).toMatchObject({ outcome: 'completed', revokedSessions: 3 }); + expect(rows[0].state).toBe('completed'); + expect(Session.update).toHaveBeenCalledWith( + expect.objectContaining({ revokedReason: 'account_recovery' }), + { where: { userId: 'user-1', revokedAt: null } }, + ); + expect(recoveryStatus(rows[0] as any).state).toBe('completed'); + expect((await completeRecovery('user-1', 'attempt-1')).outcome).toBe('not_found'); + }); + + it('finds nothing for an attempt that opened no request', async () => { + await open(); + + expect((await completeRecovery('user-1', 'attempt-2')).outcome).toBe('not_found'); + expect(await findRecoveryForAttempt('user-1', 'attempt-2')).toBeNull(); + }); + + it('refuses when another request completed it first', async () => { + await open(); + vi.useFakeTimers({ now: Date.now() + HOUR * 1000 + 1, toFake: ['Date'] }); + (AccountRecoveryRequest.update as any).mockResolvedValueOnce([0]); + + expect((await completeRecovery('user-1', 'attempt-1')).outcome).toBe('not_found'); + expect(Session.update).not.toHaveBeenCalled(); + }); +}); + +describe('cancelRecoveryByToken', () => { + it('cancels the open request the token belongs to', async () => { + const { cancelToken } = await open(); + + const cancelled = await cancelRecoveryByToken(cancelToken); + + expect(cancelled?.userId).toBe('user-1'); + expect(rows[0].state).toBe('cancelled'); + expect(recoveryStatus(rows[0] as any).state).toBe('cancelled'); + }); + + it('matches nothing for an unknown token or a request already closed', async () => { + const { cancelToken } = await open(); + + expect(await cancelRecoveryByToken('not-a-token')).toBeNull(); + await cancelRecoveryByToken(cancelToken); + expect(await cancelRecoveryByToken(cancelToken)).toBeNull(); + }); + + it('matches nothing when another request cancelled it first', async () => { + const { cancelToken } = await open(); + (AccountRecoveryRequest.update as any).mockResolvedValueOnce([0]); + + expect(await cancelRecoveryByToken(cancelToken)).toBeNull(); + }); +}); From 0a76ed80fbb5f2a00eb654ed50bd3af4c77d0474 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Thu, 22 Oct 2026 21:08:31 -0400 Subject: [PATCH 2/4] refactor(recovery): revoke with the shared revoke-all helper Completing recovery now also sets the user's revoke cutoff, so a refresh racing it cannot keep a session alive. --- src/services/accountRecoveryService.ts | 9 ++------- tests/unit/services/accountRecoveryService.spec.ts | 6 ++++++ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/services/accountRecoveryService.ts b/src/services/accountRecoveryService.ts index 47c03979..0141e396 100644 --- a/src/services/accountRecoveryService.ts +++ b/src/services/accountRecoveryService.ts @@ -12,9 +12,9 @@ import { RecoveryChannel, StoredRecoveryState, } from '../models/accountRecoveryRequests.js'; -import { Session } from '../models/sessions.js'; import { generateRandomPhoneOTP, hashOtpToken, otpHashMatches } from '../utils/otp.js'; import { hashSha256 } from '../utils/utils.js'; +import { revokeAllSessionsForUsers } from './sessionRevocation.js'; export const RECOVERY_CODE_TTL_MS = 10 * 60 * 1000; export const MAX_RECOVERY_CODE_ATTEMPTS = 5; @@ -223,12 +223,7 @@ export async function completeRecovery( return { outcome: 'not_found' }; } - // A bulk update rather than a per-session revoke, as the identity change path does. - // It becomes revokeAllSessionsForUsers once #488 lands. - const [revokedSessions] = await Session.update( - { revokedAt: now, revokedReason: 'account_recovery' }, - { where: { userId, revokedAt: null } }, - ); + const revokedSessions = await revokeAllSessionsForUsers(userId, 'account_recovery'); await request.reload(); diff --git a/tests/unit/services/accountRecoveryService.spec.ts b/tests/unit/services/accountRecoveryService.spec.ts index e163726c..3c00bd0c 100644 --- a/tests/unit/services/accountRecoveryService.spec.ts +++ b/tests/unit/services/accountRecoveryService.spec.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { AccountRecoveryRequest } from '../../../src/models/accountRecoveryRequests.js'; import { Session } from '../../../src/models/sessions.js'; +import { User } from '../../../src/models/users.js'; import { cancelRecoveryByToken, completeRecovery, @@ -172,6 +173,11 @@ describe('completeRecovery', () => { expect.objectContaining({ revokedReason: 'account_recovery' }), { where: { userId: 'user-1', revokedAt: null } }, ); + // The cutoff, so a refresh that raced completion cannot keep a session alive. + expect(User.update).toHaveBeenCalledWith( + { sessionsRevokedBefore: expect.any(Date) }, + { where: { id: 'user-1' } }, + ); expect(recoveryStatus(rows[0] as any).state).toBe('completed'); expect((await completeRecovery('user-1', 'attempt-1')).outcome).toBe('not_found'); }); From e6059078a35d7ca30b05d91879c48027da9b38f0 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 24 Oct 2026 08:15:46 -0400 Subject: [PATCH 3/4] feat(recovery): no self-service recovery in phishing-resistant-only mode It proves only an email address or phone number. Cancelling an open request keeps working. --- .changeset/account-recovery.md | 2 + docs/account-recovery.md | 4 ++ src/controllers/accountRecovery.ts | 12 ++--- src/controllers/decoyResponders.ts | 5 +-- src/controllers/systemConfig.ts | 6 ++- src/services/accountRecoveryService.ts | 12 +++++ .../recovery/accountRecovery.spec.ts | 45 ++++++++++++++++++- 7 files changed, 74 insertions(+), 12 deletions(-) diff --git a/.changeset/account-recovery.md b/.changeset/account-recovery.md index cf389b9e..ca58d222 100644 --- a/.changeset/account-recovery.md +++ b/.changeset/account-recovery.md @@ -12,3 +12,5 @@ Self-service account recovery through a verified email or phone, with a delay an - Migration: new `account_recovery_requests` table. See docs/account-recovery.md. + +Self-service recovery is unavailable in `phishing_resistant_only` mode, since it proves only an address; `GET /system-config/public` reports `accountRecovery: false` there. Cancelling an open request always works. diff --git a/docs/account-recovery.md b/docs/account-recovery.md index 850da6b8..c4e202a4 100644 --- a/docs/account-recovery.md +++ b/docs/account-recovery.md @@ -60,6 +60,10 @@ off, because enrolling a passkey is all it can do. ## Self-service recovery +Off unless `account_recovery.enabled` is true, and always off in `phishing_resistant_only` mode, since it +proves only an email address or phone number. Recovery codes are unaffected. Cancelling an open +request keeps working either way, so turning the feature off never stops an owner stopping one. + For a user with no codes, recovery through a verified email or phone. It cannot complete quietly: it waits out a delay, and every verified address on the account is told when it opens, with a link that cancels it. diff --git a/src/controllers/accountRecovery.ts b/src/controllers/accountRecovery.ts index 05f5a052..ba12bf30 100644 --- a/src/controllers/accountRecovery.ts +++ b/src/controllers/accountRecovery.ts @@ -14,6 +14,7 @@ import { cancelRecoveryByToken, completeRecovery, findRecoveryForAttempt, + isAccountRecoveryAvailable, RecoveryStatus, recoveryStatus, startRecovery, @@ -35,10 +36,11 @@ export const RECOVERY_CANCEL_MESSAGE = 'If the link was for an open recovery request, it has been cancelled.'; export async function accountRecoveryPolicy() { - const { account_recovery } = await getSystemConfig(); + const config = await getSystemConfig(); + const { account_recovery } = config; return { - enabled: account_recovery?.enabled === true, + enabled: isAccountRecoveryAvailable(config), delaySeconds: parseDurationToSeconds(account_recovery?.delay ?? '24h'), }; } @@ -234,11 +236,9 @@ export const getAccountRecoveryStatus = async (req: Request, res: Response) => { * Public on purpose: the token is the credential, and the owner may be signed out * everywhere. Answers the same whether or not it matched, so it is not an oracle. */ +// Not gated on the feature being available: turning recovery off, or switching to +// phishing-resistant-only mode, must not stop an owner cancelling a request already open. export const cancelAccountRecovery = async (req: Request, res: Response) => { - if (!(await accountRecoveryPolicy()).enabled) { - return refuseAccountRecoveryDisabled(res); - } - const cancelToken = req.body?.cancelToken; if (typeof cancelToken !== 'string' || cancelToken.length === 0) { diff --git a/src/controllers/decoyResponders.ts b/src/controllers/decoyResponders.ts index fc217d09..270e2e41 100644 --- a/src/controllers/decoyResponders.ts +++ b/src/controllers/decoyResponders.ts @@ -10,6 +10,7 @@ import { Request, Response } from 'express'; import { getSystemConfig } from '../config/getSystemConfig.js'; import { signEphemeralToken } from '../lib/token.js'; import { buildPrfAuthenticationExtensions } from '../lib/webauthnPrf.js'; +import { isAccountRecoveryAvailable } from '../services/accountRecoveryService.js'; import { AuthEventService } from '../services/authEventService.js'; import { decoyCredentialIdFor, decoyPrincipalForSubject } from '../services/decoyPrincipal.js'; import { @@ -334,9 +335,7 @@ export const decoyRedeemRecoveryCode = async (req: Request, res: Response) => { }; async function rejectAccountRecoveryDisabled(res: Response) { - const { account_recovery } = await getSystemConfig(); - - if (account_recovery?.enabled === true) { + if (isAccountRecoveryAvailable(await getSystemConfig())) { return false; } diff --git a/src/controllers/systemConfig.ts b/src/controllers/systemConfig.ts index 879f3417..7d60b0cd 100644 --- a/src/controllers/systemConfig.ts +++ b/src/controllers/systemConfig.ts @@ -14,6 +14,7 @@ import { SystemConfig } from '../models/systemConfig.js'; import { User } from '../models/users.js'; import { createPatchSystemConfigSchema } from '../schemas/systemConfig.patch.schema.js'; import { SystemConfigSchema } from '../schemas/systemConfig.schema.js'; +import { isAccountRecoveryAvailable } from '../services/accountRecoveryService.js'; import { AuthEventService } from '../services/authEventService.js'; import { getLoginPolicy } from '../services/loginPolicyService.js'; import { ServiceRequest } from '../types/types.js'; @@ -171,11 +172,12 @@ export const getAvailableRoles = async (_req: Request, res: Response) => { */ export const getPublicSystemConfig = async (_req: Request, res: Response) => { const { loginMethods } = await getLoginPolicy(); - const { recovery_codes, account_recovery } = await getSystemConfig(); + const config = await getSystemConfig(); + const { recovery_codes } = config; return res.json({ loginMethods, recoveryCodes: recovery_codes?.enabled !== false, - accountRecovery: account_recovery?.enabled === true, + accountRecovery: isAccountRecoveryAvailable(config), }); }; diff --git a/src/services/accountRecoveryService.ts b/src/services/accountRecoveryService.ts index 0141e396..d5eb9417 100644 --- a/src/services/accountRecoveryService.ts +++ b/src/services/accountRecoveryService.ts @@ -250,3 +250,15 @@ export async function cancelRecoveryByToken(cancelToken: string) { return cancelled === 1 ? request : null; } + +/** + * Whether self-service recovery can be used. Off in phishing-resistant-only mode as well + * as when disabled: it proves an email address or phone number, which a deployment that + * only trusts passkeys has decided is not enough. Recovery codes are unaffected. + */ +export function isAccountRecoveryAvailable(config: { + account_recovery?: { enabled?: boolean } | null; + phishing_resistant_only?: boolean; +}) { + return config.account_recovery?.enabled === true && config.phishing_resistant_only !== true; +} diff --git a/tests/integration/recovery/accountRecovery.spec.ts b/tests/integration/recovery/accountRecovery.spec.ts index ff45db53..d61100ae 100644 --- a/tests/integration/recovery/accountRecovery.spec.ts +++ b/tests/integration/recovery/accountRecovery.spec.ts @@ -109,7 +109,6 @@ describe('while account recovery is disabled', () => { ['post', '/recovery/start', { channel: 'email' }], ['post', '/recovery/verify', { code: '123456' }], ['get', '/recovery/status', undefined], - ['post', '/recovery/cancel', { cancelToken: 'token' }], ['post', '/recovery/complete', undefined], ] as const)('refuses %s %s, for a real account and a decoy alike', async (method, path, body) => { (getSystemConfig as any).mockResolvedValue(buildSystemConfig()); @@ -124,6 +123,38 @@ describe('while account recovery is disabled', () => { }); }); +describe('in phishing-resistant-only mode', () => { + it('refuses to start recovery, since it proves only an address', async () => { + (getSystemConfig as any).mockResolvedValue( + buildSystemConfig({ + account_recovery: { enabled: true, delay: '1h' }, + phishing_resistant_only: true, + }), + ); + + for (const asDecoy of [false, true]) { + decoy = asDecoy; + const res = await request(app).post('/recovery/start').send({ channel: 'email' }); + + expect(res.status).toBe(403); + expect(res.body).toEqual({ error: 'account_recovery_disabled' }); + } + }); + + it('is reported unavailable to sign-in screens', async () => { + (getSystemConfig as any).mockResolvedValue( + buildSystemConfig({ + account_recovery: { enabled: true, delay: '1h' }, + phishing_resistant_only: true, + }), + ); + + const res = await request(app).get('/system-config/public'); + + expect(res.body.accountRecovery).toBe(false); + }); +}); + describe('POST /recovery/start', () => { it('sends a code to the verified address on the channel', async () => { const res = await request(app).post('/recovery/start').send({ channel: 'phone' }); @@ -409,6 +440,18 @@ describe('POST /recovery/cancel', () => { expect((await request(app).post('/recovery/complete')).status).toBe(404); }); + it('still cancels after recovery is switched off or phishing-resistant-only is turned on', async () => { + await startAndVerify(); + enabled({ phishing_resistant_only: true }); + + const res = await request(app) + .post('/recovery/cancel') + .send({ cancelToken: cancelTokenFromNotice() }); + + expect(res.status).toBe(200); + expect(rows[0].state).toBe('cancelled'); + }); + it('answers the same whether or not the token matched', async () => { await startAndVerify(); const token = cancelTokenFromNotice(); From 3d785acf4db7ef4e9ab681f394e3ff22621ddeec Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 24 Oct 2026 08:34:08 -0400 Subject: [PATCH 4/4] fix(recovery): log the delivery channel as a fixed string --- src/controllers/accountRecovery.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/controllers/accountRecovery.ts b/src/controllers/accountRecovery.ts index ba12bf30..0524f695 100644 --- a/src/controllers/accountRecovery.ts +++ b/src/controllers/accountRecovery.ts @@ -133,7 +133,9 @@ export const startAccountRecovery = async (req: Request, res: Response) => { } catch (error) { if (!(error instanceof DeliveryError)) throw error; - logger.error(`Account recovery code delivery failed on ${channel}`); + logger.error( + `Account recovery code delivery failed on ${channel === 'email' ? 'email' : 'phone'}`, + ); await AuthEventService.log({ userId: user.id, type: 'account_recovery_failed',