Repository navigation
Conversation
…s email or phone Closes #475.
… feat/recovery-codes
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.
…e' into feat/account-recovery
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
# Conflicts: # src/controllers/admin.ts
Completing recovery now also sets the user's revoke cutoff, so a refresh racing it cannot keep a session alive.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 answers403 { error: 'account_recovery_disabled' }, decoys included.GET /system-config/publicaddsaccountRecovery.POST /recovery/start{ channel: 'email' | 'phone' }200 { message, delivery? }, the same whether or not the account has a verified address therePOST /recovery/verify{ code }200 AccountRecoveryStatus;401 invalid_recovery_codeGET /recovery/status200 AccountRecoveryStatus;404 recovery_not_foundPOST /recovery/cancel{ cancelToken }200 { message };400 cancel_token_requiredPOST /recovery/complete200 RecoverySessionResponse;409 recovery_not_ready;404 recovery_not_foundhashOtpToken, new constant-timeotpHashMatches), bound to the attempt (ephemeraljti), valid ten minutes, and sends it through the OTP email/SMS delivery, or returns it asdeliveryto a credentialed external-delivery caller.pendingwithcompletesAt = 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'ssendNoticeEmail/sendNoticeSms) with when it completes and a cancel link${frontend_url}/recovery/cancel#<token>.pendinguntilcompletesAt, thenready(derived, nothing has to run), orcancelled/completed.pendingandcompletesAt <= now) revokes every session with a bulk UPDATE (revokedReason: 'account_recovery'; it should switch torevokeAllSessionsForUsersonce fix: OAuth passkey requirement and race-free log out everywhere #488 lands), notifies both channels, and issuesissueSessionAndRespond({ method: 'account_recovery', recovery: true }).account_recoveryis added toSessionMethod.200message, verify401, status and complete404), storing and sending nothing.account_recovery_started,account_recovery_verified(with each notice's outcome),account_recovery_cancelled,account_recovery_completed(with sessions revoked), plusaccount_recovery_failedfor wrong codes and failed deliveries. All categorised aslogin.recovery_code_used.Design decisions worth a look
complete. The returning user signs in again and runsstart+verifyon 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.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.wantsExternalDelivery(header only), which is fine for an admin caller. Here a stranger could set it, so notices are skipped only whencanReturnExternalDeliverypasses (header plus service token), and the audit recordsnot_sent_external_delivery.Follow-ups
cancelToken, as the types allow) is not implemented yet.phishing_resistant_onlydoes not refuse email/phone recovery (it is opt-in); worth deciding whether it should.Migration
20261022160000-add-account-recovery-requests:account_recovery_requestswith camelCase columns, FK tousersON DELETE CASCADE, an index onuserIdand a unique index oncancelTokenHash. Run on a scratch database on local Postgres (roxtarget-db):db:migrate,db:migrate:undo(table gone),db:migrate, then dropped. A newtests/postgres/accountRecovery.spec.tsdrives 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: passnpm run lint: passnpx prettier --check .: passnpm run build: passnpm 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/startis a delivery route andPOST /recovery/complete preAuth -> session).Contract-affecting: new routes, new
accountRecoverypublic 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).