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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/authentication-coverage-report.md
Original file line number Diff line number Diff line change
@@ -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`.
10 changes: 10 additions & 0 deletions .changeset/phishing-resistant-only.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
126 changes: 126 additions & 0 deletions docs/admin-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<from>-to-<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.
5 changes: 4 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading
Loading