Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/account-recovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'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.

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.
10 changes: 10 additions & 0 deletions adapter-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
82 changes: 80 additions & 2 deletions docs/account-recovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,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

Expand All @@ -55,3 +57,79 @@ 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

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.

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#<token>`; 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).
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading