feat: passkey (WebAuthn) authentication on the auth subdomain #20

Merged
andrew merged 11 commits from feat/passkey-auth-subdomain into main 2026-09-27 09:43:14 -04:00
11 Commits
Author SHA1 Message Date
lyra 9523accd23 Close the coverage gaps in the passkey code, fixing what they exposed
Tests / test (pull_request) Successful in 51s
PHP Test / test (pull_request) Successful in 51s
The project's own bar is full coverage, and the new code had drifted from it —
notably every error path, which is exactly where a browser is least likely to
go on purpose and an attacker is most likely to.

Two real bugs surfaced, both of the same shape: a cache failure escaping as a
500 on the login page.

- `credentials->find()` was called outside the try block in `finishLogin()`, so
  a store failure threw instead of reporting a failed ceremony.
- `credentials->save()` was likewise unguarded in `finishRegistration()`, and
  there the consequence was worse: reporting success for a credential that was
  never stored, so the user would believe their passkey was registered and
  discover otherwise only at the next login.

Both now degrade to a failed ceremony, matching the rule the rest of the class
follows: a failure the user cannot act on must never look like a server fault.

Coverage is now at 98.7% of lines; the remainder is pre-existing defensive
catches in AcceptListener/AllowListener plus a couple of unreachable guards.
2026-09-27 11:40:01 +00:00
lyra 6bbfd44e7d Document passkey authentication
Covers the README (prerequisites, the two hard requirements, every new env var,
how registration and login work, and the counter caveat), SECURITY.md (the
ceremony model, single-use challenges, origin handling, the shared rate-limit
budget, and the attestation rationale with the conditions that would reverse
it), CHANGELOG (Added/Security/Changed), ROADMAP (Phase 2c complete, with the
deviations from the original sketch) and DESIGN_CONSIDERATIONS (the four
decisions whose reasoning is not visible in the code).

The docs lead with the two prerequisites because both are enforced rather than
advisory: enabling passkeys without central auth, or on a host that cannot
serve HTTPS, fails at container start. Neither is a runtime surprise, and a
reader needs to know that before they turn the feature on.

The plan document is updated to record that it is complete, and to note the two
places where implementation deviated from it — the listener/extraction order,
and register-begin not being a listener operation. The second was a design
error in the plan, not just an ordering change, so it is called out explicitly.
2026-09-27 11:37:02 +00:00
lyra ffe6870231 Add end-to-end functional tests with real cryptography
The whole flow through the real HTTP kernel, with nothing about the ceremony
stubbed: registration builds a genuine CBOR attestation object signed by a real
P-256 key, and login signs a real assertion. Only the browser's plumbing is
simulated — the fetch() calls become requests, which is the seam worth testing.

Covered: a real registration grants a session; a real passkey login grants a
session and reports the right Remote-User; a bad TOTP starts no ceremony; a
spent nonce starts no ceremony; a replayed ceremony fails; an assertion for
another challenge fails; an unknown credential fails with the same generic
message a wrong code gets; both login paths set an identical cookie; ceremony
responses are not cacheable and do not leak their marker; the ceremony is inert
when disabled; the page offers passkeys only when enabled; and the CSP permits
the two WebAuthn directives.

Writing these found a real bug. The registration checkbox originally submitted
a plain form POST, which returns HTML — and, more importantly, loses the fresh
nonce the failure response issues. The user's next attempt would then fail
against a nonce that had already been spent, with no visible reason why. It now
goes through the same X-Preauth AJAX path as an ordinary login, so failures come
back as JSON with a usable nonce; a non-JSON submission is treated as an
ordinary login, and LoginManager returns null for it rather than starting a
ceremony that nothing could finish.

Three test failures were also correct behaviour rather than bugs: once a session
cookie exists, AcceptListener (priority 99) answers before any ceremony listener
runs, so tests exercising a second ceremony need a visitor without that cookie.
That is the intended ordering, now documented in the tests.
2026-09-27 11:35:54 +00:00
lyra 11903bf746 Add the passkey UI and fix issues it exposed
The login page gains a sign-in button and, where the form actually POSTs, a
"register this device" checkbox. Both are rendered only when the policy says
passkeys are available for that request, so an unavailable configuration stays
byte-identical to before — a test asserts exactly that, rather than trusting the
conditional is in the right place.

