From 42569677394c6839943c374296778091a05d376c Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Thu, 22 Oct 2026 15:35:18 -0400 Subject: [PATCH 1/3] feat: break-glass admin that no sign-in policy can lock out Accounts in BREAK_GLASS_EMAILS that hold admin:write are exempt from login policy for their own sign-in but need a passkey, TOTP or a recovery code. Every use that relied on the exemption is audited at high severity and emailed to every admin. Closes #401. --- .changeset/break-glass-admin.md | 12 + .env.example | 7 + docs/admin-operations.md | 56 ++ docs/configuration.md | 11 + docs/security-posture.md | 15 + openapi.json | 2 + resources/coverage-badge.svg | 14 +- src/config/breakGlass.ts | 49 ++ src/controllers/authentication.ts | 35 +- src/controllers/magicLinks.ts | 11 +- src/controllers/oauth.ts | 11 + src/controllers/otp.ts | 9 + src/controllers/recoveryCodes.ts | 21 +- src/controllers/totp.ts | 11 +- src/controllers/webauthn.ts | 24 +- src/generated/api.ts | 2 + src/lib/authEventCategories.ts | 1 + src/schemas/authEvent.types.ts | 1 + src/server.ts | 2 + src/services/breakGlassService.ts | 202 +++++++ src/services/sessionIssuance.ts | 24 +- .../integration/breakGlass/breakGlass.spec.ts | 569 ++++++++++++++++++ tests/integration/oauth/oauth.spec.ts | 44 ++ tests/unit/config/breakGlass.spec.ts | 57 ++ tests/unit/services/breakGlassService.spec.ts | 245 ++++++++ .../unit/services/sessionIssueService.spec.ts | 56 +- 26 files changed, 1469 insertions(+), 22 deletions(-) create mode 100644 .changeset/break-glass-admin.md create mode 100644 src/config/breakGlass.ts create mode 100644 src/services/breakGlassService.ts create mode 100644 tests/integration/breakGlass/breakGlass.spec.ts create mode 100644 tests/unit/config/breakGlass.spec.ts create mode 100644 tests/unit/services/breakGlassService.spec.ts diff --git a/.changeset/break-glass-admin.md b/.changeset/break-glass-admin.md new file mode 100644 index 00000000..81bed10d --- /dev/null +++ b/.changeset/break-glass-admin.md @@ -0,0 +1,12 @@ +--- +'seamless-auth-api': minor +--- + +Break-glass admin accounts that no sign-in policy can lock out (#401). + +- New `BREAK_GLASS_EMAILS` (comma separated, matched case-insensitively). A listed account that holds a role satisfying `admin:write` is exempt from `login_methods`, `phishing_resistant_only`, `passkey_login_fallback_enabled` and `recovery_codes.enabled` for its own sign-in, and from `authenticator_policy` when it enrolls a passkey. A listed address without the role gets no exemption, and boot logs how many listed entries are not admin accounts. +- A break-glass account still needs a strong factor: a passkey, TOTP or a recovery code. Email OTP, SMS OTP, magic links and OAuth are refused for it (`403 login_method_disabled`) even where they are enabled for everyone else, and session issuance refuses them as a backstop. Lockout still applies to its TOTP. +- `/login` adds a break-glass account's passkey when `login_methods` leaves passkeys out, and lets one holding only TOTP or recovery codes continue where it would otherwise be answered as a decoy. Its `loginMethods` is otherwise shaped like any account's, so it can list methods the account is then refused. +- Every sign-in that relied on the exemption records a new `break_glass_sign_in` auth event with `severity: "high"` in its metadata and emails a notice to every admin account with a verified email. + +See the Break-glass admin section of docs/admin-operations.md. diff --git a/.env.example b/.env.example index 90c381cc..ba582eb5 100644 --- a/.env.example +++ b/.env.example @@ -153,6 +153,13 @@ OAUTH_STATE_SECRET= # Leave unset on non-managed deployments to disable the grant entirely. OWNER_EMAIL=admin@test +# BREAK-GLASS ADMIN +# Comma separated admin emails that sign-in policy cannot lock out (login methods, phishing-resistant +# only, passkey required, authenticator policy). Each still needs a passkey, TOTP or a recovery code; +# email OTP, SMS OTP, magic links and OAuth never sign one in. Only accounts holding admin:write +# count. Every use that relied on the exemption is audited and emailed to every admin. +BREAK_GLASS_EMAILS= + # EXTERNAL DELIVERY # External delivery returns OTP and magic-link secrets in the response body instead of sending # them. It requires a valid x-seamless-service-token in every environment. Set this to true only diff --git a/docs/admin-operations.md b/docs/admin-operations.md index 47c1a056..c552e0e9 100644 --- a/docs/admin-operations.md +++ b/docs/admin-operations.md @@ -105,6 +105,62 @@ The response returns counts only: It does not return credential private material, TOTP secrets, recovery codes, refresh tokens, or PRF output. +## Break-glass Admin + +A bad policy change can lock every administrator out: phishing-resistant only with no enrolled +keys, a passkey-only method list, or an authenticator allow list nobody's key is on. Accounts +listed in `BREAK_GLASS_EMAILS` ([configuration](./configuration.md#break-glass-admin)) are the way +back that policy cannot remove. + +What a break-glass account is exempt from, for its own sign-in: + +- `login_methods`: `/login` offers its passkey even when the list leaves passkeys out, and lets an + account holding only TOTP or recovery codes continue. The rest of its `loginMethods` is shaped + like any other account's, so the answer does not single out a privileged account, and may list + methods it is then refused. +- `phishing_resistant_only` and `passkey_login_fallback_enabled: false`: it can still sign in with + TOTP. +- `recovery_codes.enabled: false`: it can still make and redeem recovery codes. A code issues the + usual restricted recovery session, which can enroll a passkey or authenticator app. +- `authenticator_policy` at enrollment: the pinned attachment, the synced-passkey setting, the + AAGUID allow and deny lists and `requireKnownAuthenticator`, so it can enroll the key it has. + +What still applies: + +- It needs a strong factor: a passkey, TOTP or a recovery code. Email OTP, SMS OTP, magic links and + OAuth never sign it in, even where they are enabled for everyone else, because each proves only + an address or an outside identity. +- Lockout still applies to its TOTP. Passkeys and recovery codes are not subject to lockout for + anyone. +- The exemption needs a role that satisfies `admin:write`. Removing the role, revoking the account + or removing the address from the variable each ends it. + +### When to use it + +Only to recover the deployment: sign in, correct the policy, sign out. Day to day administration +belongs on an ordinary admin account. Enroll the break-glass account's passkey (ideally a hardware +key kept offline) and generate its recovery codes while the policy still allows them, and keep +both somewhere at least two people can reach. + +### Every use is audited and announced + +A sign-in that relied on the exemption, meaning the policy in force would otherwise have refused +it, records a `break_glass_sign_in` auth event. The audit model has no severity column, so the +event carries `severity: "high"` in its metadata, together with `factor` (`passkey`, `totp` or +`recovery_code`), `waived` (the settings it went around) and `notices` (`sent`, `failed`). Every +admin account with a verified email is sent a notice naming the account and the time. A notice that +cannot be sent never fails the sign-in; the count is in the event. A sign-in the policy would have +allowed anyway is ordinary and is not announced. An enrollment outside the authenticator policy is +marked on its `registration_success` event with `severity: "high"` and +`breakGlassWaived: ["authenticator_policy"]`. + +### Rotating it + +To move break-glass to a different account, give the new account `admin:write`, enroll its factors, +add its address to `BREAK_GLASS_EMAILS` and restart. Then remove the old address and restart again, +and revoke the old account's sessions. After any use, regenerate the account's recovery codes and, +if a key or authenticator app left the safe, re-enroll it. + ## Session Hygiene Administrative session endpoints can list sessions and revoke individual or all sessions for a user. Use these endpoints when responding to suspicious account activity or user-requested device cleanup. diff --git a/docs/configuration.md b/docs/configuration.md index ad978387..eaebf8b1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -223,6 +223,17 @@ identifier), so only someone who receives the code at the owner address can use creates the account from a verified provider email. When `OWNER_EMAIL` is unset the grant is a no-op and the instance has no admin until one is assigned another way. +### Break-glass admin + +| Variable | Required | Default | Notes | +| -------------------- | -------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `BREAK_GLASS_EMAILS` | No | - | Comma separated admin emails, trimmed and matched case-insensitively. A listed account holding a role that satisfies `admin:write` is exempt from sign-in policy but needs a strong factor. | + +Only an existing, non-revoked account that holds `admin`, `admin:*` or `admin:write` is exempt; a +listed address without the role gets nothing, and boot logs a warning with how many listed entries +are not admin accounts (never the addresses). Kept out of `system_config` on purpose, so no policy +edit can remove it. See [Break-glass admin](./admin-operations.md#break-glass-admin). + ### Admin dashboard (optional) This API can serve the admin dashboard SPA ([`seamless-auth-admin-dashboard`](https://github.com/fells-code/seamless-auth-admin-dashboard)) diff --git a/docs/security-posture.md b/docs/security-posture.md index 501568e6..3543b1e6 100644 --- a/docs/security-posture.md +++ b/docs/security-posture.md @@ -494,6 +494,21 @@ address lock its owner out of the one way back in that does not need an administ session a code issues is restricted to enrolling a new factor; see [docs/account-recovery.md](./account-recovery.md). +## Break-glass admin + +Accounts in `BREAK_GLASS_EMAILS` that hold `admin:write` are exempt from sign-in policy, so no +policy change can leave a deployment with nobody able to administer it. The exemption is set in +the environment rather than in `system_config` for that reason. It is narrow by design: a +break-glass account can only sign in with a passkey, TOTP or a recovery code, never with email OTP, +SMS OTP, a magic link or OAuth, because those prove control of an address or an outside identity, +which is what someone taking the account over would have. Session issuance enforces this as a +backstop as well as each endpoint. TOTP stays subject to lockout. Every sign-in that relied on the +exemption is a `break_glass_sign_in` event with `severity: "high"` and is emailed to every admin; +see [Break-glass admin](./admin-operations.md#break-glass-admin). + +The cost is a standing exception to policy for a few named accounts, which is why each must hold +the admin role to count, why their use is loud, and why their factors belong offline. + ## Audit write failure **Posture: the trail may be lost, no security control may be.** diff --git a/openapi.json b/openapi.json index 99070bb5..593184f6 100644 --- a/openapi.json +++ b/openapi.json @@ -3444,6 +3444,7 @@ "admin_user_import_completed", "admin_user_imported", "bearer_token_failed", + "break_glass_sign_in", "credentials_deleted", "informational", "internal_user_updated_by_owner", @@ -3526,6 +3527,7 @@ "admin_user_import_completed", "admin_user_imported", "bearer_token_failed", + "break_glass_sign_in", "credentials_deleted", "informational", "internal_user_updated_by_owner", diff --git a/resources/coverage-badge.svg b/resources/coverage-badge.svg index 8bd6c88a..322cf29d 100644 --- a/resources/coverage-badge.svg +++ b/resources/coverage-badge.svg @@ -1,5 +1,5 @@ - - coverage: 99% + + coverage: 99.1% @@ -7,17 +7,17 @@ - + - - + + coverage coverage - 99% - 99% + 99.1% + 99.1% diff --git a/src/config/breakGlass.ts b/src/config/breakGlass.ts new file mode 100644 index 00000000..448415a1 --- /dev/null +++ b/src/config/breakGlass.ts @@ -0,0 +1,49 @@ +/* + * 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 { hasScopedRole } from '../lib/scopedRoles.js'; + +export type BreakGlassEnv = Record; + +/** + * `BREAK_GLASS_EMAILS` names admin accounts that sign-in policy cannot lock out, so a + * bad policy change (phishing-resistant only with no enrolled keys, say) always leaves + * a way back. Set at deploy time rather than in `system_config`, because anything the + * API can edit is something a bad edit can remove. + */ +export function breakGlassEmails(env: BreakGlassEnv = process.env): Set { + return new Set( + (env.BREAK_GLASS_EMAILS ?? '') + .split(',') + .map((entry) => entry.trim().toLowerCase()) + .filter(Boolean), + ); +} + +export function isBreakGlassConfigured(env: BreakGlassEnv = process.env): boolean { + return breakGlassEmails(env).size > 0; +} + +export type BreakGlassCandidate = { + email?: string | null; + roles?: string[] | null; + revoked?: boolean | null; +}; + +/** + * Being listed is not enough. The account must also hold a role that satisfies + * `admin:write`, so the variable cannot hand a policy exemption to an ordinary user, + * and removing the role is enough to take the exemption away. + */ +export function isBreakGlassAdmin( + user: BreakGlassCandidate | null | undefined, + env: BreakGlassEnv = process.env, +): boolean { + if (!user?.email || user.revoked) return false; + if (!breakGlassEmails(env).has(user.email.trim().toLowerCase())) return false; + + return hasScopedRole(user.roles ?? [], 'admin:write'); +} diff --git a/src/controllers/authentication.ts b/src/controllers/authentication.ts index c8d87017..de5b9c6a 100644 --- a/src/controllers/authentication.ts +++ b/src/controllers/authentication.ts @@ -7,6 +7,7 @@ import { randomUUID } from 'crypto'; import { Request, Response } from 'express'; +import { isBreakGlassAdmin } from '../config/breakGlass.js'; import { getSystemConfig } from '../config/getSystemConfig.js'; import { EPHEMERAL_TOKEN_TTL_SECONDS } from '../lib/ephemeralTokenTtl.js'; import { @@ -19,6 +20,7 @@ import { Credential } from '../models/credentials.js'; import { Session } from '../models/sessions.js'; import { User } from '../models/users.js'; import { AuthEventService } from '../services/authEventService.js'; +import { hasBreakGlassCodeFactor } from '../services/breakGlassService.js'; import { DecoyIdentifierType, decoyPrincipalForSubject, @@ -27,6 +29,7 @@ import { import { getUserLockoutStatus } from '../services/lockoutPolicyService.js'; import { getLoginPolicy, + isLoginMethodEnabled, LoginMethod, resolveAvailableLoginMethods, } from '../services/loginPolicyService.js'; @@ -325,12 +328,20 @@ export const login = async (req: Request, res: Response) => { Credential.findOne({ where: { userId: user.id } }), getLoginPolicy(), ]); - const availableMethods = resolveAvailableLoginMethods({ + const shapedMethods = resolveAvailableLoginMethods({ policy: loginPolicy, user, hasPasskeyCredential: Boolean(credential), passkeyAvailable, }); + // A break-glass admin is offered its passkey even when the method list leaves passkeys + // out. Otherwise its list is shaped like anyone's, so the answer does not single out a + // privileged account; the weak methods in it are refused at their endpoints. + const breakGlass = isBreakGlassAdmin(user); + const availableMethods: LoginMethod[] = + breakGlass && credential && !isLoginMethodEnabled(loginPolicy, 'passkey') + ? ['passkey', ...shapedMethods] + : shapedMethods; // A lock stops the code factors, which can be guessed. A passkey cannot, so a locked // account can still continue with one, which keeps a lock someone else triggered @@ -339,7 +350,23 @@ export const login = async (req: Request, res: Response) => { ? availableMethods.filter((method) => method === 'passkey') : availableMethods; - if (lockout.locked && loginMethods.length === 0) { + // TOTP and recovery codes are not login methods, so an account holding only those + // would be answered as a decoy. A break-glass admin continues, with the list a decoy + // in its place would show. + const continueWithCode = + breakGlass && + loginMethods.length === 0 && + (await hasBreakGlassCodeFactor(user.id, lockout.locked)); + const responseMethods = continueWithCode + ? resolveAvailableLoginMethods({ + policy: loginPolicy, + user, + hasPasskeyCredential: true, + passkeyAvailable, + }).filter((method) => !lockout.locked || method === 'passkey') + : loginMethods; + + if (lockout.locked && loginMethods.length === 0 && !continueWithCode) { await AuthEventService.log({ userId: user.id, type: 'login_suspicious', @@ -358,7 +385,7 @@ export const login = async (req: Request, res: Response) => { }); } - if (loginMethods.length === 0) { + if (loginMethods.length === 0 && !continueWithCode) { logger.error('Login attempt had no allowed continuation methods'); return await respondWithDecoy({ req, @@ -391,7 +418,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/controllers/magicLinks.ts b/src/controllers/magicLinks.ts index 30fedc90..dd29f9ef 100644 --- a/src/controllers/magicLinks.ts +++ b/src/controllers/magicLinks.ts @@ -16,6 +16,7 @@ import { User } from '../models/users.js'; import { MagicLinkRequestQuerySchema } from '../schemas/magiclink.requests.js'; import { claimUnverifiedAccount } from '../services/accountClaim.js'; import { AuthEventService } from '../services/authEventService.js'; +import { rejectWeakFactorForBreakGlass } from '../services/breakGlassService.js'; import { passkeyEnrollmentPrompt } from '../services/enrollmentService.js'; import { getLoginPolicy, @@ -38,7 +39,15 @@ const logger = getLogger('magic-links'); const TTL_MINUTES = 15; -async function rejectDisabledMagicLink(req: Request, res: Response, user?: { id: string } | null) { +async function rejectDisabledMagicLink( + req: Request, + res: Response, + user?: { id: string; email?: string | null; roles?: string[] | null } | null, +) { + if (user && (await rejectWeakFactorForBreakGlass(user, 'magic_link', req, res))) { + return true; + } + const policy = await getLoginPolicy(); if ( diff --git a/src/controllers/oauth.ts b/src/controllers/oauth.ts index 24503743..d41137d8 100644 --- a/src/controllers/oauth.ts +++ b/src/controllers/oauth.ts @@ -6,6 +6,7 @@ import { Request, Response } from 'express'; +import { isBreakGlassAdmin } from '../config/breakGlass.js'; import { getSystemConfig } from '../config/getSystemConfig.js'; import { Credential } from '../models/credentials.js'; import { AuthEventService } from '../services/authEventService.js'; @@ -153,6 +154,16 @@ export async function finishOAuthLogin(req: RouteRequest, res: Response) { return res.status(403).json({ error: 'OAuth signup is disabled' }); } + if (isBreakGlassAdmin(user)) { + await AuthEventService.log({ + userId: user.id, + type: 'oauth_login_failed', + req, + metadata: { providerId: provider.id, reason: 'break_glass_strong_factor_required' }, + }); + return res.status(403).json({ error: 'login_method_disabled' }); + } + await AuthEventService.log({ userId: user.id, subjectEmail: user.email, diff --git a/src/controllers/otp.ts b/src/controllers/otp.ts index 47908d64..a5f76841 100644 --- a/src/controllers/otp.ts +++ b/src/controllers/otp.ts @@ -10,6 +10,7 @@ import { canReturnExternalDelivery } from '../lib/externalDelivery.js'; import { reviewAccountMetadata } from '../lib/reviewAccounts.js'; import { signEphemeralToken } from '../lib/token.js'; import { AuthEventService } from '../services/authEventService.js'; +import { rejectWeakFactorForBreakGlass } from '../services/breakGlassService.js'; import { DeliveryError } from '../services/deliveryError.js'; import { passkeyEnrollmentPrompt } from '../services/enrollmentService.js'; import { rejectIfUserLocked } from '../services/lockoutPolicyService.js'; @@ -40,6 +41,10 @@ async function rejectDisabledLoginMethod( const policy = await getLoginPolicy(); const user = (req as AuthenticatedRequest).user; + if (await rejectWeakFactorForBreakGlass(user, method, req, res)) { + return true; + } + if ( isLoginMethodEnabled(policy, method) && !(user?.id && (await isPasskeyRequiredForUser(user.id, policy))) @@ -323,6 +328,10 @@ export const verifyEmail = async (req: Request, res: Response) => { return; } + if (user.verified && (await rejectWeakFactorForBreakGlass(user, 'email_otp', req, res))) { + return; + } + if (user.verified && (await isPasskeyRequiredForUser(user.id))) { await AuthEventService.log({ userId: user.id, diff --git a/src/controllers/recoveryCodes.ts b/src/controllers/recoveryCodes.ts index dcd197c1..af625822 100644 --- a/src/controllers/recoveryCodes.ts +++ b/src/controllers/recoveryCodes.ts @@ -6,9 +6,11 @@ import { Request, Response } from 'express'; +import { isBreakGlassAdmin } from '../config/breakGlass.js'; import { getSystemConfig } from '../config/getSystemConfig.js'; import { requireStepUp } from '../middleware/requireStepUp.js'; import { AuthEventService } from '../services/authEventService.js'; +import { breakGlassWaivers, recordBreakGlassSignIn } from '../services/breakGlassService.js'; import { generateRecoveryCodes, getRecoveryCodeStatus, @@ -22,6 +24,12 @@ async function recoveryCodesEnabled() { return recovery_codes?.enabled !== false; } +// Turning recovery codes off does not reach a break-glass admin's, which may be the only +// factor it has left after a bad policy change. +async function recoveryCodesUsableBy(user: Parameters[0]) { + return isBreakGlassAdmin(user) || (await recoveryCodesEnabled()); +} + function refuseDisabled(res: Response) { return res.status(403).json({ error: 'recovery_codes_disabled' }); } @@ -57,11 +65,12 @@ async function issueRecoveryCodes(req: Request, res: Response) { * back into the account. */ export const createRecoveryCodes = async (req: Request, res: Response) => { - if (!(await recoveryCodesEnabled())) { + const { user } = req as AuthenticatedRequest; + + if (!(await recoveryCodesUsableBy(user))) { return refuseDisabled(res); } - const { user } = req as AuthenticatedRequest; const { total } = await getRecoveryCodeStatus(user.id); if (total === 0) { @@ -77,11 +86,13 @@ export const createRecoveryCodes = async (req: Request, res: Response) => { * 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())) { + const { user } = req as AuthenticatedRequest; + + if (!(await recoveryCodesUsableBy(user))) { return refuseDisabled(res); } - const { user } = req as AuthenticatedRequest; + const waived = isBreakGlassAdmin(user) ? await breakGlassWaivers('recovery_code', user.id) : []; const redeemed = await redeemRecoveryCode(user.id, req.body.code); if (!redeemed) { @@ -110,4 +121,6 @@ export const redeemRecoveryCodeLogin = async (req: Request, res: Response) => { req, res, }); + + await recordBreakGlassSignIn({ req, user, factor: 'recovery_code', waived }); }; diff --git a/src/controllers/totp.ts b/src/controllers/totp.ts index 0c7a888f..eed77346 100644 --- a/src/controllers/totp.ts +++ b/src/controllers/totp.ts @@ -7,8 +7,10 @@ import { Request, Response } from 'express'; import { productionSecretMissing } from '../config/authSecrets.js'; +import { isBreakGlassAdmin } from '../config/breakGlass.js'; import { getSystemConfig } from '../config/getSystemConfig.js'; import { AuthEventService } from '../services/authEventService.js'; +import { breakGlassWaivers, recordBreakGlassSignIn } from '../services/breakGlassService.js'; import { rejectIfUserLocked } from '../services/lockoutPolicyService.js'; import { isPasskeyRequiredForUser } from '../services/loginPolicyService.js'; import { issueSessionAndRespond } from '../services/sessionIssuance.js'; @@ -181,7 +183,12 @@ export const verifyTotpLogin = async (req: Request, res: Response) => { return; } - if (await isPasskeyRequiredForUser(user.id)) { + // Lockout above still binds: a break-glass account is exempt from policy, not from + // the control that stops its codes being guessed. + const breakGlass = isBreakGlassAdmin(user); + const waived = breakGlass ? await breakGlassWaivers('totp', user.id) : []; + + if (!breakGlass && (await isPasskeyRequiredForUser(user.id))) { await AuthEventService.log({ userId: user.id, type: 'login_failed', @@ -225,6 +232,8 @@ export const verifyTotpLogin = async (req: Request, res: Response) => { await user.update({ lastLogin: new Date(), }); + + await recordBreakGlassSignIn({ req, user, factor: 'totp', waived }); }; export const verifyTotpMfa = async (req: Request, res: Response) => { diff --git a/src/controllers/webauthn.ts b/src/controllers/webauthn.ts index a969789c..7d10c338 100644 --- a/src/controllers/webauthn.ts +++ b/src/controllers/webauthn.ts @@ -14,6 +14,7 @@ import { import base64url from 'base64url'; import { Request, Response } from 'express'; +import { isBreakGlassAdmin } from '../config/breakGlass.js'; import { getSystemConfig } from '../config/getSystemConfig.js'; import { classifyAttestation } from '../lib/attestationType.js'; import { SUPPORTED_ALGORITHM_IDS } from '../lib/webauthnAlgorithms.js'; @@ -29,6 +30,7 @@ import type { WebAuthnAuthenticatorAttachment } from '../schemas/webauthn.reques import { serializeCredential } from '../services/apiResponseSerializers.js'; import { evaluateAuthenticatorPolicy } from '../services/authenticatorPolicyService.js'; import { AuthEventService } from '../services/authEventService.js'; +import { breakGlassWaivers, recordBreakGlassSignIn } from '../services/breakGlassService.js'; import { ensureMetadataServiceReady, hasMetadataStatement, @@ -114,8 +116,12 @@ const registerWebAuthn = async (req: Request, res: Response) => { const { app_name, rpid, authenticator_policy } = await getSystemConfig(); const userVerification = authenticator_policy.userVerification; + // A break-glass admin enrolls whatever authenticator it has to hand, so a policy that + // pins a kind nobody holds cannot stop the way back in. const pinnedAttachment = - authenticator_policy.attachment === 'any' ? null : authenticator_policy.attachment; + authenticator_policy.attachment === 'any' || isBreakGlassAdmin(verifiedUser) + ? null + : authenticator_policy.attachment; // A pinned deployment policy bounds the request, so a caller cannot widen it by // asking for the kind the agency has chosen not to enrol. @@ -307,7 +313,9 @@ const verifyWebAuthnRegistration = async (req: Request, res: Response) => { attestationType, }); - if (!verdict.allowed) { + const breakGlassOverride = !verdict.allowed && isBreakGlassAdmin(user); + + if (!verdict.allowed && !breakGlassOverride) { logger.warn(`Registration refused by authenticator policy: ${verdict.detail}`); await AuthEventService.log({ userId: user.id, @@ -376,7 +384,13 @@ const verifyWebAuthnRegistration = async (req: Request, res: Response) => { userId: user.id, type: 'registration_success', req, - metadata: {}, + metadata: breakGlassOverride + ? { + severity: 'high', + breakGlassWaived: ['authenticator_policy'], + authenticatorPolicyRefusal: verdict.detail, + } + : {}, }); if (authReq.recoverySession) { @@ -613,6 +627,8 @@ const verifyWebAuthn = async (req: Request, res: Response) => { metadata: { reason: 'Successful login' }, }); + const waived = isBreakGlassAdmin(user) ? await breakGlassWaivers('passkey', user.id) : []; + await issueSessionAndRespond({ method: 'passkey', user: { @@ -629,6 +645,8 @@ const verifyWebAuthn = async (req: Request, res: Response) => { lastLogin: new Date(), }); + await recordBreakGlassSignIn({ req, user, factor: 'passkey', waived }); + return; } diff --git a/src/generated/api.ts b/src/generated/api.ts index be7a58aa..96b308b5 100644 --- a/src/generated/api.ts +++ b/src/generated/api.ts @@ -3136,6 +3136,7 @@ export interface paths { | 'admin_user_import_completed' | 'admin_user_imported' | 'bearer_token_failed' + | 'break_glass_sign_in' | 'credentials_deleted' | 'informational' | 'internal_user_updated_by_owner' @@ -3212,6 +3213,7 @@ export interface paths { | 'admin_user_import_completed' | 'admin_user_imported' | 'bearer_token_failed' + | 'break_glass_sign_in' | 'credentials_deleted' | 'informational' | 'internal_user_updated_by_owner' diff --git a/src/lib/authEventCategories.ts b/src/lib/authEventCategories.ts index 2c744e07..aec70874 100644 --- a/src/lib/authEventCategories.ts +++ b/src/lib/authEventCategories.ts @@ -51,6 +51,7 @@ const PREFIX_CATEGORIES: ReadonlyArray<[string, AuthEventCategory]> = [ const EXACT_CATEGORIES: Readonly> = { auth_action_incremented: 'system', + break_glass_sign_in: 'login', credentials_deleted: 'webauthn', informational: 'system', internal_user_updated_by_owner: 'system', diff --git a/src/schemas/authEvent.types.ts b/src/schemas/authEvent.types.ts index 6bf5c30e..9d70fea0 100644 --- a/src/schemas/authEvent.types.ts +++ b/src/schemas/authEvent.types.ts @@ -18,6 +18,7 @@ export const AUTH_EVENT_TYPES = [ 'admin_user_import_completed', 'admin_user_imported', 'bearer_token_failed', + 'break_glass_sign_in', 'credentials_deleted', 'informational', 'internal_user_updated_by_owner', diff --git a/src/server.ts b/src/server.ts index 3a26641e..80458ed7 100644 --- a/src/server.ts +++ b/src/server.ts @@ -16,6 +16,7 @@ import { connectToDb } from './db.js'; import { initializeModels } from './models/index.js'; import { TotpCredential } from './models/totpCredentials.js'; import { startAuditMaintenance } from './services/auditRetention.js'; +import { warnOnBreakGlassMisconfiguration } from './services/breakGlassService.js'; import { initializeMetadataService } from './services/metadataServiceBootstrap.js'; import { warnIfUnboundOAuthStateAllowed } from './services/oauthService.js'; import { assertSchemaCurrent } from './services/schemaGuard.js'; @@ -42,6 +43,7 @@ async function startServer() { // After bootstrap, since which secrets are needed depends on the stored config. assertAuthSecrets(await getSystemConfig(), (await TotpCredential.count()) > 0); + await warnOnBreakGlassMisconfiguration(); // After config is bootstrapped, since it decides whether attestation is // requested at all. Never throws: a metadata blob that cannot be fetched is diff --git a/src/services/breakGlassService.ts b/src/services/breakGlassService.ts new file mode 100644 index 00000000..08dcaefe --- /dev/null +++ b/src/services/breakGlassService.ts @@ -0,0 +1,202 @@ +/* + * 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 { Op } from 'sequelize'; + +import { breakGlassEmails, type BreakGlassEnv, isBreakGlassAdmin } from '../config/breakGlass.js'; +import { getSystemConfig } from '../config/getSystemConfig.js'; +import { hasScopedRole } from '../lib/scopedRoles.js'; +import { User } from '../models/users.js'; +import getLogger from '../utils/logger.js'; +import { AuthEventService } from './authEventService.js'; +import { + getLoginPolicy, + isLoginMethodEnabled, + isPasskeyRequiredForUser, + type LoginPolicy, +} from './loginPolicyService.js'; +import { sendNoticeEmail } from './messagingService.js'; +import { getRecoveryCodeStatus } from './recoveryCodeService.js'; +import { getTotpStatus } from './totpService.js'; + +const logger = getLogger('breakGlass'); + +// Every role string that can satisfy admin:read, used to narrow the query before the +// exact check. hasScopedRole stays the authority on what each one grants. +const ADMIN_GRANTS = ['admin', 'admin:*', 'admin:write', 'admin:read']; + +/** The factors a break-glass account may sign in with. Everything else is refused. */ +export type BreakGlassFactor = 'passkey' | 'totp' | 'recovery_code'; + +/** A policy a break-glass sign-in or enrollment went around, as named in the audit trail. */ +export type BreakGlassWaiver = + | 'login_methods' + | 'phishing_resistant_only' + | 'passkey_required' + | 'recovery_codes_disabled' + | 'authenticator_policy'; + +export type BreakGlassNoticeSummary = { sent: number; failed: number }; + +/** + * Counts, never addresses: the log is not where the list of accounts that bypass policy + * should be readable from. + */ +export async function warnOnBreakGlassMisconfiguration(env: BreakGlassEnv = process.env) { + const listed = [...breakGlassEmails(env)]; + + if (listed.length === 0) return; + + const users = await User.findAll({ + attributes: ['email', 'roles', 'revoked'], + where: { email: { [Op.in]: listed } }, + }); + const admins = users.filter((user) => isBreakGlassAdmin(user, env)).length; + const notAdmins = listed.length - admins; + + if (notAdmins > 0) { + logger.warn( + `${notAdmins} of ${listed.length} BREAK_GLASS_EMAILS entries are not admin accounts ` + + 'and get no break-glass exemption', + ); + return; + } + + logger.info(`Break-glass configured for ${listed.length} admin account(s)`); +} + +/** + * What the policy in force would have refused this sign-in for, had the account not + * been break-glass. Empty means the sign-in was ordinary and is not announced. + */ +export async function breakGlassWaivers( + factor: BreakGlassFactor, + userId: string, + policy?: LoginPolicy, +): Promise { + if (factor === 'recovery_code') { + const { recovery_codes } = await getSystemConfig(); + return recovery_codes?.enabled === false ? ['recovery_codes_disabled'] : []; + } + + const resolved = policy ?? (await getLoginPolicy()); + + if (factor === 'passkey') { + return isLoginMethodEnabled(resolved, 'passkey') ? [] : ['login_methods']; + } + + if (resolved.phishingResistantOnly) return ['phishing_resistant_only']; + + return (await isPasskeyRequiredForUser(userId, resolved)) ? ['passkey_required'] : []; +} + +/** + * Whether a break-glass account with no passkey can still continue from `/login`, with + * TOTP or a recovery code. Neither is a login method, so `/login` lists nothing for them. + * TOTP is subject to lockout like any code; recovery codes are not. + */ +export async function hasBreakGlassCodeFactor(userId: string, locked: boolean) { + if ((await getRecoveryCodeStatus(userId)).remaining > 0) return true; + + return !locked && (await getTotpStatus(userId)).enabled; +} + +async function adminRecipients() { + const admins = await User.findAll({ + attributes: ['email', 'roles'], + where: { + revoked: false, + emailVerified: true, + roles: { [Op.overlap]: ADMIN_GRANTS }, + }, + }); + + return admins + .filter((admin) => admin.email && hasScopedRole(admin.roles ?? [], 'admin:read')) + .map((admin) => admin.email); +} + +async function noticeEveryAdmin( + subjectEmail: string, + factor: BreakGlassFactor, + waived: BreakGlassWaiver[], +): Promise { + const summary = { sent: 0, failed: 0 }; + + try { + const appName = (await getSystemConfig())?.app_name ?? 'Seamless Auth'; + const subject = `Break-glass sign-in on ${appName}`; + const text = + `The break-glass admin account ${subjectEmail} signed in to ${appName} with ` + + `${factor.replace('_', ' ')}, bypassing sign-in policy (${waived.join(', ')}) at ` + + `${new Date().toISOString()}. If this was not a planned recovery, review the audit ` + + 'trail, revoke the sessions and rotate the break-glass credentials.'; + + for (const to of await adminRecipients()) { + try { + await sendNoticeEmail(to, subject, text); + summary.sent += 1; + } catch { + summary.failed += 1; + } + } + } catch (error) { + // The sign-in has already been answered, and the audit event below still records it. + logger.error(`Failed to send break-glass notices: ${error}`); + } + + return summary; +} + +/** + * Called after the session is issued, so a notice that cannot be sent never costs an + * administrator the recovery they are in the middle of. The audit model has no severity + * column, so the event carries `severity: 'high'` in its metadata. + */ +export async function recordBreakGlassSignIn(params: { + req: Request; + user: { id: string; email: string }; + factor: BreakGlassFactor; + waived: BreakGlassWaiver[]; +}) { + const { req, user, factor, waived } = params; + + if (waived.length === 0) return; + + const notices = await noticeEveryAdmin(user.email, factor, waived); + + await AuthEventService.log({ + userId: user.id, + type: 'break_glass_sign_in', + req, + metadata: { severity: 'high', factor, waived, notices }, + }); +} + +/** + * Email OTP, SMS OTP, magic links and OAuth prove control of an address or an outside + * identity, which is exactly what someone taking over a break-glass account would have. + * The account signs in with a passkey, TOTP or a recovery code, whatever else is enabled. + */ +export async function rejectWeakFactorForBreakGlass( + user: Parameters[0] & { id?: string }, + method: string, + req: Request, + res: Response, +): Promise { + if (!isBreakGlassAdmin(user)) return false; + + await AuthEventService.log({ + userId: user?.id ?? null, + type: 'login_failed', + req, + metadata: { reason: 'Break-glass account requires a strong factor', method }, + }); + + res.status(403).json({ error: 'login_method_disabled' }); + return true; +} diff --git a/src/services/sessionIssuance.ts b/src/services/sessionIssuance.ts index bfdaf837..92111713 100644 --- a/src/services/sessionIssuance.ts +++ b/src/services/sessionIssuance.ts @@ -6,6 +6,7 @@ import { Request, Response } from 'express'; +import { isBreakGlassAdmin } from '../config/breakGlass.js'; import { getSystemConfig } from '../config/getSystemConfig.js'; import { createRefreshTokenLookup, generateRefreshToken, signAccessToken } from '../lib/token.js'; import { Session } from '../models/sessions.js'; @@ -25,6 +26,19 @@ export class PasskeyRequiredError extends Error { } } +export class BreakGlassFactorError extends Error { + constructor(method: SessionMethod) { + super(`Refused to issue a break-glass admin session from ${method}`); + this.name = 'BreakGlassFactorError'; + } +} + +const BREAK_GLASS_FACTORS: ReadonlySet = new Set([ + 'passkey', + 'totp', + 'recovery_code', +]); + type IssueSessionParams = { method: SessionMethod; /** @@ -63,7 +77,15 @@ export async function issueSessionAndRespond(params: IssueSessionParams): Promis // 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 && !recovery) { + // A break-glass admin is held to its own rule instead: a strong factor, whatever the + // policy says. + const breakGlass = isBreakGlassAdmin(user); + + if (breakGlass && !accountVerification && !BREAK_GLASS_FACTORS.has(method)) { + throw new BreakGlassFactorError(method); + } + + if (!breakGlass && method !== 'passkey' && !accountVerification && !recovery) { const { phishingResistantOnly } = await getLoginPolicy(); if (phishingResistantOnly) { throw new PasskeyRequiredError(method); diff --git a/tests/integration/breakGlass/breakGlass.spec.ts b/tests/integration/breakGlass/breakGlass.spec.ts new file mode 100644 index 00000000..5f4211d6 --- /dev/null +++ b/tests/integration/breakGlass/breakGlass.spec.ts @@ -0,0 +1,569 @@ +import { isoCBOR } from '@simplewebauthn/server/helpers'; +import { Application } from 'express'; +import request from 'supertest'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +let currentUser: any; + +vi.mock('../../../src/middleware/attachAuthMiddleware.js', async (importOriginal) => { + const actual = + await importOriginal(); + + return { + ...actual, + attachAuthMiddleware: () => (req: any, _res: any, next: any) => { + req.user = currentUser; + req.sessionId = 'session-1'; + req.attemptId = 'attempt-1'; + next(); + }, + }; +}); + +vi.mock('../../../src/services/metadataServiceBootstrap.js', () => ({ + hasMetadataStatement: vi.fn(async () => false), + ensureMetadataServiceReady: vi.fn(async () => true), +})); + +import { createApp } from '../../../src/app.js'; +import { getSystemConfig } from '../../../src/config/getSystemConfig.js'; +import { + createRefreshTokenLookup, + generateRefreshToken, + signAccessToken, + signEphemeralToken, +} from '../../../src/lib/token.js'; +import { AuthFailure } from '../../../src/models/authFailures.js'; +import { Credential } from '../../../src/models/credentials.js'; +import { RecoveryCode } from '../../../src/models/recoveryCodes.js'; +import { Session } from '../../../src/models/sessions.js'; +import { TotpCredential } from '../../../src/models/totpCredentials.js'; +import { User } from '../../../src/models/users.js'; +import { WebAuthnChallenge } from '../../../src/models/webauthnChallenges.js'; +import { AuthEventService } from '../../../src/services/authEventService.js'; +import { sendNoticeEmail } from '../../../src/services/messagingService.js'; +import { encryptTotpSecret } from '../../../src/services/totpService.js'; +import { generateEmailOTP, verifyEmailOTP } from '../../../src/utils/otp.js'; +import { generateTotpCode } from '../../../src/utils/totp.js'; +import { buildCredential } from '../../factories/credentialFactory.js'; +import { buildSystemConfig } from '../../factories/systemConfigFactory.js'; +import { buildTotpCredential } from '../../factories/totpCredentialFactory.js'; +import { buildUser } from '../../factories/userFactory.js'; +import { buildWebAuthnChallenge } from '../../factories/webauthnChallengeFactory.js'; + +let app: Application; + +const TOTP_SECRET = 'JBSWY3DPEHPK3PXP'; + +function account(overrides: Record) { + return buildUser({ + verified: true, + revoked: false, + emailVerificationToken: 'hashed', + emailVerificationTokenExpiry: new Date(Date.now() + 100_000), + ...overrides, + }); +} + +const admin = () => account({ id: 'admin-1', email: 'root@example.com', roles: ['admin:write'] }); +const listedNonAdmin = () => + account({ id: 'listed-1', email: 'listed@example.com', roles: ['user'] }); +const ordinary = () => account({ id: 'user-2', email: 'person@example.com', roles: ['user'] }); + +const AUTHENTICATOR_POLICY = { + attachment: 'any', + userVerification: 'required', + attestation: 'none', + requireKnownAuthenticator: false, + syncedPasskeys: 'allow', + aaguidAllowList: [], + aaguidDenyList: [], +}; + +function policy(overrides: Record = {}) { + const { authenticator_policy, ...rest } = overrides; + + return buildSystemConfig({ + app_name: 'Acme', + login_methods: ['passkey', 'email_otp', 'magic_link'], + refresh_token_ttl: '1h', + recovery_codes: { enabled: true, requireAtEnrollment: false }, + authenticator_policy: { + ...AUTHENTICATOR_POLICY, + ...(authenticator_policy as Record | undefined), + }, + ...rest, + }); +} + +const PHISHING_RESISTANT_ONLY = { phishing_resistant_only: true }; +const PASSKEY_ONLY = { login_methods: ['passkey'], passkey_login_fallback_enabled: false }; + +function breakGlassEvents() { + return (AuthEventService.log as any).mock.calls + .map(([event]: any[]) => event) + .filter((event: any) => event.type === 'break_glass_sign_in'); +} + +function withTotp() { + (TotpCredential.findOne as any).mockResolvedValue( + buildTotpCredential({ ...encryptTotpSecret(TOTP_SECRET), lastUsedCounter: null }), + ); +} + +function totpLogin() { + return request(app) + .post('/totp/verify-login') + .send({ code: generateTotpCode({ secret: TOTP_SECRET }) }); +} + +async function passkeyAssertionVerifies() { + const { verifyAuthenticationResponse } = await import('@simplewebauthn/server'); + + (verifyAuthenticationResponse as any).mockResolvedValue({ + verified: true, + authenticationInfo: { newCounter: 1 }, + }); +} + +function passkeyLogin() { + return request(app) + .post('/webauthn/login/finish') + .send({ assertionResponse: { id: 'cred-1' } }); +} + +beforeAll(async () => { + app = await createApp(); +}); + +beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv('BREAK_GLASS_EMAILS', 'Root@Example.com, listed@example.com'); + process.env.TOTP_SECRET_ENCRYPTION_KEY = 'test-totp-encryption-key'; + process.env.REFRESH_TOKEN_LOOKUP_SECRET = 'lookup-secret'; + + currentUser = admin(); + (getSystemConfig as any).mockResolvedValue(policy()); + (Session.create as any).mockResolvedValue({ id: 'session-1' }); + (signAccessToken as any).mockResolvedValue('access-token'); + (signEphemeralToken as any).mockResolvedValue('ephemeral-token'); + (generateRefreshToken as any).mockReturnValue('refresh-token'); + (createRefreshTokenLookup as any).mockReturnValue('refresh-lookup'); + (Credential.count as any).mockResolvedValue(0); + (Credential.findAll as any).mockResolvedValue([]); + (Credential.findOne as any).mockResolvedValue(null); + (TotpCredential.findOne as any).mockResolvedValue(null); + (WebAuthnChallenge.findOne as any).mockResolvedValue( + buildWebAuthnChallenge({ userId: 'admin-1' }), + ); + (User.findAll as any).mockResolvedValue([ + { email: 'root@example.com', roles: ['admin:write'] }, + { email: 'ops@example.com', roles: ['admin'] }, + ]); +}); + +describe('/login', () => { + it('offers a break-glass admin its passkey when the policy lists no passkey', async () => { + (getSystemConfig as any).mockResolvedValue(policy({ login_methods: ['email_otp'] })); + (User.findOne as any).mockResolvedValue(admin()); + (Credential.findOne as any).mockResolvedValue(buildCredential({ userId: 'admin-1' })); + + const res = await request(app).post('/login').send({ identifier: 'root@example.com' }); + + expect(res.status).toBe(200); + expect(res.body.sub).toBe('admin-1'); + expect(res.body.loginMethods).toEqual(['passkey', 'email_otp']); + }); + + // The list is shaped like anyone's so the answer does not single out a privileged + // account. The weak methods in it are refused at their endpoints. + it('shapes the list for a break-glass admin like an ordinary account', async () => { + (User.findOne as any).mockResolvedValue(admin()); + (Credential.findOne as any).mockResolvedValue(buildCredential({ userId: 'admin-1' })); + const breakGlass = await request(app).post('/login').send({ identifier: 'root@example.com' }); + + (User.findOne as any).mockResolvedValue(ordinary()); + (Credential.findOne as any).mockResolvedValue(buildCredential({ userId: 'user-2' })); + const other = await request(app).post('/login').send({ identifier: 'person@example.com' }); + + expect(breakGlass.body.loginMethods).toEqual(other.body.loginMethods); + }); + + it('lets a TOTP-only break-glass admin continue in phishing-resistant-only mode', async () => { + (getSystemConfig as any).mockResolvedValue(policy(PHISHING_RESISTANT_ONLY)); + (User.findOne as any).mockResolvedValue(admin()); + withTotp(); + + const res = await request(app).post('/login').send({ identifier: 'root@example.com' }); + + expect(res.status).toBe(200); + expect(res.body.sub).toBe('admin-1'); + // What a decoy under this policy shows, never an empty list only this account produces. + expect(res.body.loginMethods).toEqual(['passkey']); + }); + + it('lets a break-glass admin with only recovery codes continue while locked', async () => { + (getSystemConfig as any).mockResolvedValue( + policy({ + ...PHISHING_RESISTANT_ONLY, + lockout_policy: { enabled: true, maxFailures: 3, windowSeconds: 900, lockoutSeconds: 900 }, + }), + ); + (AuthFailure.findAll as any).mockResolvedValue( + Array.from({ length: 3 }, () => ({ occurredAt: new Date() })), + ); + (RecoveryCode.findAll as any).mockResolvedValue([{ usedAt: null, createdAt: new Date() }]); + (User.findOne as any).mockResolvedValue(admin()); + + const res = await request(app).post('/login').send({ identifier: 'root@example.com' }); + + expect(res.status).toBe(200); + expect(res.body.sub).toBe('admin-1'); + expect(res.body.loginMethods).toEqual(['passkey']); + }); + + it('answers an ordinary TOTP-only account in that mode with a decoy', async () => { + (getSystemConfig as any).mockResolvedValue(policy(PHISHING_RESISTANT_ONLY)); + (User.findOne as any).mockResolvedValue(ordinary()); + withTotp(); + + const res = await request(app).post('/login').send({ identifier: 'person@example.com' }); + + expect(res.status).toBe(200); + expect(res.body.sub).not.toBe('user-2'); + }); + + it('answers a listed account without an admin role with a decoy too', async () => { + (getSystemConfig as any).mockResolvedValue(policy(PHISHING_RESISTANT_ONLY)); + (User.findOne as any).mockResolvedValue(listedNonAdmin()); + withTotp(); + + const res = await request(app).post('/login').send({ identifier: 'listed@example.com' }); + + expect(res.body.sub).not.toBe('listed-1'); + }); + + it('keeps lockout for a break-glass admin with only TOTP', async () => { + (getSystemConfig as any).mockResolvedValue( + policy({ + ...PHISHING_RESISTANT_ONLY, + lockout_policy: { enabled: true, maxFailures: 3, windowSeconds: 900, lockoutSeconds: 900 }, + }), + ); + (AuthFailure.findAll as any).mockResolvedValue( + Array.from({ length: 3 }, () => ({ occurredAt: new Date() })), + ); + (User.findOne as any).mockResolvedValue(admin()); + withTotp(); + + const res = await request(app).post('/login').send({ identifier: 'root@example.com' }); + + expect(res.status).toBe(423); + }); + + it('still answers a break-glass admin with no strong factor as a decoy', async () => { + (getSystemConfig as any).mockResolvedValue(policy(PHISHING_RESISTANT_ONLY)); + (User.findOne as any).mockResolvedValue(admin()); + + const res = await request(app).post('/login').send({ identifier: 'root@example.com' }); + + expect(res.status).toBe(200); + expect(res.body.sub).not.toBe('admin-1'); + }); +}); + +describe('passkey sign-in', () => { + beforeEach(async () => { + await passkeyAssertionVerifies(); + (Credential.findOne as any).mockResolvedValue( + buildCredential({ userId: 'admin-1', update: vi.fn() }), + ); + }); + + it.each([ + ['phishing-resistant only', PHISHING_RESISTANT_ONLY], + ['passkey only', PASSKEY_ONLY], + ])('signs a break-glass admin in under %s without announcing it', async (_name, config) => { + (getSystemConfig as any).mockResolvedValue(policy(config)); + + const res = await passkeyLogin(); + + expect(res.status).toBe(200); + expect(res.body.sub).toBe('admin-1'); + expect(breakGlassEvents()).toEqual([]); + expect(sendNoticeEmail).not.toHaveBeenCalled(); + }); + + it('audits and announces a passkey sign-in the method list would have refused', async () => { + (getSystemConfig as any).mockResolvedValue(policy({ login_methods: ['email_otp'] })); + + const res = await passkeyLogin(); + + expect(res.status).toBe(200); + expect(breakGlassEvents()).toEqual([ + expect.objectContaining({ + userId: 'admin-1', + metadata: { + severity: 'high', + factor: 'passkey', + waived: ['login_methods'], + notices: { sent: 2, failed: 0 }, + }, + }), + ]); + expect(sendNoticeEmail).toHaveBeenCalledWith( + 'ops@example.com', + 'Break-glass sign-in on Acme', + expect.stringContaining('root@example.com'), + ); + }); +}); + +describe('TOTP sign-in', () => { + beforeEach(() => { + withTotp(); + }); + + it.each([ + ['phishing-resistant only', PHISHING_RESISTANT_ONLY, 'phishing_resistant_only'], + ['passkey only', PASSKEY_ONLY, 'passkey_required'], + ])('signs a break-glass admin in under %s and announces it', async (_name, config, waiver) => { + (getSystemConfig as any).mockResolvedValue(policy(config)); + (Credential.count as any).mockResolvedValue(1); + + const res = await totpLogin(); + + expect(res.status).toBe(200); + expect(res.body.sub).toBe('admin-1'); + expect(breakGlassEvents()).toEqual([ + expect.objectContaining({ + metadata: expect.objectContaining({ severity: 'high', factor: 'totp', waived: [waiver] }), + }), + ]); + expect(sendNoticeEmail).toHaveBeenCalledTimes(2); + }); + + it.each([ + ['an ordinary user', ordinary], + ['a listed account without an admin role', listedNonAdmin], + ])('refuses %s under the same policy', async (_name, build) => { + currentUser = build(); + (getSystemConfig as any).mockResolvedValue(policy(PHISHING_RESISTANT_ONLY)); + + const res = await totpLogin(); + + expect(res.status).toBe(403); + expect(res.body).toEqual({ error: 'login_method_disabled' }); + expect(Session.create).not.toHaveBeenCalled(); + expect(breakGlassEvents()).toEqual([]); + }); + + it('keeps lockout for a break-glass admin', async () => { + (getSystemConfig as any).mockResolvedValue( + policy({ + ...PHISHING_RESISTANT_ONLY, + lockout_policy: { enabled: true, maxFailures: 3, windowSeconds: 900, lockoutSeconds: 900 }, + }), + ); + (AuthFailure.findAll as any).mockResolvedValue( + Array.from({ length: 3 }, () => ({ occurredAt: new Date() })), + ); + + const res = await totpLogin(); + + expect(res.status).toBe(423); + expect(Session.create).not.toHaveBeenCalled(); + }); + + it('does not announce a TOTP sign-in the policy allowed anyway', async () => { + const res = await totpLogin(); + + expect(res.status).toBe(200); + expect(breakGlassEvents()).toEqual([]); + }); +}); + +describe('recovery code sign-in', () => { + const disabled = { recovery_codes: { enabled: false, requireAtEnrollment: false } }; + + it('lets a break-glass admin redeem a code while codes are disabled, and announces it', async () => { + (getSystemConfig as any).mockResolvedValue(policy(disabled)); + + const res = await request(app).post('/recovery-codes/redeem').send({ code: 'ABCDE-FGHJK' }); + + expect(res.status).toBe(200); + expect(res.body.recovery).toBe(true); + expect(breakGlassEvents()).toEqual([ + expect.objectContaining({ + metadata: expect.objectContaining({ + factor: 'recovery_code', + waived: ['recovery_codes_disabled'], + }), + }), + ]); + }); + + it('refuses an ordinary user while codes are disabled', async () => { + currentUser = ordinary(); + (getSystemConfig as any).mockResolvedValue(policy(disabled)); + + const res = await request(app).post('/recovery-codes/redeem').send({ code: 'ABCDE-FGHJK' }); + + expect(res.status).toBe(403); + expect(RecoveryCode.update).not.toHaveBeenCalled(); + }); + + it('lets a break-glass admin make a set while codes are disabled', async () => { + (getSystemConfig as any).mockResolvedValue(policy(disabled)); + + const res = await request(app).post('/recovery-codes'); + + expect(res.status).toBe(201); + }); +}); + +describe('factors that prove only an address', () => { + it('refuses email OTP for a break-glass admin even though it is enabled', async () => { + const res = await request(app) + .post('/otp/verify-login-email-otp') + .send({ verificationToken: '123456' }); + + expect(res.status).toBe(403); + expect(res.body).toEqual({ error: 'login_method_disabled' }); + expect(verifyEmailOTP).not.toHaveBeenCalled(); + expect(Session.create).not.toHaveBeenCalled(); + }); + + it('does not send a break-glass admin an email OTP', async () => { + const res = await request(app).get('/otp/generate-login-email-otp'); + + expect(res.status).toBe(403); + expect(generateEmailOTP).not.toHaveBeenCalled(); + }); + + it('refuses the registration email check as a way in for a verified break-glass admin', async () => { + const res = await request(app) + .post('/otp/verify-email-otp') + .send({ verificationToken: '123456' }); + + expect(res.status).toBe(403); + expect(verifyEmailOTP).not.toHaveBeenCalled(); + }); + + it('refuses a magic link for a break-glass admin', async () => { + const res = await request(app).get('/magic-link'); + + expect(res.status).toBe(403); + expect(res.body).toEqual({ error: 'login_method_disabled' }); + }); + + it('still signs an ordinary user in with email OTP under the same policy', async () => { + currentUser = ordinary(); + (verifyEmailOTP as any).mockResolvedValue({ user: currentUser, verified: true }); + + const res = await request(app) + .post('/otp/verify-login-email-otp') + .send({ verificationToken: '123456' }); + + expect(res.status).toBe(200); + expect(Session.create).toHaveBeenCalled(); + }); + + it('signs a listed account without an admin role in with email OTP like anyone else', async () => { + currentUser = listedNonAdmin(); + (verifyEmailOTP as any).mockResolvedValue({ user: currentUser, verified: true }); + + const res = await request(app) + .post('/otp/verify-login-email-otp') + .send({ verificationToken: '123456' }); + + expect(res.status).toBe(200); + }); +}); + +describe('passkey enrollment', () => { + const NONE_ATTESTATION = isoCBOR.encode( + new Map([ + ['fmt', 'none'], + ['attStmt', new Map()], + ['authData', new Uint8Array([1, 2, 3])], + ]) as never, + ); + + async function syncedPasskeyVerifies() { + const { verifyRegistrationResponse } = await import('@simplewebauthn/server'); + + (verifyRegistrationResponse as any).mockResolvedValue({ + verified: true, + registrationInfo: { + fmt: 'none', + aaguid: '00000000-0000-0000-0000-000000000000', + attestationObject: NONE_ATTESTATION, + credential: { id: 'cred-1', publicKey: Buffer.from('key'), counter: 0, transports: [] }, + credentialBackedUp: true, + credentialDeviceType: 'multiDevice', + }, + }); + } + + function register() { + return request(app) + .post('/webauthn/register/finish') + .send({ attestationResponse: {}, metadata: {} }); + } + + beforeEach(async () => { + await syncedPasskeyVerifies(); + (getSystemConfig as any).mockResolvedValue( + policy({ ...PHISHING_RESISTANT_ONLY, authenticator_policy: { syncedPasskeys: 'block' } }), + ); + (Credential.create as any).mockResolvedValue({ createdAt: new Date() }); + (WebAuthnChallenge.findOne as any).mockResolvedValue( + buildWebAuthnChallenge({ purpose: 'registration' }), + ); + }); + + it('enrolls the passkey a break-glass admin has, outside the authenticator policy', async () => { + (User.findOne as any).mockResolvedValue(admin()); + + const res = await register(); + + expect(res.status).toBe(200); + expect(Credential.create).toHaveBeenCalled(); + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'registration_success', + metadata: expect.objectContaining({ + severity: 'high', + breakGlassWaived: ['authenticator_policy'], + }), + }), + ); + }); + + it('refuses the same passkey for an ordinary user', async () => { + (User.findOne as any).mockResolvedValue(ordinary()); + + const res = await register(); + + expect(res.status).toBe(403); + expect(res.body).toEqual({ error: 'synced_passkey_not_allowed' }); + expect(Credential.create).not.toHaveBeenCalled(); + }); + + it('lets a break-glass admin ask for an attachment the policy does not pin', async () => { + const { generateRegistrationOptions } = await import('@simplewebauthn/server'); + (generateRegistrationOptions as any).mockResolvedValue({ challenge: 'challenge' }); + (getSystemConfig as any).mockResolvedValue( + policy({ authenticator_policy: { attachment: 'cross-platform' } }), + ); + + const allowed = await request(app).get('/webauthn/register/start?attachment=platform'); + + currentUser = ordinary(); + const refused = await request(app).get('/webauthn/register/start?attachment=platform'); + + expect(allowed.status).toBe(200); + expect(refused.status).toBe(400); + }); +}); diff --git a/tests/integration/oauth/oauth.spec.ts b/tests/integration/oauth/oauth.spec.ts index 58c8b4b5..4733b211 100644 --- a/tests/integration/oauth/oauth.spec.ts +++ b/tests/integration/oauth/oauth.spec.ts @@ -222,6 +222,50 @@ describe('OAuth routes', () => { expect(res.body.returnTo).toBe('http://localhost:5174/dashboard'); }); + // A provider account proves an outside identity, which is what someone taking over a + // break-glass account would have, so it never signs one in. + it('refuses to sign in a break-glass admin with OAuth', async () => { + vi.stubEnv('BREAK_GLASS_EMAILS', 'person@example.com'); + const start = await request(app).post('/oauth/google/start').send({ + redirectUri: 'http://localhost:5174/oauth/callback', + }); + + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + fetchMock + .mockResolvedValueOnce({ ok: true, json: async () => ({ access_token: 'provider-token' }) }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + sub: 'provider-user', + email: 'person@example.com', + email_verified: true, + }), + }); + + (OAuthIdentity.findOne as any).mockResolvedValue(null); + (OAuthIdentity.findOrCreate as any).mockResolvedValue([]); + (User.findOne as any).mockResolvedValue( + buildUser({ id: 'user-1', email: 'person@example.com', phone: null, roles: ['admin'] }), + ); + + const res = await request(app) + .post('/oauth/google/callback') + .send({ code: 'oauth-code', state: start.body.state, binding: start.body.binding }); + + vi.unstubAllEnvs(); + expect(res.status).toBe(403); + expect(res.body).toEqual({ error: 'login_method_disabled' }); + expect(Session.create).not.toHaveBeenCalled(); + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'oauth_login_failed', + metadata: expect.objectContaining({ reason: 'break_glass_strong_factor_required' }), + }), + ); + }); + it('omits returnTo when the caller asked for none', async () => { const start = await request(app).post('/oauth/google/start').send({ redirectUri: 'http://localhost:5174/oauth/callback', diff --git a/tests/unit/config/breakGlass.spec.ts b/tests/unit/config/breakGlass.spec.ts new file mode 100644 index 00000000..8d41723f --- /dev/null +++ b/tests/unit/config/breakGlass.spec.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; + +import { + breakGlassEmails, + isBreakGlassAdmin, + isBreakGlassConfigured, +} from '../../../src/config/breakGlass.js'; + +const env = { BREAK_GLASS_EMAILS: ' Root@Example.com, ,second@example.com ,' }; + +describe('breakGlassEmails', () => { + it('trims, lower-cases and drops empty entries', () => { + expect([...breakGlassEmails(env)]).toEqual(['root@example.com', 'second@example.com']); + }); + + it('is empty when the variable is unset', () => { + expect(breakGlassEmails({}).size).toBe(0); + expect(isBreakGlassConfigured({})).toBe(false); + expect(isBreakGlassConfigured(env)).toBe(true); + }); +}); + +describe('isBreakGlassAdmin', () => { + it.each([['admin:write'], ['admin'], ['admin:*']])( + 'exempts a listed account holding %s', + (role) => { + expect(isBreakGlassAdmin({ email: 'ROOT@example.com ', roles: ['user', role] }, env)).toBe( + true, + ); + }, + ); + + it('gives a listed account without admin:write no exemption', () => { + expect(isBreakGlassAdmin({ email: 'root@example.com', roles: ['admin:read'] }, env)).toBe( + false, + ); + expect(isBreakGlassAdmin({ email: 'root@example.com', roles: null }, env)).toBe(false); + }); + + it('gives an unlisted admin no exemption', () => { + expect(isBreakGlassAdmin({ email: 'other@example.com', roles: ['admin'] }, env)).toBe(false); + }); + + it('gives a revoked account, or no account, no exemption', () => { + expect( + isBreakGlassAdmin({ email: 'root@example.com', roles: ['admin'], revoked: true }, env), + ).toBe(false); + expect(isBreakGlassAdmin(null, env)).toBe(false); + expect(isBreakGlassAdmin({ email: null, roles: ['admin'] }, env)).toBe(false); + }); + + it('reads process.env by default', () => { + expect(isBreakGlassAdmin({ email: 'root@example.com', roles: ['admin'] })).toBe( + breakGlassEmails().has('root@example.com'), + ); + }); +}); diff --git a/tests/unit/services/breakGlassService.spec.ts b/tests/unit/services/breakGlassService.spec.ts new file mode 100644 index 00000000..e63a00d3 --- /dev/null +++ b/tests/unit/services/breakGlassService.spec.ts @@ -0,0 +1,245 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getSystemConfig } from '../../../src/config/getSystemConfig.js'; +import { Credential } from '../../../src/models/credentials.js'; +import { RecoveryCode } from '../../../src/models/recoveryCodes.js'; +import { TotpCredential } from '../../../src/models/totpCredentials.js'; +import { User } from '../../../src/models/users.js'; +import { AuthEventService } from '../../../src/services/authEventService.js'; +import { + breakGlassWaivers, + hasBreakGlassCodeFactor, + recordBreakGlassSignIn, + rejectWeakFactorForBreakGlass, + warnOnBreakGlassMisconfiguration, +} from '../../../src/services/breakGlassService.js'; +import { DeliveryError } from '../../../src/services/deliveryError.js'; +import { sendNoticeEmail } from '../../../src/services/messagingService.js'; +import getLogger from '../../../src/utils/logger.js'; + +const logger = getLogger('breakGlass'); +const req = { ip: '127.0.0.1', headers: {} } as any; + +function mockRes() { + const res: any = {}; + res.status = vi.fn().mockReturnValue(res); + res.json = vi.fn().mockReturnValue(res); + return res; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv('BREAK_GLASS_EMAILS', 'root@example.com'); + vi.spyOn(logger, 'warn'); + vi.spyOn(logger, 'info'); + vi.spyOn(logger, 'error'); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('warnOnBreakGlassMisconfiguration', () => { + const env = { BREAK_GLASS_EMAILS: 'root@example.com,nobody@example.com,user@example.com' }; + + it('does nothing when no address is listed', async () => { + await warnOnBreakGlassMisconfiguration({}); + + expect(User.findAll).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('warns with a count of listed entries that are not admin accounts, never the addresses', async () => { + (User.findAll as any).mockResolvedValue([ + { email: 'root@example.com', roles: ['admin:write'], revoked: false }, + { email: 'user@example.com', roles: ['user'], revoked: false }, + ]); + + await warnOnBreakGlassMisconfiguration(env); + + expect(logger.warn).toHaveBeenCalledTimes(1); + const message = (logger.warn as any).mock.calls[0][0] as string; + expect(message).toContain('2 of 3 BREAK_GLASS_EMAILS entries are not admin accounts'); + expect(message).not.toContain('@'); + }); + + it('reports a clean configuration without addresses', async () => { + (User.findAll as any).mockResolvedValue([ + { email: 'root@example.com', roles: ['admin'], revoked: false }, + ]); + + await warnOnBreakGlassMisconfiguration({ BREAK_GLASS_EMAILS: 'root@example.com' }); + + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith('Break-glass configured for 1 admin account(s)'); + }); +}); + +describe('breakGlassWaivers', () => { + it('names login_methods for a passkey sign-in the method list leaves out', async () => { + (getSystemConfig as any).mockResolvedValue({ login_methods: ['email_otp'] }); + + expect(await breakGlassWaivers('passkey', 'user-1')).toEqual(['login_methods']); + }); + + it('waives nothing for a passkey the policy already allows', async () => { + (getSystemConfig as any).mockResolvedValue({ phishing_resistant_only: true }); + + expect(await breakGlassWaivers('passkey', 'user-1')).toEqual([]); + }); + + it('names phishing_resistant_only for TOTP in that mode', async () => { + (getSystemConfig as any).mockResolvedValue({ phishing_resistant_only: true }); + + expect(await breakGlassWaivers('totp', 'user-1')).toEqual(['phishing_resistant_only']); + }); + + it('names passkey_required for TOTP on an account whose passkey has no fallback', async () => { + (getSystemConfig as any).mockResolvedValue({ + login_methods: ['passkey', 'email_otp'], + passkey_login_fallback_enabled: false, + }); + (Credential.count as any).mockResolvedValue(1); + + expect(await breakGlassWaivers('totp', 'user-1')).toEqual(['passkey_required']); + }); + + it('waives nothing for TOTP when fallback is allowed', async () => { + (getSystemConfig as any).mockResolvedValue({ + login_methods: ['passkey', 'email_otp'], + passkey_login_fallback_enabled: true, + }); + + expect(await breakGlassWaivers('totp', 'user-1')).toEqual([]); + }); + + it('names recovery_codes_disabled for a code redeemed while codes are off', async () => { + (getSystemConfig as any).mockResolvedValue({ recovery_codes: { enabled: false } }); + expect(await breakGlassWaivers('recovery_code', 'user-1')).toEqual(['recovery_codes_disabled']); + + (getSystemConfig as any).mockResolvedValue({ recovery_codes: { enabled: true } }); + expect(await breakGlassWaivers('recovery_code', 'user-1')).toEqual([]); + }); +}); + +describe('hasBreakGlassCodeFactor', () => { + it('counts an unspent recovery code, even while locked', async () => { + (RecoveryCode.findAll as any).mockResolvedValue([{ usedAt: null, createdAt: new Date() }]); + + expect(await hasBreakGlassCodeFactor('user-1', true)).toBe(true); + }); + + it('counts TOTP only while the account is not locked', async () => { + (RecoveryCode.findAll as any).mockResolvedValue([]); + (TotpCredential.findOne as any).mockResolvedValue({ enabled: true }); + + expect(await hasBreakGlassCodeFactor('user-1', false)).toBe(true); + expect(await hasBreakGlassCodeFactor('user-1', true)).toBe(false); + }); +}); + +describe('recordBreakGlassSignIn', () => { + const user = { id: 'user-1', email: 'root@example.com' }; + + it('records nothing for a sign-in that relied on no exemption', async () => { + await recordBreakGlassSignIn({ req, user, factor: 'passkey', waived: [] }); + + expect(AuthEventService.log).not.toHaveBeenCalled(); + expect(sendNoticeEmail).not.toHaveBeenCalled(); + }); + + it('notices every verified admin and logs a high severity event', async () => { + (getSystemConfig as any).mockResolvedValue({ app_name: 'Acme' }); + (User.findAll as any).mockResolvedValue([ + { email: 'root@example.com', roles: ['admin:write'] }, + { email: 'reader@example.com', roles: ['admin:read'] }, + { email: 'scoped@example.com', roles: ['admin:users:read'] }, + ]); + (sendNoticeEmail as any) + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new DeliveryError('down', null)); + + await recordBreakGlassSignIn({ + req, + user, + factor: 'recovery_code', + waived: ['recovery_codes_disabled'], + }); + + expect(User.findAll).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ revoked: false, emailVerified: true }), + }), + ); + expect(sendNoticeEmail).toHaveBeenCalledTimes(2); + expect(sendNoticeEmail).toHaveBeenCalledWith( + 'reader@example.com', + 'Break-glass sign-in on Acme', + expect.stringContaining('root@example.com signed in to Acme with recovery code'), + ); + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + type: 'break_glass_sign_in', + metadata: { + severity: 'high', + factor: 'recovery_code', + waived: ['recovery_codes_disabled'], + notices: { sent: 1, failed: 1 }, + }, + }), + ); + }); + + it('still logs the event when the recipients cannot be read', async () => { + (getSystemConfig as any).mockResolvedValue(null); + (User.findAll as any).mockRejectedValue(new Error('db down')); + + await recordBreakGlassSignIn({ req, user, factor: 'totp', waived: ['passkey_required'] }); + + expect(logger.error).toHaveBeenCalled(); + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'break_glass_sign_in', + metadata: expect.objectContaining({ notices: { sent: 0, failed: 0 } }), + }), + ); + }); +}); + +describe('rejectWeakFactorForBreakGlass', () => { + it('refuses a listed admin', async () => { + const res = mockRes(); + + const refused = await rejectWeakFactorForBreakGlass( + { id: 'user-1', email: 'root@example.com', roles: ['admin'] }, + 'email_otp', + req, + res, + ); + + expect(refused).toBe(true); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ error: 'login_method_disabled' }); + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'login_failed', + metadata: { reason: 'Break-glass account requires a strong factor', method: 'email_otp' }, + }), + ); + }); + + it('leaves anyone else to the ordinary policy', async () => { + const res = mockRes(); + + expect( + await rejectWeakFactorForBreakGlass( + { email: 'root@example.com', roles: ['user'] }, + 'email_otp', + req, + res, + ), + ).toBe(false); + expect(res.status).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/services/sessionIssueService.spec.ts b/tests/unit/services/sessionIssueService.spec.ts index 3f6e21f2..7c2a3f00 100644 --- a/tests/unit/services/sessionIssueService.spec.ts +++ b/tests/unit/services/sessionIssueService.spec.ts @@ -1,6 +1,7 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { + BreakGlassFactorError, issueSessionAndRespond, PasskeyRequiredError, } from '../../../src/services/sessionIssuance.js'; @@ -207,6 +208,59 @@ describe('issueSessionAndRespond', () => { expect(Session.create).toHaveBeenCalled(); }); + describe('for a break-glass admin', () => { + const breakGlassUser = { ...mockUser, email: 'Root@Example.com', roles: ['admin:write'] }; + + beforeEach(() => { + vi.stubEnv('BREAK_GLASS_EMAILS', 'root@example.com'); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it.each(['passkey', 'totp', 'recovery_code'] as const)( + 'starts a session from %s, which policy cannot take away', + async (method) => { + await issueSessionAndRespond({ + method, + user: breakGlassUser, + req: mockReq(), + res: mockRes(), + }); + + expect(Session.create).toHaveBeenCalled(); + }, + ); + + it.each(['email_otp', 'phone_otp', 'magic_link', 'oauth'] as const)( + 'refuses %s, which proves only an address or an outside identity', + async (method) => { + await expect( + issueSessionAndRespond({ + method, + user: breakGlassUser, + req: mockReq(), + res: mockRes(), + }), + ).rejects.toBeInstanceOf(BreakGlassFactorError); + + expect(Session.create).not.toHaveBeenCalled(); + }, + ); + + it('gives a listed account without an admin role no exemption', async () => { + await expect( + issueSessionAndRespond({ + method: 'totp', + user: { ...breakGlassUser, roles: ['user'] }, + req: mockReq(), + res: mockRes(), + }), + ).rejects.toBeInstanceOf(PasskeyRequiredError); + }); + }); + it("starts the one session that completes a new account's address verification", async () => { await issueSessionAndRespond({ method: 'email_otp', From fbbb2b28808ac6aa9faf15e1bb332357a9751f42 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 24 Oct 2026 08:19:52 -0400 Subject: [PATCH 2/3] feat(break-glass): announce passkeys enrolled outside the authenticator policy --- .changeset/break-glass-admin.md | 2 + docs/admin-operations.md | 10 ++-- src/controllers/webauthn.ts | 14 +++++- src/lib/authEventCategories.ts | 1 + src/schemas/authEvent.types.ts | 1 + src/services/breakGlassService.ts | 46 ++++++++++++++++--- .../integration/breakGlass/breakGlass.spec.ts | 17 +++++++ 7 files changed, 81 insertions(+), 10 deletions(-) diff --git a/.changeset/break-glass-admin.md b/.changeset/break-glass-admin.md index 81bed10d..2f15eee4 100644 --- a/.changeset/break-glass-admin.md +++ b/.changeset/break-glass-admin.md @@ -10,3 +10,5 @@ Break-glass admin accounts that no sign-in policy can lock out (#401). - Every sign-in that relied on the exemption records a new `break_glass_sign_in` auth event with `severity: "high"` in its metadata and emails a notice to every admin account with a verified email. See the Break-glass admin section of docs/admin-operations.md. + +A passkey a break-glass admin enrolls outside the authenticator policy is announced like a waived sign-in: a `break_glass_enrollment` event with `severity: 'high'` and a notice to every admin. diff --git a/docs/admin-operations.md b/docs/admin-operations.md index 39915613..008463a7 100644 --- a/docs/admin-operations.md +++ b/docs/admin-operations.md @@ -150,9 +150,13 @@ event carries `severity: "high"` in its metadata, together with `factor` (`passk `recovery_code`), `waived` (the settings it went around) and `notices` (`sent`, `failed`). Every admin account with a verified email is sent a notice naming the account and the time. A notice that cannot be sent never fails the sign-in; the count is in the event. A sign-in the policy would have -allowed anyway is ordinary and is not announced. An enrollment outside the authenticator policy is -marked on its `registration_success` event with `severity: "high"` and -`breakGlassWaived: ["authenticator_policy"]`. +allowed anyway is ordinary and is not announced. + +An enrollment outside the authenticator policy is announced the same way: a +`break_glass_enrollment` event (`severity: "high"`, `waived: ["authenticator_policy"]`, the +refusal it went around, and `notices`), and a notice to every admin. Its `registration_success` +event is also marked with `breakGlassWaived: ["authenticator_policy"]`. The passkey keeps working +after the policy is put right, so review it then. ### Rotating it diff --git a/src/controllers/webauthn.ts b/src/controllers/webauthn.ts index 7d10c338..24186f37 100644 --- a/src/controllers/webauthn.ts +++ b/src/controllers/webauthn.ts @@ -30,7 +30,11 @@ import type { WebAuthnAuthenticatorAttachment } from '../schemas/webauthn.reques import { serializeCredential } from '../services/apiResponseSerializers.js'; import { evaluateAuthenticatorPolicy } from '../services/authenticatorPolicyService.js'; import { AuthEventService } from '../services/authEventService.js'; -import { breakGlassWaivers, recordBreakGlassSignIn } from '../services/breakGlassService.js'; +import { + breakGlassWaivers, + recordBreakGlassEnrollment, + recordBreakGlassSignIn, +} from '../services/breakGlassService.js'; import { ensureMetadataServiceReady, hasMetadataStatement, @@ -393,6 +397,14 @@ const verifyWebAuthnRegistration = async (req: Request, res: Response) => { : {}, }); + if (breakGlassOverride) { + await recordBreakGlassEnrollment({ + req, + user: { id: user.id, email: user.email }, + refusal: verdict.detail ?? 'authenticator policy', + }); + } + if (authReq.recoverySession) { await liftRecoveryRestriction(authReq.sessionId); } diff --git a/src/lib/authEventCategories.ts b/src/lib/authEventCategories.ts index aec70874..4f9bf531 100644 --- a/src/lib/authEventCategories.ts +++ b/src/lib/authEventCategories.ts @@ -51,6 +51,7 @@ const PREFIX_CATEGORIES: ReadonlyArray<[string, AuthEventCategory]> = [ const EXACT_CATEGORIES: Readonly> = { auth_action_incremented: 'system', + break_glass_enrollment: 'webauthn', break_glass_sign_in: 'login', credentials_deleted: 'webauthn', informational: 'system', diff --git a/src/schemas/authEvent.types.ts b/src/schemas/authEvent.types.ts index 9d70fea0..aa8411fb 100644 --- a/src/schemas/authEvent.types.ts +++ b/src/schemas/authEvent.types.ts @@ -18,6 +18,7 @@ export const AUTH_EVENT_TYPES = [ 'admin_user_import_completed', 'admin_user_imported', 'bearer_token_failed', + 'break_glass_enrollment', 'break_glass_sign_in', 'credentials_deleted', 'informational', diff --git a/src/services/breakGlassService.ts b/src/services/breakGlassService.ts index 08dcaefe..ace22038 100644 --- a/src/services/breakGlassService.ts +++ b/src/services/breakGlassService.ts @@ -122,17 +122,23 @@ async function adminRecipients() { async function noticeEveryAdmin( subjectEmail: string, - factor: BreakGlassFactor, - waived: BreakGlassWaiver[], + what: { kind: 'sign_in'; factor: BreakGlassFactor } | { kind: 'enrollment' }, + waived: string[], ): Promise { const summary = { sent: 0, failed: 0 }; try { const appName = (await getSystemConfig())?.app_name ?? 'Seamless Auth'; - const subject = `Break-glass sign-in on ${appName}`; + const subject = + what.kind === 'sign_in' + ? `Break-glass sign-in on ${appName}` + : `Break-glass passkey enrolled on ${appName}`; + const action = + what.kind === 'sign_in' + ? `signed in to ${appName} with ${what.factor.replace('_', ' ')}, bypassing sign-in policy` + : `enrolled a passkey on ${appName} that the authenticator policy would refuse`; const text = - `The break-glass admin account ${subjectEmail} signed in to ${appName} with ` + - `${factor.replace('_', ' ')}, bypassing sign-in policy (${waived.join(', ')}) at ` + + `The break-glass admin account ${subjectEmail} ${action} (${waived.join(', ')}) at ` + `${new Date().toISOString()}. If this was not a planned recovery, review the audit ` + 'trail, revoke the sessions and rotate the break-glass credentials.'; @@ -167,7 +173,7 @@ export async function recordBreakGlassSignIn(params: { if (waived.length === 0) return; - const notices = await noticeEveryAdmin(user.email, factor, waived); + const notices = await noticeEveryAdmin(user.email, { kind: 'sign_in', factor }, waived); await AuthEventService.log({ userId: user.id, @@ -177,6 +183,34 @@ export async function recordBreakGlassSignIn(params: { }); } +/** + * A passkey the authenticator policy refused, admitted because the account is break-glass. + * Announced like a waived sign-in: it is a credential no one else could have added, and + * it keeps working after the policy is put right. + */ +export async function recordBreakGlassEnrollment(params: { + req: Request; + user: { id: string; email: string }; + refusal: string; +}) { + const { req, user, refusal } = params; + const notices = await noticeEveryAdmin(user.email, { kind: 'enrollment' }, [ + 'authenticator_policy', + ]); + + await AuthEventService.log({ + userId: user.id, + type: 'break_glass_enrollment', + req, + metadata: { + severity: 'high', + waived: ['authenticator_policy'], + authenticatorPolicyRefusal: refusal, + notices, + }, + }); +} + /** * Email OTP, SMS OTP, magic links and OAuth prove control of an address or an outside * identity, which is exactly what someone taking over a break-glass account would have. diff --git a/tests/integration/breakGlass/breakGlass.spec.ts b/tests/integration/breakGlass/breakGlass.spec.ts index 5f4211d6..2b515c95 100644 --- a/tests/integration/breakGlass/breakGlass.spec.ts +++ b/tests/integration/breakGlass/breakGlass.spec.ts @@ -539,6 +539,22 @@ describe('passkey enrollment', () => { }), }), ); + // Announced like a waived sign-in: it is a credential no ordinary account could add. + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'break_glass_enrollment', + metadata: expect.objectContaining({ + severity: 'high', + waived: ['authenticator_policy'], + notices: { sent: 2, failed: 0 }, + }), + }), + ); + expect(sendNoticeEmail).toHaveBeenCalledWith( + 'ops@example.com', + expect.stringContaining('Break-glass passkey enrolled'), + expect.stringContaining('would refuse'), + ); }); it('refuses the same passkey for an ordinary user', async () => { @@ -549,6 +565,7 @@ describe('passkey enrollment', () => { expect(res.status).toBe(403); expect(res.body).toEqual({ error: 'synced_passkey_not_allowed' }); expect(Credential.create).not.toHaveBeenCalled(); + expect(sendNoticeEmail).not.toHaveBeenCalled(); }); it('lets a break-glass admin ask for an attachment the policy does not pin', async () => { From 7d4825ef9cde6fa16f0834fe578a5e429664f98b Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 24 Oct 2026 08:21:07 -0400 Subject: [PATCH 3/3] chore: regenerate the API description --- openapi.json | 2 ++ src/generated/api.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/openapi.json b/openapi.json index e820857a..e9b3b6b6 100644 --- a/openapi.json +++ b/openapi.json @@ -3444,6 +3444,7 @@ "admin_user_import_completed", "admin_user_imported", "bearer_token_failed", + "break_glass_enrollment", "break_glass_sign_in", "credentials_deleted", "informational", @@ -3527,6 +3528,7 @@ "admin_user_import_completed", "admin_user_imported", "bearer_token_failed", + "break_glass_enrollment", "break_glass_sign_in", "credentials_deleted", "informational", diff --git a/src/generated/api.ts b/src/generated/api.ts index a2b6d761..a3c33a8b 100644 --- a/src/generated/api.ts +++ b/src/generated/api.ts @@ -3136,6 +3136,7 @@ export interface paths { | 'admin_user_import_completed' | 'admin_user_imported' | 'bearer_token_failed' + | 'break_glass_enrollment' | 'break_glass_sign_in' | 'credentials_deleted' | 'informational' @@ -3213,6 +3214,7 @@ export interface paths { | 'admin_user_import_completed' | 'admin_user_imported' | 'bearer_token_failed' + | 'break_glass_enrollment' | 'break_glass_sign_in' | 'credentials_deleted' | 'informational'