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
4 changes: 1 addition & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,7 @@ jobs:
- name: Install dependencies
run: npm ci

# Fails on a new high or critical in anything that ships. Two moderates
# remain from sequelize, whose only offered fix is a downgrade to v3, so
# the gate sits above them rather than being disabled outright.
# Fails on a high or critical in anything that ships.
- name: Audit production dependencies
run: npm audit --omit=dev --audit-level=high

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ report real output — never claim a change works without running them.
| Types | `npm run typecheck` | `tsc --noEmit`, ~2s |
| Lint | `npm run lint` | eslint, ~2s. Enforces license headers + import sort |
| Tests | `npm run test:run` | vitest, ~2s. **Uses a mock DB by default — no Postgres needed** |
| Coverage | `npm run coverage` | thresholds: lines/functions/statements 70%, branches 65% |
| Coverage | `npm run coverage` | thresholds: lines/functions/statements 98%, branches 95% |

Tests default to `TEST_DB=mock`. Only set `TEST_DB=postgres` (with a running Postgres)
when specifically exercising real DB behavior.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,8 @@ curl http://localhost:5312/health/status
```

To get an admin account, sign up at `/console` with `owner@example.com`. That address is the compose
file's `OWNER_EMAIL`, so the account is granted the `admin:write` and `admin` roles on creation.
file's `OWNER_EMAIL`, so the account is granted `admin:write` on creation (or `admin` on an instance
whose `AVAILABLE_ROLES` lists only that).
Override it before first boot to use your own:

```bash
Expand Down
2 changes: 1 addition & 1 deletion docs/admin-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ The legacy `admin` role remains broad for backwards compatibility.
### Making scoped roles assignable

Enforcement understands scoped roles, but a role can only be handed out if it appears in
`available_roles`, which is what `GET /roles` returns and what the console's role picker offers.
`available_roles`, which is what `GET /system-config/roles` returns and what the console's role picker offers.
Ship `admin:read` and `admin:write` there so read-only admin can be granted without typing a raw
role string:

Expand Down
22 changes: 14 additions & 8 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,14 +197,20 @@ RDS global bundle.

### First admin

| Variable | Required | Default | Notes |
| ------------- | -------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `OWNER_EMAIL` | No | - | Comma separated list of tenant owner emails. A user who signs up with one of these is granted `admin` on account creation, and auth events about them carry `owner = true`. |

The grant is safe because both signup paths establish control of the email before the account is
created (email OTP verification, or a verified OAuth profile), so only someone who actually receives
mail at the owner address can claim it. When `OWNER_EMAIL` is unset the grant is a no-op and the
instance has no admin until one is assigned another way.
| Variable | Required | Default | Notes |
| ------------- | -------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `OWNER_EMAIL` | No | - | Comma separated list of owner emails. An account created for one of these is granted one admin role, and auth events about it carry `owner = true`. |

The role is `admin:write` when `available_roles` includes it, otherwise `admin`, and nothing when
neither is available ([`src/lib/ownerAdmin.ts`](../src/lib/ownerAdmin.ts)). It is granted only when
the account is created: setting `OWNER_EMAIL` after the owner already has an account promotes
nobody.

Email OTP registration creates the account, with the role, at `POST /registration/register`, before
the address is verified. An unverified account cannot sign in (`/login` answers it as an unknown
identifier), so only someone who receives the code at the owner address can use it. OAuth signup
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.

### Admin dashboard (optional)

Expand Down
4 changes: 3 additions & 1 deletion docs/fido-conformance.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ the options payload merged in alongside on the two options endpoints.
The interface is mounted only when `FIDO_CONFORMANCE_MODE=true`, and the flag is
refused outright under `NODE_ENV=production`. With the flag unset the routes are
never registered: they do not reach Express, they do not reach the OpenAPI document,
and the paths answer the ordinary 404. That is enforced by
and the paths answer the ordinary 404. The committed `openapi.json` and `adapter-manifest.json`
are built with the flag unset, so `/conformance/*` is deliberately absent from both; with the
flag set, the live `/openapi.json` of that process does list them. That is enforced by
[the mounting tests](../tests/integration/conformance/conformance.spec.ts) rather
than left to this page.

