diff --git a/.changeset/authentication-coverage-report.md b/.changeset/authentication-coverage-report.md new file mode 100644 index 0000000..8932c29 --- /dev/null +++ b/.changeset/authentication-coverage-report.md @@ -0,0 +1,10 @@ +--- +'seamless-auth-api': minor +--- + +Add an authentication coverage report for assessment and insurance responses (#178). + +- `GET /admin/reports/authentication-coverage` (admin read) reports, for a period (`from`, `to`, default the last 90 days), how many active users hold a passkey overall, per organization and per `month` or `week` bucket, alongside the login and authenticator policy enforced now, the authenticator mix by AAGUID (with backup eligibility) and completed sign-ins by method. +- `organizationId` scopes every figure to one organization's current members. +- `format=csv` returns the same report as a `text/csv` attachment for pasting into a document. +- Code sign-ins now record `metadata.channel` (`email` or `sms`) on `verify_otp_success`, so the report can tell email codes from phone codes. Older rows are reported as `otp`. diff --git a/.changeset/phishing-resistant-only.md b/.changeset/phishing-resistant-only.md new file mode 100644 index 0000000..4541f0a --- /dev/null +++ b/.changeset/phishing-resistant-only.md @@ -0,0 +1,10 @@ +--- +'seamless-auth-api': minor +--- + +Add a phishing-resistant-only login mode and enforce the passkey fallback rule on every continuation endpoint. + +- New `phishing_resistant_only` system config key (env `PHISHING_RESISTANT_ONLY`, default `false`). When on, a session starts only from a passkey: email and phone codes, magic links, TOTP and OAuth are refused with `403 login_method_disabled` (OAuth providers are hidden), whatever `login_methods` says, and the public config reports `loginMethods: ["passkey"]`. The email code that verifies a new account's address still starts one session so the first passkey can be enrolled. Session issuance refuses a non-passkey factor in this mode as a backstop. Requires `@seamless-auth/types` 0.27.0. +- `passkey_login_fallback_enabled: false` now binds on the continuation endpoints themselves, not only on the method list `/login` returns. A user who holds a passkey gets `403 login_method_disabled` from the email and phone code, magic link, TOTP login and email verification endpoints. Previously those endpoints checked only whether the method was enabled for the deployment. +- `POST /totp/verify-login` can now answer `403 login_method_disabled`. +- Decoy responses for unknown identifiers mirror both rules, so the refusals do not reveal whether an account exists. diff --git a/README.md b/README.md index 9246758..2d230fa 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,8 @@ session. `LOGIN_METHODS` accepts any of `passkey`, `magic_link`, `email_otp`, `p `oauth`, and defaults to `passkey,magic_link`. Set `PASSKEY_LOGIN_FALLBACK_ENABLED=false` when passkey-capable sessions should continue with passkeys only. When fallback is enabled, `/login` returns `loginMethods` so clients can offer only the allowed continuations for that user and device. +Set `PHISHING_RESISTANT_ONLY=true` to accept passkeys only, for every account, whatever +`LOGIN_METHODS` says. See [docs/configuration.md](./docs/configuration.md). diff --git a/docs/admin-operations.md b/docs/admin-operations.md index 39a0eeb..72fff9a 100644 --- a/docs/admin-operations.md +++ b/docs/admin-operations.md @@ -231,3 +231,129 @@ dimension, so the same rows answer every question. nothing sent). Read `started - presented` as "gave up before proving anything", `delivered - presented` as "was sent a code or link and never came back", and `presented - completed` as "tried a factor and it did not work". + +## Authentication Coverage Report + +`GET /admin/reports/authentication-coverage` answers the question an assessment, an audit +response or a cyber insurance questionnaire asks: how many staff are on phishing-resistant +authentication, is that number going up, and what does the deployment enforce. It takes an +`admin`, `admin:read` or `admin:write` role. + +| Query | Meaning | +| ---------------- | ---------------------------------------------------------------------------------------------- | +| `from`, `to` | UTC dates (`YYYY-MM-DD`), both included. Default: the 90 days ending today. At most 1827 days. | +| `organizationId` | Scope every figure to the current members of one organization. `404` if it does not exist. | +| `bucket` | `month` (default) or `week`, the granularity of `trend`. | +| `format` | `json` (default) or `csv`. | + +```json +{ + "period": { "from": "2026-07-09", "to": "2026-10-06" }, + "generatedAt": "2026-10-06T14:02:11.000Z", + "organizationId": null, + "bucket": "month", + "policy": { + "phishingResistantOnly": false, + "loginMethods": ["passkey", "email_otp"], + "passkeyFallbackEnabled": false, + "authenticator": { + "attestation": "none", + "userVerification": "required", + "attachment": "any", + "syncedPasskeys": "allow", + "requireKnownAuthenticator": false, + "aaguidAllowList": [], + "aaguidDenyList": [] + } + }, + "coverage": { "users": 240, "passkeyUsers": 198, "percent": 82.5 }, + "byOrganization": [ + { + "organizationId": "8d0c...", + "name": "Public Works", + "users": 61, + "passkeyUsers": 58, + "percent": 95.1 + }, + { "organizationId": null, "name": null, "users": 12, "passkeyUsers": 4, "percent": 33.3 } + ], + "trend": [ + { + "start": "2026-07-09", + "end": "2026-07-31", + "users": 231, + "passkeyUsers": 140, + "percent": 60.6 + } + ], + "authenticatorMix": [ + { + "aaguid": "fbfc3007-154e-4ecc-8c0b-6e020557d7bd", + "name": "iCloud Keychain", + "credentials": 120, + "users": 117, + "backupEligible": 120, + "backedUp": 118 + }, + { + "aaguid": null, + "name": null, + "credentials": 9, + "users": 9, + "backupEligible": 0, + "backedUp": 0 + } + ], + "signInMix": { + "total": 4120, + "phishingResistant": 3610, + "percent": 87.6, + "methods": [{ "method": "passkey", "phishingResistant": true, "signIns": 3610, "users": 196 }] + } +} +``` + +### What each figure means + +| Block | What is measured | +| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `policy` | What is enforced at `generatedAt`: the login methods `getLoginPolicy()` resolves (passkey only, with fallback off, when `phishing_resistant_only` is on), whether a passkey holder may fall back to another method, and the `authenticator_policy` registration rules. It is the policy now, not the policy across the period. | +| `coverage` | As of the end of `to`: active users (not revoked) created by then, and how many of them hold at least one WebAuthn credential created by then. A WebAuthn credential is the phishing-resistant credential this server issues; TOTP, codes and magic links are not. `percent` is `passkeyUsers / users`, to one decimal place, and `0` when there are no users. | +| `byOrganization` | The same figures per organization, by current membership, sorted by name, then a row with `organizationId: null` for active users in no organization. A user in two organizations is counted in both, so the rows can sum to more than `coverage.users`. With `organizationId` set there is one row and no null row. | +| `trend` | One row per calendar month or week (weeks start on Monday, UTC), the first and last clipped to the period. Each row is the coverage as of the end of its bucket, so the last row equals `coverage`. | +| `authenticatorMix` | Credentials held by active users at the end of the period, grouped by AAGUID. `aaguid: null` gathers credentials that reported no AAGUID or the all-zero one. `name` comes from a short table of well-known passkey providers, then from the FIDO Metadata Service when it is loaded (only under `attestation: 'direct'`), and is otherwise `null`. `backupEligible` counts credentials whose key can leave the device (synced passkeys); `backedUp` those already backed up. | +| `signInMix` | Completed sign-ins inside the period, by method. This is the actual side of coverage: a deployment can be at 100% enrollment and still see most sign-ins by email code if fallback is on. Sign-ins are folded by attempt, as on `/internal/metrics/sign-ins`, so an OTP sign-in that writes `verify_otp_success` twice counts once; `users` is distinct users per method. Only `passkey` is phishing resistant. | + +`signInMix.methods` always lists every method, in a fixed order, with zeros where there were +none: `passkey`, `email_otp`, `phone_otp`, `otp`, `magic_link`, `totp`, `oauth`. Code sign-ins +record their channel from this release on; `otp` holds code sign-ins written before that, whose +channel is unknown. A code that completes a registration counts as a sign-in, as it does on the +sign-in metrics. + +### Limits + +- The trend counts the credentials that exist now, by their creation date. A credential that was + deleted is gone from `credentials` and cannot be recovered, so a user who enrolled and later + removed every passkey does not count as covered in any past bucket. The trend can understate + past coverage; it never overstates it. +- Revocation has no timestamp, so a revoked user is left out of every bucket, including those + before the revocation. A deleted user is likewise absent from the whole report. +- Organization figures use current membership. Someone who left an organization is not in its + past buckets, and someone who joined is in all of them. +- `policy` is the configuration when the report was generated. For the history of policy + changes, read the `system_config_updated` auth events for the period. +- A WebAuthn credential is counted as phishing resistant whatever its attestation. A deployment + that needs to show only certified authenticators should run `attestation: 'direct'` with + `requireKnownAuthenticator` or an allow list, which the `policy` block states. + +### CSV + +`format=csv` returns the same report as `text/csv` with +`Content-Disposition: attachment; filename="authentication-coverage--to-.csv"`, for +pasting into an assessment or insurance response. It has a header block (period, organization, +generation time), then five sections each introduced by a title line, its own header row and a +blank line before it: enforced policy (setting, value), coverage by organization (with an +`All users` row first and `No organization` last), coverage trend, authenticator mix and +sign-in mix. Lines end in CRLF. Rows are in the same deterministic order as the JSON. A cell +that starts with `=`, `+`, `-` or `@` is prefixed with `'` so a spreadsheet does not evaluate an +organization name as a formula. diff --git a/docs/architecture.md b/docs/architecture.md index 2093a40..0f87841 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -92,7 +92,10 @@ Supported login methods are controlled by `login_methods` system config: - `oauth` Accounts holding a passkey can be restricted to passkey-only continuation by disabling -`passkey_login_fallback_enabled`. The client sends a `passkeyAvailable` capability hint on +`passkey_login_fallback_enabled`. `phishing_resistant_only` goes further and restricts every +account to passkeys, apart from the one session that verifies a new account's address. Both +are enforced on each continuation endpoint as well as in the `/login` method list, and session +issuance refuses a non-passkey factor in phishing-resistant-only mode as a backstop. The client sends a `passkeyAvailable` capability hint on `POST /login`, but it is advisory: it can remove passkey from a set the policy already permits, never add a weaker method to a passkey-only one. diff --git a/docs/configuration.md b/docs/configuration.md index b4d7682..e09d0b5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -74,7 +74,8 @@ first boot. | `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. | +| `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. The rule binds on every continuation endpoint, not only on the list `/login` returns, so a passkey holder calling an email code, phone code, magic link, TOTP or email verification endpoint directly gets `403 login_method_disabled`. | +| `PHISHING_RESISTANT_ONLY` | No | `false` | `phishing_resistant_only` | When `true`, a session starts only from a passkey. Email and phone codes, magic links, TOTP and OAuth are refused (`403 login_method_disabled`, and OAuth providers are hidden) whatever `LOGIN_METHODS` and `PASSKEY_LOGIN_FALLBACK_ENABLED` say, and `GET /system-config/public` reports `loginMethods: ["passkey"]`. The one exception is a new account: the email code that verifies its address starts one session so its first passkey can be enrolled. An account that never enrolls one cannot sign in again on its own and needs admin recovery. Off by default, so existing deployments are unaffected. | | `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`, measured from sign-in and not extended by refresh; 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. | @@ -317,6 +318,7 @@ Validation is enforced by [`systemConfig.schema.ts`](../src/schemas/systemConfig | `available_roles` | string[] | `AVAILABLE_ROLES` | - | | `login_methods` | enum[] | `LOGIN_METHODS` | `["passkey","magic_link"]` | | `passkey_login_fallback_enabled` | boolean | `PASSKEY_LOGIN_FALLBACK_ENABLED` | `true` | +| `phishing_resistant_only` | boolean | `PHISHING_RESISTANT_ONLY` | `false` | | `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:"allow",aaguidAllowList:[],aaguidDenyList:[]}` | diff --git a/openapi.json b/openapi.json index 8341ca7..c0890bc 100644 --- a/openapi.json +++ b/openapi.json @@ -2465,6 +2465,320 @@ } } }, + "/admin/reports/authentication-coverage": { + "get": { + "summary": "Authentication coverage report", + "description": "How many active users hold a phishing-resistant credential (a passkey), overall, per organization and across the period, alongside the login and authenticator policy enforced now, the authenticator models in use and how sign-ins in the period were completed. `from` and `to` are UTC dates, both included, defaulting to the last 90 days. `bucket` sets the trend granularity. With `format=csv` the same report is returned as `text/csv` for pasting into an assessment or insurance response. Answers 404 when `organizationId` names no organization.", + "tags": ["Admin"], + "security": [{ "bearerAuth": [] }], + "parameters": [ + { + "schema": { "type": "string", "format": "date" }, + "required": false, + "name": "from", + "in": "query" + }, + { + "schema": { "type": "string", "format": "date" }, + "required": false, + "name": "to", + "in": "query" + }, + { + "schema": { "type": "string", "format": "uuid" }, + "required": false, + "name": "organizationId", + "in": "query" + }, + { + "schema": { "type": "string", "enum": ["week", "month"], "default": "month" }, + "required": false, + "name": "bucket", + "in": "query" + }, + { + "schema": { "type": "string", "enum": ["json", "csv"], "default": "json" }, + "required": false, + "name": "format", + "in": "query" + } + ], + "responses": { + "200": { + "description": "HTTP 200", + "content": { + "application/json": { + "example": { + "period": { "from": null, "to": null }, + "generatedAt": null, + "organizationId": null, + "bucket": null, + "policy": { + "phishingResistantOnly": true, + "loginMethods": [null], + "passkeyFallbackEnabled": true, + "authenticator": { + "attestation": "string", + "userVerification": "string", + "attachment": "string", + "syncedPasskeys": "string", + "requireKnownAuthenticator": true, + "aaguidAllowList": [null], + "aaguidDenyList": [null] + } + }, + "coverage": { "users": 0, "passkeyUsers": 0, "percent": 0 }, + "byOrganization": [null], + "trend": [null], + "authenticatorMix": [null], + "signInMix": { + "total": 0, + "phishingResistant": 0, + "percent": 0, + "methods": [null] + } + }, + "schema": { + "type": "object", + "properties": { + "period": { + "type": "object", + "properties": { + "from": { "type": "string", "format": "date" }, + "to": { "type": "string", "format": "date" } + }, + "required": ["from", "to"] + }, + "generatedAt": { "type": "string", "format": "date-time" }, + "organizationId": { "type": "string", "nullable": true, "format": "uuid" }, + "bucket": { "type": "string", "enum": ["week", "month"] }, + "policy": { + "type": "object", + "properties": { + "phishingResistantOnly": { "type": "boolean" }, + "loginMethods": { "type": "array", "items": { "type": "string" } }, + "passkeyFallbackEnabled": { "type": "boolean" }, + "authenticator": { + "type": "object", + "properties": { + "attestation": { "type": "string" }, + "userVerification": { "type": "string" }, + "attachment": { "type": "string" }, + "syncedPasskeys": { "type": "string" }, + "requireKnownAuthenticator": { "type": "boolean" }, + "aaguidAllowList": { "type": "array", "items": { "type": "string" } }, + "aaguidDenyList": { "type": "array", "items": { "type": "string" } } + }, + "required": [ + "attestation", + "userVerification", + "attachment", + "syncedPasskeys", + "requireKnownAuthenticator", + "aaguidAllowList", + "aaguidDenyList" + ] + } + }, + "required": [ + "phishingResistantOnly", + "loginMethods", + "passkeyFallbackEnabled", + "authenticator" + ] + }, + "coverage": { + "type": "object", + "properties": { + "users": { "type": "integer" }, + "passkeyUsers": { "type": "integer" }, + "percent": { "type": "number" } + }, + "required": ["users", "passkeyUsers", "percent"] + }, + "byOrganization": { + "type": "array", + "items": { + "type": "object", + "properties": { + "users": { "type": "integer" }, + "passkeyUsers": { "type": "integer" }, + "percent": { "type": "number" }, + "organizationId": { "type": "string", "nullable": true }, + "name": { "type": "string", "nullable": true } + }, + "required": ["users", "passkeyUsers", "percent", "organizationId", "name"] + } + }, + "trend": { + "type": "array", + "items": { + "type": "object", + "properties": { + "users": { "type": "integer" }, + "passkeyUsers": { "type": "integer" }, + "percent": { "type": "number" }, + "start": { "type": "string", "format": "date" }, + "end": { "type": "string", "format": "date" } + }, + "required": ["users", "passkeyUsers", "percent", "start", "end"] + } + }, + "authenticatorMix": { + "type": "array", + "items": { + "type": "object", + "properties": { + "aaguid": { "type": "string", "nullable": true }, + "name": { "type": "string", "nullable": true }, + "credentials": { "type": "integer" }, + "users": { "type": "integer" }, + "backupEligible": { "type": "integer" }, + "backedUp": { "type": "integer" } + }, + "required": [ + "aaguid", + "name", + "credentials", + "users", + "backupEligible", + "backedUp" + ] + } + }, + "signInMix": { + "type": "object", + "properties": { + "total": { "type": "integer" }, + "phishingResistant": { "type": "integer" }, + "percent": { "type": "number" }, + "methods": { + "type": "array", + "items": { + "type": "object", + "properties": { + "method": { + "type": "string", + "enum": [ + "passkey", + "email_otp", + "phone_otp", + "otp", + "magic_link", + "totp", + "oauth" + ] + }, + "phishingResistant": { "type": "boolean" }, + "signIns": { "type": "integer" }, + "users": { "type": "integer" } + }, + "required": ["method", "phishingResistant", "signIns", "users"] + } + } + }, + "required": ["total", "phishingResistant", "percent", "methods"] + } + }, + "required": [ + "period", + "generatedAt", + "organizationId", + "bucket", + "policy", + "coverage", + "byOrganization", + "trend", + "authenticatorMix", + "signInMix" + ] + } + } + } + }, + "400": { + "description": "HTTP 400", + "content": { + "application/json": { + "example": { + "error": "string", + "message": "string", + "details": { "issues": [null] } + }, + "schema": { + "type": "object", + "properties": { + "error": { "type": "string" }, + "message": { "type": "string" }, + "details": { + "type": "object", + "properties": { + "issues": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "array", + "items": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + }, + "code": { "type": "string" }, + "message": { "type": "string" } + }, + "required": ["path", "code", "message"] + } + } + }, + "required": ["issues"] + } + }, + "required": ["error"] + } + } + } + }, + "404": { + "description": "HTTP 404", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "429": { + "description": "HTTP 429", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "500": { + "description": "HTTP 500", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + } + } + } + }, "/admin/users": { "get": { "summary": "List users (internal)", @@ -11083,6 +11397,7 @@ "login_methods": [null], "passkey_login_fallback_enabled": true, "prompt_passkey_enrollment": null, + "phishing_resistant_only": null, "oauth_providers": null, "lockout_policy": null, "authenticator_policy": null, @@ -11128,6 +11443,7 @@ }, "passkey_login_fallback_enabled": { "type": "boolean" }, "prompt_passkey_enrollment": { "type": "boolean", "default": false }, + "phishing_resistant_only": { "type": "boolean", "default": false }, "oauth_providers": { "type": "array", "items": { @@ -11459,6 +11775,7 @@ }, "passkey_login_fallback_enabled": { "type": "boolean" }, "prompt_passkey_enrollment": { "type": "boolean" }, + "phishing_resistant_only": { "type": "boolean" }, "oauth_providers": { "type": "array", "items": { @@ -12966,6 +13283,19 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { diff --git a/package-lock.json b/package-lock.json index f256994..4082f96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@seamless-auth/messaging": "^0.2.0", "@seamless-auth/messaging-aws": "^0.2.0", "@seamless-auth/messaging-twilio": "^0.2.0", - "@seamless-auth/types": "^0.26.0", + "@seamless-auth/types": "^0.27.0", "@simplewebauthn/server": "^14.0.3", "base64url": "^3.0.1", "bcrypt-ts": "^9.0.2", @@ -3082,15 +3082,15 @@ } }, "node_modules/@seamless-auth/types": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.26.0.tgz", - "integrity": "sha512-O1a6LO/NGE8IYQEc5fTg+54PLzOvmqMmqnKe20OQ0/++0kLjvH7a9xodghzXCMnxiR/76UV2zOvNcy7r0kQ9ZQ==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.27.0.tgz", + "integrity": "sha512-kIrPtdnSOxhDKdxI7NfoRO7pHT23jgpT3pgB8Aa2asGMnasU6pPeBceN1CngUgKpU8ZkOv10mRN44bfgVZGNSA==", "license": "Apache-2.0", "dependencies": { "zod": "^4.3.6" }, "engines": { - "node": ">=24 <25" + "node": ">=22" } }, "node_modules/@simple-libs/child-process-utils": { diff --git a/package.json b/package.json index 2077d75..cb58ab5 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "@seamless-auth/messaging": "^0.2.0", "@seamless-auth/messaging-aws": "^0.2.0", "@seamless-auth/messaging-twilio": "^0.2.0", - "@seamless-auth/types": "^0.26.0", + "@seamless-auth/types": "^0.27.0", "@simplewebauthn/server": "^14.0.3", "base64url": "^3.0.1", "bcrypt-ts": "^9.0.2", diff --git a/resources/coverage-badge.svg b/resources/coverage-badge.svg index 8bd6c88..322cf29 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/systemConfig.defaults.ts b/src/config/systemConfig.defaults.ts index b491129..ddb8a35 100644 --- a/src/config/systemConfig.defaults.ts +++ b/src/config/systemConfig.defaults.ts @@ -34,6 +34,7 @@ export const SYSTEM_CONFIG_DEFAULTS: Partial = { magic_link_redirect_uris: [], passkey_login_fallback_enabled: true, prompt_passkey_enrollment: false, + phishing_resistant_only: false, // The constants the flow limiters carried in code before the key existed, so an // instance that predates it keeps the limits it had. flow_rate_limits: DefaultFlowRateLimits, diff --git a/src/config/systemConfig.envMap.ts b/src/config/systemConfig.envMap.ts index 2c7f84f..ae29887 100644 --- a/src/config/systemConfig.envMap.ts +++ b/src/config/systemConfig.envMap.ts @@ -13,6 +13,7 @@ export const SYSTEM_CONFIG_ENV_MAP = { authenticator_policy: 'AUTHENTICATOR_POLICY', passkey_login_fallback_enabled: 'PASSKEY_LOGIN_FALLBACK_ENABLED', prompt_passkey_enrollment: 'PROMPT_PASSKEY_ENROLLMENT', + phishing_resistant_only: 'PHISHING_RESISTANT_ONLY', access_token_ttl: 'ACCESS_TOKEN_TTL', session_idle_ttl: 'SESSION_IDLE_TTL', max_concurrent_sessions: 'MAX_CONCURRENT_SESSIONS', diff --git a/src/controllers/coverageReport.ts b/src/controllers/coverageReport.ts new file mode 100644 index 0000000..d344156 --- /dev/null +++ b/src/controllers/coverageReport.ts @@ -0,0 +1,38 @@ +/* + * 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 { CoverageReportQuerySchema } from '../schemas/coverageReport.js'; +import { + buildCoverageReport, + coverageReportCsv, + CoverageReportError, +} from '../services/coverageReport.js'; + +export async function getCoverageReport(req: Request, res: Response) { + // Re-parsed so the defaults are typed; `defineRoute` has already validated the query. + const query = CoverageReportQuerySchema.parse(req.query); + + try { + const report = await buildCoverageReport(query); + + if (query.format === 'csv') { + const filename = `authentication-coverage-${report.period.from}-to-${report.period.to}.csv`; + + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + return res.send(coverageReportCsv(report)); + } + + return res.json(report); + } catch (error) { + if (error instanceof CoverageReportError) { + return res.status(error.status).json({ error: error.message }); + } + throw error; + } +} diff --git a/src/controllers/decoyResponders.ts b/src/controllers/decoyResponders.ts index ed7992b..30f0d74 100644 --- a/src/controllers/decoyResponders.ts +++ b/src/controllers/decoyResponders.ts @@ -15,6 +15,7 @@ import { decoyCredentialIdFor, decoyPrincipalForSubject } from '../services/deco import { getLoginPolicy, isLoginMethodEnabled, + isPasskeyRequired, LoginMethod, } from '../services/loginPolicyService.js'; import { @@ -79,13 +80,17 @@ async function logDecoy(req: Request, endpoint: string) { /** * Mirrors `rejectDisabledLoginMethod` in the OTP controller and `rejectDisabledMagicLink` - * in the magic link controller. Both answer 403 before looking at the account at all, so - * a decoy has to reach the same answer from the same policy read. + * in the magic link controller. Both answer 403 from the policy plus whether the account + * holds a passkey, so a decoy reaches the same answer from the same policy read and the + * passkey its shape was given, which is the one `/login` advertised for it. */ async function rejectDisabledMethod(method: LoginMethod, req: Request, res: Response) { const policy = await getLoginPolicy(); - if (isLoginMethodEnabled(policy, method)) { + if ( + isLoginMethodEnabled(policy, method) && + !isPasskeyRequired(policy, decoyPrincipal(req).hasPasskey) + ) { return false; } @@ -151,7 +156,35 @@ async function respondOtpVerifyFailed(req: Request, res: Response) { return res.status(401).json({ error: 'Not allowed' }); } -export const decoyVerifyEmailOtp = respondOtpVerifyFailed; +/** + * Mirrors the passkey rule on the paths a real verified account cannot use once a passkey + * is required: email verification, which signs such an account in, and TOTP login. + */ +async function rejectPasskeyRequired(method: string, req: Request, res: Response) { + const policy = await getLoginPolicy(); + + if (!isPasskeyRequired(policy, decoyPrincipal(req).hasPasskey)) { + return false; + } + + await AuthEventService.log({ + userId: null, + type: 'login_failed', + req, + metadata: { reason: 'Passkey required', method }, + }); + + res.status(403).json({ error: 'login_method_disabled' }); + return true; +} + +export const decoyVerifyEmailOtp = async (req: Request, res: Response) => { + if (await rejectPasskeyRequired('email_otp', req, res)) { + return; + } + + return respondOtpVerifyFailed(req, res); +}; export const decoyVerifyPhoneOtp = respondOtpVerifyFailed; export const decoyVerifyLoginEmailOtp = async (req: Request, res: Response) => { @@ -273,6 +306,10 @@ export const decoyFinishWebAuthnLogin = async (req: Request, res: Response) => { }; export const decoyVerifyTotpLogin = async (req: Request, res: Response) => { + if (await rejectPasskeyRequired('totp', req, res)) { + return; + } + await logDecoy(req, 'totp:verify_login'); return res.status(401).json({ error: 'totp_verification_failed' }); diff --git a/src/controllers/magicLinks.ts b/src/controllers/magicLinks.ts index 7c1dd19..1330dcb 100644 --- a/src/controllers/magicLinks.ts +++ b/src/controllers/magicLinks.ts @@ -15,7 +15,11 @@ import { User } from '../models/users.js'; import { MagicLinkRequestQuerySchema } from '../schemas/magiclink.requests.js'; import { AuthEventService } from '../services/authEventService.js'; import { passkeyEnrollmentPrompt } from '../services/enrollmentService.js'; -import { getLoginPolicy, isLoginMethodEnabled } from '../services/loginPolicyService.js'; +import { + getLoginPolicy, + isLoginMethodEnabled, + isPasskeyRequiredForUser, +} from '../services/loginPolicyService.js'; import { MagicLinkRedirectNotAllowedError, resolveMagicLinkUrl, @@ -31,15 +35,18 @@ const logger = getLogger('magic-links'); const TTL_MINUTES = 15; -async function rejectDisabledMagicLink(req: Request, res: Response, userId?: string | null) { +async function rejectDisabledMagicLink(req: Request, res: Response, user?: { id: string } | null) { const policy = await getLoginPolicy(); - if (isLoginMethodEnabled(policy, 'magic_link')) { + if ( + isLoginMethodEnabled(policy, 'magic_link') && + !(user?.id && (await isPasskeyRequiredForUser(user.id, policy))) + ) { return false; } await AuthEventService.log({ - userId: userId ?? null, + userId: user?.id ?? null, type: 'login_failed', req, metadata: { reason: 'Login method disabled', method: 'magic_link' }, @@ -72,7 +79,7 @@ export async function requestMagicLink(req: MagicLinkRequest, res: Response) { const preAuthUser = authReq.user; const useExternalDelivery = await canReturnExternalDelivery(req); - if (await rejectDisabledMagicLink(req, res, preAuthUser?.id)) { + if (await rejectDisabledMagicLink(req, res, preAuthUser)) { return; } @@ -237,7 +244,7 @@ export async function pollMagicLinkConfirmation(req: Request, res: Response) { const authReq = req as AuthenticatedRequest; const preAuthUser = authReq.user; - if (await rejectDisabledMagicLink(req, res, preAuthUser?.id)) { + if (await rejectDisabledMagicLink(req, res, preAuthUser)) { return; } @@ -294,6 +301,7 @@ export async function pollMagicLinkConfirmation(req: Request, res: Response) { }); await issueSessionAndRespond({ + method: 'magic_link', user: { id: user.id, email: user.email, diff --git a/src/controllers/oauth.ts b/src/controllers/oauth.ts index 9f46054..18bfe70 100644 --- a/src/controllers/oauth.ts +++ b/src/controllers/oauth.ts @@ -159,6 +159,7 @@ export async function finishOAuthLogin(req: RouteRequest, res: Response) { (await Credential.count({ where: { userId: user.id } })) === 0; return issueSessionAndRespond({ + method: 'oauth', user: { id: user.id, email: user.email, diff --git a/src/controllers/otp.ts b/src/controllers/otp.ts index e5bab02..c3cc6ca 100644 --- a/src/controllers/otp.ts +++ b/src/controllers/otp.ts @@ -15,6 +15,7 @@ import { rejectIfUserLocked } from '../services/lockoutPolicyService.js'; import { getLoginPolicy, isLoginMethodEnabled, + isPasskeyRequiredForUser, type LoginMethod, } from '../services/loginPolicyService.js'; import { issueSessionAndRespond } from '../services/sessionIssuance.js'; @@ -36,13 +37,15 @@ async function rejectDisabledLoginMethod( res: Response, ): Promise { const policy = await getLoginPolicy(); + const user = (req as AuthenticatedRequest).user; - if (isLoginMethodEnabled(policy, method)) { + if ( + isLoginMethodEnabled(policy, method) && + !(user?.id && (await isPasskeyRequiredForUser(user.id, policy))) + ) { return false; } - const user = (req as AuthenticatedRequest).user; - await AuthEventService.log({ userId: user?.id ?? null, type: 'login_failed', @@ -271,7 +274,7 @@ export const verifyPhoneNumber = async (req: Request, res: Response) => { userId: user.id, type: 'verify_otp_success', req, - metadata: { reason: 'User verified their phone number' }, + metadata: { reason: 'User verified their phone number', channel: 'sms' }, }); if (user.phoneVerified && user.emailVerified && user.verified) { @@ -280,7 +283,7 @@ export const verifyPhoneNumber = async (req: Request, res: Response) => { userId: user.id, type: 'verify_otp_success', req, - metadata: { reason: 'User completed verification of phone and email' }, + metadata: { reason: 'User completed verification of phone and email', channel: 'sms' }, }); return res.status(200).json({ message: 'Success' }); @@ -311,13 +314,24 @@ export const verifyEmail = async (req: Request, res: Response) => { // This endpoint issues a session for an already-verified account, so it is a login // whatever its name says, and the lockout policy has to bind here too. Without it, // an account locked out of /otp/verify-login-email-otp could still authenticate - // through this one. The login-method policy deliberately does not gate it: email OTP + // through this one. The login-method list deliberately does not gate it: email OTP // is how registration proves an address, whether or not the deployment offers it as - // a way to sign in. + // a way to sign in. A rule that requires a passkey does, once the account is already + // verified, or this endpoint would be the way around it. if (await rejectIfUserLocked({ userId: user.id, req, res })) { return; } + if (user.verified && (await isPasskeyRequiredForUser(user.id))) { + await AuthEventService.log({ + userId: user.id, + type: 'login_failed', + req, + metadata: { reason: 'Passkey required', method: 'email_otp' }, + }); + return res.status(403).json({ error: 'login_method_disabled' }); + } + logger.info('Verifying email'); if (!user || !user.emailVerificationTokenExpiry || !user.emailVerificationToken) { @@ -364,7 +378,7 @@ export const verifyEmail = async (req: Request, res: Response) => { userId: user.id, type: 'verify_otp_success', req, - metadata: { reason: 'User verified their email' }, + metadata: { reason: 'User verified their email', channel: 'email' }, }); if (user.emailVerified && user.verified) { @@ -374,10 +388,12 @@ export const verifyEmail = async (req: Request, res: Response) => { userId: user.id, type: 'verify_otp_success', req, - metadata: { reason: 'User completed email verification' }, + metadata: { reason: 'User completed email verification', channel: 'email' }, }); await issueSessionAndRespond({ + method: 'email_otp', + accountVerification: true, user: { id: user.id, email: user.email, @@ -459,6 +475,7 @@ export const verifyLoginPhoneNumber = async (req: Request, res: Response) => { userId: user.id, type: 'verify_otp_success', req, + metadata: { channel: 'sms' }, }); if (user.phoneVerified && user.emailVerified && user.verified) { @@ -468,10 +485,11 @@ export const verifyLoginPhoneNumber = async (req: Request, res: Response) => { userId: user.id, type: 'verify_otp_success', req, - metadata: { reason: 'User completed verification of phone and email' }, + metadata: { reason: 'User completed verification of phone and email', channel: 'sms' }, }); await issueSessionAndRespond({ + method: 'phone_otp', user: { id: user.id, email: user.email, @@ -566,6 +584,7 @@ export const verifyLoginEmail = async (req: Request, res: Response) => { userId: user.id, type: 'verify_otp_success', req, + metadata: { channel: 'email' }, }); if (user.emailVerified && user.verified) { @@ -575,10 +594,11 @@ export const verifyLoginEmail = async (req: Request, res: Response) => { userId: user.id, type: 'verify_otp_success', req, - metadata: { reason: 'User completed email verification' }, + metadata: { reason: 'User completed email verification', channel: 'email' }, }); await issueSessionAndRespond({ + method: 'email_otp', user: { id: user.id, email: user.email, diff --git a/src/controllers/registration.ts b/src/controllers/registration.ts index 26a3bc4..8537dc5 100644 --- a/src/controllers/registration.ts +++ b/src/controllers/registration.ts @@ -399,7 +399,7 @@ export const verifyRegisteredPhone = async (req: Request, res: Response) => { userId: user.id, type: 'verify_otp_success', req, - metadata: { reason: 'User verified their phone number.' }, + metadata: { reason: 'User verified their phone number.', channel: 'sms' }, }); return res.status(200).json({ message: 'Success' }); diff --git a/src/controllers/totp.ts b/src/controllers/totp.ts index 2b3f8f5..42968e2 100644 --- a/src/controllers/totp.ts +++ b/src/controllers/totp.ts @@ -9,6 +9,7 @@ import { Request, Response } from 'express'; import { getSystemConfig } from '../config/getSystemConfig.js'; import { AuthEventService } from '../services/authEventService.js'; import { rejectIfUserLocked } from '../services/lockoutPolicyService.js'; +import { isPasskeyRequiredForUser } from '../services/loginPolicyService.js'; import { issueSessionAndRespond } from '../services/sessionIssuance.js'; import { recordStepUpVerification, serializeStepUpStatus } from '../services/stepUpService.js'; import { @@ -166,6 +167,16 @@ export const verifyTotpLogin = async (req: Request, res: Response) => { return; } + if (await isPasskeyRequiredForUser(user.id)) { + await AuthEventService.log({ + userId: user.id, + type: 'login_failed', + req, + metadata: { reason: 'Passkey required', method: 'totp' }, + }); + return res.status(403).json({ error: 'login_method_disabled' }); + } + const result = await verifyEnabledTotp(user.id, code); if (!result.verified) { @@ -186,6 +197,7 @@ export const verifyTotpLogin = async (req: Request, res: Response) => { }); await issueSessionAndRespond({ + method: 'totp', user: { id: user.id, email: user.email, diff --git a/src/controllers/webauthn.ts b/src/controllers/webauthn.ts index fc204cd..8f2015a 100644 --- a/src/controllers/webauthn.ts +++ b/src/controllers/webauthn.ts @@ -595,6 +595,7 @@ const verifyWebAuthn = async (req: Request, res: Response) => { }); await issueSessionAndRespond({ + method: 'passkey', user: { id: user.id, email: user.email, diff --git a/src/generated/api.ts b/src/generated/api.ts index 357dde9..f10ca4a 100644 --- a/src/generated/api.ts +++ b/src/generated/api.ts @@ -2216,6 +2216,257 @@ export interface paths { patch?: never; trace?: never; }; + '/admin/reports/authentication-coverage': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Authentication coverage report + * @description How many active users hold a phishing-resistant credential (a passkey), overall, per organization and across the period, alongside the login and authenticator policy enforced now, the authenticator models in use and how sign-ins in the period were completed. `from` and `to` are UTC dates, both included, defaulting to the last 90 days. `bucket` sets the trend granularity. With `format=csv` the same report is returned as `text/csv` for pasting into an assessment or insurance response. Answers 404 when `organizationId` names no organization. + */ + get: { + parameters: { + query?: { + from?: string; + to?: string; + organizationId?: string; + bucket?: 'week' | 'month'; + format?: 'json' | 'csv'; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description HTTP 200 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "period": { + * "from": null, + * "to": null + * }, + * "generatedAt": null, + * "organizationId": null, + * "bucket": null, + * "policy": { + * "phishingResistantOnly": true, + * "loginMethods": [ + * null + * ], + * "passkeyFallbackEnabled": true, + * "authenticator": { + * "attestation": "string", + * "userVerification": "string", + * "attachment": "string", + * "syncedPasskeys": "string", + * "requireKnownAuthenticator": true, + * "aaguidAllowList": [ + * null + * ], + * "aaguidDenyList": [ + * null + * ] + * } + * }, + * "coverage": { + * "users": 0, + * "passkeyUsers": 0, + * "percent": 0 + * }, + * "byOrganization": [ + * null + * ], + * "trend": [ + * null + * ], + * "authenticatorMix": [ + * null + * ], + * "signInMix": { + * "total": 0, + * "phishingResistant": 0, + * "percent": 0, + * "methods": [ + * null + * ] + * } + * } + */ + 'application/json': { + period: { + /** Format: date */ + from: string; + /** Format: date */ + to: string; + }; + /** Format: date-time */ + generatedAt: string; + /** Format: uuid */ + organizationId: string | null; + /** @enum {string} */ + bucket: 'week' | 'month'; + policy: { + phishingResistantOnly: boolean; + loginMethods: string[]; + passkeyFallbackEnabled: boolean; + authenticator: { + attestation: string; + userVerification: string; + attachment: string; + syncedPasskeys: string; + requireKnownAuthenticator: boolean; + aaguidAllowList: string[]; + aaguidDenyList: string[]; + }; + }; + coverage: { + users: number; + passkeyUsers: number; + percent: number; + }; + byOrganization: { + users: number; + passkeyUsers: number; + percent: number; + organizationId: string | null; + name: string | null; + }[]; + trend: { + users: number; + passkeyUsers: number; + percent: number; + /** Format: date */ + start: string; + /** Format: date */ + end: string; + }[]; + authenticatorMix: { + aaguid: string | null; + name: string | null; + credentials: number; + users: number; + backupEligible: number; + backedUp: number; + }[]; + signInMix: { + total: number; + phishingResistant: number; + percent: number; + methods: { + /** @enum {string} */ + method: + 'passkey' | 'email_otp' | 'phone_otp' | 'otp' | 'magic_link' | 'totp' | 'oauth'; + phishingResistant: boolean; + signIns: number; + users: number; + }[]; + }; + }; + }; + }; + /** @description HTTP 400 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "error": "string", + * "message": "string", + * "details": { + * "issues": [ + * null + * ] + * } + * } + */ + 'application/json': { + error: string; + message?: string; + details?: { + issues: { + path: (string | number)[]; + code: string; + message: string; + }[]; + }; + }; + }; + }; + /** @description HTTP 404 */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 500 */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/admin/users': { parameters: { query?: never; @@ -11403,6 +11654,7 @@ export interface paths { * ], * "passkey_login_fallback_enabled": true, * "prompt_passkey_enrollment": null, + * "phishing_resistant_only": null, * "oauth_providers": null, * "lockout_policy": null, * "authenticator_policy": null, @@ -11429,6 +11681,8 @@ export interface paths { passkey_login_fallback_enabled: boolean; /** @default false */ prompt_passkey_enrollment: boolean; + /** @default false */ + phishing_resistant_only: boolean; /** @default [] */ oauth_providers: { id: string; @@ -11684,6 +11938,7 @@ export interface paths { login_methods?: ('passkey' | 'magic_link' | 'email_otp' | 'phone_otp' | 'oauth')[]; passkey_login_fallback_enabled?: boolean; prompt_passkey_enrollment?: boolean; + phishing_resistant_only?: boolean; oauth_providers?: { id: string; name: string; @@ -13283,6 +13538,24 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { diff --git a/src/lib/knownAuthenticators.ts b/src/lib/knownAuthenticators.ts new file mode 100644 index 0000000..62bfad6 --- /dev/null +++ b/src/lib/knownAuthenticators.ts @@ -0,0 +1,36 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +/** The all-zero AAGUID: an authenticator declining to say what it is. */ +export const ANONYMOUS_AAGUID = '00000000-0000-0000-0000-000000000000'; + +/** + * Names for the passkey providers most credentials come from. These are synced and platform + * authenticators, which do not appear in the FIDO Metadata Service and present no + * attestation, so without this table nearly every row of an authenticator report would be + * nameless. Each AAGUID is the one the provider publishes for itself. + */ +const KNOWN_AUTHENTICATORS: Readonly> = { + 'ea9b8d66-4d01-1d21-3ce4-b6b48cb575d4': 'Google Password Manager', + 'adce0002-35bc-c60a-648b-0b25f1f05503': 'Chrome on Mac', + 'fbfc3007-154e-4ecc-8c0b-6e020557d7bd': 'iCloud Keychain', + 'dd4ec289-e01d-41c9-bb89-70fa845d4bf2': 'iCloud Keychain (Managed)', + '08987058-cadc-4b81-b6e1-30de50dcbe96': 'Windows Hello', + '9ddd1817-af5a-4672-a2b9-3e3dd95000a9': 'Windows Hello', + '6028b017-b1d4-4c02-b4b3-afcdafc96bb2': 'Windows Hello', + '53414d53-554e-4700-0000-000000000000': 'Samsung Pass', + 'bada5566-a7aa-401f-bd96-45619a55120d': '1Password', + 'd548826e-79b4-db40-a3d8-11116f7e8349': 'Bitwarden', + '531126d6-e717-415c-9320-3d9aa6981239': 'Dashlane', + '0ea242b4-43c4-4a1b-8b17-dd6d0b6baec6': 'Keeper', + 'b84e4048-15dc-4dd0-8640-f4f60813c8af': 'NordPass', + '50726f74-6f6e-5061-7373-50726f746f6e': 'Proton Pass', + 'fdb141b2-5d84-443e-8a35-4698c205a502': 'KeePassXC', +}; + +export function knownAuthenticatorName(aaguid: string | null | undefined): string | null { + return aaguid ? (KNOWN_AUTHENTICATORS[aaguid.trim().toLowerCase()] ?? null) : null; +} diff --git a/src/routes/admin.routes.ts b/src/routes/admin.routes.ts index 3a96fa6..c6fcc2c 100644 --- a/src/routes/admin.routes.ts +++ b/src/routes/admin.routes.ts @@ -21,6 +21,7 @@ import { revokeUserSessionById, updateUser, } from '../controllers/admin.js'; +import { getCoverageReport } from '../controllers/coverageReport.js'; import { getEnrollment, sendEnrollmentInvites } from '../controllers/enrollment.js'; import { addMember, @@ -54,6 +55,10 @@ import { ImportUsersResponseSchema, UserResponseSchema, } from '../schemas/admin.responses.js'; +import { + CoverageReportQuerySchema, + CoverageReportResponseSchema, +} from '../schemas/coverageReport.js'; import { AdminEnrollmentQuerySchema, AdminEnrollmentResponseSchema, @@ -338,6 +343,26 @@ adminRouter.post( sendEnrollmentInvites, ); +adminRouter.get( + '/reports/authentication-coverage', + { + auth: 'access', + summary: 'Authentication coverage report', + description: + 'How many active users hold a phishing-resistant credential (a passkey), overall, per organization and across the period, alongside the login and authenticator policy enforced now, the authenticator models in use and how sign-ins in the period were completed. `from` and `to` are UTC dates, both included, defaulting to the last 90 days. `bucket` sets the trend granularity. With `format=csv` the same report is returned as `text/csv` for pasting into an assessment or insurance response. Answers 404 when `organizationId` names no organization.', + tags: ['Admin'], + middleware: [requireAdmin('read')], + schemas: { + query: CoverageReportQuerySchema, + response: { + 200: CoverageReportResponseSchema, + 404: InternalErrorSchema, + }, + }, + }, + getCoverageReport, +); + adminRouter.get( '/users', { diff --git a/src/routes/totp.routes.ts b/src/routes/totp.routes.ts index 45a149f..ab6dea8 100644 --- a/src/routes/totp.routes.ts +++ b/src/routes/totp.routes.ts @@ -117,6 +117,7 @@ totpRouter.post( response: { 200: WebAuthnTokenSuccessSchema, 401: ErrorSchema, + 403: ErrorSchema, }, }, }, diff --git a/src/schemas/coverageReport.ts b/src/schemas/coverageReport.ts new file mode 100644 index 0000000..135ea99 --- /dev/null +++ b/src/schemas/coverageReport.ts @@ -0,0 +1,149 @@ +/* + * 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 { z } from 'zod'; + +export const COVERAGE_REPORT_DEFAULT_DAYS = 90; +export const COVERAGE_REPORT_MAX_DAYS = 1827; + +const DAY_MS = 24 * 60 * 60 * 1000; + +export interface CoverageWindow { + from: string; + to: string; + /** Midnight UTC on `from`. */ + start: Date; + /** Midnight UTC the day after `to`, so `to` is covered in full. */ + end: Date; +} + +function isoDay(date: Date) { + return date.toISOString().slice(0, 10); +} + +/** Both dates are whole UTC days and both ends are included. */ +export function resolveCoverageWindow( + query: { from?: string; to?: string }, + now: Date = new Date(), +): CoverageWindow { + const to = query.to ?? isoDay(now); + const end = new Date(Date.parse(`${to}T00:00:00.000Z`) + DAY_MS); + const from = + query.from ?? isoDay(new Date(end.getTime() - COVERAGE_REPORT_DEFAULT_DAYS * DAY_MS)); + + return { from, to, start: new Date(`${from}T00:00:00.000Z`), end }; +} + +const IsoDay = z.iso.date(); + +export const CoverageBucketSchema = z.enum(['week', 'month']); +export const CoverageFormatSchema = z.enum(['json', 'csv']); + +export const CoverageReportQuerySchema = z + .object({ + from: IsoDay.optional(), + to: IsoDay.optional(), + organizationId: z.uuid().optional(), + bucket: CoverageBucketSchema.default('month'), + format: CoverageFormatSchema.default('json'), + }) + .superRefine((query, ctx) => { + // Zod still runs this when a date failed its own format check, which it has reported. + if ([query.from, query.to].some((day) => day !== undefined && !IsoDay.safeParse(day).success)) { + return; + } + + const { start, end } = resolveCoverageWindow(query); + const days = Math.round((end.getTime() - start.getTime()) / DAY_MS); + + if (days < 1) { + ctx.addIssue({ code: 'custom', path: ['from'], message: 'from must not be after to' }); + } else if (days > COVERAGE_REPORT_MAX_DAYS) { + ctx.addIssue({ + code: 'custom', + path: ['from'], + message: `The period may cover at most ${COVERAGE_REPORT_MAX_DAYS} days`, + }); + } + }); + +export type CoverageReportQuery = z.infer; + +const CoverageFiguresSchema = z.object({ + users: z.number().int(), + passkeyUsers: z.number().int(), + /** `passkeyUsers` over `users`, as a percentage to one decimal place. */ + percent: z.number(), +}); + +export const SIGN_IN_MIX_METHODS = [ + 'passkey', + 'email_otp', + 'phone_otp', + 'otp', + 'magic_link', + 'totp', + 'oauth', +] as const; + +export const CoverageReportResponseSchema = z.object({ + period: z.object({ from: z.iso.date(), to: z.iso.date() }), + generatedAt: z.iso.datetime(), + organizationId: z.uuid().nullable(), + bucket: CoverageBucketSchema, + policy: z.object({ + phishingResistantOnly: z.boolean(), + loginMethods: z.array(z.string()), + passkeyFallbackEnabled: z.boolean(), + authenticator: z.object({ + attestation: z.string(), + userVerification: z.string(), + attachment: z.string(), + syncedPasskeys: z.string(), + requireKnownAuthenticator: z.boolean(), + aaguidAllowList: z.array(z.string()), + aaguidDenyList: z.array(z.string()), + }), + }), + coverage: CoverageFiguresSchema, + byOrganization: z.array( + CoverageFiguresSchema.extend({ + organizationId: z.string().nullable(), + name: z.string().nullable(), + }), + ), + trend: z.array( + CoverageFiguresSchema.extend({ + start: z.iso.date(), + end: z.iso.date(), + }), + ), + authenticatorMix: z.array( + z.object({ + aaguid: z.string().nullable(), + name: z.string().nullable(), + credentials: z.number().int(), + users: z.number().int(), + backupEligible: z.number().int(), + backedUp: z.number().int(), + }), + ), + signInMix: z.object({ + total: z.number().int(), + phishingResistant: z.number().int(), + percent: z.number(), + methods: z.array( + z.object({ + method: z.enum(SIGN_IN_MIX_METHODS), + phishingResistant: z.boolean(), + signIns: z.number().int(), + users: z.number().int(), + }), + ), + }), +}); + +export type CoverageReport = z.infer; diff --git a/src/services/authenticatorPolicyService.ts b/src/services/authenticatorPolicyService.ts index 73f146b..f03108d 100644 --- a/src/services/authenticatorPolicyService.ts +++ b/src/services/authenticatorPolicyService.ts @@ -7,9 +7,7 @@ import type { AuthenticatorPolicy } from '@seamless-auth/types'; import type { AttestationType } from '../lib/attestationType.js'; - -/** The all-zero AAGUID: an authenticator declining to say what it is. */ -const ANONYMOUS_AAGUID = '00000000-0000-0000-0000-000000000000'; +import { ANONYMOUS_AAGUID } from '../lib/knownAuthenticators.js'; export type AuthenticatorRefusal = 'authenticator_not_allowed' | 'synced_passkey_not_allowed'; diff --git a/src/services/coverageReport.ts b/src/services/coverageReport.ts new file mode 100644 index 0000000..f86ecfc --- /dev/null +++ b/src/services/coverageReport.ts @@ -0,0 +1,465 @@ +/* + * 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 { MetadataService } from '@simplewebauthn/server'; +import { QueryTypes } from 'sequelize'; + +import { getSystemConfig } from '../config/getSystemConfig.js'; +import { ANONYMOUS_AAGUID, knownAuthenticatorName } from '../lib/knownAuthenticators.js'; +import { getSequelize } from '../models/index.js'; +import { Organization } from '../models/organizations.js'; +import type { AuthEventType } from '../schemas/authEvent.types.js'; +import { + type CoverageReport, + type CoverageReportQuery, + type CoverageWindow, + resolveCoverageWindow, + SIGN_IN_MIX_METHODS, +} from '../schemas/coverageReport.js'; +import { getLoginPolicy } from './loginPolicyService.js'; +import { isMetadataServiceReady } from './metadataServiceBootstrap.js'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +type SignInMixMethod = (typeof SIGN_IN_MIX_METHODS)[number]; + +/** The success event each method writes when a sign-in completes. */ +export const SIGN_IN_EVENT_TYPES = { + passkey: 'webauthn_login_success', + otp: 'verify_otp_success', + magic_link: 'magic_link_poll_completed_successfully', + totp: 'totp_success', + oauth: 'oauth_login_success', +} as const satisfies Record; + +export class CoverageReportError extends Error { + constructor( + readonly status: number, + message: string, + ) { + super(message); + this.name = 'CoverageReportError'; + } +} + +interface Bucket { + start: Date; + stop: Date; +} + +interface Figures { + users: number; + passkeyUsers: number; +} + +function isoDay(date: Date) { + return date.toISOString().slice(0, 10); +} + +function percent(part: number, whole: number) { + return whole > 0 ? Math.round((part / whole) * 1000) / 10 : 0; +} + +function withPercent(figures: T) { + return { ...figures, percent: percent(figures.passkeyUsers, figures.users) }; +} + +/** + * Calendar buckets in UTC. The first and last are clipped to the period, so a bucket can be + * shorter than a week or a month. Weeks start on Monday. + */ +export function coverageBuckets(window: CoverageWindow, bucket: 'week' | 'month'): Bucket[] { + const buckets: Bucket[] = []; + let cursor = window.start; + + while (cursor < window.end) { + const next = + bucket === 'month' + ? new Date(Date.UTC(cursor.getUTCFullYear(), cursor.getUTCMonth() + 1, 1)) + : new Date(cursor.getTime() + ((8 - cursor.getUTCDay()) % 7 || 7) * DAY_MS); + const stop = next < window.end ? next : window.end; + + buckets.push({ start: cursor, stop }); + cursor = stop; + } + + return buckets; +} + +function organizationClause(organizationId: string | undefined, userColumn = 'u.id') { + return organizationId + ? `AND EXISTS ( + SELECT 1 FROM organization_memberships m + WHERE m.user_id = ${userColumn} AND m.organization_id = :organizationId + )` + : ''; +} + +/** + * Active users, and those of them holding a passkey, as of each instant. A user counts from + * `created_at` and a passkey holder from their earliest surviving credential. + */ +async function figuresAsOf(stops: Date[], end: Date, organizationId?: string): Promise { + const rows = await getSequelize().query( + ` + WITH scoped AS ( + SELECT u.created_at, + (SELECT MIN(c."createdAt") FROM credentials c WHERE c."userId" = u.id) AS first_passkey_at + FROM users u + WHERE u.revoked = false + AND u.created_at < :end + ${organizationClause(organizationId)} + ) + SELECT b.stop, + (COUNT(s.created_at) FILTER (WHERE s.created_at < b.stop))::int AS users, + (COUNT(s.created_at) FILTER (WHERE s.first_passkey_at < b.stop))::int AS "passkeyUsers" + FROM unnest(ARRAY[:stops]::timestamptz[]) AS b(stop) + LEFT JOIN scoped s ON true + GROUP BY b.stop + ORDER BY b.stop + `, + { + replacements: { stops, end, organizationId: organizationId ?? null }, + type: QueryTypes.SELECT, + }, + ); + + return stops.map((_, index) => ({ + users: rows[index]?.users ?? 0, + passkeyUsers: rows[index]?.passkeyUsers ?? 0, + })); +} + +async function figuresByOrganization(end: Date, organizationId?: string) { + const rows = await getSequelize().query< + Figures & { organizationId: string | null; name: string | null } + >( + ` + WITH scoped AS ( + SELECT u.id, + EXISTS ( + SELECT 1 FROM credentials c WHERE c."userId" = u.id AND c."createdAt" < :end + ) AS has_passkey + FROM users u + WHERE u.revoked = false AND u.created_at < :end + ) + SELECT o.id AS "organizationId", + o.name, + COUNT(s.id)::int AS users, + (COUNT(s.id) FILTER (WHERE s.has_passkey))::int AS "passkeyUsers" + FROM organizations o + LEFT JOIN organization_memberships m ON m.organization_id = o.id + LEFT JOIN scoped s ON s.id = m.user_id + ${organizationId ? 'WHERE o.id = :organizationId' : ''} + GROUP BY o.id, o.name + ${ + organizationId + ? '' + : `UNION ALL + SELECT NULL, NULL, COUNT(*)::int, (COUNT(*) FILTER (WHERE s.has_passkey))::int + FROM scoped s + WHERE NOT EXISTS (SELECT 1 FROM organization_memberships m WHERE m.user_id = s.id)` + } + `, + { + replacements: { end, organizationId: organizationId ?? null }, + type: QueryTypes.SELECT, + }, + ); + + // Named organizations first, alphabetically, and the users in none of them last. + return [...rows] + .sort((a, b) => { + if (a.organizationId === null) return 1; + if (b.organizationId === null) return -1; + return ( + (a.name ?? '').localeCompare(b.name ?? '') || + a.organizationId.localeCompare(b.organizationId) + ); + }) + .map((row) => withPercent(row)); +} + +async function metadataName(aaguid: string) { + if (!isMetadataServiceReady()) return null; + + try { + return (await MetadataService.getStatement(aaguid))?.description ?? null; + } catch { + return null; + } +} + +async function authenticatorMix(end: Date, organizationId?: string) { + const rows = await getSequelize().query<{ + aaguid: string | null; + credentials: number; + users: number; + backupEligible: number; + backedUp: number; + }>( + ` + SELECT CASE + WHEN NULLIF(TRIM(c.aaguid), '') IS NULL OR c.aaguid = :anonymous THEN NULL + ELSE LOWER(TRIM(c.aaguid)) + END AS aaguid, + COUNT(*)::int AS credentials, + COUNT(DISTINCT c."userId")::int AS users, + (COUNT(*) FILTER (WHERE c."deviceType" = 'multiDevice'))::int AS "backupEligible", + (COUNT(*) FILTER (WHERE c.backedup))::int AS "backedUp" + FROM credentials c + JOIN users u ON u.id = c."userId" + WHERE u.revoked = false + AND c."createdAt" < :end + ${organizationClause(organizationId)} + GROUP BY 1 + ORDER BY 2 DESC, 1 ASC NULLS LAST + `, + { + replacements: { end, anonymous: ANONYMOUS_AAGUID, organizationId: organizationId ?? null }, + type: QueryTypes.SELECT, + }, + ); + + return Promise.all( + rows.map(async (row) => ({ + ...row, + name: row.aaguid + ? (knownAuthenticatorName(row.aaguid) ?? (await metadataName(row.aaguid))) + : null, + })), + ); +} + +/** + * Completed sign-ins in the period, folded by attempt the way the sign-in metrics are, so an + * OTP sign-in that writes `verify_otp_success` twice counts once. A code verification is + * split by the channel it recorded; rows written before the channel was recorded stay `otp`. + */ +async function signInMix(window: CoverageWindow, organizationId?: string) { + const rows = await getSequelize().query<{ + method: SignInMixMethod; + signIns: number; + users: number; + }>( + ` + WITH completed AS ( + SELECT COALESCE(e.attempt_id::text, e.id::text) AS attempt, + e.user_id, + CASE + WHEN e.type = :passkeyType THEN 'passkey' + WHEN e.type = :otpType AND e.metadata->>'channel' = 'email' THEN 'email_otp' + WHEN e.type = :otpType AND e.metadata->>'channel' = 'sms' THEN 'phone_otp' + WHEN e.type = :otpType THEN 'otp' + WHEN e.type = :magicLinkType THEN 'magic_link' + WHEN e.type = :totpType THEN 'totp' + WHEN e.type = :oauthType THEN 'oauth' + END AS method + FROM auth_events e + WHERE e.type IN (:types) + AND e.created_at >= :start + AND e.created_at < :end + ${organizationClause(organizationId, 'e.user_id')} + ) + SELECT method, + COUNT(DISTINCT attempt)::int AS "signIns", + COUNT(DISTINCT user_id)::int AS users + FROM completed + GROUP BY method + `, + { + replacements: { + start: window.start, + end: window.end, + organizationId: organizationId ?? null, + types: Object.values(SIGN_IN_EVENT_TYPES), + passkeyType: SIGN_IN_EVENT_TYPES.passkey, + otpType: SIGN_IN_EVENT_TYPES.otp, + magicLinkType: SIGN_IN_EVENT_TYPES.magic_link, + totpType: SIGN_IN_EVENT_TYPES.totp, + oauthType: SIGN_IN_EVENT_TYPES.oauth, + }, + type: QueryTypes.SELECT, + }, + ); + + const byMethod = new Map(rows.map((row) => [row.method, row])); + const methods = SIGN_IN_MIX_METHODS.map((method) => ({ + method, + phishingResistant: method === 'passkey', + signIns: byMethod.get(method)?.signIns ?? 0, + users: byMethod.get(method)?.users ?? 0, + })); + const total = methods.reduce((sum, row) => sum + row.signIns, 0); + const phishingResistant = methods + .filter((row) => row.phishingResistant) + .reduce((sum, row) => sum + row.signIns, 0); + + return { total, phishingResistant, percent: percent(phishingResistant, total), methods }; +} + +async function enforcedPolicy() { + const [login, config] = await Promise.all([getLoginPolicy(), getSystemConfig()]); + const authenticator = config.authenticator_policy; + + return { + phishingResistantOnly: login.phishingResistantOnly, + loginMethods: [...login.loginMethods], + passkeyFallbackEnabled: login.passkeyFallbackEnabled, + authenticator: { + attestation: authenticator.attestation, + userVerification: authenticator.userVerification, + attachment: authenticator.attachment, + syncedPasskeys: authenticator.syncedPasskeys, + requireKnownAuthenticator: authenticator.requireKnownAuthenticator, + aaguidAllowList: [...authenticator.aaguidAllowList], + aaguidDenyList: [...authenticator.aaguidDenyList], + }, + }; +} + +export async function buildCoverageReport( + query: Pick, + now: Date = new Date(), +): Promise { + const { organizationId, bucket } = query; + + if (organizationId && !(await Organization.findByPk(organizationId))) { + throw new CoverageReportError(404, 'Organization not found'); + } + + const window = resolveCoverageWindow(query, now); + const buckets = coverageBuckets(window, bucket); + + const [policy, figures, byOrganization, mix, signIns] = await Promise.all([ + enforcedPolicy(), + figuresAsOf( + buckets.map((entry) => entry.stop), + window.end, + organizationId, + ), + figuresByOrganization(window.end, organizationId), + authenticatorMix(window.end, organizationId), + signInMix(window, organizationId), + ]); + + return { + period: { from: window.from, to: window.to }, + generatedAt: now.toISOString(), + organizationId: organizationId ?? null, + bucket, + policy, + // The last bucket closes at the end of the period, so it is the coverage as of `to`. + coverage: withPercent(figures[figures.length - 1] ?? { users: 0, passkeyUsers: 0 }), + byOrganization, + trend: buckets.map((entry, index) => ({ + start: isoDay(entry.start), + end: isoDay(new Date(entry.stop.getTime() - DAY_MS)), + ...withPercent(figures[index]), + })), + authenticatorMix: mix, + signInMix: signIns, + }; +} + +/** Spreadsheet formula injection: a cell starting with one of these is evaluated on paste. */ +const FORMULA_PREFIX = /^[=+\-@\t\r]/; + +function csvCell(value: string | number | boolean | null) { + if (value === null) return ''; + if (typeof value !== 'string') return String(value); + + const text = FORMULA_PREFIX.test(value) ? `'${value}` : value; + + return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; +} + +function csvRow(...cells: Array) { + return cells.map(csvCell).join(','); +} + +const METHOD_LABELS: Record = { + passkey: 'Passkey', + email_otp: 'Email code', + phone_otp: 'Phone code', + otp: 'Code (channel not recorded)', + magic_link: 'Magic link', + totp: 'Authenticator app (TOTP)', + oauth: 'OAuth', +}; + +/** Sections are separated by a blank line and each carries its own header row. */ +export function coverageReportCsv(report: CoverageReport) { + const { policy } = report; + const lines = [ + csvRow('Authentication coverage report'), + csvRow('Period from', report.period.from), + csvRow('Period to', report.period.to), + csvRow('Organization ID', report.organizationId), + csvRow('Generated at', report.generatedAt), + '', + csvRow('Enforced policy'), + csvRow('Setting', 'Value'), + csvRow('Phishing-resistant only', policy.phishingResistantOnly), + csvRow('Login methods', policy.loginMethods.join(' ')), + csvRow('Passkey fallback enabled', policy.passkeyFallbackEnabled), + csvRow('Attestation', policy.authenticator.attestation), + csvRow('User verification', policy.authenticator.userVerification), + csvRow('Authenticator attachment', policy.authenticator.attachment), + csvRow('Synced passkeys', policy.authenticator.syncedPasskeys), + csvRow('Require known authenticator', policy.authenticator.requireKnownAuthenticator), + csvRow('AAGUID allow list', policy.authenticator.aaguidAllowList.join(' ')), + csvRow('AAGUID deny list', policy.authenticator.aaguidDenyList.join(' ')), + '', + csvRow('Coverage by organization'), + csvRow('Organization', 'Organization ID', 'Active users', 'Passkey holders', 'Coverage %'), + csvRow( + 'All users', + null, + report.coverage.users, + report.coverage.passkeyUsers, + report.coverage.percent, + ), + ...report.byOrganization.map((row) => + csvRow( + row.organizationId === null ? 'No organization' : (row.name ?? ''), + row.organizationId, + row.users, + row.passkeyUsers, + row.percent, + ), + ), + '', + csvRow('Coverage trend'), + csvRow('Period start', 'Period end', 'Active users', 'Passkey holders', 'Coverage %'), + ...report.trend.map((row) => + csvRow(row.start, row.end, row.users, row.passkeyUsers, row.percent), + ), + '', + csvRow('Authenticator mix'), + csvRow('AAGUID', 'Authenticator', 'Credentials', 'Users', 'Backup eligible', 'Backed up'), + ...report.authenticatorMix.map((row) => + csvRow( + row.aaguid ?? 'Not reported', + row.name, + row.credentials, + row.users, + row.backupEligible, + row.backedUp, + ), + ), + '', + csvRow('Sign-in mix'), + csvRow('Method', 'Phishing resistant', 'Sign-ins', 'Users'), + ...report.signInMix.methods.map((row) => + csvRow(METHOD_LABELS[row.method], row.phishingResistant, row.signIns, row.users), + ), + csvRow('All methods', null, report.signInMix.total, null), + csvRow('Phishing-resistant share %', null, report.signInMix.percent), + ]; + + return `${lines.join('\r\n')}\r\n`; +} diff --git a/src/services/loginPolicyService.ts b/src/services/loginPolicyService.ts index 0152427..e0399a5 100644 --- a/src/services/loginPolicyService.ts +++ b/src/services/loginPolicyService.ts @@ -6,6 +6,7 @@ import { getSystemConfig } from '../config/getSystemConfig.js'; import { SYSTEM_CONFIG_DEFAULTS } from '../config/systemConfig.defaults.js'; +import { Credential } from '../models/credentials.js'; import { LoginMethodSchema } from '../schemas/systemConfig.schema.js'; export type LoginMethod = 'passkey' | 'magic_link' | 'email_otp' | 'phone_otp' | 'oauth'; @@ -13,6 +14,7 @@ export type LoginMethod = 'passkey' | 'magic_link' | 'email_otp' | 'phone_otp' | export interface LoginPolicy { loginMethods: LoginMethod[]; passkeyFallbackEnabled: boolean; + phishingResistantOnly: boolean; } type LoginMethodUser = { @@ -48,6 +50,16 @@ export function normalizeLoginPolicy(config: Record | null | un const loginMethods = LOGIN_METHOD_ORDER.filter((method) => validConfiguredMethods.has(method)); + // Strictly true, so a malformed value leaves the mode off the way the schema default + // would rather than turning it on by accident. + if (config?.phishing_resistant_only === true) { + return { + loginMethods: ['passkey'] satisfies LoginMethod[], + passkeyFallbackEnabled: false, + phishingResistantOnly: true, + }; + } + return { loginMethods: loginMethods.length ? loginMethods @@ -56,6 +68,7 @@ export function normalizeLoginPolicy(config: Record | null | un typeof config?.passkey_login_fallback_enabled === 'boolean' ? config.passkey_login_fallback_enabled : SYSTEM_CONFIG_DEFAULTS.passkey_login_fallback_enabled!, + phishingResistantOnly: false, }; } @@ -116,3 +129,32 @@ export function resolveAvailableLoginMethods({ return hasValue(user.phone); }); } + +/** + * Whether a sign-in has to be a passkey, whatever other method the deployment enables. + * True in phishing-resistant-only mode, and for an account holding a passkey when + * fallback is off. + * + * The continuation endpoints check this as well as the method list. `/login` already + * leaves a fallback out of what it offers such an account, but the fallback's endpoint + * was still callable directly with the ephemeral token `/login` handed out. + */ +export function isPasskeyRequired(policy: LoginPolicy, hasPasskeyCredential: boolean) { + if (policy.phishingResistantOnly) return true; + + return ( + hasPasskeyCredential && + !policy.passkeyFallbackEnabled && + isLoginMethodEnabled(policy, 'passkey') + ); +} + +export async function isPasskeyRequiredForUser(userId: string, policy?: LoginPolicy) { + const resolvedPolicy = policy ?? (await getLoginPolicy()); + + // Settled without a query whenever the answer does not depend on the account. + if (resolvedPolicy.phishingResistantOnly) return true; + if (!isPasskeyRequired(resolvedPolicy, true)) return false; + + return isPasskeyRequired(resolvedPolicy, (await Credential.count({ where: { userId } })) > 0); +} diff --git a/src/services/oauthService.ts b/src/services/oauthService.ts index 4e0cb17..2ce6931 100644 --- a/src/services/oauthService.ts +++ b/src/services/oauthService.ts @@ -15,6 +15,7 @@ import { UserExternalId } from '../models/userExternalIds.js'; import { User } from '../models/users.js'; import type { OAuthProviderConfig } from '../schemas/systemConfig.schema.js'; import getLogger from '../utils/logger.js'; +import { normalizeLoginPolicy } from './loginPolicyService.js'; import { findOrganizationsRetiringOAuthProvider } from './organizationService.js'; const logger = getLogger('oauthService'); @@ -168,7 +169,13 @@ function providerRedirectAllowlist(provider: OAuthProviderConfig) { export async function getEnabledOAuthProviders() { const config = await getSystemConfig(); - if (!config.login_methods.includes('oauth')) { + // Through the normalized policy rather than the raw list, so phishing-resistant-only + // mode switches every provider off along with the other non-passkey methods. + if ( + !normalizeLoginPolicy(config as unknown as Record).loginMethods.includes( + 'oauth', + ) + ) { return []; } diff --git a/src/services/sessionIssuance.ts b/src/services/sessionIssuance.ts index 7920154..4e571e5 100644 --- a/src/services/sessionIssuance.ts +++ b/src/services/sessionIssuance.ts @@ -11,9 +11,27 @@ import { createRefreshTokenLookup, generateRefreshToken, signAccessToken } from import { Session } from '../models/sessions.js'; import { computeSessionTimes, parseDurationToSeconds } from '../utils/utils.js'; import { enforceConcurrentSessionLimit } from './concurrentSessionPolicy.js'; +import { getLoginPolicy } from './loginPolicyService.js'; import { getDefaultOrganizationIdForUser } from './organizationService.js'; +/** The factor that proved the user, as far as session issuance needs to know it. */ +export type SessionMethod = 'passkey' | 'email_otp' | 'phone_otp' | 'magic_link' | 'totp' | 'oauth'; + +export class PasskeyRequiredError extends Error { + constructor(method: SessionMethod) { + super(`Refused to issue a session from ${method} in phishing-resistant-only mode`); + this.name = 'PasskeyRequiredError'; + } +} + type IssueSessionParams = { + method: SessionMethod; + /** + * The session that completes a new account's address verification. The one session a + * non-passkey factor may start in phishing-resistant-only mode, because the account has + * nothing else to sign in with until its first passkey is enrolled. + */ + accountVerification?: boolean; user: { id: string; email: string; @@ -34,7 +52,16 @@ type IssueSessionParams = { }; export async function issueSessionAndRespond(params: IssueSessionParams): Promise { - const { user, req, res, extraFields } = params; + const { user, req, res, extraFields, method, accountVerification } = params; + + // Every endpoint that reaches here with another factor refuses first. This is the + // backstop for one that does not, so it fails closed rather than trusting the gates. + if (method !== 'passkey' && !accountVerification) { + const { phishingResistantOnly } = await getLoginPolicy(); + if (phishingResistantOnly) { + throw new PasskeyRequiredError(method); + } + } const refreshToken = generateRefreshToken(); const refreshTokenLookup = createRefreshTokenLookup(refreshToken); diff --git a/src/utils/parseEnvConfigs.ts b/src/utils/parseEnvConfigs.ts index 0829e6e..e8863f0 100644 --- a/src/utils/parseEnvConfigs.ts +++ b/src/utils/parseEnvConfigs.ts @@ -49,6 +49,7 @@ export function parseSystemConfigEnvValue(key: keyof typeof SYSTEM_CONFIG_ENV_MA case 'passkey_login_fallback_enabled': case 'prompt_passkey_enrollment': + case 'phishing_resistant_only': return raw.trim().toLowerCase() === 'true'; case 'access_token_ttl': diff --git a/tests/integration/admin/coverageReport.spec.ts b/tests/integration/admin/coverageReport.spec.ts new file mode 100644 index 0000000..f89c631 --- /dev/null +++ b/tests/integration/admin/coverageReport.spec.ts @@ -0,0 +1,184 @@ +import { Application } from 'express'; +import request from 'supertest'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getSystemConfig } from '../../../src/config/getSystemConfig.js'; +import { getSequelize } from '../../../src/models/index.js'; +import { Organization } from '../../../src/models/organizations.js'; +import { CoverageReportResponseSchema } from '../../../src/schemas/coverageReport.js'; +import { validateBearerToken } from '../../../src/services/sessionService.js'; +import { buildSystemConfig } from '../../factories/systemConfigFactory.js'; + +// Driven through the real bearer and admin checks, which every other admin spec stubs out. +vi.unmock('../../../src/middleware/attachAuthMiddleware.js'); +vi.unmock('../../../src/middleware/requireAdmin.js'); + +vi.mock('../../../src/models/index.js', async (importOriginal) => ({ + ...(await importOriginal()), + getSequelize: vi.fn(), +})); + +const PATH = '/admin/reports/authentication-coverage'; +const orgId = '11111111-1111-4111-8111-111111111111'; +const query = vi.fn(); +let app: Application; + +function asRoles(roles: string[]) { + vi.mocked(validateBearerToken).mockResolvedValue({ + user: { id: 'admin-1', email: 'admin@example.gov', roles }, + sessionId: 'session-1', + } as never); +} + +beforeAll(async () => { + const { createApp } = await import('../../../src/app.js'); + + app = await createApp(); +}); + +beforeEach(() => { + vi.clearAllMocks(); + query.mockReset(); + vi.mocked(getSequelize).mockReturnValue({ query } as never); + vi.mocked(getSystemConfig).mockResolvedValue( + buildSystemConfig({ + login_methods: ['passkey', 'email_otp'], + authenticator_policy: { + attachment: 'any', + userVerification: 'required', + attestation: 'none', + requireKnownAuthenticator: false, + syncedPasskeys: 'allow', + aaguidAllowList: [], + aaguidDenyList: [], + }, + }) as never, + ); + asRoles(['admin:read']); + + query.mockImplementation(async (sql: string) => { + if (sql.includes('unnest(')) return [{ users: 4, passkeyUsers: 3 }]; + if (sql.includes('FROM organizations o')) { + return [ + { organizationId: orgId, name: 'Public Works', users: 3, passkeyUsers: 3 }, + { organizationId: null, name: null, users: 1, passkeyUsers: 0 }, + ]; + } + if (sql.includes('"backupEligible"')) { + return [ + { + aaguid: 'ea9b8d66-4d01-1d21-3ce4-b6b48cb575d4', + credentials: 3, + users: 3, + backupEligible: 3, + backedUp: 3, + }, + ]; + } + return [{ method: 'passkey', signIns: 12, users: 3 }]; + }); +}); + +describe('GET /admin/reports/authentication-coverage', () => { + it('requires a bearer token', async () => { + const res = await request(app).get(PATH); + + expect(res.status).toBe(401); + expect(query).not.toHaveBeenCalled(); + }); + + it('requires an admin role', async () => { + asRoles(['user']); + + const res = await request(app).get(PATH).set('Authorization', 'Bearer token'); + + expect(res.status).toBe(403); + expect(query).not.toHaveBeenCalled(); + }); + + it('validates the token as an access token', async () => { + await request(app).get(PATH).set('Authorization', 'Bearer token'); + + expect(validateBearerToken).toHaveBeenCalledWith('token', 'access'); + }); + + it('returns the report as JSON', async () => { + const res = await request(app) + .get(`${PATH}?from=2026-01-01&to=2026-01-31`) + .set('Authorization', 'Bearer token'); + + expect(res.status).toBe(200); + expect(res.headers['content-type']).toContain('application/json'); + expect(CoverageReportResponseSchema.safeParse(res.body).success).toBe(true); + expect(res.body.period).toEqual({ from: '2026-01-01', to: '2026-01-31' }); + expect(res.body.bucket).toBe('month'); + expect(res.body.coverage).toEqual({ users: 4, passkeyUsers: 3, percent: 75 }); + expect(res.body.policy.loginMethods).toEqual(['passkey', 'email_otp']); + expect(res.body.byOrganization).toHaveLength(2); + expect(res.body.trend).toEqual([ + { start: '2026-01-01', end: '2026-01-31', users: 4, passkeyUsers: 3, percent: 75 }, + ]); + expect(res.body.authenticatorMix[0].name).toBe('Google Password Manager'); + expect(res.body.signInMix).toEqual(expect.objectContaining({ total: 12, percent: 100 })); + }); + + it('returns the report as a CSV attachment', async () => { + const res = await request(app) + .get(`${PATH}?from=2026-01-01&to=2026-01-31&format=csv`) + .set('Authorization', 'Bearer token'); + + expect(res.status).toBe(200); + expect(res.headers['content-type']).toBe('text/csv; charset=utf-8'); + expect(res.headers['content-disposition']).toBe( + 'attachment; filename="authentication-coverage-2026-01-01-to-2026-01-31.csv"', + ); + expect(res.text.split('\r\n')).toEqual( + expect.arrayContaining([ + 'Authentication coverage report', + 'Phishing-resistant only,false', + 'All users,,4,3,75', + `Public Works,${orgId},3,3,100`, + 'No organization,,1,0,0', + ]), + ); + }); + + it('scopes the report to an organization', async () => { + vi.mocked(Organization.findByPk).mockResolvedValue({ id: orgId } as never); + + const res = await request(app) + .get(`${PATH}?organizationId=${orgId}&bucket=week`) + .set('Authorization', 'Bearer token'); + + expect(res.status).toBe(200); + expect(res.body.organizationId).toBe(orgId); + expect(res.body.bucket).toBe('week'); + }); + + it('answers 404 for an unknown organization', async () => { + vi.mocked(Organization.findByPk).mockResolvedValue(null as never); + + const res = await request(app) + .get(`${PATH}?organizationId=${orgId}`) + .set('Authorization', 'Bearer token'); + + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'Organization not found' }); + expect(query).not.toHaveBeenCalled(); + }); + + it.each([ + 'from=2026-13-01', + 'from=2026-05-01&to=2026-04-01', + 'from=2015-01-01&to=2026-01-01', + 'bucket=day', + 'format=pdf', + 'organizationId=not-a-uuid', + ])('rejects %s', async (search) => { + const res = await request(app).get(`${PATH}?${search}`).set('Authorization', 'Bearer token'); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('invalid_request'); + expect(query).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/integration/authentication/decoyContinuation.spec.ts b/tests/integration/authentication/decoyContinuation.spec.ts index ed45347..74d9bd1 100644 --- a/tests/integration/authentication/decoyContinuation.spec.ts +++ b/tests/integration/authentication/decoyContinuation.spec.ts @@ -294,3 +294,71 @@ describe('decoy continuation: TOTP', () => { expect(res.body).toEqual({ error: 'totp_verification_failed' }); }); }); + +// The real continuation endpoints refuse a passkey holder's fallback when fallback is +// off, and everything but a passkey in phishing-resistant-only mode. A decoy has to +// answer the same way for the shape `/login` gave it, or the refusal is the oracle. +describe('decoy continuation: passkey required', () => { + const PASSKEY_ONLY_PATHS: [string, 'get' | 'post'][] = [ + ['/otp/generate-login-email-otp', 'get'], + ['/otp/verify-login-email-otp', 'post'], + ['/otp/verify-email-otp', 'post'], + ['/magic-link', 'get'], + ['/totp/verify-login', 'post'], + ]; + + function call(path: string, method: 'get' | 'post') { + return method === 'get' + ? request(app).get(path) + : request(app).post(path).send({ verificationToken: '123456', code: '123456' }); + } + + it.each(PASSKEY_ONLY_PATHS)( + 'refuses %s for a decoy holding a passkey when fallback is off', + async (path, method) => { + (getSystemConfig as any).mockResolvedValue({ + origins: ['http://localhost:5137'], + login_methods: ['passkey', 'magic_link', 'email_otp', 'phone_otp'], + passkey_login_fallback_enabled: false, + }); + + const res = await call(path, method); + + expect(res.status).toBe(403); + expect(res.body).toEqual({ error: 'login_method_disabled' }); + }, + ); + + it.each(PASSKEY_ONLY_PATHS)( + 'answers %s as usual for a passkeyless decoy when fallback is off', + async (path, method) => { + principal = PASSKEYLESS_DECOY; + (getSystemConfig as any).mockResolvedValue({ + origins: ['http://localhost:5137'], + login_methods: ['passkey', 'magic_link', 'email_otp', 'phone_otp'], + passkey_login_fallback_enabled: false, + }); + + const res = await call(path, method); + + expect(res.status).not.toBe(403); + }, + ); + + it.each(PASSKEY_ONLY_PATHS)( + 'refuses %s for every decoy in phishing-resistant-only mode', + async (path, method) => { + principal = PASSKEYLESS_DECOY; + (getSystemConfig as any).mockResolvedValue({ + origins: ['http://localhost:5137'], + login_methods: ['passkey', 'magic_link', 'email_otp', 'phone_otp'], + phishing_resistant_only: true, + }); + + const res = await call(path, method); + + expect(res.status).toBe(403); + expect(res.body).toEqual({ error: 'login_method_disabled' }); + }, + ); +}); diff --git a/tests/integration/magicLink/magicLink.spec.ts b/tests/integration/magicLink/magicLink.spec.ts index 3158ced..93ff78f 100644 --- a/tests/integration/magicLink/magicLink.spec.ts +++ b/tests/integration/magicLink/magicLink.spec.ts @@ -4,6 +4,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { Application } from 'express'; import { User } from '../../../src/models/users.js'; +import { Credential } from '../../../src/models/credentials.js'; import { MagicLinkToken } from '../../../src/models/magicLinks.js'; import { Session } from '../../../src/models/sessions.js'; @@ -193,6 +194,37 @@ describe('GET /magic-link', () => { expect(MagicLinkToken.create).not.toHaveBeenCalled(); }); + it('refuses a magic link to a passkey holder when fallback is off', async () => { + (getSystemConfig as any).mockResolvedValue({ + origins: ['http://localhost:5174'], + login_methods: ['passkey', 'magic_link'], + passkey_login_fallback_enabled: false, + }); + (Credential.count as any).mockResolvedValue(1); + + const requested = await request(app).get('/magic-link'); + const polled = await request(app).get('/magic-link/check'); + + expect(requested.status).toBe(403); + expect(requested.body.error).toBe('login_method_disabled'); + expect(polled.status).toBe(403); + expect(MagicLinkToken.create).not.toHaveBeenCalled(); + }); + + it('refuses magic links in phishing-resistant-only mode', async () => { + (getSystemConfig as any).mockResolvedValue({ + origins: ['http://localhost:5174'], + login_methods: ['passkey', 'magic_link'], + passkey_login_fallback_enabled: true, + phishing_resistant_only: true, + }); + + const res = await request(app).get('/magic-link'); + + expect(res.status).toBe(403); + expect(MagicLinkToken.create).not.toHaveBeenCalled(); + }); + it('rejects magic link requests when the method is disabled', async () => { (getSystemConfig as any).mockResolvedValue({ origins: ['http://localhost:5174'], diff --git a/tests/unit/config/systemConfigEnvMap.spec.ts b/tests/unit/config/systemConfigEnvMap.spec.ts index ee767a8..f9800bf 100644 --- a/tests/unit/config/systemConfigEnvMap.spec.ts +++ b/tests/unit/config/systemConfigEnvMap.spec.ts @@ -21,6 +21,7 @@ describe('SYSTEM_CONFIG_ENV_MAP', () => { authenticator_policy: 'AUTHENTICATOR_POLICY', passkey_login_fallback_enabled: 'PASSKEY_LOGIN_FALLBACK_ENABLED', prompt_passkey_enrollment: 'PROMPT_PASSKEY_ENROLLMENT', + phishing_resistant_only: 'PHISHING_RESISTANT_ONLY', access_token_ttl: 'ACCESS_TOKEN_TTL', refresh_token_ttl: 'REFRESH_TOKEN_TTL', session_idle_ttl: 'SESSION_IDLE_TTL', diff --git a/tests/unit/controllers/otp.spec.ts b/tests/unit/controllers/otp.spec.ts index 1c5ce32..fcb8e0a 100644 --- a/tests/unit/controllers/otp.spec.ts +++ b/tests/unit/controllers/otp.spec.ts @@ -300,6 +300,7 @@ describe('otp controller', () => { await verifyLoginPhoneNumber(req, res); expect(issueSessionAndRespondMock).toHaveBeenCalledWith({ + method: 'phone_otp', user: { id: verifiedUser.id, email: verifiedUser.email, @@ -313,6 +314,9 @@ describe('otp controller', () => { expect(verifiedUser.update).toHaveBeenCalledWith({ lastLogin: expect.any(Date), }); + expect(authEventLogMock).toHaveBeenCalledWith( + expect.objectContaining({ type: 'verify_otp_success', metadata: { channel: 'sms' } }), + ); }); it('rejects disabled login phone OTP verification', async () => { @@ -375,6 +379,8 @@ describe('otp controller', () => { await verifyEmail(req, res); expect(issueSessionAndRespondMock).toHaveBeenCalledWith({ + method: 'email_otp', + accountVerification: true, user: { id: verifiedUser.id, email: verifiedUser.email, @@ -492,6 +498,7 @@ describe('otp controller', () => { await verifyLoginEmail(req, res); expect(issueSessionAndRespondMock).toHaveBeenCalledWith({ + method: 'email_otp', user: { id: verifiedUser.id, email: verifiedUser.email, @@ -504,7 +511,11 @@ describe('otp controller', () => { }); expect(verifiedUser.update).toHaveBeenCalledWith({ lastLogin: expect.any(Date) }); expect(authEventLogMock).toHaveBeenCalledWith( - expect.objectContaining({ userId: verifiedUser.id, type: 'verify_otp_success' }), + expect.objectContaining({ + userId: verifiedUser.id, + type: 'verify_otp_success', + metadata: expect.objectContaining({ channel: 'email' }), + }), ); }); @@ -979,4 +990,122 @@ describe('otp controller', () => { expect(res.status).toHaveBeenCalledWith(500); }); }); + + describe('when a passkey is required', () => { + async function loadWithPasskeyHolder(credentialCount = 1) { + const controller = await loadOtpController(); + const { Credential } = await import('../../../src/models/credentials.js'); + (Credential.count as any).mockResolvedValue(credentialCount); + return controller; + } + + const fallbackOff = { + login_methods: ['passkey', 'magic_link', 'email_otp', 'phone_otp'], + passkey_login_fallback_enabled: false, + }; + + // The bypass this closes: `/login` offered such an account only a passkey, but the + // OTP endpoints checked the deployment-wide method list alone and signed it in. + it('refuses login email OTP to a passkey holder when fallback is off', async () => { + const { sendLoginEmailOTP, verifyLoginEmail } = await loadWithPasskeyHolder(); + getSystemConfigMock.mockResolvedValue(fallbackOff); + + const sendRes = buildRes(); + await sendLoginEmailOTP(buildReq(buildUser()), sendRes); + + const verifyRes = buildRes(); + await verifyLoginEmail( + buildReq(buildUser(), { body: { verificationToken: 'EMAILOTP' } }), + verifyRes, + ); + + expect(generateEmailOTPMock).not.toHaveBeenCalled(); + expect(verifyEmailOTPMock).not.toHaveBeenCalled(); + expect(issueSessionAndRespondMock).not.toHaveBeenCalled(); + expect(sendRes.status).toHaveBeenCalledWith(403); + expect(verifyRes.json).toHaveBeenCalledWith({ error: 'login_method_disabled' }); + }); + + it('refuses login phone OTP to a passkey holder when fallback is off', async () => { + const { verifyLoginPhoneNumber } = await loadWithPasskeyHolder(); + getSystemConfigMock.mockResolvedValue(fallbackOff); + const res = buildRes(); + + await verifyLoginPhoneNumber( + buildReq(buildUser(), { body: { verificationToken: '123456' } }), + res, + ); + + expect(verifyPhoneOTPMock).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + }); + + it('still lets an account without a passkey use the fallback', async () => { + const { sendLoginEmailOTP } = await loadWithPasskeyHolder(0); + getSystemConfigMock.mockResolvedValue(fallbackOff); + const res = buildRes(); + + await sendLoginEmailOTP(buildReq(buildUser()), res); + + expect(generateEmailOTPMock).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalledWith(403); + }); + + it('refuses to sign an already verified passkey holder in through email verification', async () => { + const { verifyEmail } = await loadWithPasskeyHolder(); + getSystemConfigMock.mockResolvedValue(fallbackOff); + const res = buildRes(); + + await verifyEmail(buildReq(buildUser(), { body: { verificationToken: 'EMAILOTP' } }), res); + + expect(verifyEmailOTPMock).not.toHaveBeenCalled(); + expect(issueSessionAndRespondMock).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ error: 'login_method_disabled' }); + }); + + it('refuses every code sign-in in phishing-resistant-only mode', async () => { + const { sendLoginEmailOTP, verifyEmail } = await loadWithPasskeyHolder(0); + getSystemConfigMock.mockResolvedValue({ + login_methods: ['passkey', 'email_otp'], + passkey_login_fallback_enabled: true, + phishing_resistant_only: true, + }); + + const sendRes = buildRes(); + await sendLoginEmailOTP(buildReq(buildUser()), sendRes); + const verifyRes = buildRes(); + await verifyEmail( + buildReq(buildUser(), { body: { verificationToken: 'EMAILOTP' } }), + verifyRes, + ); + + expect(sendRes.status).toHaveBeenCalledWith(403); + expect(verifyRes.status).toHaveBeenCalledWith(403); + expect(issueSessionAndRespondMock).not.toHaveBeenCalled(); + }); + + it('still verifies a new account in phishing-resistant-only mode, once', async () => { + const { verifyEmail } = await loadWithPasskeyHolder(0); + getSystemConfigMock.mockResolvedValue({ + login_methods: ['passkey'], + passkey_login_fallback_enabled: false, + phishing_resistant_only: true, + }); + const newcomer = buildUser({ verified: false, emailVerified: false }); + verifyEmailOTPMock.mockResolvedValue({ + user: buildUser({ verified: true, emailVerified: true }), + verified: true, + }); + + await verifyEmail( + buildReq(newcomer, { body: { verificationToken: 'EMAILOTP' } }), + buildRes(), + ); + + expect(issueSessionAndRespondMock).toHaveBeenCalledWith( + expect.objectContaining({ method: 'email_otp', accountVerification: true }), + ); + }); + }); }); diff --git a/tests/unit/controllers/totp.spec.ts b/tests/unit/controllers/totp.spec.ts index 9dc3e94..a3d978e 100644 --- a/tests/unit/controllers/totp.spec.ts +++ b/tests/unit/controllers/totp.spec.ts @@ -329,6 +329,38 @@ describe('totp controller', () => { expect(issueSessionAndRespondMock).not.toHaveBeenCalled(); }); + it('refuses TOTP sign-in in phishing-resistant-only mode', async () => { + const { verifyTotpLogin } = await loadTotpController(); + const res = buildRes(); + getSystemConfigMock.mockResolvedValue({ + login_methods: ['passkey'], + phishing_resistant_only: true, + }); + + await verifyTotpLogin(buildReq(buildUser(), { body: { code: '123456' } }), res); + + expect(verifyEnabledTotpMock).not.toHaveBeenCalled(); + expect(issueSessionAndRespondMock).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ error: 'login_method_disabled' }); + }); + + it('refuses TOTP sign-in to a passkey holder when fallback is off', async () => { + const { verifyTotpLogin } = await loadTotpController(); + const { Credential } = await import('../../../src/models/credentials.js'); + (Credential.count as any).mockResolvedValue(1); + const res = buildRes(); + getSystemConfigMock.mockResolvedValue({ + login_methods: ['passkey', 'email_otp'], + passkey_login_fallback_enabled: false, + }); + + await verifyTotpLogin(buildReq(buildUser(), { body: { code: '123456' } }), res); + + expect(verifyEnabledTotpMock).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + }); + it('returns 401 and logs failure when the code is invalid', async () => { const { verifyTotpLogin } = await loadTotpController(); const res = buildRes(); @@ -357,6 +389,7 @@ describe('totp controller', () => { await verifyTotpLogin(req, res); expect(issueSessionAndRespondMock).toHaveBeenCalledWith({ + method: 'totp', user: { id: 'user-1', email: 'test@example.com', diff --git a/tests/unit/services/coverageReport.spec.ts b/tests/unit/services/coverageReport.spec.ts new file mode 100644 index 0000000..75fa1e8 --- /dev/null +++ b/tests/unit/services/coverageReport.spec.ts @@ -0,0 +1,419 @@ +import { MetadataService } from '@simplewebauthn/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getSystemConfig } from '../../../src/config/getSystemConfig.js'; +import { knownAuthenticatorName } from '../../../src/lib/knownAuthenticators.js'; +import { getSequelize } from '../../../src/models/index.js'; +import { Organization } from '../../../src/models/organizations.js'; +import { SIGN_IN_SUCCESS_TYPES } from '../../../src/schemas/authEvent.types.js'; +import { + CoverageReportQuerySchema, + resolveCoverageWindow, +} from '../../../src/schemas/coverageReport.js'; +import { + buildCoverageReport, + coverageBuckets, + coverageReportCsv, + CoverageReportError, + SIGN_IN_EVENT_TYPES, +} from '../../../src/services/coverageReport.js'; +import { isMetadataServiceReady } from '../../../src/services/metadataServiceBootstrap.js'; +import { buildSystemConfig } from '../../factories/systemConfigFactory.js'; + +vi.mock('../../../src/models/index.js', () => ({ + getSequelize: vi.fn(), +})); + +vi.mock('@simplewebauthn/server', () => ({ + MetadataService: { getStatement: vi.fn() }, +})); + +vi.mock('../../../src/services/metadataServiceBootstrap.js', () => ({ + isMetadataServiceReady: vi.fn(), +})); + +const query = vi.fn(); +const orgId = '11111111-1111-4111-8111-111111111111'; +const now = new Date('2026-10-06T12:00:00Z'); + +const authenticatorPolicy = { + attachment: 'any', + userVerification: 'required', + attestation: 'direct', + requireKnownAuthenticator: false, + syncedPasskeys: 'block', + aaguidAllowList: [], + aaguidDenyList: ['aaaaaaaa-0000-0000-0000-000000000000'], +}; + +function sqlFor(fragment: string) { + return query.mock.calls.find(([sql]) => String(sql).includes(fragment)); +} + +/** Routes each of the report's queries to a canned answer by a fragment unique to it. */ +function answer(rows: { + figures?: unknown[]; + organizations?: unknown[]; + mix?: unknown[]; + signIns?: unknown[]; +}) { + query.mockImplementation(async (sql: string) => { + if (sql.includes('unnest(')) return rows.figures ?? []; + if (sql.includes('FROM organizations o')) return rows.organizations ?? []; + if (sql.includes('"backupEligible"')) return rows.mix ?? []; + if (sql.includes('FROM auth_events')) return rows.signIns ?? []; + throw new Error(`unexpected query: ${sql}`); + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + query.mockReset(); + vi.mocked(getSequelize).mockReturnValue({ query } as never); + vi.mocked(getSystemConfig).mockResolvedValue( + buildSystemConfig({ + login_methods: ['passkey', 'email_otp'], + passkey_login_fallback_enabled: false, + authenticator_policy: authenticatorPolicy, + }) as never, + ); + vi.mocked(isMetadataServiceReady).mockReturnValue(false); +}); + +describe('resolveCoverageWindow', () => { + it('defaults to the 90 days ending today, both ends included', () => { + const window = resolveCoverageWindow({}, now); + + expect(window.from).toBe('2026-07-09'); + expect(window.to).toBe('2026-10-06'); + expect(window.start.toISOString()).toBe('2026-07-09T00:00:00.000Z'); + expect(window.end.toISOString()).toBe('2026-10-07T00:00:00.000Z'); + expect((window.end.getTime() - window.start.getTime()) / 86_400_000).toBe(90); + }); + + it('runs a period given only `to` back 90 days from it', () => { + expect(resolveCoverageWindow({ to: '2026-03-31' }, now).from).toBe('2026-01-01'); + }); +}); + +describe('CoverageReportQuerySchema', () => { + it('applies the defaults', () => { + expect(CoverageReportQuerySchema.parse({})).toEqual({ bucket: 'month', format: 'json' }); + }); + + it.each([ + [{ from: '2026-05-01', to: '2026-04-01' }, 'from must not be after to'], + [{ from: '2010-01-01', to: '2026-01-01' }, 'at most 1827 days'], + ])('rejects %j', (input, message) => { + const result = CoverageReportQuerySchema.safeParse(input); + + expect(result.success).toBe(false); + expect(result.error?.issues[0].message).toContain(message); + }); + + it.each([ + { from: '2026-02-30' }, + { to: '2026-04-01T00:00:00Z' }, + { bucket: 'day' }, + { format: 'xlsx' }, + { organizationId: 'nope' }, + ])('rejects %j', (input) => { + expect(CoverageReportQuerySchema.safeParse(input).success).toBe(false); + }); + + it('accepts a single-day period', () => { + expect( + CoverageReportQuerySchema.safeParse({ from: '2026-04-01', to: '2026-04-01' }).success, + ).toBe(true); + }); +}); + +describe('coverageBuckets', () => { + const day = (date: Date) => date.toISOString().slice(0, 10); + + it('clips calendar months to the period', () => { + const window = resolveCoverageWindow({ from: '2026-01-15', to: '2026-03-10' }); + + expect(coverageBuckets(window, 'month').map((b) => [day(b.start), day(b.stop)])).toEqual([ + ['2026-01-15', '2026-02-01'], + ['2026-02-01', '2026-03-01'], + ['2026-03-01', '2026-03-11'], + ]); + }); + + it('starts weeks on Monday', () => { + // 2026-03-01 is a Sunday. + const window = resolveCoverageWindow({ from: '2026-03-01', to: '2026-03-17' }); + + expect(coverageBuckets(window, 'week').map((b) => [day(b.start), day(b.stop)])).toEqual([ + ['2026-03-01', '2026-03-02'], + ['2026-03-02', '2026-03-09'], + ['2026-03-09', '2026-03-16'], + ['2026-03-16', '2026-03-18'], + ]); + }); + + it('crosses a year boundary', () => { + const window = resolveCoverageWindow({ from: '2025-12-20', to: '2026-01-05' }); + + expect(coverageBuckets(window, 'month').map((b) => day(b.start))).toEqual([ + '2025-12-20', + '2026-01-01', + ]); + }); +}); + +describe('SIGN_IN_EVENT_TYPES', () => { + it('covers exactly the sign-in success types the metrics count', () => { + expect([...Object.values(SIGN_IN_EVENT_TYPES)].sort()).toEqual( + [...SIGN_IN_SUCCESS_TYPES].sort(), + ); + }); +}); + +describe('knownAuthenticatorName', () => { + it('matches case-insensitively and knows nothing of unlisted models', () => { + expect(knownAuthenticatorName('EA9B8D66-4D01-1D21-3CE4-B6B48CB575D4')).toBe( + 'Google Password Manager', + ); + expect(knownAuthenticatorName('ffffffff-0000-0000-0000-000000000000')).toBeNull(); + expect(knownAuthenticatorName(null)).toBeNull(); + }); +}); + +describe('buildCoverageReport', () => { + const query3Months = { from: '2026-01-01', to: '2026-03-31', bucket: 'month' as const }; + + it('assembles policy, coverage, organizations, trend, authenticators and sign-ins', async () => { + answer({ + figures: [ + { users: 10, passkeyUsers: 2 }, + { users: 12, passkeyUsers: 5 }, + { users: 12, passkeyUsers: 9 }, + ], + organizations: [ + { organizationId: null, name: null, users: 2, passkeyUsers: 0 }, + { organizationId: 'org-b', name: 'Public Works', users: 6, passkeyUsers: 6 }, + { organizationId: 'org-a', name: 'Clerk', users: 4, passkeyUsers: 3 }, + ], + mix: [ + { + aaguid: 'fbfc3007-154e-4ecc-8c0b-6e020557d7bd', + credentials: 7, + users: 7, + backupEligible: 7, + backedUp: 6, + }, + { aaguid: null, credentials: 2, users: 2, backupEligible: 0, backedUp: 0 }, + ], + signIns: [ + { method: 'passkey', signIns: 30, users: 9 }, + { method: 'email_otp', signIns: 10, users: 3 }, + ], + }); + + const report = await buildCoverageReport(query3Months, now); + + expect(report.period).toEqual({ from: '2026-01-01', to: '2026-03-31' }); + expect(report.generatedAt).toBe('2026-10-06T12:00:00.000Z'); + expect(report.organizationId).toBeNull(); + expect(report.policy).toEqual({ + phishingResistantOnly: false, + loginMethods: ['passkey', 'email_otp'], + passkeyFallbackEnabled: false, + authenticator: authenticatorPolicy, + }); + expect(report.coverage).toEqual({ users: 12, passkeyUsers: 9, percent: 75 }); + expect(report.byOrganization.map((row) => [row.name, row.percent])).toEqual([ + ['Clerk', 75], + ['Public Works', 100], + [null, 0], + ]); + expect(report.trend).toEqual([ + { start: '2026-01-01', end: '2026-01-31', users: 10, passkeyUsers: 2, percent: 20 }, + { start: '2026-02-01', end: '2026-02-28', users: 12, passkeyUsers: 5, percent: 41.7 }, + { start: '2026-03-01', end: '2026-03-31', users: 12, passkeyUsers: 9, percent: 75 }, + ]); + expect(report.authenticatorMix.map((row) => row.name)).toEqual(['iCloud Keychain', null]); + expect(report.signInMix.total).toBe(40); + expect(report.signInMix.phishingResistant).toBe(30); + expect(report.signInMix.percent).toBe(75); + expect(report.signInMix.methods.map((row) => row.method)).toEqual([ + 'passkey', + 'email_otp', + 'phone_otp', + 'otp', + 'magic_link', + 'totp', + 'oauth', + ]); + expect(report.signInMix.methods.find((row) => row.method === 'totp')).toEqual({ + method: 'totp', + phishingResistant: false, + signIns: 0, + users: 0, + }); + + const [, figuresOptions] = sqlFor('unnest(')!; + expect(figuresOptions.replacements.stops.map((d: Date) => d.toISOString())).toEqual([ + '2026-02-01T00:00:00.000Z', + '2026-03-01T00:00:00.000Z', + '2026-04-01T00:00:00.000Z', + ]); + expect(String(sqlFor('unnest(')![0])).not.toContain('organization_memberships'); + + const [signInSql, signInOptions] = sqlFor('FROM auth_events')!; + expect(signInSql).toContain("e.metadata->>'channel' = 'email'"); + expect(signInOptions.replacements).toEqual( + expect.objectContaining({ + start: new Date('2026-01-01T00:00:00Z'), + end: new Date('2026-04-01T00:00:00Z'), + }), + ); + }); + + it('states phishing-resistant-only as enforced passkey-only policy', async () => { + vi.mocked(getSystemConfig).mockResolvedValue( + buildSystemConfig({ + login_methods: ['passkey', 'email_otp', 'magic_link'], + phishing_resistant_only: true, + authenticator_policy: authenticatorPolicy, + }) as never, + ); + answer({}); + + const report = await buildCoverageReport(query3Months, now); + + expect(report.policy).toEqual( + expect.objectContaining({ + phishingResistantOnly: true, + loginMethods: ['passkey'], + passkeyFallbackEnabled: false, + }), + ); + }); + + it('reports zeros, not NaN, for an empty deployment', async () => { + answer({}); + + const report = await buildCoverageReport(query3Months, now); + + expect(report.coverage).toEqual({ users: 0, passkeyUsers: 0, percent: 0 }); + expect(report.trend.every((row) => row.users === 0 && row.percent === 0)).toBe(true); + expect(report.signInMix).toEqual(expect.objectContaining({ total: 0, percent: 0 })); + }); + + it('scopes every query to the organization', async () => { + vi.mocked(Organization.findByPk).mockResolvedValue({ id: orgId } as never); + answer({}); + + const report = await buildCoverageReport({ ...query3Months, organizationId: orgId }, now); + + expect(report.organizationId).toBe(orgId); + for (const fragment of ['unnest(', '"backupEligible"', 'FROM auth_events']) { + const [sql, options] = sqlFor(fragment)!; + expect(sql).toContain('m.organization_id = :organizationId'); + expect(options.replacements.organizationId).toBe(orgId); + } + expect(sqlFor('FROM auth_events')![0]).toContain('m.user_id = e.user_id'); + + const [orgSql] = sqlFor('FROM organizations o')!; + expect(orgSql).toContain('WHERE o.id = :organizationId'); + expect(orgSql).not.toContain('UNION ALL'); + }); + + it('refuses an organization that does not exist', async () => { + vi.mocked(Organization.findByPk).mockResolvedValue(null as never); + + await expect( + buildCoverageReport({ ...query3Months, organizationId: orgId }, now), + ).rejects.toEqual(new CoverageReportError(404, 'Organization not found')); + expect(query).not.toHaveBeenCalled(); + }); + + it('names an unlisted authenticator from the metadata service when it is up', async () => { + vi.mocked(isMetadataServiceReady).mockReturnValue(true); + vi.mocked(MetadataService.getStatement).mockImplementation(async (aaguid) => { + if (aaguid === 'bbbbbbbb-0000-0000-0000-000000000000') { + return { description: 'Security Key NFC' } as never; + } + throw new Error('not listed'); + }); + answer({ + mix: [ + { + aaguid: 'bbbbbbbb-0000-0000-0000-000000000000', + credentials: 1, + users: 1, + backupEligible: 0, + backedUp: 0, + }, + { + aaguid: 'cccccccc-0000-0000-0000-000000000000', + credentials: 1, + users: 1, + backupEligible: 0, + backedUp: 0, + }, + ], + }); + + const report = await buildCoverageReport(query3Months, now); + + expect(report.authenticatorMix.map((row) => row.name)).toEqual(['Security Key NFC', null]); + }); +}); + +describe('coverageReportCsv', () => { + it('renders each section with its own header, escaped and guarded against formulas', async () => { + answer({ + figures: [ + { users: 1, passkeyUsers: 0 }, + { users: 2, passkeyUsers: 1 }, + { users: 3, passkeyUsers: 2 }, + ], + organizations: [ + { organizationId: 'org-a', name: 'Clerk, "Office"', users: 2, passkeyUsers: 1 }, + { organizationId: 'org-b', name: '=HYPERLINK("x")', users: 1, passkeyUsers: 1 }, + { organizationId: null, name: null, users: 0, passkeyUsers: 0 }, + ], + mix: [{ aaguid: null, credentials: 2, users: 2, backupEligible: 1, backedUp: 1 }], + signIns: [{ method: 'passkey', signIns: 4, users: 2 }], + }); + + const csv = coverageReportCsv( + await buildCoverageReport({ ...query3Months(), bucket: 'month' }, now), + ); + const lines = csv.split('\r\n'); + + expect(csv.endsWith('\r\n')).toBe(true); + expect(lines).toContain('Phishing-resistant only,false'); + expect(lines).toContain('Login methods,passkey email_otp'); + expect(lines).toContain('AAGUID deny list,aaaaaaaa-0000-0000-0000-000000000000'); + expect(lines).toContain('Organization,Organization ID,Active users,Passkey holders,Coverage %'); + expect(lines).toContain('All users,,3,2,66.7'); + expect(lines).toContain('"Clerk, ""Office""",org-a,2,1,50'); + expect(lines).toContain(`"'=HYPERLINK(""x"")",org-b,1,1,100`); + expect(lines).toContain('No organization,,0,0,0'); + expect(lines).toContain('2026-01-01,2026-01-31,1,0,0'); + expect(lines).toContain('Not reported,,2,2,1,1'); + expect(lines).toContain('Passkey,true,4,2'); + expect(lines).toContain('Code (channel not recorded),false,0,0'); + expect(lines).toContain('Phishing-resistant share %,,100'); + + // A blank line before every section title, so a pasted sheet keeps them apart. + for (const title of [ + 'Enforced policy', + 'Coverage by organization', + 'Coverage trend', + 'Authenticator mix', + 'Sign-in mix', + ]) { + expect(lines[lines.indexOf(title) - 1]).toBe(''); + } + }); + + function query3Months() { + return { from: '2026-01-01', to: '2026-03-31' }; + } +}); diff --git a/tests/unit/services/loginPolicyService.spec.ts b/tests/unit/services/loginPolicyService.spec.ts index c7ccad7..d0e99c6 100644 --- a/tests/unit/services/loginPolicyService.spec.ts +++ b/tests/unit/services/loginPolicyService.spec.ts @@ -1,8 +1,11 @@ import { describe, expect, it, vi } from 'vitest'; import { getSystemConfig } from '../../../src/config/getSystemConfig.js'; +import { Credential } from '../../../src/models/credentials.js'; import { getLoginPolicy, + isPasskeyRequired, + isPasskeyRequiredForUser, normalizeLoginPolicy, resolveAvailableLoginMethods, } from '../../../src/services/loginPolicyService.js'; @@ -20,6 +23,7 @@ describe('loginPolicyService', () => { await expect(getLoginPolicy()).resolves.toEqual({ loginMethods: ['passkey', 'email_otp'], passkeyFallbackEnabled: false, + phishingResistantOnly: false, }); vi.clearAllMocks(); @@ -29,6 +33,7 @@ describe('loginPolicyService', () => { expect(normalizeLoginPolicy(null)).toEqual({ loginMethods: ['passkey', 'magic_link'], passkeyFallbackEnabled: true, + phishingResistantOnly: false, }); }); @@ -180,4 +185,98 @@ describe('loginPolicyService', () => { ).toEqual(['magic_link', 'phone_otp']); }); }); + + describe('phishing-resistant-only mode', () => { + it('narrows sign-in to passkeys whatever the method list and fallback say', () => { + expect( + normalizeLoginPolicy({ + login_methods: ['passkey', 'magic_link', 'email_otp', 'phone_otp', 'oauth'], + passkey_login_fallback_enabled: true, + phishing_resistant_only: true, + }), + ).toEqual({ + loginMethods: ['passkey'], + passkeyFallbackEnabled: false, + phishingResistantOnly: true, + }); + }); + + it('only turns on for a literal true', () => { + expect(normalizeLoginPolicy({ phishing_resistant_only: 'true' }).phishingResistantOnly).toBe( + false, + ); + expect(normalizeLoginPolicy({ phishing_resistant_only: 1 }).phishingResistantOnly).toBe( + false, + ); + }); + + it('offers no fallback to an account without a passkey', () => { + const policy = normalizeLoginPolicy({ + login_methods: ['passkey', 'magic_link'], + phishing_resistant_only: true, + }); + + expect( + resolveAvailableLoginMethods({ + policy, + user: { email: 'a@example.com', phone: '+15555555555' }, + hasPasskeyCredential: false, + }), + ).toEqual([]); + }); + }); + + describe('isPasskeyRequired', () => { + const fallbackOff = normalizeLoginPolicy({ + login_methods: ['passkey', 'email_otp'], + passkey_login_fallback_enabled: false, + }); + const fallbackOn = normalizeLoginPolicy({ + login_methods: ['passkey', 'email_otp'], + passkey_login_fallback_enabled: true, + }); + + it('requires a passkey of everyone in phishing-resistant-only mode', () => { + const strict = normalizeLoginPolicy({ phishing_resistant_only: true }); + + expect(isPasskeyRequired(strict, false)).toBe(true); + expect(isPasskeyRequired(strict, true)).toBe(true); + }); + + it('requires one of a passkey holder when fallback is off', () => { + expect(isPasskeyRequired(fallbackOff, true)).toBe(true); + expect(isPasskeyRequired(fallbackOff, false)).toBe(false); + }); + + it('requires nothing when fallback is on', () => { + expect(isPasskeyRequired(fallbackOn, true)).toBe(false); + }); + + it('requires nothing when passkeys are not a sign-in method at all', () => { + const noPasskeys = normalizeLoginPolicy({ + login_methods: ['email_otp'], + passkey_login_fallback_enabled: false, + }); + + expect(isPasskeyRequired(noPasskeys, true)).toBe(false); + }); + + it('looks the account up only when the answer depends on it', async () => { + (Credential.count as any).mockResolvedValue(1); + + await expect( + isPasskeyRequiredForUser('user-1', normalizeLoginPolicy({ phishing_resistant_only: true })), + ).resolves.toBe(true); + await expect(isPasskeyRequiredForUser('user-1', fallbackOn)).resolves.toBe(false); + expect(Credential.count).not.toHaveBeenCalled(); + + await expect(isPasskeyRequiredForUser('user-1', fallbackOff)).resolves.toBe(true); + expect(Credential.count).toHaveBeenCalledWith({ where: { userId: 'user-1' } }); + + (Credential.count as any).mockResolvedValue(0); + await expect(isPasskeyRequiredForUser('user-1', fallbackOff)).resolves.toBe(false); + + vi.clearAllMocks(); + }); + }); }); diff --git a/tests/unit/services/oauthService.spec.ts b/tests/unit/services/oauthService.spec.ts index 7b47055..9576fd2 100644 --- a/tests/unit/services/oauthService.spec.ts +++ b/tests/unit/services/oauthService.spec.ts @@ -435,6 +435,19 @@ describe('oauthService', () => { expect(OAuthIdentity.findOrCreate).not.toHaveBeenCalled(); }); + it('returns no providers in phishing-resistant-only mode', async () => { + (getSystemConfig as any).mockResolvedValue( + buildSystemConfig({ + login_methods: ['passkey', 'oauth'], + oauth_providers: [provider], + phishing_resistant_only: true, + }), + ); + + await expect(getEnabledOAuthProviders()).resolves.toEqual([]); + await expect(getOAuthProvider(provider.id)).resolves.toBeNull(); + }); + it('returns no providers when oauth login is not enabled', async () => { (getSystemConfig as any).mockResolvedValue( buildSystemConfig({ login_methods: ['passkey'], oauth_providers: [provider] }), diff --git a/tests/unit/services/sessionIssueService.spec.ts b/tests/unit/services/sessionIssueService.spec.ts index 45a48fd..872bdda 100644 --- a/tests/unit/services/sessionIssueService.spec.ts +++ b/tests/unit/services/sessionIssueService.spec.ts @@ -1,6 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { issueSessionAndRespond } from '../../../src/services/sessionIssuance.js'; +import { + issueSessionAndRespond, + PasskeyRequiredError, +} from '../../../src/services/sessionIssuance.js'; vi.mock('../../../src/lib/token.js', () => ({ generateRefreshToken: vi.fn(), @@ -149,6 +152,52 @@ describe('issueSessionAndRespond', () => { ); }); + describe('in phishing-resistant-only mode', () => { + beforeEach(() => { + (getSystemConfig as any).mockResolvedValue({ + access_token_ttl: '15m', + refresh_token_ttl: '1h', + session_idle_ttl: '8h', + login_methods: ['passkey'], + phishing_resistant_only: true, + }); + }); + + it.each(['email_otp', 'phone_otp', 'magic_link', 'totp', 'oauth'] as const)( + 'refuses to start a session from %s even if an endpoint let it through', + async (method) => { + await expect( + issueSessionAndRespond({ method, user: mockUser, req: mockReq(), res: mockRes() }), + ).rejects.toBeInstanceOf(PasskeyRequiredError); + + expect(Session.create).not.toHaveBeenCalled(); + }, + ); + + it('starts a session from a passkey', async () => { + await issueSessionAndRespond({ + method: 'passkey', + user: mockUser, + req: mockReq(), + res: mockRes(), + }); + + expect(Session.create).toHaveBeenCalled(); + }); + + it("starts the one session that completes a new account's address verification", async () => { + await issueSessionAndRespond({ + method: 'email_otp', + accountVerification: true, + user: mockUser, + req: mockReq(), + res: mockRes(), + }); + + expect(Session.create).toHaveBeenCalled(); + }); + }); + it('throws if token generation fails', async () => { const req = mockReq(); const res = mockRes();