Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/break-glass-admin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'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.

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.
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 60 additions & 0 deletions docs/admin-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,66 @@ 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 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

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. Revoking all of a user's sessions also records a cutoff on the account, so a session a concurrent refresh was creating at that moment is refused as well (see [Log out everywhere](./security-posture.md#log-out-everywhere)).
Expand Down
11 changes: 11 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,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))
Expand Down
15 changes: 15 additions & 0 deletions docs/security-posture.md
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,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.**
Expand Down
4 changes: 4 additions & 0 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -3444,6 +3444,8 @@
"admin_user_import_completed",
"admin_user_imported",
"bearer_token_failed",
"break_glass_enrollment",
"break_glass_sign_in",
"credentials_deleted",
"informational",
"internal_user_updated_by_owner",
Expand Down Expand Up @@ -3526,6 +3528,8 @@
"admin_user_import_completed",
"admin_user_imported",
"bearer_token_failed",
"break_glass_enrollment",
"break_glass_sign_in",
"credentials_deleted",
"informational",
"internal_user_updated_by_owner",
Expand Down
49 changes: 49 additions & 0 deletions src/config/breakGlass.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>;

/**
* `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<string> {
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');
}
28 changes: 21 additions & 7 deletions src/controllers/authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand All @@ -27,6 +29,7 @@ import {
import { getUserLockoutStatus } from '../services/lockoutPolicyService.js';
import {
getLoginPolicy,
isLoginMethodEnabled,
LoginMethod,
resolveAvailableLoginMethods,
} from '../services/loginPolicyService.js';
Expand Down Expand Up @@ -327,12 +330,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
Expand All @@ -344,10 +355,13 @@ export const login = async (req: Request, res: Response) => {
// 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
// the code can be redeemed and the answer still looks like any other account's. A
// break-glass admin also continues on its TOTP.
const continueWithCode =
loginMethods.length === 0 &&
((await hasUsableRecoveryCode(user.id)) ||
(breakGlass && (await hasBreakGlassCodeFactor(user.id, lockout.locked))));
const responseMethods = continueWithCode
? resolveAvailableLoginMethods({
policy: loginPolicy,
user,
Expand All @@ -356,7 +370,7 @@ export const login = async (req: Request, res: Response) => {
}).filter((method) => !lockout.locked || method === 'passkey')
: loginMethods;

if (lockout.locked && loginMethods.length === 0 && !continueWithRecoveryCode) {
if (lockout.locked && loginMethods.length === 0 && !continueWithCode) {
await AuthEventService.log({
userId: user.id,
type: 'login_suspicious',
Expand All @@ -375,7 +389,7 @@ export const login = async (req: Request, res: Response) => {
});
}

if (loginMethods.length === 0 && !continueWithRecoveryCode) {
if (loginMethods.length === 0 && !continueWithCode) {
logger.error('Login attempt had no allowed continuation methods');
return await respondWithDecoy({
req,
Expand Down
11 changes: 10 additions & 1 deletion src/controllers/magicLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 (
Expand Down
11 changes: 11 additions & 0 deletions src/controllers/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -154,6 +155,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' });
}

if (await isPasskeyRequiredForUser(user.id)) {
await AuthEventService.log({
userId: user.id,
Expand Down
9 changes: 9 additions & 0 deletions src/controllers/otp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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)))
Expand Down Expand Up @@ -333,6 +338,10 @@ export const verifyEmail = async (req: Request, res: Response) => {

const wasVerified = Boolean(user.verified);

if (wasVerified && (await rejectWeakFactorForBreakGlass(user, 'email_otp', req, res))) {
return;
}

if (wasVerified && (await rejectDisabledLoginMethod('email_otp', req, res))) {
return;
}
Expand Down
Loading
Loading