The registration checkbox is omitted on hosts the form does not POST from,
because a registration ceremony is authorised by the TOTP code carried in that
submission. The button is still offered there; only the checkbox is not.

Writing the tests surfaced three real problems, all fixed here:

- ConfigBag had no passkeyButtonName()/passkeyRegisterName() accessors, so the
  template referenced configuration that was never exposed.
- ListenerTestHelper's anonymous rate-limiter classes were missing #[Override],
  and the baseline pinned them by line number — so adding two array keys broke
  it. Fixed at the source instead: the attributes are now present, which also
  let 36 line-pinned baseline entries be deleted.
- Those classes threw ReserveNotSupportedException with no arguments, which the
  Symfony signature forbids. The baseline had been hiding this behind
  path-specific ignores; phpstan reports it correctly now.

The net baseline change is 216 deletions and no additions: every entry removed
was one whose underlying issue is now genuinely fixed.
2026-09-27 11:32:16 +00:00
lyra 69ee5e99aa Route passkey registration through the TOTP check, not the listener
Corrects a design error in the previous commit. I had exposed register-begin as
a listener operation and gated it on a session cookie, but the approved flow has
no session at that point: the whole point is that a valid TOTP code is what
authorises registration, and the session is only issued once the new credential
has been verified.

Two consequences, both bad:

- There is no session cookie to check, so the gate could never have worked. It
  would have been dead code that looked like a security control.
- More seriously, a listener-side register-begin would hand out a challenge
  without proving anything. Anyone could obtain ceremony options and attempt
  registration. The cookie check was not a weak control; the operation itself
  was the hole.

Registration is now started by LoginManager, after it has verified both the code
and the nonce, and its options are returned with the login response. That is the
flow in the plan, and it keeps nonce validation in the one place that already
enforces it. The capability for register-finish is the single-use ceremonyId,
which is server-issued and bound to the identity that passed the check.

The listener now serves three operations, and a test pins that register-begin is
not one of them.

Also adds Payload::$register so the checkbox intent survives from the form to
LoginManager, and moves the ceremony marker constant to AppConstants since both
LoginManager and the listener now produce marked responses.
2026-09-27 11:29:17 +00:00
lyra ffb824c652 Add the passkey listener at priority 70
Priority 70 sits after RejectListener (77) and before LoginListener (66), and
both bounds are load-bearing:

- After 77 so a rate-limited IP never reaches a ceremony. Passkeys cannot be
  used to sidestep a lockout (D3), which is the point of the reviewer's third
  clarification.
- Before 66 because LoginListener treats any POST to the auth subdomain as a
  login attempt. A ceremony finish body has no username/totp, so Payload::load()
  returns null and the request would be scored as a failed login, burning a
  rate-limit token for every legitimate passkey login.

Verified in the live container rather than assumed: debug:event-dispatcher
confirms 77 -> 70 -> 66.

Other properties asserted by tests: every header-bearing request gets a JSON
response so fetch() callers never receive HTML; registration identity comes from
the live session, never the request body; a failed ceremony is indistinguishable
from a wrong TOTP code and spends the same shared budget; and begin is bounded
by a separate resource guard that deliberately does not consume failure budget.

The listener also marks its responses so SecurityHeadersListener can apply
no-store: these are the only browser-facing 2xx this application produces, since
the auth subdomain has no forward_auth in front of it. CSP gains
publickey-credentials-get/-create only when passkeys are available, so the
unavailable case stays byte-identical to before.
2026-09-27 10:50:09 +00:00
lyra fed7b1b48c Extract session issuing so both login paths share it
SessionIssuer now owns 'grant access after authenticating', which LoginManager
previously did internally. The passkey ceremony needs the same behaviour, and
two implementations would inevitably drift — most likely in cookie attributes,
where a difference stays invisible until it breaks in a browser.

LoginManager keeps what is specific to code-based login: verifying the TOTP or
backup code and enforcing the single-use nonce. Its 18 existing tests pass
unchanged, which is the evidence that this is behaviour-preserving rather than a
rewrite.

Also folds the redundant early-return into a single guard in checkToken so the
success path reads straight through.
2026-09-27 10:45:58 +00:00
lyra 436450cdc2 Add passkey ceremony store and manager
Builds both WebAuthn ceremonies on top of the library, with real cryptography
proven in tests rather than stubbed:

