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
14 changes: 14 additions & 0 deletions .changeset/recovery-codes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'seamless-auth-api': minor
---

Recovery codes, and restricted recovery sessions (#398).

- `GET /recovery-codes` reports how many of the user's codes are left. `POST /recovery-codes` returns a new set of ten single-use codes (`XXXXX-XXXXX`), the only time they are shown, and invalidates the previous set; replacing a set needs a fresh step-up. Codes are stored as keyed hashes.
- `POST /recovery-codes/redeem`, with the pre-auth token from `/login`, signs in with a code. It is rate limited like an OTP, is not subject to account lockout, and works whatever `login_methods` or `phishing_resistant_only` say. Turned off with `recovery_codes.enabled`.
- The session a code issues is a recovery session: `recovery: true` and `nextStep: "enroll_passkey"` in the response, a `recovery: true` claim in the access token, and `403 enrollment_required` on every access route except passkey and TOTP enrollment, recovery codes, `GET /users/me` and logout, until a new factor is enrolled. Refreshing keeps the restriction; enrolling lifts it.
- `POST /login` lets an account continue to the code when it would otherwise be answered with a lock or a decoy (no passkey, locked or left with no allowed method) and holds an unused code.
- `GET /system-config/public` reports `recoveryCodes`.
- Migration: new `recovery_codes` table, and `sessions.recovery`.

See docs/account-recovery.md.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ docker-data/
# Lint / tooling cache
.eslintcache
docs/*
!docs/account-recovery.md
!docs/admin-operations.md
!docs/api-contract.md
!docs/architecture.md
Expand Down
8 changes: 8 additions & 0 deletions adapter-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,14 @@
"credential": "registration",
"issues": "session"
},
{ "method": "GET", "path": "/recovery-codes", "credential": "access" },
{ "method": "POST", "path": "/recovery-codes", "credential": "access" },
{
"method": "POST",
"path": "/recovery-codes/redeem",
"credential": "preAuth",
"issues": "session"
},
{ "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
57 changes: 57 additions & 0 deletions docs/account-recovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Account Recovery

How a user who lost their passkey, or every factor, gets back into their account without an
administrator.

## Recovery codes

A user can generate a set of ten single-use codes, shown once as `XXXXX-XXXXX`.

| Route | Auth | What it does |
| ----------------------------- | ------------------------------------ | -------------------------------------------------------------------------- |
| `GET /recovery-codes` | access | `{ enabled, remaining, total, createdAt }` |
| `POST /recovery-codes` | access, plus step-up if a set exists | Returns a new set (`201 { codes, createdAt }`) and invalidates the old one |
| `POST /recovery-codes/redeem` | pre-auth token from `/login` | `{ code }`, accepted with or without the dash and in either case |

- Codes are stored as HMAC-SHA256 hashes keyed with `REFRESH_TOKEN_LOOKUP_SECRET`, never in
plain text. A code has 50 bits, which an unkeyed hash from a database dump would not protect.
Rotating that secret therefore also voids every recovery code.
- Each code works once. Spending one is a conditional update, so two requests with the same
code cannot both sign in.
- Redeeming is limited by the OTP flow limits but is **not** subject to account lockout, and
does not count towards it: a code cannot be guessed under those limits, and lockout is
exactly what someone recovering may be locked out by (see GHSA-66gq-4pwg-33jq for why a
stranger must not be able to shut an owner out of their way back in).
- An account that `/login` would otherwise answer with a lock or a decoy (no passkey, and
either locked or left with no method the policy allows) continues with a real pre-auth token
when it holds an unused code, so the code can be redeemed. The method list it gets is the one
a decoy would show, so the answer does not single out accounts that hold codes.
- Recovery codes are not a login method, so no `login_methods` change can remove them. They
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`.

## Recovery sessions

Redeeming a code issues a **recovery session**: the response carries `recovery: true` and
`nextStep: "enroll_passkey"`, and the access token carries a `recovery: true` claim.

Until the user enrolls a new passkey or authenticator app, the API answers
`403 { "error": "enrollment_required" }` on every access route except:

- passkey enrollment (`/webauthn/register/start`, `/webauthn/register/finish`)
- TOTP status and enrollment (`/totp/status`, `/totp/enroll/start`, `/totp/enroll/verify`)
- recovery codes (`GET` and `POST /recovery-codes`)
- `GET /users/me`
- `/logout` and `/logout/all`

The restriction lives on the session row, so refreshing keeps it, and enrolling a factor
lifts it on the very next request. The access token keeps its `recovery` claim until the
client refreshes, so a client should refresh after enrollment. Server adapters refuse a
`recovery: true` token in their `requireAuth` by default, so an application does not treat a
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.
9 changes: 9 additions & 0 deletions docs/security-posture.md
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,15 @@ What remains: a stranger with several network locations can still fill the per-a
window, and the owner then waits it out, as with lockout. Wrong codes from every attempt still
count toward lockout together, because lockout is per account (see [Account lockout](#account-lockout)).

## Recovery codes and lockout

Redeeming a recovery code (`POST /recovery-codes/redeem`) is rate limited like an OTP but is
not subject to lockout and does not count towards it. Codes carry 50 bits, so guessing one
under the flow limits is not practical, while counting failures would let anyone who knows an
address lock its owner out of the one way back in that does not need an administrator. The
session a code issues is restricted to enrolling a new factor; see
[docs/account-recovery.md](./account-recovery.md).

## Audit write failure

**Posture: the trail may be lost, no security control may be.**
Expand Down
Loading
Loading