From 7ae63d1933d5fe6b3e5329f9595c745a2cd611a8 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Thu, 22 Oct 2026 15:17:04 -0400 Subject: [PATCH 1/2] feat: recovery codes and restricted recovery sessions 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. --- .changeset/recovery-codes.md | 13 + .gitignore | 1 + adapter-manifest.json | 8 + docs/account-recovery.md | 53 +++ docs/security-posture.md | 9 + openapi.json | 309 ++++++++++++++ resources/coverage-badge.svg | 14 +- src/controllers/authentication.ts | 7 +- src/controllers/decoyResponders.ts | 12 + src/controllers/recoveryCodes.ts | 113 +++++ src/controllers/systemConfig.ts | 3 +- src/controllers/totp.ts | 6 + src/controllers/webauthn.ts | 5 + src/generated/api.ts | 402 ++++++++++++++++++ src/lib/authEventCategories.ts | 2 + src/lib/defineRoute.ts | 11 +- src/lib/token.ts | 3 + src/middleware/attachAuthMiddleware.ts | 7 +- src/middleware/verifyBearerAuth.ts | 10 + .../20261022140000-add-recovery-codes.cjs | 44 ++ src/models/recoveryCodes.ts | 63 +++ src/models/sessions.ts | 8 + src/routes/auth.routes.ts | 2 + src/routes/recoveryCodes.routes.ts | 89 ++++ src/routes/totp.routes.ts | 3 + src/routes/users.routes.ts | 1 + src/routes/webauthn.routes.ts | 2 + src/schemas/authEvent.types.ts | 3 + src/schemas/recoveryCodes.ts | 12 + src/services/recoveryCodeService.ts | 90 ++++ src/services/sessionIssuance.ts | 19 +- src/services/sessionService.ts | 21 + src/types/types.ts | 2 + .../authentication/authentication.spec.ts | 1 + .../recovery/recoveryCodes.spec.ts | 129 ++++++ .../systemConfig/systemConfig.spec.ts | 14 +- tests/setup/mocks.ts | 11 + tests/unit/controllers/authentication.spec.ts | 4 +- tests/unit/controllers/totp.spec.ts | 32 ++ .../middleware/attachAuthMiddleware.spec.ts | 4 +- .../unit/middleware/verifyBearerAuth.spec.ts | 32 ++ tests/unit/models/models.spec.ts | 1 + .../routes/adapterManifest.routes.spec.ts | 1 + .../unit/services/recoveryCodeService.spec.ts | 90 ++++ .../unit/services/sessionIssueService.spec.ts | 34 +- tests/unit/services/sessionService.spec.ts | 21 + 46 files changed, 1698 insertions(+), 23 deletions(-) create mode 100644 .changeset/recovery-codes.md create mode 100644 docs/account-recovery.md create mode 100644 src/controllers/recoveryCodes.ts create mode 100644 src/migrations/20261022140000-add-recovery-codes.cjs create mode 100644 src/models/recoveryCodes.ts create mode 100644 src/routes/recoveryCodes.routes.ts create mode 100644 src/schemas/recoveryCodes.ts create mode 100644 src/services/recoveryCodeService.ts create mode 100644 tests/integration/recovery/recoveryCodes.spec.ts create mode 100644 tests/unit/services/recoveryCodeService.spec.ts diff --git a/.changeset/recovery-codes.md b/.changeset/recovery-codes.md new file mode 100644 index 00000000..a7f3d466 --- /dev/null +++ b/.changeset/recovery-codes.md @@ -0,0 +1,13 @@ +--- +'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. +- `GET /system-config/public` reports `recoveryCodes`. +- Migration: new `recovery_codes` table, and `sessions.recovery`. + +See docs/account-recovery.md. diff --git a/.gitignore b/.gitignore index d223d527..b0dbb7d1 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/adapter-manifest.json b/adapter-manifest.json index d9f0a976..c1168a42 100644 --- a/adapter-manifest.json +++ b/adapter-manifest.json @@ -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" }, diff --git a/docs/account-recovery.md b/docs/account-recovery.md new file mode 100644 index 00000000..695a095c --- /dev/null +++ b/docs/account-recovery.md @@ -0,0 +1,53 @@ +# 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). +- 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. diff --git a/docs/security-posture.md b/docs/security-posture.md index f4393303..501568e6 100644 --- a/docs/security-posture.md +++ b/docs/security-posture.md @@ -485,6 +485,15 @@ factor. Enrolling a passkey keeps a user able to sign in through a lock someone The counter lives in Postgres, so every instance of a deployment sees the same count. When it cannot be read, the account is treated as locked (see [An unknown count means locked](#an-unknown-count-means-locked)). +## 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.** diff --git a/openapi.json b/openapi.json index b5e8bc7f..18c3da03 100644 --- a/openapi.json +++ b/openapi.json @@ -3473,6 +3473,9 @@ "refresh_token_suspicious", "registration_failed", "registration_success", + "recovery_code_failed", + "recovery_code_used", + "recovery_codes_generated", "registration_suspicious", "request_suspicious", "service_token_failed", @@ -3552,6 +3555,9 @@ "refresh_token_suspicious", "registration_failed", "registration_success", + "recovery_code_failed", + "recovery_code_used", + "recovery_codes_generated", "registration_suspicious", "request_suspicious", "service_token_failed", @@ -11319,6 +11325,309 @@ } } }, + "/recovery-codes/": { + "get": { + "summary": "Recovery code status", + "description": "How many of the current set are unused, and when the set was made.", + "tags": ["Recovery"], + "security": [{ "bearerAuth": [] }], + "responses": { + "200": { + "description": "HTTP 200", + "content": { + "application/json": { + "example": { "enabled": true, "remaining": 0, "total": 0, "createdAt": null }, + "schema": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "remaining": { "type": "integer", "minimum": 0 }, + "total": { "type": "integer", "minimum": 0 }, + "createdAt": { "type": "string", "nullable": true, "format": "date-time" } + }, + "required": ["enabled", "remaining", "total", "createdAt"] + } + } + } + }, + "401": { + "description": "HTTP 401", + "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"] + } + } + } + } + } + }, + "post": { + "summary": "Generate recovery codes", + "description": "Returns a new set of single-use codes, the only time they are shown, and invalidates the previous set. Replacing an existing set needs a fresh step-up.", + "tags": ["Recovery"], + "security": [{ "bearerAuth": [] }], + "responses": { + "201": { + "description": "HTTP 201", + "content": { + "application/json": { + "example": { "message": "string", "codes": [null], "createdAt": null }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "codes": { + "type": "array", + "items": { "type": "string", "pattern": "^[A-Za-z2-7]{5}-?[A-Za-z2-7]{5}$" }, + "minItems": 1 + }, + "createdAt": { "type": "string", "nullable": true, "format": "date-time" } + }, + "required": ["message", "codes", "createdAt"] + } + } + } + }, + "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-codes/redeem": { + "post": { + "summary": "Sign in with a recovery code", + "description": "Spends one code and issues a recovery session (`recovery: true`), which can only enroll a new passkey or authenticator app, manage recovery codes, read the user and sign out until a factor is enrolled. Not subject to account lockout.", + "tags": ["Recovery"], + "security": [{ "bearerAuth": [] }], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { "type": "string", "pattern": "^[A-Za-z2-7]{5}-?[A-Za-z2-7]{5}$" } + }, + "required": ["code"] + } + } + } + }, + "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"] + } + } + } + }, + "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"] + } + } + } + } + } + } + }, "/registration/register": { "post": { "summary": "Register a new user", diff --git a/resources/coverage-badge.svg b/resources/coverage-badge.svg index c931b02b..8bd6c88a 100644 --- a/resources/coverage-badge.svg +++ b/resources/coverage-badge.svg @@ -1,5 +1,5 @@ - - coverage: 99.2% + + coverage: 99% @@ -7,17 +7,17 @@ - + - - + + coverage coverage - 99.2% - 99.2% + 99% + 99% diff --git a/src/controllers/authentication.ts b/src/controllers/authentication.ts index dac23938..c8d87017 100644 --- a/src/controllers/authentication.ts +++ b/src/controllers/authentication.ts @@ -609,6 +609,8 @@ export const refreshSession = async (req: Request, res: Response) => { expiresAt, idleExpiresAt, chainStartedAt, + // Refreshing does not lift the restriction; enrolling a new factor does. + recovery: session.recovery === true, }); // The read, the reuse check and this link are separate statements, so two refreshes @@ -648,7 +650,9 @@ export const refreshSession = async (req: Request, res: Response) => { return res.status(401).json({ error: 'refresh_token_reused' }); } - const token = await signAccessToken(newSession.id, user.id, user.roles, session.organizationId); + const token = await signAccessToken(newSession.id, user.id, user.roles, session.organizationId, { + recovery: session.recovery === true, + }); if (token && newRefreshToken) { logger.info( @@ -670,6 +674,7 @@ export const refreshSession = async (req: Request, res: Response) => { // What is left of the chain, not a full lifetime, so a caller sizing a cookie or a // keychain entry from it does not outlive the session. refreshTtl: Math.floor((expiresAt.getTime() - now.getTime()) / 1000), + ...(session.recovery === true ? { recovery: true } : {}), }); } diff --git a/src/controllers/decoyResponders.ts b/src/controllers/decoyResponders.ts index 30f0d741..13acf7a5 100644 --- a/src/controllers/decoyResponders.ts +++ b/src/controllers/decoyResponders.ts @@ -314,3 +314,15 @@ export const decoyVerifyTotpLogin = async (req: Request, res: Response) => { return res.status(401).json({ error: 'totp_verification_failed' }); }; + +export const decoyRedeemRecoveryCode = async (req: Request, res: Response) => { + const { recovery_codes } = await getSystemConfig(); + + if (recovery_codes?.enabled === false) { + return res.status(403).json({ error: 'recovery_codes_disabled' }); + } + + await logDecoy(req, 'recovery-codes:redeem'); + + return res.status(401).json({ error: 'invalid_recovery_code' }); +}; diff --git a/src/controllers/recoveryCodes.ts b/src/controllers/recoveryCodes.ts new file mode 100644 index 00000000..dcd197c1 --- /dev/null +++ b/src/controllers/recoveryCodes.ts @@ -0,0 +1,113 @@ +/* + * 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 { requireStepUp } from '../middleware/requireStepUp.js'; +import { AuthEventService } from '../services/authEventService.js'; +import { + generateRecoveryCodes, + getRecoveryCodeStatus, + redeemRecoveryCode, +} from '../services/recoveryCodeService.js'; +import { issueSessionAndRespond } from '../services/sessionIssuance.js'; +import { AuthenticatedRequest } from '../types/types.js'; + +async function recoveryCodesEnabled() { + const { recovery_codes } = await getSystemConfig(); + return recovery_codes?.enabled !== false; +} + +function refuseDisabled(res: Response) { + return res.status(403).json({ error: 'recovery_codes_disabled' }); +} + +export const getRecoveryCodes = async (req: Request, res: Response) => { + const { user } = req as AuthenticatedRequest; + const status = await getRecoveryCodeStatus(user.id); + + return res.json({ + enabled: await recoveryCodesEnabled(), + remaining: status.remaining, + total: status.total, + createdAt: status.createdAt ? status.createdAt.toISOString() : null, + }); +}; + +async function issueRecoveryCodes(req: Request, res: Response) { + const { user } = req as AuthenticatedRequest; + const { codes, createdAt } = await generateRecoveryCodes(user.id); + + await AuthEventService.log({ userId: user.id, type: 'recovery_codes_generated', req }); + + return res.status(201).json({ + message: 'Success', + codes, + createdAt: createdAt.toISOString(), + }); +} + +/** + * A first set needs only the session. Replacing a set needs a fresh step-up, since it + * invalidates codes the user may be relying on and hands whoever holds the session a way + * back into the account. + */ +export const createRecoveryCodes = async (req: Request, res: Response) => { + if (!(await recoveryCodesEnabled())) { + return refuseDisabled(res); + } + + const { user } = req as AuthenticatedRequest; + const { total } = await getRecoveryCodeStatus(user.id); + + if (total === 0) { + return issueRecoveryCodes(req, res); + } + + return requireStepUp()(req, res, () => issueRecoveryCodes(req, res)); +}; + +/** + * Signs in with a recovery code. Not subject to lockout, like a passkey: a code cannot be + * guessed under the flow rate limits, and lockout is exactly what someone recovering may + * be locked out by. The session it issues is restricted until a new factor is enrolled. + */ +export const redeemRecoveryCodeLogin = async (req: Request, res: Response) => { + if (!(await recoveryCodesEnabled())) { + return refuseDisabled(res); + } + + const { user } = req as AuthenticatedRequest; + const redeemed = await redeemRecoveryCode(user.id, req.body.code); + + if (!redeemed) { + await AuthEventService.log({ userId: user.id, type: 'recovery_code_failed', req }); + return res.status(401).json({ error: 'invalid_recovery_code' }); + } + + const { remaining } = await getRecoveryCodeStatus(user.id); + + await AuthEventService.log({ + userId: user.id, + type: 'recovery_code_used', + req, + metadata: { remaining }, + }); + + await issueSessionAndRespond({ + method: 'recovery_code', + recovery: true, + user: { + id: user.id, + email: user.email, + phone: user.phone, + roles: user.roles ?? [], + }, + req, + res, + }); +}; diff --git a/src/controllers/systemConfig.ts b/src/controllers/systemConfig.ts index 0eaede2d..8977a029 100644 --- a/src/controllers/systemConfig.ts +++ b/src/controllers/systemConfig.ts @@ -171,6 +171,7 @@ 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(); - return res.json({ loginMethods }); + return res.json({ loginMethods, recoveryCodes: recovery_codes?.enabled !== false }); }; diff --git a/src/controllers/totp.ts b/src/controllers/totp.ts index 93083276..0c7a888f 100644 --- a/src/controllers/totp.ts +++ b/src/controllers/totp.ts @@ -12,6 +12,7 @@ import { AuthEventService } from '../services/authEventService.js'; import { rejectIfUserLocked } from '../services/lockoutPolicyService.js'; import { isPasskeyRequiredForUser } from '../services/loginPolicyService.js'; import { issueSessionAndRespond } from '../services/sessionIssuance.js'; +import { liftRecoveryRestriction } from '../services/sessionService.js'; import { recordStepUpVerification, serializeStepUpStatus } from '../services/stepUpService.js'; import { disableTotp, @@ -125,6 +126,11 @@ export const verifyCurrentTotpEnrollment = async (req: Request, res: Response) = req, }); + const authReq = req as AuthenticatedRequest; + if (authReq.recoverySession) { + await liftRecoveryRestriction(authReq.sessionId); + } + return res.json({ message: 'Success' }); }; diff --git a/src/controllers/webauthn.ts b/src/controllers/webauthn.ts index d2012e93..a969789c 100644 --- a/src/controllers/webauthn.ts +++ b/src/controllers/webauthn.ts @@ -34,6 +34,7 @@ import { hasMetadataStatement, } from '../services/metadataServiceBootstrap.js'; import { issueSessionAndRespond } from '../services/sessionIssuance.js'; +import { liftRecoveryRestriction } from '../services/sessionService.js'; import { consumeChallenge, issueChallenge } from '../services/webauthnChallengeService.js'; import { AuthenticatedRequest } from '../types/types.js'; import getLogger from '../utils/logger.js'; @@ -378,6 +379,10 @@ const verifyWebAuthnRegistration = async (req: Request, res: Response) => { metadata: {}, }); + if (authReq.recoverySession) { + await liftRecoveryRestriction(authReq.sessionId); + } + // Enrollment is not a sign-in. The caller reached here with an access session, so // issuing another one would leave the first live and unrevoked, and count against // max_concurrent_sessions, which can evict the user's other devices. `verified` and diff --git a/src/generated/api.ts b/src/generated/api.ts index 085dfd7e..8f14fc79 100644 --- a/src/generated/api.ts +++ b/src/generated/api.ts @@ -3165,6 +3165,9 @@ export interface paths { | 'refresh_token_suspicious' | 'registration_failed' | 'registration_success' + | 'recovery_code_failed' + | 'recovery_code_used' + | 'recovery_codes_generated' | 'registration_suspicious' | 'request_suspicious' | 'service_token_failed' @@ -3238,6 +3241,9 @@ export interface paths { | 'refresh_token_suspicious' | 'registration_failed' | 'registration_success' + | 'recovery_code_failed' + | 'recovery_code_used' + | 'recovery_codes_generated' | 'registration_suspicious' | 'request_suspicious' | 'service_token_failed' @@ -11408,6 +11414,402 @@ export interface paths { patch?: never; trace?: never; }; + '/recovery-codes/': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Recovery code status + * @description How many of the current set are unused, and when the set was made. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description HTTP 200 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "enabled": true, + * "remaining": 0, + * "total": 0, + * "createdAt": null + * } + */ + 'application/json': { + enabled: boolean; + remaining: number; + total: number; + /** Format: date-time */ + createdAt: string | null; + }; + }; + }; + /** @description HTTP 401 */ + 401: { + 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; + /** + * Generate recovery codes + * @description Returns a new set of single-use codes, the only time they are shown, and invalidates the previous set. Replacing an existing set needs a fresh step-up. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description HTTP 201 */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "codes": [ + * null + * ], + * "createdAt": null + * } + */ + 'application/json': { + message: string; + codes: string[]; + /** Format: date-time */ + createdAt: 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 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-codes/redeem': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Sign in with a recovery code + * @description Spends one code and issues a recovery session (`recovery: true`), which can only enroll a new passkey or authenticator app, manage recovery codes, read the user and sign out until a factor is enrolled. Not subject to account lockout. + */ + 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 { + * "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 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; + }; '/registration/register': { parameters: { query?: never; diff --git a/src/lib/authEventCategories.ts b/src/lib/authEventCategories.ts index b57cf5f9..2c744e07 100644 --- a/src/lib/authEventCategories.ts +++ b/src/lib/authEventCategories.ts @@ -38,6 +38,8 @@ const PREFIX_CATEGORIES: ReadonlyArray<[string, AuthEventCategory]> = [ ['registration_', 'registration'], ['login_', 'login'], ['logout_', 'login'], + // Signing in with a recovery code, and managing the set, are sign-in events. + ['recovery_code', 'login'], ['refresh_token_', 'token'], ['bearer_token_', 'token'], ['service_token_', 'token'], diff --git a/src/lib/defineRoute.ts b/src/lib/defineRoute.ts index 7e6f608e..3e4d5a28 100644 --- a/src/lib/defineRoute.ts +++ b/src/lib/defineRoute.ts @@ -37,6 +37,13 @@ interface DefineRouteOptions { auth?: AuthTokenType | undefined; + /** + * Accepts an access session a recovery issued (`recovery: true`). Every other access + * route answers `403 enrollment_required` to one, so a half-recovered account can only + * enroll a new factor, manage recovery codes, read itself and sign out. + */ + allowRecoverySession?: boolean; + /** * What a server adapter does with this route. Required on public and ephemeral * routes; access routes default to an exposed passthrough. See `adapterManifest.ts`. @@ -380,7 +387,9 @@ export function defineRoute( const middlewareStack: RequestHandler[] = []; if (auth) { - middlewareStack.push(attachAuthMiddleware(auth)); + middlewareStack.push( + attachAuthMiddleware(auth, { allowRecoverySession: options.allowRecoverySession }), + ); } if (options.middleware) { diff --git a/src/lib/token.ts b/src/lib/token.ts index fe73635b..6320ec7c 100644 --- a/src/lib/token.ts +++ b/src/lib/token.ts @@ -29,6 +29,7 @@ export async function signAccessToken( userId: string, roles?: string[], organizationId?: string | null, + { recovery = false }: { recovery?: boolean } = {}, ) { const { kid, privateKeyPem } = await getSigningKey(); @@ -42,6 +43,8 @@ export async function signAccessToken( typ: 'access', roles, ...(organizationId ? { org_id: organizationId } : {}), + // Lets a server adapter refuse a half-recovered account without asking the API. + ...(recovery ? { recovery: true } : {}), }) .setProtectedHeader({ alg: 'RS256', kid }) .setIssuedAt() diff --git a/src/middleware/attachAuthMiddleware.ts b/src/middleware/attachAuthMiddleware.ts index 2665b17b..6bc8c696 100644 --- a/src/middleware/attachAuthMiddleware.ts +++ b/src/middleware/attachAuthMiddleware.ts @@ -17,9 +17,12 @@ export function getSecuritySchemeName(_authType: AuthTokenType): string { return 'bearerAuth'; } -export function attachAuthMiddleware(authType: AuthTokenType = 'access') { +export function attachAuthMiddleware( + authType: AuthTokenType = 'access', + options: { allowRecoverySession?: boolean } = {}, +) { const handler = ((req, res, next) => - verifyBearerAuth(req, res, next, authType)) as AuthAwareRequestHandler; + verifyBearerAuth(req, res, next, authType, options)) as AuthAwareRequestHandler; handler.seamlessAuthType = authType; diff --git a/src/middleware/verifyBearerAuth.ts b/src/middleware/verifyBearerAuth.ts index 0cb1d798..e0a24951 100644 --- a/src/middleware/verifyBearerAuth.ts +++ b/src/middleware/verifyBearerAuth.ts @@ -54,6 +54,7 @@ export async function verifyBearerAuth( res: Response, next: NextFunction, authType: AuthTokenType = 'access', + { allowRecoverySession = false }: { allowRecoverySession?: boolean } = {}, ) { const auth = req.headers.authorization; if (!auth?.startsWith('Bearer ')) { @@ -69,7 +70,16 @@ export async function verifyBearerAuth( await recordBearerMisuse(req, token, authType); return res.status(401).json({ error: 'unauthorized' }); } + if (result.recovery && !allowRecoverySession) { + return res.status(403).json({ + error: 'enrollment_required', + message: 'Enroll a passkey or authenticator app to finish recovering this account', + }); + } (req as AuthenticatedRequest).user = result.user; + if (result.recovery) { + (req as AuthenticatedRequest).recoverySession = true; + } if (result.decoy) { (req as AuthenticatedRequest).decoy = true; } diff --git a/src/migrations/20261022140000-add-recovery-codes.cjs b/src/migrations/20261022140000-add-recovery-codes.cjs new file mode 100644 index 00000000..d92d7291 --- /dev/null +++ b/src/migrations/20261022140000-add-recovery-codes.cjs @@ -0,0 +1,44 @@ +/* + * 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'; + +/** + * Single-use recovery codes, stored as keyed hashes, and the flag that marks a session a + * recovery issued: restricted to enrolling a new factor until one is enrolled. + * + * @type {import('sequelize-cli').Migration} + */ +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query(` + CREATE TABLE IF NOT EXISTS public.recovery_codes ( + id uuid NOT NULL, + "userId" uuid NOT NULL, + "codeHash" character varying(64) NOT NULL, + "usedAt" timestamp with time zone, + "createdAt" timestamp with time zone NOT NULL, + "updatedAt" timestamp with time zone NOT NULL, + CONSTRAINT recovery_codes_pkey PRIMARY KEY (id), + CONSTRAINT "recovery_codes_userId_fkey" + FOREIGN KEY ("userId") REFERENCES public.users(id) ON UPDATE CASCADE ON DELETE CASCADE + ); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_recovery_codes_user_hash + ON public.recovery_codes USING btree ("userId", "codeHash"); + + ALTER TABLE public.sessions + ADD COLUMN IF NOT EXISTS recovery boolean DEFAULT false NOT NULL; + `); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query(` + ALTER TABLE public.sessions DROP COLUMN IF EXISTS recovery; + DROP TABLE IF EXISTS public.recovery_codes; + `); + }, +}; diff --git a/src/models/recoveryCodes.ts b/src/models/recoveryCodes.ts new file mode 100644 index 00000000..ebd63ef4 --- /dev/null +++ b/src/models/recoveryCodes.ts @@ -0,0 +1,63 @@ +/* + * 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 interface RecoveryCodeAttributes { + id: string; + userId: string; + codeHash: string; + usedAt?: Date | null; + createdAt?: Date; + updatedAt?: Date; +} + +type RecoveryCodeCreationAttributes = Optional; + +export class RecoveryCode + extends Model + implements RecoveryCodeAttributes +{ + declare id: string; + declare userId: string; + declare codeHash: string; + declare usedAt: Date | null; + declare readonly createdAt: Date; + declare readonly updatedAt: Date; +} + +const initializeRecoveryCodeModel = (sequelize: Sequelize) => { + RecoveryCode.init( + { + id: { + type: DataTypes.UUID, + primaryKey: true, + defaultValue: DataTypes.UUIDV4, + }, + userId: { + type: DataTypes.UUID, + allowNull: false, + }, + codeHash: { + type: DataTypes.STRING(64), + allowNull: false, + }, + usedAt: { + type: DataTypes.DATE, + allowNull: true, + }, + }, + { + sequelize, + tableName: 'recovery_codes', + modelName: 'RecoveryCode', + }, + ); + + return RecoveryCode; +}; + +export default initializeRecoveryCodeModel; diff --git a/src/models/sessions.ts b/src/models/sessions.ts index 3c8169f4..a26a2086 100644 --- a/src/models/sessions.ts +++ b/src/models/sessions.ts @@ -22,6 +22,7 @@ export interface SessionAttributes { chainStartedAt?: Date | null; stepUpVerifiedAt?: Date | null; stepUpMethod?: string | null; + recovery?: boolean; replacedBySessionId?: string | null; revokedAt?: Date | null; revokedReason?: string | null; @@ -60,6 +61,7 @@ export class Session declare chainStartedAt: Date | null; declare stepUpVerifiedAt: Date | null; declare stepUpMethod: string | null; + declare recovery: boolean; declare replacedBySessionId: string | null; declare revokedAt: Date | null; declare revokedReason: string | null; @@ -126,6 +128,12 @@ const initializeSessionModel = (sequelize: Sequelize) => { type: DataTypes.STRING, allowNull: true, }, + // A session a recovery issued, restricted to enrolling a new factor until one is. + recovery: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, replacedBySessionId: { type: DataTypes.UUID, allowNull: true, diff --git a/src/routes/auth.routes.ts b/src/routes/auth.routes.ts index 8e117d80..3f0ced8e 100644 --- a/src/routes/auth.routes.ts +++ b/src/routes/auth.routes.ts @@ -52,6 +52,7 @@ authRouter.delete( { adapter: { clears: ['access', 'registration', 'refresh'] }, auth: 'access', + allowRecoverySession: true, summary: 'Logout current session', tags: ['Authentication'], @@ -70,6 +71,7 @@ authRouter.delete( { adapter: { clears: ['access', 'registration', 'refresh'] }, auth: 'access', + allowRecoverySession: true, summary: 'Logout all sessions for the current user', tags: ['Authentication'], diff --git a/src/routes/recoveryCodes.routes.ts b/src/routes/recoveryCodes.routes.ts new file mode 100644 index 00000000..169b4ac6 --- /dev/null +++ b/src/routes/recoveryCodes.routes.ts @@ -0,0 +1,89 @@ +/* + * 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 { decoyRedeemRecoveryCode } from '../controllers/decoyResponders.js'; +import { + createRecoveryCodes, + getRecoveryCodes, + redeemRecoveryCodeLogin, +} from '../controllers/recoveryCodes.js'; +import { createRouter } from '../lib/createRouter.js'; +import { otpIdentityLimiter, otpIpLimiter } from '../middleware/rateLimit.js'; +import { ErrorSchema } from '../schemas/generic.responses.js'; +import { + RecoveryCodeRedeemRequestSchema, + RecoveryCodesGeneratedSchema, + RecoveryCodesStatusSchema, + RecoverySessionResponseSchema, +} from '../schemas/recoveryCodes.js'; + +const recoveryCodesRouter = createRouter('/recovery-codes'); + +recoveryCodesRouter.get( + '/', + { + auth: 'access', + allowRecoverySession: true, + summary: 'Recovery code status', + description: 'How many of the current set are unused, and when the set was made.', + tags: ['Recovery'], + schemas: { + response: { + 200: RecoveryCodesStatusSchema, + 401: ErrorSchema, + }, + }, + }, + getRecoveryCodes, +); + +recoveryCodesRouter.post( + '/', + { + auth: 'access', + allowRecoverySession: true, + summary: 'Generate recovery codes', + description: + 'Returns a new set of single-use codes, the only time they are shown, and invalidates ' + + 'the previous set. Replacing an existing set needs a fresh step-up.', + tags: ['Recovery'], + schemas: { + response: { + 201: RecoveryCodesGeneratedSchema, + 401: ErrorSchema, + 403: ErrorSchema, + }, + }, + }, + createRecoveryCodes, +); + +recoveryCodesRouter.post( + '/redeem', + { + adapter: { credential: 'preAuth', issues: 'session' }, + auth: 'ephemeral', + decoy: decoyRedeemRecoveryCode, + middleware: [otpIpLimiter, otpIdentityLimiter], + summary: 'Sign in with a recovery code', + description: + 'Spends one code and issues a recovery session (`recovery: true`), which can only ' + + 'enroll a new passkey or authenticator app, manage recovery codes, read the user and ' + + 'sign out until a factor is enrolled. Not subject to account lockout.', + tags: ['Recovery'], + schemas: { + body: RecoveryCodeRedeemRequestSchema, + response: { + 200: RecoverySessionResponseSchema, + 401: ErrorSchema, + 403: ErrorSchema, + }, + }, + }, + redeemRecoveryCodeLogin, +); + +export default recoveryCodesRouter.router; diff --git a/src/routes/totp.routes.ts b/src/routes/totp.routes.ts index cbf9b99c..3b4602e2 100644 --- a/src/routes/totp.routes.ts +++ b/src/routes/totp.routes.ts @@ -36,6 +36,7 @@ totpRouter.get( '/status', { auth: 'access', + allowRecoverySession: true, summary: 'Get TOTP status for the current user', tags: ['TOTP'], @@ -53,6 +54,7 @@ totpRouter.post( '/enroll/start', { auth: 'access', + allowRecoverySession: true, summary: 'Start TOTP enrollment', tags: ['TOTP'], @@ -73,6 +75,7 @@ totpRouter.post( { middleware: [otpIpLimiter, otpIdentityLimiter], auth: 'access', + allowRecoverySession: true, summary: 'Verify TOTP enrollment', tags: ['TOTP'], diff --git a/src/routes/users.routes.ts b/src/routes/users.routes.ts index c66d13b2..34d23edb 100644 --- a/src/routes/users.routes.ts +++ b/src/routes/users.routes.ts @@ -19,6 +19,7 @@ usersRouter.get( { adapter: { clears: ['preAuth'] }, auth: 'access', + allowRecoverySession: true, tags: ['Users'], summary: 'Get authenticated user', schemas: { diff --git a/src/routes/webauthn.routes.ts b/src/routes/webauthn.routes.ts index 05aed21b..b38ff7e5 100644 --- a/src/routes/webauthn.routes.ts +++ b/src/routes/webauthn.routes.ts @@ -40,6 +40,7 @@ webauthnRouter.get( '/register/start', { auth: 'access', + allowRecoverySession: true, summary: 'Start WebAuthn registration', tags: ['WebAuthn'], @@ -62,6 +63,7 @@ webauthnRouter.post( '/register/finish', { auth: 'access', + allowRecoverySession: true, summary: 'Finish WebAuthn registration', tags: ['WebAuthn'], diff --git a/src/schemas/authEvent.types.ts b/src/schemas/authEvent.types.ts index fcbadb8a..6bf5c30e 100644 --- a/src/schemas/authEvent.types.ts +++ b/src/schemas/authEvent.types.ts @@ -47,6 +47,9 @@ export const AUTH_EVENT_TYPES = [ 'refresh_token_suspicious', 'registration_failed', 'registration_success', + 'recovery_code_failed', + 'recovery_code_used', + 'recovery_codes_generated', 'registration_suspicious', 'request_suspicious', 'service_token_failed', diff --git a/src/schemas/recoveryCodes.ts b/src/schemas/recoveryCodes.ts new file mode 100644 index 00000000..ba8a74d7 --- /dev/null +++ b/src/schemas/recoveryCodes.ts @@ -0,0 +1,12 @@ +/* + * 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 { + RecoveryCodeRedeemRequestSchema, + RecoveryCodesGeneratedSchema, + RecoveryCodesStatusSchema, + RecoverySessionResponseSchema, +} from '@seamless-auth/types'; diff --git a/src/services/recoveryCodeService.ts b/src/services/recoveryCodeService.ts new file mode 100644 index 00000000..663f6eb6 --- /dev/null +++ b/src/services/recoveryCodeService.ts @@ -0,0 +1,90 @@ +/* + * 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 { createHmac, randomInt } from 'crypto'; + +import { authSecret } from '../config/authSecrets.js'; +import { RecoveryCode } from '../models/recoveryCodes.js'; + +export const RECOVERY_CODE_COUNT = 10; + +// RFC 4648 base32: no 0/1/8/9, so a code copied from paper cannot be misread as O/I/B/g. +const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; +const HALF = 5; + +function generateCode() { + let code = ''; + for (let i = 0; i < HALF * 2; i += 1) { + code += ALPHABET[randomInt(ALPHABET.length)]; + } + return `${code.slice(0, HALF)}-${code.slice(HALF)}`; +} + +/** What a user typed, in the one form codes are hashed in: upper case, no dash or spaces. */ +export function normalizeRecoveryCode(code: string) { + return code.replace(/[\s-]/g, '').toUpperCase(); +} + +/** + * Keyed with the refresh lookup secret, under its own label, rather than plain SHA-256: + * a code is 50 bits, few enough that an unkeyed hash taken from a database dump could be + * searched offline. Rotating that secret therefore also voids every recovery code. + */ +function hashRecoveryCode(code: string) { + return createHmac('sha256', authSecret('REFRESH_TOKEN_LOOKUP_SECRET')) + .update(`recovery-code:${normalizeRecoveryCode(code)}`) + .digest('hex'); +} + +export async function getRecoveryCodeStatus(userId: string) { + const codes = await RecoveryCode.findAll({ + where: { userId }, + attributes: ['usedAt', 'createdAt'], + }); + + const createdAt = codes.reduce( + (oldest, code) => (oldest && oldest <= code.createdAt ? oldest : code.createdAt), + null, + ); + + return { + remaining: codes.filter((code) => !code.usedAt).length, + total: codes.length, + createdAt, + }; +} + +/** + * Replaces the user's set with a new one and returns the codes, which are never stored + * or shown again. + */ +export async function generateRecoveryCodes(userId: string) { + const codes = Array.from({ length: RECOVERY_CODE_COUNT }, generateCode); + const createdAt = new Date(); + + await RecoveryCode.sequelize!.transaction(async (transaction) => { + await RecoveryCode.destroy({ where: { userId }, transaction }); + await RecoveryCode.bulkCreate( + codes.map((code) => ({ userId, codeHash: hashRecoveryCode(code), createdAt })), + { transaction }, + ); + }); + + return { codes, createdAt }; +} + +/** + * Spends one code if it is the user's and unused. The update is conditional on `usedAt` + * being null, so two requests with the same code cannot both succeed. + */ +export async function redeemRecoveryCode(userId: string, code: string) { + const [spent] = await RecoveryCode.update( + { usedAt: new Date() }, + { where: { userId, codeHash: hashRecoveryCode(code), usedAt: null } }, + ); + + return spent === 1; +} diff --git a/src/services/sessionIssuance.ts b/src/services/sessionIssuance.ts index 4e571e54..bfdaf837 100644 --- a/src/services/sessionIssuance.ts +++ b/src/services/sessionIssuance.ts @@ -15,7 +15,8 @@ import { getLoginPolicy } from './loginPolicyService.js'; 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'; +export type SessionMethod = + 'passkey' | 'email_otp' | 'phone_otp' | 'magic_link' | 'totp' | 'oauth' | 'recovery_code'; export class PasskeyRequiredError extends Error { constructor(method: SessionMethod) { @@ -32,6 +33,12 @@ type IssueSessionParams = { * nothing else to sign in with until its first passkey is enrolled. */ accountVerification?: boolean; + /** + * A session for someone who proved the account another way after losing their factors. + * Restricted until they enroll a new one, so policy that removes their other sign-in + * methods does not apply: this is how they get one back. + */ + recovery?: boolean; user: { id: string; email: string; @@ -52,11 +59,11 @@ type IssueSessionParams = { }; export async function issueSessionAndRespond(params: IssueSessionParams): Promise { - const { user, req, res, extraFields, method, accountVerification } = params; + const { user, req, res, extraFields, method, accountVerification, recovery = false } = params; // Every endpoint that reaches here with another factor refuses first. This is the // backstop for one that does not, so it fails closed rather than trusting the gates. - if (method !== 'passkey' && !accountVerification) { + if (method !== 'passkey' && !accountVerification && !recovery) { const { phishingResistantOnly } = await getLoginPolicy(); if (phishingResistantOnly) { throw new PasskeyRequiredError(method); @@ -96,9 +103,12 @@ export async function issueSessionAndRespond(params: IssueSessionParams): Promis idleExpiresAt, chainStartedAt: now, lastUsedAt: undefined, + recovery, }); - const token = await signAccessToken(session.id, user.id, user.roles, organizationId); + const token = await signAccessToken(session.id, user.id, user.roles, organizationId, { + recovery, + }); if (!token || !refreshToken) { throw new Error('Failed to issue session tokens'); @@ -119,5 +129,6 @@ export async function issueSessionAndRespond(params: IssueSessionParams): Promis phone: user.phone, ttl: parseDurationToSeconds(access_token_ttl || '15m'), refreshTtl: parseDurationToSeconds(refresh_token_ttl || '1d'), + ...(recovery ? { recovery: true, nextStep: 'enroll_passkey' } : {}), }); } diff --git a/src/services/sessionService.ts b/src/services/sessionService.ts index 20a31da3..de290bb4 100644 --- a/src/services/sessionService.ts +++ b/src/services/sessionService.ts @@ -265,6 +265,8 @@ export interface ValidatedBearerToken { decoy?: boolean; /** The ephemeral token's `jti`, absent on tokens minted before it was set. */ attemptId?: string; + /** An access session a recovery issued, restricted until a new factor is enrolled. */ + recovery?: boolean; } export async function validateEphemeralToken(token: string): Promise { @@ -331,6 +333,25 @@ export async function validateBearerToken( user, sessionId: accessToken.sessionId, organizationId: accessToken.organizationId, + // Read from the session rather than the token: enrolling a factor clears it on + // the row, which takes effect at once, before the client next refreshes. + recovery: session.recovery === true, } : null; } + +/** + * Ends a recovery session's restriction once the user has enrolled a factor of their + * own. The API reads the flag from the row, so this applies to the very next request; + * the access token keeps its `recovery` claim until the client refreshes. + */ +export async function liftRecoveryRestriction(sessionId: string | undefined) { + if (!sessionId) return false; + + const [updated] = await Session.update( + { recovery: false }, + { where: { id: sessionId, recovery: true } }, + ); + + return updated > 0; +} diff --git a/src/types/types.ts b/src/types/types.ts index e2e845fd..555c6ce6 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -33,6 +33,8 @@ export interface AuthenticatedRequest extends RouteRequest { * Unset on access sessions, and on ephemeral tokens minted before the claim existed. */ attemptId?: string; + /** Set on an access session a recovery issued; see `allowRecoverySession`. */ + recoverySession?: boolean; organizationId?: string | null; clientId?: string; trustedClientIp?: string; diff --git a/tests/integration/authentication/authentication.spec.ts b/tests/integration/authentication/authentication.spec.ts index 215c0a26..7c4fa7d2 100644 --- a/tests/integration/authentication/authentication.spec.ts +++ b/tests/integration/authentication/authentication.spec.ts @@ -615,6 +615,7 @@ describe('POST /refresh', () => { expect.any(String), expect.any(Array), undefined, + { recovery: false }, ); expect(Session.create).toHaveBeenCalledWith( expect.objectContaining({ refreshTokenLookup: 'refresh-lookup' }), diff --git a/tests/integration/recovery/recoveryCodes.spec.ts b/tests/integration/recovery/recoveryCodes.spec.ts new file mode 100644 index 00000000..62bea3f7 --- /dev/null +++ b/tests/integration/recovery/recoveryCodes.spec.ts @@ -0,0 +1,129 @@ +import { Application } from 'express'; +import request from 'supertest'; +import { 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, +})); + +import { createApp } from '../../../src/app.js'; +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 { buildSystemConfig } from '../../factories/systemConfigFactory.js'; + +let app: Application; + +beforeAll(async () => { + app = await createApp(); +}); + +beforeEach(() => { + vi.clearAllMocks(); + process.env.REFRESH_TOKEN_LOOKUP_SECRET = 'lookup-secret'; + (getSystemConfig as any).mockResolvedValue(buildSystemConfig()); +}); + +function withExistingSet() { + (RecoveryCode.findAll as any).mockResolvedValue([ + { usedAt: null, createdAt: new Date('2026-10-22T00:00:00Z') }, + ]); +} + +describe('GET /recovery-codes', () => { + it('reports the set', async () => { + withExistingSet(); + + const res = await request(app).get('/recovery-codes'); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + enabled: true, + remaining: 1, + total: 1, + createdAt: '2026-10-22T00:00:00.000Z', + }); + }); +}); + +describe('POST /recovery-codes', () => { + it('makes a first set without step-up and returns it once', async () => { + const res = await request(app).post('/recovery-codes'); + + expect(res.status).toBe(201); + expect(res.body.codes).toHaveLength(10); + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ type: 'recovery_codes_generated' }), + ); + }); + + it('needs a fresh step-up to replace an existing set', async () => { + withExistingSet(); + (Session.findOne as any).mockResolvedValue({ id: 'session-1', stepUpVerifiedAt: null }); + + const res = await request(app).post('/recovery-codes'); + + expect(res.status).toBe(403); + expect(res.body.error).toBe('step_up_required'); + expect(RecoveryCode.bulkCreate).not.toHaveBeenCalled(); + }); + + it('is refused while recovery codes are disabled', async () => { + (getSystemConfig as any).mockResolvedValue( + buildSystemConfig({ recovery_codes: { enabled: false, requireAtEnrollment: false } }), + ); + + const res = await request(app).post('/recovery-codes'); + + expect(res.status).toBe(403); + expect(res.body).toEqual({ error: 'recovery_codes_disabled' }); + }); +}); + +describe('POST /recovery-codes/redeem', () => { + it('spends the code and issues a restricted recovery session', async () => { + const res = await request(app).post('/recovery-codes/redeem').send({ code: 'ABCDE-FGH23' }); + + expect(res.status).toBe(200); + expect(issueSessionAndRespondMock).toHaveBeenCalledWith( + expect.objectContaining({ method: 'recovery_code', recovery: true }), + ); + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ type: 'recovery_code_used' }), + ); + }); + + it('refuses a wrong or used code without issuing a session', async () => { + (RecoveryCode.update as any).mockResolvedValueOnce([0]); + + const res = await request(app).post('/recovery-codes/redeem').send({ code: 'ABCDE-FGH23' }); + + expect(res.status).toBe(401); + expect(res.body).toEqual({ error: 'invalid_recovery_code' }); + expect(issueSessionAndRespondMock).not.toHaveBeenCalled(); + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ type: 'recovery_code_failed' }), + ); + }); + + it('refuses a malformed code at validation', async () => { + const res = await request(app).post('/recovery-codes/redeem').send({ code: '123456' }); + + expect(res.status).toBe(400); + }); + + it('is refused while recovery codes are disabled', async () => { + (getSystemConfig as any).mockResolvedValue( + buildSystemConfig({ recovery_codes: { enabled: false, requireAtEnrollment: false } }), + ); + + const res = await request(app).post('/recovery-codes/redeem').send({ code: 'ABCDE-FGH23' }); + + expect(res.status).toBe(403); + }); +}); diff --git a/tests/integration/systemConfig/systemConfig.spec.ts b/tests/integration/systemConfig/systemConfig.spec.ts index bbad1832..0ddd2f39 100644 --- a/tests/integration/systemConfig/systemConfig.spec.ts +++ b/tests/integration/systemConfig/systemConfig.spec.ts @@ -254,12 +254,22 @@ describe('GET /system-config/public', () => { expect(res.body.loginMethods).toEqual(['passkey', 'magic_link']); }); - it('exposes nothing but the login methods', async () => { + it('exposes only what the sign-in screens need', async () => { (getSystemConfig as any).mockResolvedValue(buildSystemConfig()); const res = await request(app).get('/system-config/public'); expect(res.status).toBe(200); - expect(Object.keys(res.body)).toEqual(['loginMethods']); + expect(Object.keys(res.body)).toEqual(['loginMethods', 'recoveryCodes']); + }); + + it('says whether recovery codes can be used', async () => { + (getSystemConfig as any).mockResolvedValue( + buildSystemConfig({ recovery_codes: { enabled: false, requireAtEnrollment: false } }), + ); + + const res = await request(app).get('/system-config/public'); + + expect(res.body.recoveryCodes).toBe(false); }); }); diff --git a/tests/setup/mocks.ts b/tests/setup/mocks.ts index 9926a1e6..81c5bbad 100644 --- a/tests/setup/mocks.ts +++ b/tests/setup/mocks.ts @@ -79,6 +79,16 @@ vi.mock('../../src/models/totpCredentials.js', () => ({ }, })); +vi.mock('../../src/models/recoveryCodes.js', () => ({ + RecoveryCode: { + findAll: vi.fn(async () => []), + update: vi.fn(async () => [1]), + destroy: vi.fn(), + bulkCreate: vi.fn(), + sequelize: { transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn({})) }, + }, +})); + vi.mock('../../src/models/oauthIdentities.js', () => ({ OAuthIdentity: { create: vi.fn(), @@ -164,6 +174,7 @@ vi.mock('../../src/services/sessionService.js', () => ({ verifyJwtWithKid: vi.fn(), revokeSessionChain: vi.fn(), hardRevokeSession: vi.fn(), + liftRecoveryRestriction: vi.fn(async () => true), })); vi.mock('../../src/middleware/attachAuthMiddleware.js', async (importOriginal) => { diff --git a/tests/unit/controllers/authentication.spec.ts b/tests/unit/controllers/authentication.spec.ts index a2b3f82e..96376ce8 100644 --- a/tests/unit/controllers/authentication.spec.ts +++ b/tests/unit/controllers/authentication.spec.ts @@ -148,7 +148,9 @@ describe('refreshSession', () => { refreshTokenLookup: 'new-refresh-lookup', }), ); - expect(signAccessToken).toHaveBeenCalledWith('session-2', user.id, user.roles, undefined); + expect(signAccessToken).toHaveBeenCalledWith('session-2', user.id, user.roles, undefined, { + recovery: false, + }); expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith({ message: 'Success', diff --git a/tests/unit/controllers/totp.spec.ts b/tests/unit/controllers/totp.spec.ts index a3d978e9..6434ce70 100644 --- a/tests/unit/controllers/totp.spec.ts +++ b/tests/unit/controllers/totp.spec.ts @@ -258,6 +258,38 @@ describe('totp controller', () => { }); }); + describe('enrolling from a recovery session', () => { + it('lifts the restriction once the new factor is confirmed', async () => { + const { liftRecoveryRestriction } = await import('../../../src/services/sessionService.js'); + const { verifyCurrentTotpEnrollment } = await loadTotpController(); + verifyTotpEnrollmentMock.mockResolvedValue({ verified: true }); + + await verifyCurrentTotpEnrollment( + buildReq(buildUser(), { + body: { code: '123456' }, + recoverySession: true, + sessionId: 'session-1', + }), + buildRes(), + ); + + expect(liftRecoveryRestriction).toHaveBeenCalledWith('session-1'); + }); + + it('leaves an ordinary session alone', async () => { + const { liftRecoveryRestriction } = await import('../../../src/services/sessionService.js'); + const { verifyCurrentTotpEnrollment } = await loadTotpController(); + verifyTotpEnrollmentMock.mockResolvedValue({ verified: true }); + + await verifyCurrentTotpEnrollment( + buildReq(buildUser(), { body: { code: '123456' }, sessionId: 'session-1' }), + buildRes(), + ); + + expect(liftRecoveryRestriction).not.toHaveBeenCalled(); + }); + }); + describe('disableCurrentTotp', () => { it('returns 401 when the user is missing an id', async () => { const { disableCurrentTotp } = await loadTotpController(); diff --git a/tests/unit/middleware/attachAuthMiddleware.spec.ts b/tests/unit/middleware/attachAuthMiddleware.spec.ts index fdebd3db..090b1496 100644 --- a/tests/unit/middleware/attachAuthMiddleware.spec.ts +++ b/tests/unit/middleware/attachAuthMiddleware.spec.ts @@ -23,7 +23,7 @@ describe('attachAuthMiddleware', () => { await middleware(req as any, res as any, next); - expect(verifyBearerAuth).toHaveBeenCalledWith(req, res, next, 'access'); + expect(verifyBearerAuth).toHaveBeenCalledWith(req, res, next, 'access', {}); }); it('tracks ephemeral bearer auth for route metadata', async () => { @@ -39,7 +39,7 @@ describe('attachAuthMiddleware', () => { await middleware(req as any, res as any, next); - expect(verifyBearerAuth).toHaveBeenCalledWith(req, res, next, 'ephemeral'); + expect(verifyBearerAuth).toHaveBeenCalledWith(req, res, next, 'ephemeral', {}); }); it('always maps protected routes to bearer security', async () => { diff --git a/tests/unit/middleware/verifyBearerAuth.spec.ts b/tests/unit/middleware/verifyBearerAuth.spec.ts index a3374f2f..06a89cf1 100644 --- a/tests/unit/middleware/verifyBearerAuth.spec.ts +++ b/tests/unit/middleware/verifyBearerAuth.spec.ts @@ -93,6 +93,38 @@ describe('verifyBearerAuth', () => { expect(next).toHaveBeenCalled(); }); + it('refuses a recovery session on a route that does not allow one', async () => { + req.headers.authorization = 'Bearer token'; + (validateBearerToken as any).mockResolvedValue({ + user: { id: 'user-1' }, + sessionId: 'session-1', + recovery: true, + }); + + await verifyBearerAuth(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ error: 'enrollment_required' }), + ); + expect(next).not.toHaveBeenCalled(); + expect(req.user).toBeUndefined(); + }); + + it('lets a recovery session through where the route allows it, and marks the request', async () => { + req.headers.authorization = 'Bearer token'; + (validateBearerToken as any).mockResolvedValue({ + user: { id: 'user-1' }, + sessionId: 'session-1', + recovery: true, + }); + + await verifyBearerAuth(req, res, next, 'access', { allowRecoverySession: true }); + + expect(next).toHaveBeenCalled(); + expect(req.recoverySession).toBe(true); + }); + it('attaches the organization id when present on the validated result', async () => { req.headers.authorization = 'Bearer token'; diff --git a/tests/unit/models/models.spec.ts b/tests/unit/models/models.spec.ts index 03cb0877..a7df3f0f 100644 --- a/tests/unit/models/models.spec.ts +++ b/tests/unit/models/models.spec.ts @@ -7,6 +7,7 @@ vi.unmock('../../../src/models/users.js'); 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/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 fa8aa1a4..6118267c 100644 --- a/tests/unit/routes/adapterManifest.routes.spec.ts +++ b/tests/unit/routes/adapterManifest.routes.spec.ts @@ -79,6 +79,7 @@ describe('adapter manifest for the live routes', () => { 'POST /otp/verify-login-email-otp preAuth -> session', 'POST /otp/verify-login-phone-otp preAuth -> session', 'POST /otp/verify-phone-otp registration -> session', + 'POST /recovery-codes/redeem preAuth -> session', 'POST /refresh refresh -> session', 'POST /registration/register none -> registration', 'POST /totp/verify-login preAuth -> session', diff --git a/tests/unit/services/recoveryCodeService.spec.ts b/tests/unit/services/recoveryCodeService.spec.ts new file mode 100644 index 00000000..c7795227 --- /dev/null +++ b/tests/unit/services/recoveryCodeService.spec.ts @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { RecoveryCode } from '../../../src/models/recoveryCodes.js'; +import { + generateRecoveryCodes, + getRecoveryCodeStatus, + normalizeRecoveryCode, + RECOVERY_CODE_COUNT, + redeemRecoveryCode, +} from '../../../src/services/recoveryCodeService.js'; + +beforeEach(() => { + process.env.REFRESH_TOKEN_LOOKUP_SECRET = 'lookup-secret'; +}); + +describe('generateRecoveryCodes', () => { + it('returns ten base32 codes shown as XXXXX-XXXXX and stores only their hashes', async () => { + const { codes } = await generateRecoveryCodes('user-1'); + + expect(codes).toHaveLength(RECOVERY_CODE_COUNT); + expect(new Set(codes).size).toBe(RECOVERY_CODE_COUNT); + for (const code of codes) { + expect(code).toMatch(/^[A-Z2-7]{5}-[A-Z2-7]{5}$/); + } + + expect(RecoveryCode.destroy).toHaveBeenCalledWith( + expect.objectContaining({ where: { userId: 'user-1' } }), + ); + const stored = (RecoveryCode.bulkCreate as any).mock.calls[0][0] as Array<{ codeHash: string }>; + expect(stored).toHaveLength(RECOVERY_CODE_COUNT); + for (const row of stored) { + expect(row.codeHash).toMatch(/^[0-9a-f]{64}$/); + expect(codes.some((code) => row.codeHash.includes(normalizeRecoveryCode(code)))).toBe(false); + } + }); + + it('keys the hash, so the same code hashes differently under another secret', async () => { + const hashUnder = async (secret: string) => { + process.env.REFRESH_TOKEN_LOOKUP_SECRET = secret; + await redeemRecoveryCode('user-1', 'ABCDE-FGH23'); + return (RecoveryCode.update as any).mock.calls.at(-1)[1].where.codeHash; + }; + + expect(await hashUnder('one-secret')).not.toBe(await hashUnder('another-secret')); + expect(await hashUnder('one-secret')).toBe(await hashUnder('one-secret')); + }); +}); + +describe('redeemRecoveryCode', () => { + it('spends an unused code with one conditional update, whatever its case or dash', async () => { + await redeemRecoveryCode('user-1', 'abcde-fgh23'); + const lower = (RecoveryCode.update as any).mock.calls.at(-1); + await redeemRecoveryCode('user-1', 'ABCDEFGH23'); + const upper = (RecoveryCode.update as any).mock.calls.at(-1); + + expect(lower[0]).toEqual({ usedAt: expect.any(Date) }); + expect(lower[1].where).toEqual({ + userId: 'user-1', + codeHash: expect.any(String), + usedAt: null, + }); + expect(lower[1].where.codeHash).toBe(upper[1].where.codeHash); + }); + + it('fails when no unused code matched', async () => { + (RecoveryCode.update as any).mockResolvedValueOnce([0]); + + expect(await redeemRecoveryCode('user-1', 'ABCDE-FGH23')).toBe(false); + }); +}); + +describe('getRecoveryCodeStatus', () => { + it('counts the unused codes and dates the set', async () => { + const createdAt = new Date('2026-10-22T00:00:00Z'); + (RecoveryCode.findAll as any).mockResolvedValueOnce([ + { usedAt: null, createdAt }, + { usedAt: new Date(), createdAt }, + ]); + + expect(await getRecoveryCodeStatus('user-1')).toEqual({ remaining: 1, total: 2, createdAt }); + }); + + it('has no date before a set exists', async () => { + expect(await getRecoveryCodeStatus('user-1')).toEqual({ + remaining: 0, + total: 0, + createdAt: null, + }); + }); +}); diff --git a/tests/unit/services/sessionIssueService.spec.ts b/tests/unit/services/sessionIssueService.spec.ts index 872bdda0..3f6e21f2 100644 --- a/tests/unit/services/sessionIssueService.spec.ts +++ b/tests/unit/services/sessionIssueService.spec.ts @@ -107,7 +107,9 @@ describe('issueSessionAndRespond', () => { refreshTokenLookup: 'refresh-lookup', }), ); - expect(signAccessToken).toHaveBeenCalledWith('session-1', mockUser.id, mockUser.roles, null); + expect(signAccessToken).toHaveBeenCalledWith('session-1', mockUser.id, mockUser.roles, null, { + recovery: false, + }); expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith({ message: 'Success', @@ -174,6 +176,26 @@ describe('issueSessionAndRespond', () => { }, ); + it('starts a restricted recovery session, which is how a user gets a passkey back', async () => { + const res = mockRes(); + + await issueSessionAndRespond({ + method: 'recovery_code', + recovery: true, + user: mockUser, + req: mockReq(), + res, + }); + + expect(Session.create).toHaveBeenCalledWith(expect.objectContaining({ recovery: true })); + expect(signAccessToken).toHaveBeenCalledWith('session-1', mockUser.id, mockUser.roles, null, { + recovery: true, + }); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ recovery: true, nextStep: 'enroll_passkey' }), + ); + }); + it('starts a session from a passkey', async () => { await issueSessionAndRespond({ method: 'passkey', @@ -249,7 +271,15 @@ describe('issueSessionAndRespond', () => { organizationId: 'org-1', }), ); - expect(signAccessToken).toHaveBeenCalledWith('session-1', mockUser.id, mockUser.roles, 'org-1'); + expect(signAccessToken).toHaveBeenCalledWith( + 'session-1', + mockUser.id, + mockUser.roles, + 'org-1', + { + recovery: false, + }, + ); }); it('uses default TTL values when config missing', async () => { diff --git a/tests/unit/services/sessionService.spec.ts b/tests/unit/services/sessionService.spec.ts index c75ee3f4..da2ae9f7 100644 --- a/tests/unit/services/sessionService.spec.ts +++ b/tests/unit/services/sessionService.spec.ts @@ -524,6 +524,7 @@ describe('sessionService', () => { user, sessionId: 'session-1', organizationId: 'org-1', + recovery: false, }); }); @@ -763,3 +764,23 @@ describe('sessionService', () => { expect(result).toBeNull(); }); }); + +describe('liftRecoveryRestriction', () => { + it('clears the flag on a recovery session only', async () => { + const { Session } = await import('../../../src/models/sessions'); + const { liftRecoveryRestriction } = await import('../../../src/services/sessionService'); + (Session.update as any).mockResolvedValueOnce([1]); + + expect(await liftRecoveryRestriction('session-1')).toBe(true); + expect(Session.update).toHaveBeenCalledWith( + { recovery: false }, + { where: { id: 'session-1', recovery: true } }, + ); + }); + + it('does nothing without a session', async () => { + const { liftRecoveryRestriction } = await import('../../../src/services/sessionService'); + + expect(await liftRecoveryRestriction(undefined)).toBe(false); + }); +}); From 4b5bed80490ae14e4c51232335a4a5940f497a53 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Thu, 22 Oct 2026 15:38:10 -0400 Subject: [PATCH 2/2] fix(login): let an account reach its recovery codes past a lock 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. --- .changeset/recovery-codes.md | 1 + docs/account-recovery.md | 4 ++ src/controllers/authentication.ts | 22 ++++++++-- src/services/recoveryCodeService.ts | 10 +++++ .../authentication/authentication.spec.ts | 43 +++++++++++++++++++ tests/setup/mocks.ts | 1 + 6 files changed, 78 insertions(+), 3 deletions(-) diff --git a/.changeset/recovery-codes.md b/.changeset/recovery-codes.md index a7f3d466..3813dfcd 100644 --- a/.changeset/recovery-codes.md +++ b/.changeset/recovery-codes.md @@ -7,6 +7,7 @@ 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`. diff --git a/docs/account-recovery.md b/docs/account-recovery.md index 695a095c..5f9a3680 100644 --- a/docs/account-recovery.md +++ b/docs/account-recovery.md @@ -22,6 +22,10 @@ A user can generate a set of ten single-use codes, shown once as `XXXXX-XXXXX`. 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 diff --git a/src/controllers/authentication.ts b/src/controllers/authentication.ts index c8d87017..6c41d01b 100644 --- a/src/controllers/authentication.ts +++ b/src/controllers/authentication.ts @@ -30,6 +30,7 @@ import { LoginMethod, resolveAvailableLoginMethods, } from '../services/loginPolicyService.js'; +import { hasUsableRecoveryCode } from '../services/recoveryCodeService.js'; import { claimSessionRotation, classifyExpiredRefreshToken, @@ -339,7 +340,22 @@ export const login = async (req: Request, res: Response) => { ? availableMethods.filter((method) => method === 'passkey') : availableMethods; - if (lockout.locked && loginMethods.length === 0) { + // Recovery codes are not a login method, so an account left with nothing else, by a + // lock or by a policy its factors no longer meet, would be answered as a decoy or a + // lock. It continues with a real pre-auth token and the list a decoy would show, so + // the code can be redeemed and the answer still looks like any other account's. + const continueWithRecoveryCode = + loginMethods.length === 0 && (await hasUsableRecoveryCode(user.id)); + const responseMethods = continueWithRecoveryCode + ? resolveAvailableLoginMethods({ + policy: loginPolicy, + user, + hasPasskeyCredential: true, + passkeyAvailable, + }).filter((method) => !lockout.locked || method === 'passkey') + : loginMethods; + + if (lockout.locked && loginMethods.length === 0 && !continueWithRecoveryCode) { await AuthEventService.log({ userId: user.id, type: 'login_suspicious', @@ -358,7 +374,7 @@ export const login = async (req: Request, res: Response) => { }); } - if (loginMethods.length === 0) { + if (loginMethods.length === 0 && !continueWithRecoveryCode) { logger.error('Login attempt had no allowed continuation methods'); return await respondWithDecoy({ req, @@ -391,7 +407,7 @@ export const login = async (req: Request, res: Response) => { subject: user.id, attemptId, identifierType: identifierType!, - loginMethods, + loginMethods: responseMethods, }); } catch (error: unknown) { if (error instanceof Error) { diff --git a/src/services/recoveryCodeService.ts b/src/services/recoveryCodeService.ts index 663f6eb6..48771d66 100644 --- a/src/services/recoveryCodeService.ts +++ b/src/services/recoveryCodeService.ts @@ -7,6 +7,7 @@ import { createHmac, randomInt } from 'crypto'; import { authSecret } from '../config/authSecrets.js'; +import { getSystemConfig } from '../config/getSystemConfig.js'; import { RecoveryCode } from '../models/recoveryCodes.js'; export const RECOVERY_CODE_COUNT = 10; @@ -39,6 +40,15 @@ function hashRecoveryCode(code: string) { .digest('hex'); } +/** Whether the user can sign in with a recovery code right now. */ +export async function hasUsableRecoveryCode(userId: string) { + const { recovery_codes } = await getSystemConfig(); + if (recovery_codes?.enabled === false) return false; + + const unused = await RecoveryCode.count({ where: { userId, usedAt: null } }); + return unused > 0; +} + export async function getRecoveryCodeStatus(userId: string) { const codes = await RecoveryCode.findAll({ where: { userId }, diff --git a/tests/integration/authentication/authentication.spec.ts b/tests/integration/authentication/authentication.spec.ts index 7c4fa7d2..1b7eaaba 100644 --- a/tests/integration/authentication/authentication.spec.ts +++ b/tests/integration/authentication/authentication.spec.ts @@ -382,6 +382,49 @@ describe('POST /login', () => { expect(res.body.retryAfterSeconds).toBeLessThanOrEqual(900); }); + it('lets a locked account with no passkey continue when it holds a recovery code', async () => { + const { RecoveryCode } = await import('../../../src/models/recoveryCodes.js'); + (User.findOne as any).mockResolvedValue(buildUser({ verified: true })); + (Credential.findOne as any).mockResolvedValue(null); + (RecoveryCode.count as any).mockResolvedValueOnce(3); + (signEphemeralToken as any).mockResolvedValue('token'); + (AuthFailure.findAll as any).mockResolvedValue( + Array.from({ length: 10 }, () => ({ occurredAt: new Date() })), + ); + (getSystemConfig as any).mockResolvedValue({ + login_methods: ['passkey', 'email_otp', 'magic_link'], + passkey_login_fallback_enabled: true, + lockout_policy: { enabled: true, maxFailures: 10, windowSeconds: 900, lockoutSeconds: 900 }, + }); + + const res = await request(app).post('/login').send({ identifier: 'test@example.com' }); + + // The pre-auth token is what /recovery-codes/redeem needs; the list is the one a + // decoy would show, so a recovery code holder is not singled out. + expect(res.status).toBe(200); + expect(res.body.token).toBe('token'); + expect(res.body.loginMethods).toEqual(['passkey']); + }); + + it('still answers a lock when recovery codes are turned off', async () => { + const { RecoveryCode } = await import('../../../src/models/recoveryCodes.js'); + (User.findOne as any).mockResolvedValue(buildUser({ verified: true })); + (Credential.findOne as any).mockResolvedValue(null); + (RecoveryCode.count as any).mockResolvedValue(3); + (AuthFailure.findAll as any).mockResolvedValue( + Array.from({ length: 10 }, () => ({ occurredAt: new Date() })), + ); + (getSystemConfig as any).mockResolvedValue({ + recovery_codes: { enabled: false, requireAtEnrollment: false }, + lockout_policy: { enabled: true, maxFailures: 10, windowSeconds: 900, lockoutSeconds: 900 }, + }); + + const res = await request(app).post('/login').send({ identifier: 'test@example.com' }); + + (RecoveryCode.count as any).mockResolvedValue(0); + expect(res.status).toBe(423); + }); + it('lets a locked account continue with its passkey only', async () => { (User.findOne as any).mockResolvedValue(buildUser({ verified: true })); (Credential.findOne as any).mockResolvedValue({ id: 'cred-1' }); diff --git a/tests/setup/mocks.ts b/tests/setup/mocks.ts index 81c5bbad..3523873a 100644 --- a/tests/setup/mocks.ts +++ b/tests/setup/mocks.ts @@ -81,6 +81,7 @@ vi.mock('../../src/models/totpCredentials.js', () => ({ vi.mock('../../src/models/recoveryCodes.js', () => ({ RecoveryCode: { + count: vi.fn(async () => 0), findAll: vi.fn(async () => []), update: vi.fn(async () => [1]), destroy: vi.fn(),