- PasskeyCeremonyStore: server-authoritative, single-use challenge state in the
  nonceCache pool. The client's challenge copy is never trusted, and consume()
  deletes before verifying so a replay cannot retry the same challenge.
- PasskeyManager: registration and login ceremonies. Library types are confined
  to this class and PasskeyCeremonyFactory. Failures return null rather than
  distinguishing unknown-credential from bad-signature, so the endpoint is not
  an enumeration oracle.
- PasskeyTestHelper: builds genuinely valid ceremonies (real P-256 keypair,
  COSE key, signed authenticatorData, CBOR attestation object).
- PasskeyRealCryptoSpikeTest: proves registration and assertion verify, that
  http:// origins are refused (D4), that challenges and rpIdHash are bound, and
  that a synchronised passkey with a constant zero counter can log in repeatedly.
2026-09-27 10:42:59 +00:00
lyra 69609db8af Use a lenient signature-counter check for passkeys
The library default (ThrowExceptionIfInvalid) requires the reported counter to
be strictly greater than the stored one. Synchronised passkeys report a
constant 0 forever, so the default rejects a brand-new credential on its first
login — and only on real hardware, never in a unit test that increments the
counter.

The replacement still rejects a counter that moves backwards, which is the only
signal the counter can carry. Clone detection remains explicitly not a property
this feature claims; see SECURITY.md.
2026-09-27 10:38:02 +00:00
lyra c84cf8c308 Add passkey credential store
Persistence for registered passkeys, backed by sessionCache so credentials
survive a container restart the way sessions do.

The pool is wrapped in MonitorCacheKeys, matching LoginManager and
BackupCodeManager. Without that wrapper the credentials would live only in the
APCu-side pool and vanish on the next restart, because PersistCache::persist()
only flushes keys a monitor recorded. A test asserts visibility to the
persistent pool rather than trusting the wrapper.

Two storage hazards found while building this and covered by tests:

  - makeCacheKey() is not injective for base64url. It collapses the whole
    punctuation alphabet to "_", so "abc-def" and "abc_def" would share one
    cache slot and one credential would silently overwrite the other.
    Credential ids are therefore hashed, and a test uses precisely that pair.
  - A record's own credential id is authoritative. An index entry pointing at
    a record that disagrees with its key is rejected rather than trusted.

Unreadable or wrong-shaped entries degrade to "credential unavailable" so a
corrupt value cannot 500 the login page.

PasskeyCeremonyFactory is the single seam onto webauthn-lib: it builds the
serializer and pins attestation to `none` only, so a future version that moves
or renames library types touches one file.

Suite: 353 tests / 831 assertions, 100% coverage on all new files.
phpstan level 6 clean, php-cs-fixer clean, conformance 35/35.
2026-09-27 02:40:31 +00:00
lyra 108e9623e6 Add passkey configuration and availability policy (inert)
Groundwork for passkey authentication, with the feature switched off by
default and no behaviour change when it is off.

Decision D1: passkeys require central authentication. A passkey is scoped to
a relying party spanning the base domain, which only exists when
SUBDOMAIN_REDIRECT is on and AUTH_SUBDOMAIN resolves to a base domain. The
RP ID is therefore always that base domain, never the request host.

Decision D4: HTTPS is required and is not exemptible. The allowed origin is
built as https://{authSubdomain} from configuration and never from the
request, so an http:// origin cannot be accepted, and isAvailableFor()
additionally refuses to offer the UI on a non-secure connection. The
deprecated setSecuredRelyingPartyId() escape hatch is not used and there is
deliberately no override that could reintroduce one.

Enabling PASSKEY_ENABLED without a usable configuration is a hard error via
a non-optional cache warmer, because entrypoint.sh runs cache:warmup on every
production boot: a misconfigured deployment fails to start instead of
offering a button that cannot work.

Also drops 12 obsolete phpstan-baseline entries for TotpTestHelper: adding
#[\Override] to its anonymous clock removed the rule violation at its source
rather than suppressing it.

Suite: 333 tests / 770 assertions (was 313 / 738), 100% coverage on new
files. phpstan level 6 clean, php-cs-fixer clean, conformance 35/35.
2026-09-27 02:37:07 +00:00