Skip to content

feat(admin): add an authentication coverage report - #357

Merged
Bccorb merged 3 commits into
mainfrom
feat/authentication-coverage-report
Oct 7, 2026
Merged

Bccorb merged 3 commits into
mainfrom
feat/authentication-coverage-report

Conversation

@Bccorb

@Bccorb Bccorb commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

Closes #178. Stacked on #355 (feat/phishing-resistant-only), because the report states the enforced policy, including phishingResistantOnly.

What it adds

GET /admin/reports/authentication-coverage (auth: 'access', requireAdmin('read')).

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 one organization's current members. 404 if it does not exist.
bucket month (default) or week.
format json (default) or csv (text/csv, Content-Disposition: attachment).

The response carries:

  • policy: what is enforced now. phishingResistantOnly, loginMethods and passkeyFallbackEnabled from getLoginPolicy(), and the authenticator_policy rules (attestation, user verification, attachment, synced passkeys, require-known, allow and deny lists).
  • coverage: active (non-revoked) users as of the end of to, how many hold at least one WebAuthn credential, and the percentage.
  • byOrganization: the same per organization, plus a row for users in no organization. A user in two organizations counts in both.
  • trend: per month or week (Monday start, UTC), clipped to the period, coverage as of the end of each bucket. The last bucket equals coverage.
  • authenticatorMix: credentials by AAGUID, with backup-eligible and backed-up counts. Missing and all-zero AAGUIDs are grouped as null. Names come from a short table of well-known passkey providers, then the FIDO Metadata Service when it is loaded.
  • signInMix: completed sign-ins in the period by method (passkey, email_otp, phone_otp, otp, magic_link, totp, oauth), folded by attempt the way /internal/metrics/sign-ins is, plus the phishing-resistant share.

The CSV has a header block, then policy, coverage by organization, trend, authenticator mix and sign-in mix sections, each with its own header row. Rows are in a fixed order, and cells starting with =, +, - or @ are prefixed with ' so a spreadsheet does not run an organization name as a formula.

Small supporting change: verify_otp_success now records metadata.channel (email or sms, matching what otp_success already records), so the report can tell email codes from phone codes. Rows written before this release land in otp.

Limits (also in docs/admin-operations.md)

  • The trend counts current credentials by creation date. Deleted credentials cannot be recovered, so past coverage can be understated, never overstated.
  • Revocation has no timestamp, so a revoked user is left out of every bucket. Deleted users are absent.
  • Organization figures use current membership.
  • policy is the configuration at generation time, not a history. system_config_updated events hold the history.
  • Every WebAuthn credential counts as phishing resistant, whatever its attestation. The policy block says whether attestation and an allow list are enforced.

Testing

  • Unit tests for the service (windows, buckets, policy, org scoping, 404, metadata names, CSV escaping and formula guard) and an integration test run through the real bearer and admin middleware (401, 403, JSON, CSV, org scope, 404, query validation).
  • Ran every query against a migrated Postgres 15 with seeded users, organizations, credentials and auth events, and checked each figure by hand, including revoked users, credentials after the period, mixed-case and all-zero AAGUIDs, and the double verify_otp_success folding.
  • npm run typecheck, npm run lint, npm run format:check, npm run build and npm run coverage (125 files, 1662 tests) pass. openapi.json and src/generated/api.ts are regenerated.

Ripple

New route, so this is contract-affecting for the adapter. Nothing is changed outside this repo.

seamless-auth-server has no catch-all proxy, so the route needs passthroughs. Following #179 (enrollment) as the template:

  • packages/core/src/ensureCookies.ts (route entry)
  • packages/express/src/createServer.ts (proxyWithIdentity("admin/reports/authentication-coverage", "access", "GET"))
  • packages/fastify/src/routes/proxyRoutes.ts
  • packages/nextjs/src/routes/proxyRoutes.ts
  • packages/express/tests/queryForwarding.test.js, packages/fastify/tests/queryForwarding.test.js (the query string has to be forwarded)
  • packages/fastify/tests/parity.test.js, packages/nextjs/tests/parity.test.js
  • a changeset

format=csv needs more than a passthrough entry. packages/core/src/proxyRequest.ts always returns { status, body: await upstream.json() }, and authFetch's tolerant json() turns a text body into { message: "<csv>" }, dropping Content-Type and Content-Disposition. The adapter needs a raw mode that forwards the body and those two headers unchanged, in core and all three framework packages. Until then the adapter can serve the JSON report and a client can build its own CSV from it.

seamless-auth-admin-dashboard is the natural place to surface it, next to the enrollment page: a useCoverageReport hook (modeled on src/hooks/useEnrollment.ts), a page under src/pages/, a route in src/App.tsx, a src/components/Sidebar.tsx entry, their tests and an e2e mock in e2e/mockApi.ts.

The response schemas live in src/schemas/coverageReport.ts for now. Moving them into @seamless-auth/types would let the adapter and dashboard share them, and is worth doing when the dashboard work starts.

Adds the phishing_resistant_only config key, which accepts passkeys only
for every account apart from the one session that verifies a new
account's address. The passkey fallback rule is now enforced on each
continuation endpoint rather than only in the /login method list, and
decoys mirror both rules.

Closes #177.
@Bccorb
Bccorb force-pushed the feat/phishing-resistant-only branch from 8369444 to 4e9460e Compare October 7, 2026 00:39
GET /admin/reports/authentication-coverage reports, for a period, how many active users
hold a passkey overall, per organization and per month or week, next to the login and
authenticator policy enforced now, the authenticator mix by AAGUID and completed
sign-ins by method. format=csv returns the same report for pasting into an assessment
or insurance response.

Code sign-ins now record metadata.channel on verify_otp_success so the report can tell
email codes from phone codes.

Closes #178.
@Bccorb
Bccorb marked this pull request as ready for review October 7, 2026 00:39
@Bccorb
Bccorb force-pushed the feat/authentication-coverage-report branch from ccf9bb0 to 71b7c40 Compare October 7, 2026 00:39
Base automatically changed from feat/phishing-resistant-only to main October 7, 2026 01:05
@Bccorb
Bccorb merged commit bb7de70 into main Oct 7, 2026
7 checks passed
@Bccorb
Bccorb deleted the feat/authentication-coverage-report branch October 7, 2026 01:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

No authentication coverage report for assessment and insurance responses

1 participant