From 458c65b33271a54abfe3c271ab6c57bccab4b82a Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Thu, 3 Sep 2026 18:32:22 -0400 Subject: [PATCH] docs(config): document the conformance and testing variables The configuration reference is described as covering every environment variable, but seven were missing: the four FIDO_CONFORMANCE_* values, the two testing flags that are refused in production, and FRONTEND_URL. Add a testing and conformance section for the six flags. Document FRONTEND_URL as boot-required: it is enforced by bootstrapSystemConfig rather than validateEnvs.sh, so an instance with no frontend_url row and no value set clears env validation and then exits. .env.example promised a fallback to the first ORIGINS entry, which is not what happens. Ship a permissive syncedPasskeys in .env.example so a stock local instance can enrol the passkey a developer's device offers. The built-in default stays block. Also fix the getEndpoints example, which posted a port the conformance page never introduces, and correct the pinned dashboard tag. --- .env.example | 14 ++++--- Dockerfile | 2 +- docs/configuration.md | 85 +++++++++++++++++++++++++--------------- docs/fido-conformance.md | 2 +- 4 files changed, 64 insertions(+), 39 deletions(-) diff --git a/.env.example b/.env.example index 1761e18..8683c4c 100644 --- a/.env.example +++ b/.env.example @@ -104,12 +104,14 @@ LOCKOUT_POLICY={"enabled":true,"maxFailures":10,"windowSeconds":900,"lockoutSeco # attests or does not attest at all, because neither can be looked up, so setting # it rules out authenticators that ship no attestation certificate. # Changing attestation needs a restart. -# syncedPasskeys is block or allow. block, the default, refuses a credential that can -# leave the device it was created on, which includes every iCloud Keychain and Google -# Password Manager passkey. Set allow for a consumer deployment. +# syncedPasskeys is block or allow. The built-in default is block, which refuses a +# credential that can leave the device it was created on, including every iCloud +# Keychain and Google Password Manager passkey. This file ships allow so a stock local +# instance can enrol the passkey a developer's laptop or phone actually offers. Set +# block for a deployment that issues its own authenticators. # aaguidAllowList and aaguidDenyList restrict which authenticator models may register # and need attestation set to direct to mean anything. -AUTHENTICATOR_POLICY={"attachment":"any","userVerification":"required","attestation":"none","requireKnownAuthenticator":false,"syncedPasskeys":"block","aaguidAllowList":[],"aaguidDenyList":[]} +AUTHENTICATOR_POLICY={"attachment":"any","userVerification":"required","attestation":"none","requireKnownAuthenticator":false,"syncedPasskeys":"allow","aaguidAllowList":[],"aaguidDenyList":[]} # SERVICE TOKENS # Required for trusted server adapters and internal bearer validation. @@ -126,7 +128,9 @@ TOTP_SECRET_ENCRYPTION_KEY= RPID=localhost ORIGINS=http://localhost:5173,http://localhost:5174 -# Base URL for links emailed to users (magic links). Falls back to the first ORIGINS entry when unset. +# Base URL for links emailed to users (magic links). Required at boot, even though +# validateEnvs.sh does not check it: with no frontend_url row in system_config and no value +# here, startup fails with Missing required system config "frontend_url". FRONTEND_URL=http://localhost:5173 # OAUTH diff --git a/Dockerfile b/Dockerfile index c2683f5..28b4d1e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,7 +27,7 @@ FROM node:24-slim AS admin-dashboard WORKDIR /dashboard ARG SEAMLESS_ADMIN_DASHBOARD_REPO=https://github.com/fells-code/seamless-auth-admin-dashboard.git -# Pinned dashboard release tag. v0.2.0 is the first release with the same-origin /console build. +# Pinned dashboard release tag. The same-origin /console build has shipped since v0.2.0. ARG SEAMLESS_ADMIN_DASHBOARD_REF=v0.4.0 RUN apt-get update && \ diff --git a/docs/configuration.md b/docs/configuration.md index 868f2bf..e69a283 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -34,6 +34,7 @@ For local development, these are the only values you must set (all are present i | `DELAY_AFTER` | `50` | Requests before slow-down kicks in | | `RPID` | `localhost` | WebAuthn relying party ID | | `ORIGINS` | `http://localhost:5173` | WebAuthn allowed origins (comma-separated) | +| `FRONTEND_URL` | `http://localhost:5173` | Base URL for emailed magic links | | `API_SERVICE_TOKEN` | `<32-byte hex>` | Trusted server-adapter and internal bearer secret | | Database connectivity | see below | `DATABASE_URL`/`DB_URI` **or** the `DB_*` set | @@ -60,23 +61,24 @@ first boot. | `APP_ID` | Yes | - | No | Stable app identifier. | | `APP_ORIGINS` | Yes | - | No | CORS allowlist for callers of this API (comma-separated). Distinct from WebAuthn `ORIGINS`. A cross-origin request from an origin not listed here is refused with `403` and the route never runs. A request carrying no `Origin` header, such as one from a server adapter, is unaffected, and so is a same-origin request, which is why the admin console needs no entry. | | `ISSUER` | Yes | - | No | JWT `iss` and issuer URL. | +| `FRONTEND_URL` | Yes | - | `frontend_url` | Base URL that emailed magic links point at. Boot-required, but unlike the rest of this table it is enforced by `bootstrapSystemConfig`, not `validateEnvs.sh`: an instance with no `frontend_url` row and no `FRONTEND_URL` clears env validation and then exits with `Missing required system config "frontend_url"`. Once a value exists, magic-link URLs fall back to the first `ORIGINS` entry only if the row is later cleared. | | `DEFAULT_ROLES` | Yes | - | `default_roles` | Roles for new users (comma-separated). | | `AVAILABLE_ROLES` | Yes | - | `available_roles` | Roles permitted in the system (comma-separated). Assigning a role that is not listed is rejected. Include `admin:read` and `admin:write` to offer scoped admin. See [Scoped Admin Roles](./admin-operations.md#scoped-admin-roles). | ### Auth and tokens -| Variable | Required | Default | Seeds `system_config` | Notes | -| -------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ACCESS_TOKEN_TTL` | Yes | - | `access_token_ttl` | Format `\d+[smhd]`, e.g. `30m`. | -| `REFRESH_TOKEN_TTL` | Yes | - | `refresh_token_ttl` | Format `\d+[smhd]`, e.g. `1d`. Falls back to `1d` when unset. | -| `RATE_LIMIT` | Yes | - | `rate_limit` | Global limit, positive integer. | -| `DELAY_AFTER` | Yes | - | `delay_after` | Slow-down threshold, non-negative integer. | -| `LOGIN_METHODS` | No | `passkey,magic_link` | `login_methods` | Any of `passkey,magic_link,email_otp,phone_otp,oauth`. `.env.example` ships `passkey,magic_link,email_otp` so a stock instance is CLI/headless-loginable; `email_otp` needs a configured messaging transport or the external-delivery path. | -| `PASSKEY_LOGIN_FALLBACK_ENABLED` | No | `true` | `passkey_login_fallback_enabled` | When `false`, an account that holds a passkey continues with passkey only. The client's `passkeyAvailable` hint on `POST /login` cannot widen that: a caller reporting no passkey support is still offered passkey only, because otherwise any caller could ask for a weaker method by claiming not to support passkeys. A browser that genuinely cannot run the ceremony cannot sign in, which is what this setting means. Accounts with no passkey are unaffected and keep the configured methods. | -| `LOCKOUT_POLICY` | No | `{"enabled":true,"maxFailures":10,"windowSeconds":900,"lockoutSeconds":900}` | `lockout_policy` | JSON. Set `enabled:false` only when an upstream policy handles lockout. | -| `SESSION_IDLE_TTL` | No | `8h` | `session_idle_ttl` | Format `\d+[smhd]`. How long a session may go unrefreshed. The absolute session lifetime is `REFRESH_TOKEN_TTL`; this only binds while it is the shorter of the two. | -| `MAX_CONCURRENT_SESSIONS` | No | unlimited | `max_concurrent_sessions` | How many sessions one user may hold at once. Unset, empty, `null`, `none` or `unlimited` all mean no cap, which is the default. When a signed-in user is at the limit, the oldest session is revoked to make room and a `session_evicted` auth event is recorded; the sign-in itself always succeeds. Lowering the limit converges on each user's next sign-in rather than one session per login. NIST 800-53 AC-10. | -| `AUTHENTICATOR_POLICY` | No | `{"attachment":"any","userVerification":"required","attestation":"none","requireKnownAuthenticator":false,"syncedPasskeys":"block","aaguidAllowList":[],"aaguidDenyList":[]}` | `authenticator_policy` | JSON. `attachment` is `any`, `platform` or `cross-platform`; `any` offers both built-in authenticators and roaming security keys, and naming one narrows the browser picker and rejects a request asking for the other. `userVerification` is `required`, `preferred` or `discouraged` and drives both what the browser is asked for and what the server enforces. `attestation` is `none` or `direct`; `direct` asks the authenticator to identify itself, which is what enables validation against the FIDO Metadata Service, and carries a privacy cost so it is off unless needed. `requireKnownAuthenticator` refuses an authenticator the metadata service does not list, and only applies under `direct`. It also refuses a credential that self attests or presents no attestation, since neither can be looked up, so it rules out authenticators that ship no attestation certificate. Changing `attestation` needs a restart, because the metadata service is prepared at startup. `syncedPasskeys` is `block` or `allow`; `block`, the default, refuses a credential that can leave the device it was created on, which includes every iCloud Keychain and Google Password Manager passkey. `aaguidAllowList` and `aaguidDenyList` restrict which authenticator models may register, and need `attestation` set to `direct` to mean anything. | +| Variable | Required | Default | Seeds `system_config` | Notes | +| -------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `ACCESS_TOKEN_TTL` | Yes | - | `access_token_ttl` | Format `\d+[smhd]`, e.g. `30m`. | +| `REFRESH_TOKEN_TTL` | Yes | - | `refresh_token_ttl` | Format `\d+[smhd]`, e.g. `1d`. Falls back to `1d` when unset. | +| `RATE_LIMIT` | Yes | - | `rate_limit` | Global limit, positive integer. | +| `DELAY_AFTER` | Yes | - | `delay_after` | Slow-down threshold, non-negative integer. | +| `LOGIN_METHODS` | No | `passkey,magic_link` | `login_methods` | Any of `passkey,magic_link,email_otp,phone_otp,oauth`. `.env.example` ships `passkey,magic_link,email_otp` so a stock instance is CLI/headless-loginable; `email_otp` needs a configured messaging transport or the external-delivery path. | +| `PASSKEY_LOGIN_FALLBACK_ENABLED` | No | `true` | `passkey_login_fallback_enabled` | When `false`, an account that holds a passkey continues with passkey only. The client's `passkeyAvailable` hint on `POST /login` cannot widen that: a caller reporting no passkey support is still offered passkey only, because otherwise any caller could ask for a weaker method by claiming not to support passkeys. A browser that genuinely cannot run the ceremony cannot sign in, which is what this setting means. Accounts with no passkey are unaffected and keep the configured methods. | +| `LOCKOUT_POLICY` | No | `{"enabled":true,"maxFailures":10,"windowSeconds":900,"lockoutSeconds":900}` | `lockout_policy` | JSON. Set `enabled:false` only when an upstream policy handles lockout. | +| `SESSION_IDLE_TTL` | No | `8h` | `session_idle_ttl` | Format `\d+[smhd]`. How long a session may go unrefreshed. The absolute session lifetime is `REFRESH_TOKEN_TTL`; this only binds while it is the shorter of the two. | +| `MAX_CONCURRENT_SESSIONS` | No | unlimited | `max_concurrent_sessions` | How many sessions one user may hold at once. Unset, empty, `null`, `none` or `unlimited` all mean no cap, which is the default. When a signed-in user is at the limit, the oldest session is revoked to make room and a `session_evicted` auth event is recorded; the sign-in itself always succeeds. Lowering the limit converges on each user's next sign-in rather than one session per login. NIST 800-53 AC-10. | +| `AUTHENTICATOR_POLICY` | No | `{"attachment":"any","userVerification":"required","attestation":"none","requireKnownAuthenticator":false,"syncedPasskeys":"block","aaguidAllowList":[],"aaguidDenyList":[]}` | `authenticator_policy` | JSON. `attachment` is `any`, `platform` or `cross-platform`; `any` offers both built-in authenticators and roaming security keys, and naming one narrows the browser picker and rejects a request asking for the other. `userVerification` is `required`, `preferred` or `discouraged` and drives both what the browser is asked for and what the server enforces. `attestation` is `none` or `direct`; `direct` asks the authenticator to identify itself, which is what enables validation against the FIDO Metadata Service, and carries a privacy cost so it is off unless needed. `requireKnownAuthenticator` refuses an authenticator the metadata service does not list, and only applies under `direct`. It also refuses a credential that self attests or presents no attestation, since neither can be looked up, so it rules out authenticators that ship no attestation certificate. Changing `attestation` needs a restart, because the metadata service is prepared at startup. `syncedPasskeys` is `block` or `allow`; `block`, the default, refuses a credential that can leave the device it was created on, which includes every iCloud Keychain and Google Password Manager passkey. `.env.example` ships `allow` so a stock local instance can enrol the passkey a developer's device offers; the built-in default is unchanged. `aaguidAllowList` and `aaguidDenyList` restrict which authenticator models may register, and need `attestation` set to `direct` to mean anything. | ### Service tokens and secrets @@ -181,8 +183,8 @@ sets `VITE_BASE_PATH=/console/` and `VITE_SAME_ORIGIN=true` so the assets, the r and the origin-derived API base all agree) in a dedicated stage (see the `admin-dashboard` stage in [`Dockerfile`](../Dockerfile)). Bump the dashboard shipped to tenants by raising the `SEAMLESS_ADMIN_DASHBOARD_REF` build ARG; the new version then flows to tenants through the normal -upstream auth-image release. The ref is pinned to the dashboard release tag `v0.2.0`, the first -release that ships the same-origin `/console` build. +upstream auth-image release. The ref is pinned to the dashboard release tag `v0.4.0`; +`v0.2.0` was the first release to ship the same-origin `/console` build. Because the same-origin build derives its API base from the page origin **and still speaks the server-adapter contract** (it calls `/auth/...` with `credentials: include`), the origin @@ -253,6 +255,24 @@ Required when `NODE_ENV=production`. In development, signing keys are generated See [docs/production-operations.md](./production-operations.md) for key rotation. +### Testing and conformance (never production) + +These loosen the server deliberately, so each one is refused under `NODE_ENV=production` and logs +that it was ignored. They exist for automated suites and FIDO2 conformance runs, never for a +customer deployment. + +| Variable | Required | Default | Notes | +| --------------------------------------- | -------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ALLOW_UNCREDENTIALED_DELIVERY_SECRETS` | No | `false` | Lets a caller request external delivery without a valid `x-seamless-service-token`, so OTPs and magic-link URLs come back in the response body and the stack runs with no mail or SMS transport. | +| `DISABLE_AUTH_RATE_LIMITS` | No | `false` | Skips every auth limiter (global, OTP, magic link, registration, OAuth, JWKS). A conformance run drives hundreds of ceremonies from one IP and is otherwise throttled. | +| `FIDO_CONFORMANCE_MODE` | No | `false` | Mounts the FIDO2 conformance interface at `/conformance`. Unset, the routes are never registered, so they reach neither Express nor the OpenAPI document and the paths answer the ordinary 404. The surface takes no authentication and issues no sessions. | +| `FIDO_CONFORMANCE_MDS_URLS` | No | - | Comma separated MDS3 endpoints the run stands up. Read only in conformance mode. | +| `FIDO_CONFORMANCE_MDS_ROOT_CERT_FILE` | No | - | Path to the PEM the run signs its metadata blobs with. Read only in conformance mode. Without it the server trusts only the production FIDO root and every metadata test fails on a certificate error. | +| `FIDO_CONFORMANCE_METADATA_DIR` | No | - | Directory of metadata statement JSON files, read recursively. Read only in conformance mode. Statements are accepted bare or wrapped in an MDS entry, so the tools' metadata archive can be unzipped in unedited. | + +See [docs/fido-conformance.md](./fido-conformance.md) for the full run procedure, including the +`AUTHENTICATOR_POLICY` posture a conformance run expects. + ## `system_config` keys These runtime values live in the `system_config` table. On first boot, @@ -261,24 +281,25 @@ its mapped environment variable ([`systemConfig.envMap.ts`](../src/config/system or from a built-in default ([`systemConfig.defaults.ts`](../src/config/systemConfig.defaults.ts)). Validation is enforced by [`systemConfig.schema.ts`](../src/schemas/systemConfig.schema.ts). -| Key | Type | Seeded from env | Default | -| -------------------------------- | -------------------- | -------------------------------- | --------------------------------------------------------------------------------------------------- | -| `app_name` | string (min 3) | `APP_NAME` | - | -| `default_roles` | string[] | `DEFAULT_ROLES` | - | -| `available_roles` | string[] | `AVAILABLE_ROLES` | - | -| `login_methods` | enum[] | `LOGIN_METHODS` | `["passkey","magic_link"]` | -| `passkey_login_fallback_enabled` | boolean | `PASSKEY_LOGIN_FALLBACK_ENABLED` | `true` | -| `oauth_providers` | provider[] | `OAUTH_PROVIDERS` | `[]` | -| `lockout_policy` | object | `LOCKOUT_POLICY` | `{enabled,maxFailures:10,windowSeconds:900,lockoutSeconds:900}` | -| `authenticator_policy` | object | `AUTHENTICATOR_POLICY` | `{attachment:"any",userVerification:"required",attestation:"none",requireKnownAuthenticator:false}` | -| `session_idle_ttl` | string (`\d+[smhd]`) | `SESSION_IDLE_TTL` | `8h` | -| `max_concurrent_sessions` | number or null | `MAX_CONCURRENT_SESSIONS` | `null` (no limit) | -| `access_token_ttl` | string (`\d+[smhd]`) | `ACCESS_TOKEN_TTL` | - | -| `refresh_token_ttl` | string (`\d+[smhd]`) | `REFRESH_TOKEN_TTL` | - | -| `rate_limit` | integer > 0 | `RATE_LIMIT` | - | -| `delay_after` | integer >= 0 | `DELAY_AFTER` | - | -| `rpid` | string | `RPID` | - | -| `origins` | url[] | `ORIGINS` | - | +| Key | Type | Seeded from env | Default | +| -------------------------------- | -------------------- | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `app_name` | string (min 3) | `APP_NAME` | - | +| `default_roles` | string[] | `DEFAULT_ROLES` | - | +| `available_roles` | string[] | `AVAILABLE_ROLES` | - | +| `login_methods` | enum[] | `LOGIN_METHODS` | `["passkey","magic_link"]` | +| `passkey_login_fallback_enabled` | boolean | `PASSKEY_LOGIN_FALLBACK_ENABLED` | `true` | +| `oauth_providers` | provider[] | `OAUTH_PROVIDERS` | `[]` | +| `lockout_policy` | object | `LOCKOUT_POLICY` | `{enabled,maxFailures:10,windowSeconds:900,lockoutSeconds:900}` | +| `authenticator_policy` | object | `AUTHENTICATOR_POLICY` | `{attachment:"any",userVerification:"required",attestation:"none",requireKnownAuthenticator:false,syncedPasskeys:"block",aaguidAllowList:[],aaguidDenyList:[]}` | +| `session_idle_ttl` | string (`\d+[smhd]`) | `SESSION_IDLE_TTL` | `8h` | +| `max_concurrent_sessions` | number or null | `MAX_CONCURRENT_SESSIONS` | `null` (no limit) | +| `access_token_ttl` | string (`\d+[smhd]`) | `ACCESS_TOKEN_TTL` | - | +| `refresh_token_ttl` | string (`\d+[smhd]`) | `REFRESH_TOKEN_TTL` | - | +| `rate_limit` | integer > 0 | `RATE_LIMIT` | - | +| `delay_after` | integer >= 0 | `DELAY_AFTER` | - | +| `rpid` | string | `RPID` | - | +| `origins` | url[] | `ORIGINS` | - | +| `frontend_url` | url | `FRONTEND_URL` | - | ## Environment vs `system_config` diff --git a/docs/fido-conformance.md b/docs/fido-conformance.md index fd27d60..d86bda8 100644 --- a/docs/fido-conformance.md +++ b/docs/fido-conformance.md @@ -117,7 +117,7 @@ URL you will give the tools: ``` curl -X POST https://mds3.fido.tools/getEndpoints \ -H 'Content-Type: application/json' \ - -d '{"endpoint":"http://localhost:5313/conformance"}' + -d '{"endpoint":"http://localhost:5312/conformance"}' ``` The root certificate is the FIDO test root published at