Skip to content

feat: self-service account recovery with a delay and notices - #493

Open
Bccorb wants to merge 15 commits into
mainfrom
feat/account-recovery
Open

Bccorb wants to merge 15 commits into
mainfrom
feat/account-recovery

Conversation

@Bccorb

@Bccorb Bccorb commented Oct 22, 2026

Copy link
Copy Markdown
Contributor

Closes #399. Depends on #491 (recovery codes and recovery sessions, which carries #482 types 0.30.0 and #489 auth secrets) and #485 (account notice functions); this branch merges both, so the diff shrinks once they land.

Behaviour

Self-service recovery through a verified email or phone, off by default (account_recovery: { enabled: false, delay: '24h' }). While off, every /recovery/* route answers 403 { error: 'account_recovery_disabled' }, decoys included. GET /system-config/public adds accountRecovery.

Route Auth Request Response
POST /recovery/start ephemeral (adapter: preAuth, delivery) { channel: 'email' | 'phone' } 200 { message, delivery? }, the same whether or not the account has a verified address there
POST /recovery/verify ephemeral, same attempt { code } 200 AccountRecoveryStatus; 401 invalid_recovery_code
GET /recovery/status ephemeral, same attempt 200 AccountRecoveryStatus; 404 recovery_not_found
POST /recovery/cancel public { cancelToken } uniform 200 { message }; 400 cancel_token_required
POST /recovery/complete ephemeral, same attempt (adapter: preAuth, issues session) 200 RecoverySessionResponse; 409 recovery_not_ready; 404 recovery_not_found
  • Start stores a six-digit code hashed with the OTP helpers (hashOtpToken, new constant-time otpHashMatches), bound to the attempt (ephemeral jti), valid ten minutes, and sends it through the OTP email/SMS delivery, or returns it as delivery to a credentialed external-delivery caller.
  • Verify claims each try with a conditional update before comparing, so parallel requests cannot exceed five; the fifth wrong code voids the request. The right code makes it pending with completesAt = now + delay, creates a random cancel token stored as a SHA-256 hash, cancels any other open request for the user, and notifies the verified email and phone (via feat(admin): revoke sessions and notify when an admin changes a user's email or phone #485's sendNoticeEmail/sendNoticeSms) with when it completes and a cancel link ${frontend_url}/recovery/cancel#<token>.
  • Status is pending until completesAt, then ready (derived, nothing has to run), or cancelled/completed.
  • Complete (conditional on pending and completesAt <= now) revokes every session with a bulk UPDATE (revokedReason: 'account_recovery'; it should switch to revokeAllSessionsForUsers once fix: OAuth passkey requirement and race-free log out everywhere #488 lands), notifies both channels, and issues issueSessionAndRespond({ method: 'account_recovery', recovery: true }). account_recovery is added to SessionMethod.
  • Cancel looks the token up by hash, so it is not a timing oracle, and answers the same whether or not it matched.
  • Lockout does not block any of this; wrong codes are counted per request. All ephemeral routes use the OTP flow limiters, cancel the per-IP one.
  • Every ephemeral route has a decoy responder answering exactly like a real account with no verified address and no open request (start 200 message, verify 401, status and complete 404), storing and sending nothing.
  • Audit: account_recovery_started, account_recovery_verified (with each notice's outcome), account_recovery_cancelled, account_recovery_completed (with sessions revoked), plus account_recovery_failed for wrong codes and failed deliveries. All categorised as login.
  • The "a recovery code was used" notice deferred by feat: recovery codes and restricted recovery sessions #491 now goes to the verified email and phone on redeem, with outcomes in recovery_code_used.

Design decisions worth a look

  • Coming back after the delay. A pre-auth token lives 300 seconds and the delay is at least an hour, so "same attempt" alone could never reach complete. The returning user signs in again and runs start + verify on the same channel: that resumes the open request on the new attempt with its clock unchanged and no new notice. A different channel opens a fresh request with its own delay and notices.
  • Only a verified code changes anything. Anyone who knows an address can call start, so start never cancels the owner's open request; earlier requests are cancelled only when a new one is verified. Five wrong codes on a resume void that code and leave the open request intact.
  • Notices cannot be suppressed by a header. feat(admin): revoke sessions and notify when an admin changes a user's email or phone #485 skips notices on wantsExternalDelivery (header only), which is fine for an admin caller. Here a stranger could set it, so notices are skipped only when canReturnExternalDelivery passes (header plus service token), and the audit records not_sent_external_delivery.

Follow-ups

  • Cancelling from an access session (no cancelToken, as the types allow) is not implemented yet.
  • The issue asks to tell "every active session"; there is no in-session notice channel, so only the verified email and phone are told.
  • External-delivery deployments get no recovery notices at all until the delivery contract gains a notice kind.
  • Completion does not remove existing passkeys or TOTP; the recovery session only adds a factor.
  • phishing_resistant_only does not refuse email/phone recovery (it is opt-in); worth deciding whether it should.

Migration

20261022160000-add-account-recovery-requests: account_recovery_requests with camelCase columns, FK to users ON DELETE CASCADE, an index on userId and a unique index on cancelTokenHash. Run on a scratch database on local Postgres (roxtarget-db): db:migrate, db:migrate:undo (table gone), db:migrate, then dropped. A new tests/postgres/accountRecovery.spec.ts drives the service against it (open, early completion refused, resume, cancel, completion with session revoke, cascade on user delete); the Postgres project passed (3 files, 16 tests).

Checks

  • npm run typecheck: pass
  • npm run lint: pass
  • npx prettier --check .: pass
  • npm run build: pass
  • npm run coverage: 148 files, 2081 tests passed (1 todo); statements 98.81%, branches 95.08%, functions 99.05%, lines 99.06%
  • npm run generate:api: output committed (adapter manifest gains the five /recovery/* routes; POST /recovery/start is a delivery route and POST /recovery/complete preAuth -> session).

Contract-affecting: new routes, new accountRecovery public config field and new audit event types, all from types 0.30.0. Adapters pick the ephemeral routes up from the manifest; the react SDK needs the screens (cancel page at /recovery/cancel, reading the fragment).

A user can generate single-use codes and sign in with one; the session it issues can only
enroll a new factor until one is enrolled.
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.
} catch (error) {
if (!(error instanceof DeliveryError)) throw error;

logger.error(`Account recovery code delivery failed on ${channel}`);
A locked account with no passkey, or one the policy leaves with no method, continues with a
pre-auth token when it holds an unused recovery code.
…hange-notice

# Conflicts:
#	src/controllers/admin.ts
# Conflicts:
#	docs/security-posture.md
#	resources/coverage-badge.svg
#	src/controllers/authentication.ts
#	src/services/sessionIssuance.ts
Completing recovery now also sets the user's revoke cutoff, so a refresh racing it cannot keep
a session alive.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: self-service account recovery with a delay and notifications

2 participants