Expand Down
22 changes: 20 additions & 2 deletions docs/production-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,10 @@ mechanism.
2. **Flip.** Point `SEAMLESS_JWKS_ACTIVE_KID` at the new key id, inject its
`SEAMLESS_JWKS_KEY_<kid>_PRIVATE`, and deploy. The new key signs. Tokens already issued keep
verifying, because the outgoing public key is still published.
3. **Retire.** Once `refresh_token_ttl` has passed, so no token signed by the outgoing key can
still be live, remove it from the document and drop its private key. Deploy.
3. **Retire.** Once `access_token_ttl` has passed, so no token signed by the outgoing key can still
be live, remove it from the document and drop its private key. Deploy. Access tokens are the
longest-lived signed tokens (ephemeral tokens last five minutes). Refresh tokens are opaque and
not signed, so rotation never affects them.

Each step needs a restart to take effect, because the values arrive as environment variables. The
server re-reads the document every five minutes within a process, which covers a secret updated in
Expand Down Expand Up @@ -120,6 +122,22 @@ OAuth provider client secrets live in environment variables referenced by `clien

Direct email/SMS delivery requires provider credentials. If you use an external trusted server adapter for delivery, keep delivery tokens on that server and avoid returning delivery payloads to browsers.

## Running More Than One Instance

Everything that has to agree between instances lives in Postgres: sessions and refresh rotation
(the rotation claim is a single statement the database serialises), the lockout counter
(`auth_failures`), OTP codes, magic links, WebAuthn challenges, `system_config` and the audit trail.
Several instances behind a load balancer share all of it.

Some state is held in each process, and with more than one instance it behaves differently:

| State | Where | With N instances |
| --------------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Rate limit and slow-down counters | `express-rate-limit` and `express-slow-down` in-memory stores ([`src/middleware`](../src/middleware)) | Each instance counts on its own, so a client whose requests are spread across instances gets up to N times the configured budget. Route by client address at the load balancer, or enforce a limit at the proxy as well. |
| `system_config` cache | Five minutes per process ([`getSystemConfig.ts`](../src/config/getSystemConfig.ts)) | A config write clears the cache only on the instance that served it. The others keep the old values (roles, origins, login methods, lockout policy, token lifetimes) for up to five minutes. Restart the instances when a change must apply at once. |
| OAuth `state` replay cache | Consumed states, for ten minutes ([`oauthService.ts`](../src/services/oauthService.ts)) | A state is refused a second time only by the instance that consumed it, so another instance could accept a replayed callback within those ten minutes. The state is still HMAC signed, bound to its provider and expires after ten minutes, and the provider's authorization code is single use. |
| Audit degraded flag | Five minutes after a failed audit write ([`auditHealth.ts`](../src/services/auditHealth.ts)) | `/health/status` reports `degraded` only on the instance whose write failed. Monitor every instance, not one. |

## Redaction

Logs and auth events redact sensitive metadata by default. Sensitive values include tokens, OTPs, magic-link URLs, OAuth state/codes, PRF salts and output, TOTP secrets, email/phone snapshots, private keys, and configured provider secrets.
Expand Down
97 changes: 67 additions & 30 deletions docs/security-posture.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,18 @@ The reason is still recorded in the `login_failed` auth event metadata, now with

Returning `200` for an unknown identifier is worth nothing on its own. If the next
request distinguished the decoy, the oracle would simply have moved one step later. All
thirteen endpoints that accept an ephemeral token therefore answer for a decoy the way
fourteen endpoints that accept an ephemeral token therefore answer for a decoy the way
they answer for a real account:

| Endpoint group | A decoy gets |
| --------------------- | --------------------------------------------------------------- |
| OTP send (4) | `200 { message: 'success', token }`, with nothing sent |
| OTP verify (4) | `401 { error: 'Not allowed' }`, the body a wrong code gets |
| Magic link request | `200`, the same "if an account exists" body a real request gets |
| Magic link poll | `204`, the state a real account sits in until someone clicks |
| WebAuthn login start | An assertion challenge over a fabricated credential id |
| WebAuthn login finish | `401 { error: 'Authentication failed.' }` |
| TOTP login verify | `401 { error: 'totp_verification_failed' }` |
| Endpoint group | A decoy gets |
| ---------------------- | --------------------------------------------------------------- |
| OTP send (4) | `200 { message: 'success', token }`, with nothing sent |
| OTP verify (4) | `401 { error: 'Not allowed' }`, the body a wrong code gets |
| Magic link request (2) | `200`, the same "if an account exists" body a real request gets |
| Magic link poll | `204`, the state a real account sits in until someone clicks |
| WebAuthn login start | An assertion challenge over a fabricated credential id |
| WebAuthn login finish | `401 { error: 'Authentication failed.' }` |
| TOTP login verify | `401 { error: 'totp_verification_failed' }` |

Policy-dependent branches are reproduced rather than skipped. A deployment with
`email_otp` disabled answers `403 login_method_disabled` for every identifier, so a decoy
Expand Down Expand Up @@ -120,20 +120,14 @@ Neither validation writes anything, so reproducing them costs a decoy nothing.

### What this still does not cover

`/webauthn/register/start` echoes the account's email as `user.name` in the options it
returns, because that is what an authenticator displays. A decoy has no real address, so
it echoes its synthetic one, and a caller that reads `user.name` sees an `@example.invalid`
address where a real account shows the identifier the caller typed. That is a complete
oracle, one request past `/login`.
`/webauthn/register/start` used to be reachable with a pre-auth token, and its options echo
the account's email as `user.name`. A decoy has no real address, so it echoed a synthetic
`@example.invalid` one, which separated decoys from real accounts one request past `/login`.
Passkey enrollment has required an access session since 0.11.0
([`src/routes/webauthn.routes.ts`](../src/routes/webauthn.routes.ts)), so neither a decoy nor a
real pre-auth token reaches that endpoint any more.

Closing it means the decoy echoing the identifier that was supplied, which it cannot do:
a decoy is rebuilt from its subject alone, and the subject is a one-way HMAC. Carrying the
identifier would mean putting it in the ephemeral token, and putting it only in decoy
tokens would be the `decoy` claim by another name, so every ephemeral token would have to
carry it. That is a second change to the token contract and a second coordinated release,
so it is tracked separately rather than folded in here.

For the same reason, a decoy's shape is derived from its subject and cannot depend on
A decoy is rebuilt from its subject alone, which is a one-way HMAC, so its shape cannot depend on
which kind of identifier was looked up. `/login` finds a phone account by its number, so
such an account always has a phone and is always offered `phone_otp`, while only about
half of decoys are. Where `phone_otp` is an enabled method, a phone identifier whose
Expand All @@ -151,18 +145,21 @@ Identical bodies that arrive at measurably different times still answer the ques
real path reads the users table, the lockout counter, the credentials table and the login
policy; the decoy path reads only the policy.

`LOGIN_RESPONSE_FLOOR_MS` (default `250`) holds every `/login` answer to a minimum, which
`LOGIN_RESPONSE_FLOOR_MS` (default `250`) holds every `200` pre-auth answer from `/login`, real or
decoy, to a minimum, which
removes the difference as long as both paths finish under it. Set it above the slowest
real login the deployment sees. It is an environment variable rather than a
`system_config` key because that schema is shared through `@seamless-auth/types`, and a
key there means a coordinated release across both SDKs for an operational tuning knob.

### What is still observable

- **Account lockout** answers `423` with `retryAfterSeconds`. Only a real account can be
locked, so this still discloses existence. It requires prior failed attempts against
that specific account, and suppressing it would leave a locked-out user with no way to
understand what happened. Accepted, unchanged.
- **Account lockout.** A locked account with a passkey continues from `/login` with
`loginMethods: ["passkey"]`; one without a passkey is answered `423` with
`retryAfterSeconds`. Only a real account can be locked, so either answer discloses
existence. It requires prior failed attempts against that specific account, and
suppressing it would leave a locked-out user with no way to understand what happened.
Accepted. See [Account lockout](#account-lockout).
- **A malformed identifier** answers `400`. This does not depend on whether any account
exists, so it is not an enumeration signal.
- **External delivery mode** answers a real OTP or magic link request with a `delivery`
Expand Down Expand Up @@ -416,8 +413,18 @@ of one token signs its user out, which is the direction this has to fail: the al
is that whoever wins the race keeps a session, and the winner is not always the client that
should have it.

The absolute lifetime that a rotation resets is separate, and is
[issue #185](https://github.com/fells-code/seamless-auth-api/issues/185).
### Absolute and idle lifetime

Rotation does not extend a session. Every session in a chain carries `chainStartedAt`, the time of
the sign-in that began it, and a rotated session's `expiresAt` is `chainStartedAt` plus
`refresh_token_ttl` (`REFRESH_TOKEN_TTL`, which must be set), whatever happened in between
([`src/controllers/authentication.ts`](../src/controllers/authentication.ts)). A client that
refreshes constantly is still signed out at the absolute limit. Shortening `refresh_token_ttl`
applies to chains already running: the next refresh past the new limit revokes the session.

Separately, `session_idle_ttl` (`SESSION_IDLE_TTL`, default `8h`) ends a session that has not been
refreshed for that long. Using an access token does not extend it; only a refresh does. A refusal for either reason is recorded with `absolute_lifetime_reached` or
`idle_timeout`, so the trail says which limit ended the session.

## Concurrent sessions per user

Expand Down Expand Up @@ -448,6 +455,36 @@ than briefly exceeding the cap.

This is NIST 800-53 AC-10.

## Account lockout

**Posture: repeated wrong codes lock the code factors, never the passkeys.**

The policy is `lockout_policy` (`LOCKOUT_POLICY`), default
`{ "enabled": true, "maxFailures": 10, "windowSeconds": 900, "lockoutSeconds": 900 }`
([`src/services/lockoutPolicyService.ts`](../src/services/lockoutPolicyService.ts)).

- **When it locks.** When `maxFailures` failed attempts fall inside one `windowSeconds` window. It
is not a rolling count that unlocks as old failures age out.
- **How long.** `lockoutSeconds`, measured from the most recent of the last `maxFailures` failures,
which is the one that crossed the threshold unless more were recorded after it.
`retryAfterSeconds` in the `423` answer is the time actually left.
- **What counts.** Failed logins, OTP codes, TOTP codes (sign-in and step-up) and magic links
against the account, recorded in `auth_failures` (`LOCKOUT_FAILURE_TYPES` in
[`src/services/authFailureCounter.ts`](../src/services/authFailureCounter.ts)). Failed passkey
assertions do not count, because an assertion cannot be guessed.
- **What it blocks.** The code factors. Passkey sign-in is not subject to lockout, so `/login` for a
locked account with a passkey continues with `loginMethods: ["passkey"]`, and answers
`423 account_locked` only for an account with no passkey.

The reason passkeys are spared is that `/login` mints an ephemeral token from an identifier alone.
Anyone who knows an address can submit wrong codes until the account locks, and before 0.19.0 that
locked the owner out of every method, passkeys included (GHSA-66gq-4pwg-33jq). An account with no
passkey can still be locked out this way, which is the cost of lockout protecting a guessable
factor. Enrolling a passkey keeps a user able to sign in through a lock someone else triggered.

The counter lives in Postgres, so every instance of a deployment sees the same count. When it cannot
be read, the account is treated as locked (see [An unknown count means locked](#an-unknown-count-means-locked)).

## Audit write failure

**Posture: the trail may be lost, no security control may be.**
Expand Down
Loading
Loading