13 Commits
Author SHA1 Message Date
andrew 1335c31d4e Merge pull request 'feat: passkey (WebAuthn) authentication on the auth subdomain' (#20) from feat/passkey-auth-subdomain into main
Sync GitHub / sync (push) Successful in 8s
PHP Test / test (push) Successful in 1m10s
Tests / test (push) Successful in 1m10s
Docker Publish / docker (push) Successful in 1m47s
Push Develop / docker (push) Successful in 1m47s
Reviewed-on: #20
Reviewed-by: Andrew <andrew@digitaladapt.com>
2026-09-27 09:43:13 -04:00
lyra 9523accd23 Close the coverage gaps in the passkey code, fixing what they exposed
PHP Test / test (pull_request) Successful in 51s
Tests / 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
lyra 0458d9b8d2 minor text formatting change
Sync GitHub / sync (push) Successful in 7s
Docker Publish / docker (push) Successful in 20s
Push Develop / docker (push) Successful in 20s
PHP Test / test (push) Successful in 50s
Tests / test (push) Successful in 50s
2026-09-25 19:22:33 -04:00
66 changed files with 8177 additions and 446 deletions
+8
View File
@@ -12,6 +12,14 @@ BURST_COUNT=10
BURST_TIME=30
UPPER_COUNT=100
UPPER_TIME=3600
PASSKEY_ENABLED=0
PASSKEY_RP_NAME=''
PASSKEY_USER_VERIFICATION='required'
PASSKEY_TIMEOUT=60000
PASSKEY_BUTTON_NAME='Sign in with a passkey'
PASSKEY_REGISTER_NAME='Register this device as a passkey'
PASSKEY_BEGIN_BURST_COUNT=30
PASSKEY_BEGIN_BURST_TIME=60
PUBLIC_PATHS=''
PUBLIC_BURST_COUNT=100
PUBLIC_BURST_TIME=60
+33
View File
@@ -8,6 +8,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] — v1.1
### Added
- **Passkey authentication (WebAuthn)** — Registered passkeys can replace the
TOTP code for everyday logins. Registration itself still requires a valid
code, so a passkey can never be created without already holding the secret.
- New dependency: `web-auth/webauthn-lib` `^5.3` (resolved to 5.3.9);
`composer audit` reports no advisories.
- **Requires central auth** (`SUBDOMAIN_REDIRECT` + `AUTH_SUBDOMAIN`) so
there is one relying party for the whole domain, and **requires HTTPS in
every environment, development included**. Enabling it in a configuration
that cannot work fails at container start rather than in a browser.
- Registration is a checkbox on the login form; login is a button. Passive
keys and OS pickers work normally, with the code as a fallback.
- New `PasskeyListener` (priority 70) — after the rate-limit gate so a
blocked IP never reaches a ceremony, and before `LoginListener` so a
ceremony request is not misfiled as a failed login.
- New env vars: `PASSKEY_ENABLED`, `PASSKEY_RP_NAME`,
`PASSKEY_USER_VERIFICATION`, `PASSKEY_TIMEOUT`, `PASSKEY_BUTTON_NAME`,
`PASSKEY_REGISTER_NAME`, `PASSKEY_BEGIN_BURST_COUNT`,
`PASSKEY_BEGIN_BURST_TIME`.
- New `docs/examples/Caddyfile` section describing local development over
real TLS, because there is deliberately no `http://` exemption.
- **Public rate-limited access** — Select paths can now be made publicly
accessible without TOTP authentication, with separate per-IP rate limiting.
This is useful for exposing public content (e.g., public Gitea repositories)
@@ -25,7 +45,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- New `PublicPathMatcher` service for path pattern matching.
- New `PublicAccessListener` (priority 84) in the request pipeline.
### Security
- **Passkey ceremonies** — The challenge is issued and stored server-side and
the client's copy is never trusted; it is single-use, deleted before
verification so a failed or replayed attempt cannot be retried against the
same challenge. Only the derived `https://{AUTH_SUBDOMAIN}` origin is ever
accepted, and an unknown credential produces the same response as a wrong
code so the endpoint cannot be used for enumeration.
### Changed
- **Session issuing extracted into `SessionIssuer`** — `LoginManager`
previously built the session cookie itself. Both login paths now share one
implementation, so a passkey login and a code login set an identical cookie;
two copies would have drifted, most likely in cookie attributes, where the
difference is invisible until it breaks in a browser.
- **Upgraded Symfony 7.4 → 8.1** — All `symfony/*` components bumped to
`8.1.*` (resolved to 8.1.2–8.1.6). The 7.4 deprecation sweep was clean
(test suite runs with `failOnDeprecation`), so the major-version jump
+54
View File
@@ -8,6 +8,60 @@ This document was originally prepared as a design review. Items that have been a
---
## 0. Passkey implementation notes
Four decisions are worth recording because the reasoning is not obvious from the
code, and each looks like an odd choice without it.
### 0.1 The signature counter is checked leniently, against the library's default
`webauthn-lib`'s default checker requires a *strictly increasing* counter. That
is wrong for the passkeys this feature targets: a synchronised passkey reports a
constant `0` forever, so the default rejects a brand-new credential on its
**first** login. Measured against the installed version: stored `0`, reported
`0` → `CounterException`.
The failure mode is what makes this worth a note. It cannot happen in a unit
test that increments the counter — only on real hardware, and only for the most
common kind of passkey. `PasskeyCounterChecker` therefore accepts equal-or-greater
and rejects only a counter that moves *backwards*. Clone detection is explicitly
not claimed as a property of this feature.
### 0.2 The ceremony replies are the only browser-facing 2xx
`SecurityHeadersListener` applies `no-store` to non-2xx responses only, on the
assumption that a 2xx is consumed by the proxy's `forward_auth` check. That
assumption is false for a ceremony reply: the auth subdomain is `reverse_proxy`-ed
with no `forward_auth` in front of it, so the JSON goes straight to the browser.
Left alone it would be cacheable, and a browser could replay a stale challenge.
The producer marks the response (`PasskeyListener` or `LoginManager`) and the
caching policy lives in one place that consumes the marker, rather than being
duplicated at each site that happens to return 2xx.
### 0.3 Registration is a checkbox on the login form, not an endpoint
A separate `register-begin` endpoint was the first design and would have been a
vulnerability: it hands out a challenge without proving anything. The TOTP check
is what authorises registration, and that check happens inside `LoginManager` as
part of an ordinary login submission — so `LoginManager` is where the ceremony
starts.
There is no session cookie to check at that point either, which makes the point
neatly: the whole flow is what *produces* the session, so anything gated on one
cannot be part of it. The capability at `register-finish` is the single-use
ceremony id, issued server-side and bound to the identity that passed the check.
### 0.4 Both login paths share one session-issuing implementation
`SessionIssuer` was extracted from `LoginManager` when the passkey ceremony
needed the same behaviour. Two implementations would have drifted, and the most
likely place to drift is cookie attributes — where a difference is invisible
until it breaks in a browser, on one path only. A functional test compares the
cookies the two paths produce, field by field.
---
## 1. Security
### 1.1 Missing Security Response Headers [HIGH PRIORITY] ✅ Addressed
+26 -31
View File
@@ -295,6 +295,9 @@ the management surface is incomplete.
### Phase 2c — Passkey Authentication
**Status: complete.** See `docs/passkey-auth-subdomain-plan.md` for the full
plan, the evidence behind each decision, and the deviations noted below.
**Goal:** Add WebAuthn/FIDO2 passkey support as an alternative
authentication method alongside TOTP and backup codes.
@@ -305,39 +308,31 @@ codes. For a pre-auth gate that friends and family use, passkeys would
be a major UX improvement — especially for non-technical users who
struggle with TOTP apps.
**Design considerations:**
**What was built**, and how it differs from the sketch above:
- Passkeys are **per-device**, not shared secrets. Unlike TOTP (one
secret shared with all devices), each device registers its own
passkey. This is actually better for a family-use gate — you can
register mom's phone separately from dad's laptop.
- **A Symfony bundle was not used**, only `web-auth/webauthn-lib`. The bundle
brings a database-backed credential repository and a controller setup that do
not fit a no-database, listener-only application; the library alone is a
clean fit and its types are confined to `PasskeyManager` and
`PasskeyCeremonyFactory` so a major-version rename touches two files.
- **Registration happens in the browser, not a console command.** The checkbox
on the login form is authorised by the TOTP code in the same submission, so
it needs no separate token and no CLI. This also settles the "how does the
identity get specified" question: it is the identity that just authenticated.
- **Central auth is a hard prerequisite.** A passkey is scoped to one relying
party, so passkeys require `SUBDOMAIN_REDIRECT` + `AUTH_SUBDOMAIN`; the RP ID
is always that base domain. Without it the feature stays off, rather than
quietly scoping credentials to a single host.
- **HTTPS is required with no exemption**, development included, since an
`http://` escape hatch is how the same weakness reaches production.
- **Failed attempts share the TOTP rate-limit budget**, so passkeys cannot be
used to sidestep a lockout.
- **Attestation is `none`**, measured rather than assumed — see SECURITY.md and
plan §2.3.
- WebAuthn requires a **challenge-response flow**:
1. Client requests a challenge (preauth generates and stores a
challenge nonce, similar to the existing nonce system)
2. Browser prompts for biometric/PIN, creates a signed assertion
3. Server verifies the assertion against the registered credential
- This is a **two-step flow** unlike TOTP's single-step, which means
the login page JS and `LoginListener` need to handle an additional
round-trip. The existing nonce + AJAX pattern in `_script.html.twig`
is a good foundation — extend it with a "use passkey" button that
initiates the `navigator.credentials.get()` flow.
- Library: `web-auth/webauthn-framework` (PHP WebAuthn library,
Symfony bundle available). Would add registration ceremony (console
command or initial-setup flow to register a passkey).
- [ ] Research `web-auth/webauthn-framework` integration with Symfony
8.1 and FrankenPHP
- [ ] Design passkey registration flow (console command? first-visit
setup? separate registration endpoint?)
- [ ] Implement challenge generation and storage (extend existing
nonce/cache infrastructure)
- [ ] Implement assertion verification in a new `PasskeyManager`
service (implements a shared `AuthMethodInterface`?)
- [ ] Add passkey option to login page JS (`navigator.credentials.get()`)
- [ ] Handle multiple registered passkeys (per-device)
**Remaining work:** none for the feature itself. Discoverable-credential
(usernameless) login is possible but not needed, since the login page already
lists registered credentials.
- [ ] Console command: `app:list-passkeys` — show registered devices
- [ ] Console command: `app:remove-passkey` — revoke a passkey
- [ ] Config: `PASSKEY_ENABLED=false` — enable/disable passkey auth
+53
View File
@@ -51,6 +51,59 @@ calls it per request to decide whether a request may reach the upstream service.
secrets belong in `.env.local` or `bin/console secrets:set`, read via
`%env(...)%`. `.env.example` and `.env.test` are the committed env files.
### Passkeys (WebAuthn)
Off by default (`PASSKEY_ENABLED=0`). When on, the following hold:
- **Registration requires a valid TOTP code.** The checkbox rides on an
ordinary login submission, and the code check in that same request is what
authorises the ceremony. There is no enrolment token, no CLI path, and no way
to create a credential without already holding the secret. The identity comes
from the authenticated session, never from the request body.
- **The challenge is server-authoritative and single-use.** It is generated and
stored server-side; the client's copy is never trusted. The stored record is
deleted *before* verification runs, so a failed or replayed attempt cannot be
retried against the same challenge. Records live in the in-memory `nonceCache`
with a 300-second TTL and deliberately do not survive a restart.
- **Only the derived origin is accepted.** The allowed origin is always
`https://{AUTH_SUBDOMAIN}`, computed from configuration and never from the
request. `http://` is therefore rejected regardless of how the request
arrived, and there is no setting that re-enables it. The library's deprecated
`setSecuredRelyingPartyId()` escape hatch is not used, and development uses
real TLS instead of an exemption.
- **The RP ID is the base domain**, so a credential is scoped to every service
on that domain. This is the intended behaviour and the reason central auth is
a hard prerequisite: without a single shared domain there is no sane RP ID.
- **Failures are indistinguishable.** An unknown credential, a bad signature
and a wrong origin all produce the same response as a wrong TOTP code, so the
endpoint cannot be used to enumerate credentials.
- **Failures share the login rate-limit budget.** A failed ceremony costs the
same token as a wrong code, and once the limit is reached every method is
blocked. Passkeys cannot be used to sidestep a lockout, and the resource guard
that bounds ceremony *starts* is deliberately separate, so a legitimate login
never spends failure budget.
- **The signature counter is not a security control.** Most passkeys —
anything synchronised through a keychain — report a constant counter, so a
counter-based clone check would lock users out of their own credentials.
preauth accepts an unchanged counter and rejects only one that moves
*backwards*, which is the only signal the value can carry. **Clone detection is
deliberately not a property this feature claims.**
- **Attestation is deliberately not requested** (`attestation: 'none'`).
Attestation conveyance is only a preference a client may ignore, and the FIDO
metadata service is bypassed both by the zero AAGUID that privacy-preserving
passkeys already send and by self attestation — while still refusing
legitimate authenticators newer than its cached blob. This was measured rather
than assumed; see §2.3 of
`docs/passkey-auth-subdomain-plan.md` for the evidence. **Revisit if** a
deployment needs to prove which make and model of authenticator is enrolled,
or if a policy (rather than a preference) requires attested keys — in which
case the metadata service must be pinned and kept current, and the zero-AAGUID
case decided explicitly rather than by omission.
- **The ceremony replies are not cacheable.** They are the only 2xx this
application returns straight to a browser (every other 2xx is consumed by the
proxy's `forward_auth` check), so they carry the same anti-caching headers as
the rest of the login flow.
## Scope
In scope: the application code in `src/`, the shipped `Caddyfile`, the
+2 -1
View File
@@ -18,7 +18,8 @@
"symfony/runtime": "8.1.*",
"symfony/twig-bundle": "8.1.*",
"symfony/uid": "8.1.*",
"symfony/yaml": "8.1.*"
"symfony/yaml": "8.1.*",
"web-auth/webauthn-lib": "^5.3"
},
"config": {
"allow-plugins": {
Generated
+1166 -1
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -12,6 +12,8 @@ framework:
adapters: cache.adapter.filesystem
publicRateLimitCache:
adapters: cache.adapter.apcu
passkeyRateLimitCache:
adapters: cache.adapter.apcu
# Unique name of your app: used to compute stable namespaces for cache keys.
prefix_seed: digitaladapt/preauth
+3
View File
@@ -0,0 +1,3 @@
framework:
property_info:
with_constructor_extractor: true
+12
View File
@@ -27,3 +27,15 @@ framework:
public_limiter:
policy: compound
limiters: [public_burst, public_upper]
# bounds how many ceremonies one caller can *start*.
#
# This is a resource guard, NOT part of the login budget: D3 makes the
# existing login_limiter the single shared budget for every login method,
# and a legitimate `begin` must not consume failure budget. Without this,
# an unauthenticated caller could fill the ceremony cache with records.
passkey_begin_burst:
policy: 'sliding_window'
limit: '%env(int:PASSKEY_BEGIN_BURST_COUNT)%'
interval: '%env(int:PASSKEY_BEGIN_BURST_TIME)% seconds'
cache_pool: 'passkeyRateLimitCache'
+2
View File
@@ -12,3 +12,5 @@ framework:
adapters: cache.adapter.array
publicRateLimitCache:
adapters: cache.adapter.array
passkeyRateLimitCache:
adapters: cache.adapter.array
+6
View File
@@ -15,4 +15,10 @@ twig:
teapot_message: '%env(TEAPOT_MESSAGE)%'
too_many_title: '%env(TOO_MANY_TITLE)%'
too_many_message: '%env(TOO_MANY_MESSAGE)%'
passkey_button_name: '%env(PASSKEY_BUTTON_NAME)%'
passkey_register_name: '%env(PASSKEY_REGISTER_NAME)%'
debug: '%env(SHELL_VERBOSITY)%'
# `passkeys` is computed per request by the controller-facing templates via
# PasskeyPolicyInterface, never from an env var, so that availability and the
# D1/D4 prerequisites cannot drift apart.
+37
View File
@@ -55,6 +55,22 @@ parameters:
env(PUBLIC_UPPER_COUNT): 500 # max requests per sustained window per IP
env(PUBLIC_UPPER_TIME): 3600 # sustained window in seconds (1 hour)
# --- passkey authentication ---
# Requires central auth (SUBDOMAIN_REDIRECT=1 + AUTH_SUBDOMAIN) and HTTPS.
# Enabling this without central auth makes the container fail at cache warmup
# rather than offering a feature that cannot work.
env(PASSKEY_ENABLED): '0' # boolean, 1 to offer passkeys on the auth subdomain
env(PASSKEY_RP_NAME): '' # blank to use TITLE
env(PASSKEY_USER_VERIFICATION): 'required' # required|preferred|discouraged
env(PASSKEY_TIMEOUT): '60000' # milliseconds
# Extra options, custom labels
env(PASSKEY_BUTTON_NAME): 'Sign in with a passkey'
env(PASSKEY_REGISTER_NAME): 'Register this device as a passkey'
# bounds how many ceremonies one caller can start (resource guard, not the
# login budget — see config/packages/rate_limiter.yaml)
env(PASSKEY_BEGIN_BURST_COUNT): 30
env(PASSKEY_BEGIN_BURST_TIME): 60
# --- styling options ---
env(TITLE): 'Pre-Authentication System'
env(BG_COLOR): '#029386' # teal
@@ -96,6 +112,15 @@ parameters:
app.error_message: '%env(ERROR_MESSAGE)%'
app.teapot_title: '%env(TEAPOT_TITLE)%'
app.too_many_title: '%env(TOO_MANY_TITLE)%'
app.title: '%env(TITLE)%'
app.passkey_enabled: '%env(bool:PASSKEY_ENABLED)%'
app.passkey_rp_name: '%env(PASSKEY_RP_NAME)%'
app.passkey_user_verification: '%env(PASSKEY_USER_VERIFICATION)%'
app.passkey_timeout: '%env(int:PASSKEY_TIMEOUT)%'
app.passkey_button_name: '%env(PASSKEY_BUTTON_NAME)%'
app.passkey_register_name: '%env(PASSKEY_REGISTER_NAME)%'
services:
# default configuration for services in *this* file
@@ -110,3 +135,15 @@ services:
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones
# the boot-time passkey configuration check runs during `cache:warmup`, so a
# misconfigured deployment fails to start instead of failing in a browser
App\Service\PasskeyPolicyInterface: '@App\Service\PasskeyPolicy'
App\Service\PasskeyInterface: '@App\Service\PasskeyManager'
App\Service\PasskeyCeremonyStoreInterface: '@App\Service\PasskeyCeremonyStore'
App\Service\PasskeyCredentialStoreInterface: '@App\Service\PasskeyCredentialStore'
App\Service\SessionIssuerInterface: '@App\Service\SessionIssuer'
# the ceremony factory takes no constructor arguments and holds no state, so
# it is built once and shared rather than re-created per ceremony
App\Service\PasskeyCeremonyFactory: ~
+4 -4
View File
@@ -10,18 +10,18 @@
# MAX_REQUESTS=0 docker buildx bake # specify variables to override
variable "DOCKERHUB_TARGET" {
default = "digitaladapt/preauth"
default = "digitaladapt/preauth"
description = "Docker Hub repo/org (Gitea repo variable DOCKERHUB_TARGET)."
}
variable "TAG" {
default = "latest"
default = "latest"
description = "Base tag for this build: latest (release), develop (main push), or a version."
}
variable "VERSION" {
default = ""
description = "Full version (v stripped) to also tag with; empty for develop builds."
default = ""
description = "Optional full version (v stripped) to also tag with; empty for develop builds."
}
variable "MAX_REQUESTS" {
+22
View File
@@ -15,6 +15,28 @@
#SUBDOMAIN_REDIRECT=false # default disabled, boolean
#AUTH_SUBDOMAIN='' # blank, hostname we send user to, to see login page
# --- passkey authentication ---
# Passkeys (Touch ID / Windows Hello / security keys) as an alternative to TOTP.
#
# REQUIRES central authentication (SUBDOMAIN_REDIRECT=true plus AUTH_SUBDOMAIN)
# and HTTPS. Passkeys are bound to a relying party that spans the base domain,
# which only exists when central auth is configured; and browsers refuse to run
# a ceremony over plain HTTP.
#
# Enabling this without central auth is a hard error: the container fails at
# start-up (cache:warmup) rather than offering a passkey button that cannot work.
#
# There is deliberately no option to allow an http:// origin, and none to relax
# the requirement for local development. See the README for the local TLS setup.
#PASSKEY_ENABLED=false # default disabled, boolean
#PASSKEY_RP_NAME='' # blank to use TITLE
#PASSKEY_USER_VERIFICATION='required' # required | preferred | discouraged
#PASSKEY_TIMEOUT=60000 # ceremony timeout in milliseconds
#PASSKEY_BUTTON_NAME='Sign in with a passkey'
#PASSKEY_REGISTER_NAME='Register this device as a passkey'
#PASSKEY_BEGIN_BURST_COUNT=30 # ceremonies one caller may start per window
#PASSKEY_BEGIN_BURST_TIME=60 # window for the above, in seconds
# --- extra options ---
# how long do we allow *ALL* traffic from an ip address after successful login
+33
View File
@@ -49,10 +49,43 @@ protected.example.com {
# optionally, if you want to use a subdomain for central preauth
# set SUBDOMAIN_REDIRECT to true
# and AUTH_SUBDOMAIN to match the subdomain you use here
#
# Passkeys (PASSKEY_ENABLED) require this block AND HTTPS: the ceremony runs
# here and the credential is scoped to the base domain. Caddy provisions a
# certificate automatically for a real hostname, so nothing extra is needed in
# production. This block is also deliberately NOT behind forward_auth — the
# browser talks to it directly during a ceremony.
auth.example.com {
reverse_proxy preauth
}
# --- local development with passkeys ---
# Browsers only allow a WebAuthn ceremony over HTTPS, and preauth does not offer
# an exemption for http://localhost (that would be a way to run passkeys
# insecurely in production). So to exercise passkeys locally, give yourself a
# real hostname and a locally-trusted certificate:
#
# 1. Point the names at your machine:
# # /etc/hosts
# 127.0.0.1 auth.preauthtest.local app.preauthtest.local
# 2. Trust a certificate for them (mkcert installs a local CA):
# mkcert auth.preauthtest.local app.preauthtest.local
#
# 3. In preauth's .env:
# SUBDOMAIN_REDIRECT=true
# AUTH_SUBDOMAIN=auth.preauthtest.local
# PASSKEY_ENABLED=true
#
# 4. Terminate TLS here and proxy to the container:
#
# auth.preauthtest.local, "*.preauthtest.local" {
# tls /path/to/auth.preauthtest.local+1.pem /path/to/auth.preauthtest.local+1-key.pem
# reverse_proxy preauth
# }
#
# Note "localhost" itself cannot be used: it has no base domain, so central
# auth cannot be configured and passkeys stay disabled.
# --- public rate-limited access (v1.1) ---
# Configure PUBLIC_PATHS env var to specify which paths are public.
# Example: PUBLIC_PATHS=/public/**
+849
View File
@@ -0,0 +1,849 @@
# Plan — Passkey Authentication for the Dedicated Auth Subdomain
**Status:** 📋 Draft for review — no application code written yet
**Target:** next minor release (version to confirm — see Q1.1)
**Prepared:** 2026-09-26 against `main` @ `0458d9b`
**Revised:** 2026-09-27 — review round 2 (D4/D5, §2.3)
**Verified against:** `web-auth/webauthn-lib` 5.3.9 on PHP 8.5.11 / Symfony 8.1
---
## 0. Decisions locked in (from review feedback)
Five clarifications from the project owner reshape this plan. They are
**decisions**, not options, and everything below follows from them.
| # | Decision | Consequence |
|---|---|---|
| **D1** | **Central auth (dedicated auth subdomain) is a hard prerequisite** for passkeys. Without it, a passkey would collide with / confuse the passkey for the protected service itself. | Passkeys are simply **not offered** unless `SUBDOMAIN_REDIRECT=true` *and* `AUTH_SUBDOMAIN` is set. The RP ID is *always* `authBase()`. There is no single-host passkey mode, no per-service RP ID, and no ambiguity to document away. |
| **D2** | **Registration happens in the browser**, initiated by a simple "register passkey" checkbox on the login form — not a CLI command. | Registration reuses the existing login form, nonce/CSRF machinery and TOTP verification. This also **answers the identity question**: the identity is the `Session ID` field the user already types, exactly as with TOTP. |
| **D3** | **Rate limiting covers all forms of login.** If an IP is rate-limited, that includes passkeys. | Passkey ceremonies run **behind** the existing `RejectListener` gate and consume the **same** login limiter budget on failure. No way to sidestep a lockout by switching methods. |
| **D4** | **HTTPS is required — in development too.** No "secured relying party" exemption is supported, deprecated or otherwise. | The derived allowed-origin is *always* `https://…`, built from config and never from the request. The `PASSKEY_ALLOWED_ORIGINS` escape hatch from the first draft is **deleted**. Local development uses real TLS (§4.2). HTTPS becomes part of the boot-time assertion alongside D1 (Q3.1). |
| **D5** | **Attestation is `none`, deliberately.** The "set a real value instead" instinct was tested and is wrong *here* — every alternative is either broken or bypassable (§2.3). | Records are anonymous: zero AAGUID, `EmptyTrustPath`. No metadata service, no `web-token/jwt-library` dependency, no download of the FIDO BLOB. `SECURITY.md` states the reasoning and the conditions that would change it. |
Consequences worth stating plainly:
- The separate `passkey_limiter`, `PASSKEY_ENABLED=false` default, and the whole
"should we support single-host passkeys?" question from the first draft are
**gone**. D1 removes the configuration matrix; D3 removes the second limiter.
- The first draft's §7 (CLI registration, enrolment tokens, `--identity`) is
**deleted**. D2 replaces it with a checkbox.
- D4 keeps D1 exactly as strict — HTTPS is an **additional** requirement, never a
relaxation. D5 is the one place where "use the stricter-sounding option" loses,
and §2.3 shows the measurements behind that.
---
## 1. Goal
Let a user authenticate with a passkey (Touch ID, Windows Hello, Android
biometrics, hardware security key) instead of typing a 6-digit TOTP code —
served from the dedicated auth subdomain, where one passkey unlocks every
service on the base domain.
TOTP and backup codes remain and are never removed (Q2.1).
---
## 2. What the spike proved
The first draft contained claims that had not been executed. They now have been:
the library was installed, and a script performed a **complete registration and
assertion ceremony with real ES256 cryptography**, plus the negative cases.
**Environment:** PHP 8.5.11, Composer 2.10.3, `web-auth/webauthn-lib` **5.3.9**.
Baseline suite green before and after install: **313 tests / 738 assertions**.
`composer audit`: *"No security vulnerability advisories found."*
`.ci/conformance.sh --profile=auth-gateway`: **all 35 checks pass**.
### 2.1 Confirmed correct
| Claim | Result |
|---|---|
| Installs on PHP 8.5 + Symfony 8.1 with no conflicts | ✅ resolves to 5.3.9; `lint:container`, `lint:yaml`, `lint:twig` all pass |
| Only needs `ext-json` + `ext-openssl` | ✅ (`ext-openssl` is present in every official PHP image, so `Dockerfile` needs **no** extension work) |
| No Symfony Security bundle, no Doctrine, no bundler | ✅ `CeremonyStepManagerFactory` + `Authenticator*ResponseValidator::create()` are pure; the `webauthn-symfony-bundle` is unnecessary |
| rpId `example.com` admits an origin on `auth.example.com` | ✅ assertion ACCEPTED |
| …and also on `app.example.com` with the *same* credential | ✅ ACCEPTED — one passkey across all subdomains, as designed |
| Credential is cryptographically bound to the rpId | ✅ a forged `rpIdHash` is rejected: *"rpId hash mismatch"* |
| An origin outside the allow-list is rejected | ✅ *"Invalid origin. Not in the list of allowed origins."* |
| Wrong origin / wrong challenge rejected | ✅ `AuthenticatorResponseVerificationException` in both cases |
| CSP `publickey-credentials-*` do **not** inherit `default-src` | ✅ confirmed in the CSP3 spec (§6.8.3 fallback list omits WebAuthn directives) — the CSP change in §5.5 is required |
| base64url credential IDs survive `makeCacheKey()` without collision | ✅ 200 000 random 32-byte IDs, zero collisions |
| `attestation: 'none'` yields an anonymous record | ✅ `attestationType="none"`, zero AAGUID, `EmptyTrustPath` (§2.3, config A) |
| Origin scheme can never be inferred from the request | ✅ the scheme comes from the single allow-list string; an `https://` entry rejects an `http://` origin (§4.2) |
| `localhost` cannot accidentally enable passkeys | ✅ `baseDomain('localhost') === null` ⇒ `authBase() === null` ⇒ D1 unsatisfied (§4.2) |
| `auth.localhost` *does* satisfy D1 | ✅ `authBase() === 'auth.localhost'` (§4.2) |
| No MDS ⇒ no `web-token/jwt-library` needed | ✅ `FidoAllianceCompliantMetadataService` throws unless the JWT library is present; not installed, and MDS is not used (D5) |
### 2.2 Corrections to the first draft (things that would have bitten us)
| # | First draft said | Reality | Impact |
|---|---|---|---|
| **C1** | "`CredentialRecord` is JSON-serializable, so it fits the no-database constraint." | It is a **plain class**, not `JsonSerializable`. Persistence goes through `WebauthnSerializerFactory` (a Symfony Serializer with ~25 custom normalizers). | The store must use that factory. `symfony/serializer`, `property-info`, `property-access` arrive as transitive deps — no extra work, but the store can't just `json_encode()`. |
| **C2** | (unstated) treat option objects as plain JSON | `json_encode($creationOptions)` **throws** `JsonException: Malformed UTF-8` — the challenge is raw binary. Options **must** be serialized by the same factory, which base64url-encodes binary fields. | Both the `begin` payload and the stored record go through one `SerializerInterface`. Caught immediately by the spike; would otherwise have been a runtime 500 on first test. |
| **C3** | "the package carries 3 published advisories" | `composer audit` against 5.3.9 reports **none**. | No remediation work; record the clean audit in the CHANGELOG. |
| **C4** | counter handling not mentioned | Counter replay raises `CounterException`, which can **mask** the real reason a verification failed. | Test helper must use an incrementing counter per ceremony, or negative tests give false passes (this actually happened during the spike and had to be fixed). |
| **C5** | "keep the library default" for the counter | The default requires a strictly *increasing* counter. Measured: stored `0`, reported `0` → `CounterException`. A synchronised passkey reports `0` forever, so **every** such credential fails on its **first** login — and only on real hardware, since a test helper that increments never reproduces it. | `PasskeyCounterChecker` accepts `>=` and rejects strictly backwards. Pinned by `PasskeyCounterCheckerTest`, including a test asserting the library default still rejects `0`/`0` so this reasoning is re-checked if the dependency is upgraded. |
| **C5** | separate `passkey_limiter` + `publicRateLimitCache`-style pool | Decision D3 makes it redundant for the *login* budget. | Drop it. One small limiter remains, for a different purpose (§5.4). |
### 2.3 Attestation: why `none`, measured rather than assumed
The review asked the right question — *"is there any downside to `null`, and if it
needs a note in `SECURITY.md`, shouldn't we set a real value?"* — so it was
tested instead of argued. Seven configurations were run against 5.3.9
(`spike_attestation.php`, `spike_att2.php`). Results are summarised, not
predicted:
| # | Configuration | Outcome | What the server actually learns |
|---|---|---|---|
| **A** | `attestation=none`, `fmt=none` | ✅ accepted | `attestationType="none"`, aaguid all-zero, `EmptyTrustPath`. **Nothing.** |
| **B** | `attestation=direct`, `fmt=packed` **self**, no MDS | ✅ accepted | A real AAGUID string — but no metadata to interpret it against, so it is untrusted and uninterpretable. |
| **C** | `attestation=direct`, `fmt=packed` **basic** (`x5c` cert), no MDS | ❌ **rejected** | *"The Metadata Statement Repository is mandatory when requesting attestation objects."* |
| **C2** | …same, MDS enabled, metadata **empty** | ❌ **rejected** | *"The Metadata Statement for the AAGUID … is missing."* This is the real cost of MDS: **every** authenticator must be known in advance. |
| **C3** | MDS enabled, but the client sends a **zero** AAGUID | ✅ **accepted** | *"Null AAGUID detected. Skipping metadata verification."* — **MDS is bypassable by design.** |
| **C4** | MDS enabled, `fmt=packed` **self** attestation, AAGUID **unknown** to MDS | ✅ **accepted** | `processSelfAttestation()` returns early when the AAGUID has no metadata entry, so **self attestation is never refused by MDS** — even a *known-unknown* device passes. |
| **D** | `attestation=direct` requested, client sends `fmt=none` | ✅ **accepted** | Asking for `direct` does **not** compel compliance — conveyance is a *preference*, so the RP cannot force it. |
Three conclusions follow, and they are the reason D5 is `none`:
1. **Attestation cannot be *enforced*, only *requested*.** Configuration D shows a
client answering a `direct` request with `none` and being accepted regardless.
Any policy that depends on the client cooperating is not a security control.
2. **MDS is bypassable two different ways.** C3 is the decisive row: a zero AAGUID
short-circuits metadata verification *before* the repository is ever consulted.
Since passkeys from Apple/Google/Windows deliberately send zero AAGUIDs, an
attacker can present the same shape and skip MDS entirely — while legitimate
users are unaffected. C4 closes the remaining door on the same conclusion: with
`fmt=packed` **self** attestation (the format a software/platform authenticator
can produce without any vendor certificate), `processSelfAttestation()` returns
early when the AAGUID has no metadata entry, so even a device that is *unknown*
to MDS is accepted. Taken together: an MDS deployment refuses honest
certificate-bearing authenticators that postdate its cached BLOB (C2), while
still admitting the bypassable and self-attested cases. That is the worst
combination — friction for legitimate users, no assurance gained.
3. **`none` is not a weaker version of the same check — it is the honest
description of reality.** The property that actually protects users is that the
credential is cryptographically bound to the RP ID and origin (§2.1), which
holds identically in every row above. Attestation answers *"which device model
is this?"* — a question this project does not need to answer, because it does
not run a device-allow-list policy.
**What a real value would actually cost**, for the record: `direct` requires the
metadata repository (C) — verified as a hard failure, not a warning — which means
`web-token/jwt-library`, `symfony/http-client`, a periodic download of the FIDO
Alliance BLOB, certificate-chain validation on every registration, and a new
failure mode where a legitimate new phone is **rejected at enrolment** because its
AAGUID postdates the cached BLOB. All of that to gain a bypassable signal.
> **Where to revisit this.** D5 is the right call *for a self-hosted
gateway that does not distinguish devices*. It stops being the right call if the
project ever wants to (a) refuse specific authenticator models, or (b) prove
enrolment happened on hardware rather than a synced passkey. Both would require
MDS **plus** a decision to reject zero AAGUIDs — which is why the reasoning is
recorded in `SECURITY.md` rather than left implicit in a constant.
---
## 3. The flow, end to end
### 3.1 First-time setup (D2 — in the browser)
```
Browser → https://app.example.com/dashboard
forward_auth → preauth (host=app.example.com) → InterceptListener
matchesAuth() && host !== auth subdomain
⇒ 303 https://auth.example.com/?return=https%3A%2F%2Fapp.example.com%2Fdashboard
Browser → https://auth.example.com/?return=…
┌──────────────────────────────────────────────────────┐
│ Pre-Authentication System │
│ │
│ Session ID: [ lyra ] │
│ Authentication Token:[ 123456 ] │
│ [x] Register this device as a passkey ← new │
│ [ Submit ] │
└──────────────────────────────────────────────────────┘
```
The checkbox only appears when passkeys are available (D1 satisfied) — see §4.
**Submission with the box ticked** becomes a three-step ceremony:
```
1. POST / (auth host), form fields username+totp+nonce+register=passkey
LoginListener → LoginManager verifies TOTP/backup code + nonce [unchanged]
↳ instead of issuing a session, it starts a REGISTRATION ceremony:
stores passkey_reg_<cid> → { challenge, identity, userHandle } (TTL 300s)
⇐ 200 JSON { register: { publicKey: <options>, ceremonyId: <cid> } }
2. Browser: navigator.credentials.create({ publicKey: options })
→ user approves with Touch ID / Windows Hello / security key
3. POST / (auth host) X-Preauth-Passkey: register-finish
body: { ceremonyId, credential: <attestation JSON> }
PasskeyListener → PasskeyManager verifies attestation against the stored
challenge; stores the credential under the identity from the record
⇐ 303 Location: <return url> + Set-Cookie: __Http-Domain-Preauth=…
```
The TOTP check in step 1 is what authorises registration. There is no separate
enrolment token, no CLI, and **no way to create a credential without already
holding a valid TOTP code** — which is exactly the security property the CLI
design was reaching for.
> **Implementation note — where the hand-off actually goes.**
> `LoginListener::onKernelRequest()` is a straight chain: it builds a `Payload`,
> calls `$this->loginManager->checkToken(...)`, and on a non-null response it
> does `$event->setResponse($response); return;` — on `null` it immediately
> scores a failure and consumes a rate-limit token. There is no "authenticated
> but do not issue a session" branch to hook.
>
> So the clean split is: `LoginListener` detects `register=passkey` in the POST
> body and marks the **`Payload`** with the intent; `LoginManager::checkToken()`
> verifies TOTP/backup-code **and the nonce** exactly as it does today, and only
> then, if the intent is set, delegates to the registration ceremony instead of
> issuing a session. That keeps the nonce/CSRF guarantee in the one place that
> already enforces it — the alternative (starting a ceremony from the listener
> before `checkToken` runs) would move nonce validation and would need care to
> avoid double-spending it.
### 3.2 Everyday login (assertion)
```
Browser → https://auth.example.com/?return=…
[ 🔑 Sign in with a passkey ] ← button, one tap
─────────── or use a code ───────────
Session ID: [ … ] Token: [ … ] [ Submit ]
Passkey button:
1. POST / X-Preauth-Passkey: login-begin
⇐ 200 JSON { publicKey: { challenge, rpId, allowCredentials[], … },
ceremonyId }
2. navigator.credentials.get({ publicKey })
3. POST / X-Preauth-Passkey: login-finish body: { ceremonyId, credential }
PasskeyManager verifies the assertion against the stored record
⇐ 303 + cookie, or 401 JSON { message, nonce }
```
No username is typed: the credential carries its own identity (stored at
registration). `allowCredentials` lists all registered credentials, so the OS
picker decides which device to use.
### 3.3 Listener priority (D3)
```
Priority Listener Action
──────── ───────────────────── ─────────────────────────────────────────
99 AcceptListener Valid cookie → 200 OK
88 AllowListener Valid IP session → 200 OK
84 PublicAccessListener Public path + rate limit → 200/429
77 RejectListener LOGIN RATE-LIMIT GATE → 418/429
70 PasskeyListener (new) WebAuthn ceremony → JSON
66 LoginListener TOTP / backup-code login
55 InterceptListener Fallback → redirect or login page
```
**Why 70 — after `RejectListener` and before `LoginListener`:**
- **After 77 (D3):** a rate-limited IP is refused *before* any ceremony can
start. Passkeys cannot be used to sidestep a lockout. This is the whole point
of the reviewer's third clarification, and it reverses the first draft.
- **Before 66:** essential. `LoginListener` treats *any* POST to the auth
subdomain as a login attempt (`$domainManager->getAuthSubdomain() === $host`).
A ceremony `finish` POST has no `username`/`totp`, so `Payload::load()` returns
`null` and the request would be scored as a **failed login and burn a rate-limit
token**. `PasskeyListener` must claim the request first.
`PasskeyListener` sets a response for *every* request carrying its header —
including malformed ones — so control never falls through to
`InterceptListener`, which would render HTML to a `fetch()` caller. (Q3.2)
---
## 4. Availability rule (D1 + D4)
Passkeys are offered **only** when all of these hold:
```php
$passkeysAvailable =
$config->passkeyEnabled() // PASSKEY_ENABLED=1 (default 0)
&& null !== $domainManager->authBase() // SUBDOMAIN_REDIRECT=1 && AUTH_SUBDOMAIN set
&& $domainManager->getAuthSubdomain() === $request->getHost(); // we are ON the auth host
```
…and, separately, the **deployment** must satisfy HTTPS (D4). That is checked
once at boot rather than per request, because "is this request HTTPS" is not the
right question behind a TLS-terminating proxy — see §4.2.
Consequences:
- **RP ID is always `authBase()`** — never the request host, never configurable
per-service. `example.com` for `auth.example.com`.
- **Allowed origins is exactly one entry**: `https://{AUTH_SUBDOMAIN}`, built
from config. Because `InterceptListener` funnels every unauthenticated user to
the auth host, no other origin ever needs to run a ceremony. This is the
tightest configuration that still delivers "one passkey, every service" (§2.1).
- On a protected host, `InterceptListener` already redirects before rendering a
login page, so the checkbox is naturally absent there.
- If someone sets `PASSKEY_ENABLED=1` without central auth, the app must
**fail loudly at boot**, not silently ignore it (Q3.1). A silent ignore is how
you get "I enrolled a passkey and now I can't log in" support tickets.
`rpName` for the OS prompt defaults to `TITLE`.
### 4.1 Identity and userHandle
The identity is the `Session ID` the user typed — the same value TOTP uses, so
`Remote-User` modes (`session`/`static`/`mapped`) keep working unchanged.
- `userHandle` = `hash('sha256', $identity, true)` (32 raw bytes). Fixed length,
stable per identity, and does not leak the label into the authenticator.
- On assertion, the identity is read from the **stored credential record**, not
from the client-returned `userHandle`. The client's copy is never trusted.
- Because registration is gated behind a successful TOTP login, one identity
cannot be registered by someone who does not already hold the TOTP secret.
### 4.2 HTTPS (D4) — enforced, not exempted
D4 removes the exemption system entirely: **there is no code path that accepts an
`http://` origin for passkeys**, and no configuration that re-enables one. The
library's `setSecuredRelyingPartyId()` (deprecated since 5.2, confirmed in
`CeremonyStepManagerFactory`) is **never called**.
Measured behaviour of the origin check (`spike_origin.php`), all with rpId
`example.com`:
| Allowed origins | Client origin | Result |
|---|---|---|
| `https://auth.example.com` | `https://auth.example.com` | ✅ accepted |
| `http://localhost:8000` | `http://localhost:8000` | ✅ accepted — **only** because `http://` was explicitly allow-listed |
| `localhost:8000` (host-only) | `http://localhost:8000` | ❌ rejected |
| `https://auth.example.com` | `http://auth.example.com` | ❌ rejected |
| `https://example.com` +subdomains | `https://app.example.com` | ✅ accepted |
| `https://example.com` +subdomains | `http://app.example.com` | ❌ rejected |
| `https://example.com` (no subdomains) | `https://app.example.com` | ❌ rejected — *"Subdomains are not allowed."* |
The scheme is therefore never inferred from the request; it comes from the single
`https://{AUTH_SUBDOMAIN}` string. Note the second row — the library *will* accept
plain HTTP **if the operator writes it into the allow-list**, which is precisely
the hole D4 closes by deleting `PASSKEY_ALLOWED_ORIGINS`.
**Two gotchas this creates for local development**, both verified against
`DomainManager` (`spike_devhost.php`):
1. `baseDomain('localhost')` returns **`null`** by design, so `authBase()` is also
`null` and **`localhost` can never satisfy D1** — passkeys stay off there no
matter what. `auth.localhost`, by contrast, resolves to `authBase()` of
`auth.localhost` and *does* satisfy D1.
2. Because the origin must be `https://`, dev cannot simply point a browser at
`http://auth.localhost`. The supported dev workflow is therefore **a local TLS
certificate**, not an exemption:
```
# Development with real TLS — the only supported way to exercise passkeys
AUTH_SUBDOMAIN=auth.preauthtest.local
SUBDOMAIN_REDIRECT=true
PASSKEY_ENABLED=1
# /etc/hosts → 127.0.0.1 auth.preauthtest.local app.preauthtest.local
# mkcert auth.preauthtest.local app.preauthtest.local
# Caddy terminates TLS with the mkcert cert and reverse_proxies to :80
```
This is a **documentation and CI** change, not an application-code change: the app
already sits behind a TLS-terminating proxy in production (`docker/Caddyfile`
serves plain HTTP on `:80`, `trusted_headers` includes `x-forwarded-proto`), so
D4 adds no runtime branching. `docs/examples/Caddyfile` gains a TLS-enabled
development block, and the functional tests (§7.2) drive the HTTPS origin directly
because they build `clientDataJSON` by hand — no real TLS needed in the suite.
> **Not `localhost`.** Because D4 forbids `http://`, the classic
> `http://localhost` dev story simply does not apply to passkeys. `localhost` is
> treated as *"passkeys unavailable"*, which keeps D1 intact instead of carving
> out an exception that would then need its own tests.
---
## 5. Design detail
### 5.1 `PasskeyManager` (new service)
Owns both ceremonies. Library types stay inside this class so a future v6 rename
touches one file.
```php
final readonly class PasskeyManager implements PasskeyInterface
{
public function beginLogin(Request $request): array; // → options + ceremonyId
public function finishLogin(array $body, Request $request): ?Response;
public function beginRegistration(string $identity, Request $request): array;
public function finishRegistration(array $body, Request $request): ?Response;
}
```
Built on the verified recipe:
```php
$attestationManager = AttestationStatementSupportManager::create();
$attestationManager->add(NoneAttestationStatementSupport::create()); // D5 (§2.3)
$csm = new CeremonyStepManagerFactory();
$csm->setAllowedOrigins(["https://{$domainManager->getAuthSubdomain()}"]);
$csm->setAlgorithmManager(AlgorithmManager::create()->add(ES256::create()));
$csm->setAttestationStatementSupportManager($attestationManager);
$attestationValidator = AuthenticatorAttestationResponseValidator::create($csm->creationCeremony());
$assertionValidator = AuthenticatorAssertionResponseValidator::create($csm->requestCeremony());
$serializer = (new WebauthnSerializerFactory($attestationManager))->create();
```
- `setSecuredRelyingPartyId()` is **deprecated in 5.2** (confirmed in the source,
`@deprecated since 5.2.0 … Use setAllowedOrigins instead`) — **never called
(D4)**. Development uses real TLS, not an exemption (§4.2).
- `attestation: 'none'` for registration **(D5, §2.3)**; no metadata service, so
neither `web-token/jwt-library` nor `symfony/http-client` is needed — the
latter confirmed absent from the current install, so reaching for MDS would add
a second new dependency, not just code.
- Counter: **replaced the library default** — see §2.2 C5. `ThrowExceptionIfInvalid`
requires a *strictly increasing* counter, which rejects a synchronised passkey
on its first login; `PasskeyCounterChecker` accepts equal-or-greater and still
rejects moves backwards. Clone detection is not relied upon. Test helpers must
still increment (C4).
### 5.2 Ceremony state
Stored in the **`nonceCache`** pool (already APCu, already excluded from
`kernel.reset` in `TestKernel`, already short-lived, and — correctly — *not*
persisted to disk, so ceremonies do not survive a restart):
```
passkey_cer_<ceremonyId> → { type: 'login'|'register',
challenge: <base64url>,
identity?: string, // register only
userHandle?: string, // register only
returnUrl?: string,
createdAt: <iso8601> } TTL 300s
```
- `ceremonyId` is a fresh 15-byte base64url string, issued to the client. The
client's copy of the challenge is **never** trusted; the server-side record is
authoritative.
- **Single-use**: deleted on read at `finish`, before verification, so a failed
or replayed assertion cannot be retried against the same challenge.
- TTL 300 s (5 min) rather than the nonce's 120 s, because a user has to
interact with a biometric prompt.
### 5.3 Credential store (new service)
```php
final readonly class PasskeyCredentialStore implements PasskeyCredentialStoreInterface
{
public function all(): array; // for allowCredentials
public function find(string $credentialId): ?array; // record + metadata
public function save(CredentialRecord $record, string $identity, string $label): void;
public function updateCounter(CredentialRecord $record): void;
public function remove(string $credentialId): bool;
public function count(): int;
}
```
Cache layout in **`sessionCache`** (the persisted pool):
```
passkey_cred_<makeCacheKey(credentialId)> → { record: <serialized CredentialRecord>,
identity: string,
label: string,
createdAt: iso8601,
lastUsedAt: iso8601|null }
passkey_index → { <credentialId>: {identity, label, createdAt}, … }
```
> ⚠️ **Verified gotcha.** `PersistCache::persist()` only flushes keys recorded by
> a `MonitorCacheKeys` instance, and it watches `sessionCache`. `LoginManager`
> and `BackupCodeManager` therefore each wrap their injected pool:
> `$this->sessionCache = new MonitorCacheKeys($sessionCache);`.
> `PasskeyCredentialStore` **must do the same**, or credentials live only in APCu
> and vanish on the next container restart — a bug that would surface only after
> a redeploy. Add an explicit test asserting the write is visible in the
> underlying persistent pool.
`passkey_index` avoids scanning the whole key space for the login page's
`allowCredentials` list.
### 5.4 Rate limiting (D3)
**No new limiter for the login budget.** Instead:
| Event | Limiter behaviour |
|---|---|
| Any request to the auth host, incl. `*-begin` | `RejectListener` (77) gates first — a blocked IP never reaches `PasskeyListener` |
| `login-finish` **failure** | consumes `login_limiter` (1 token) — identical to a wrong TOTP code |
| `register-finish` **failure** | consumes `login_limiter` |
| successful ceremony | consumes nothing |
| `*-begin` | not consumed (a legitimate login must not burn failure budget) |
This satisfies "if the login attempt has been rate limited, that would include
all forms of login": after 10 failures the IP is blocked for *every* method, and
failures from any method count toward the same 10.
To stop `begin`-spam from filling the cache with ceremony records, add **one**
small limiter that bounds *starts* only — it is a resource guard, not the auth
budget:
```yaml
passkey_begin_burst:
policy: 'sliding_window'
limit: '%env(int:PASSKEY_BEGIN_BURST_COUNT)%' # default 30
interval: '%env(int:PASSKEY_BEGIN_BURST_TIME)% seconds' # default 60
cache_pool: 'passkeyRateLimitCache'
```
plus a `passkeyRateLimitCache` pool (APCu in prod, array in test) and an entry in
`tests/TestKernel`'s reset-exclusion list. On over-limit, `begin` answers
`429` with `Retry-After`, matching `PublicAccessListener`. (Q3.6 asks whether this
guard is wanted at all.)
### 5.5 Response caching and CSP
**Caching.** `SecurityHeadersListener` sets `no-store` only on *non-2xx*
responses, on the assumption that 2xx is consumed by `forward_auth`. That is
false here: `begin` returns a **`200` JSON body straight to the browser**, and
the auth subdomain is `reverse_proxy`-ed with no `forward_auth` in front of it at
all. Ceremony responses must therefore be no-store too. Proposed: `PasskeyListener`
marks them with an internal `X-Preauth-Ceremony` header, and
`SecurityHeadersListener` turns that into the full no-store set and strips the
marker — keeping the caching policy in the one place that owns it. (Q3.7)
**CSP.** `publickey-credentials-get` / `publickey-credentials-create` do **not**
fall back to `default-src` (confirmed, §2.1), and the current policy is
`default-src 'none'`. When passkeys are available the policy becomes:
```
default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';
connect-src 'self'; publickey-credentials-get 'self'; publickey-credentials-create 'self';
```
`connect-src 'self'` must be added **in both modes** — today it is added only for
the inline (non-auth-subdomain) case, but the passkey flow always uses `fetch()`.
When passkeys are unavailable the header is byte-identical to today.
### 5.6 Templates and script
| File | Purpose |
|---|---|
| `templates/_passkey.html.twig` | "Sign in with a passkey" button + `navigator.credentials.get()` handler |
| `templates/login.html.twig` | gains the checkbox (register) and includes the button, both only when available |
| `templates/_passkey_register.html.twig` | `navigator.credentials.create()` handler, driven by the JSON returned in step 1 of §3.1 |
`_script.html.twig` keeps its existing submit handler; ticking the checkbox
switches the submit into the registration branch. Kept as separate templates so
the "passkeys unavailable ⇒ byte-identical login page" property stays testable.
Base64url helpers must mirror the library's encoding exactly (no padding,
`-`/`_` alphabet); the spike's working script is the reference.
### 5.7 Configuration
| Variable | Default | Notes |
|---|---|---|
| `PASSKEY_ENABLED` | `0` | master switch; requires **D1 and D4** or the app fails at boot (Q3.1) |
| `PASSKEY_RP_NAME` | `%env(TITLE)%` | shown by the OS prompt |
| `PASSKEY_USER_VERIFICATION` | `required` | `required`/`preferred`/`discouraged` |
| `PASSKEY_TIMEOUT` | `60000` | ms, passed to the browser |
| `PASSKEY_BEGIN_BURST_COUNT` / `_TIME` | `30` / `60` | §5.4 resource guard |
| `PASSKEY_BUTTON_NAME` | `Sign in with a passkey` | styling-option family |
| `PASSKEY_REGISTER_NAME` | `Register this device as a passkey` | checkbox label |
> **Deleted by D4:** `PASSKEY_ALLOWED_ORIGINS`. The allowed origin is always
> derived as `https://{AUTH_SUBDOMAIN}` and there is no override — see §4.2.
Defaults preserve today's behaviour exactly.
---
## 6. Files
**New**
| File | Purpose |
|---|---|
| `src/Service/PasskeyManager.php` + `PasskeyInterface.php` | both ceremonies, library types contained |
| `src/Service/PasskeyCredentialStore.php` + interface | cache-backed records + index |
| `src/Listener/PasskeyListener.php` | priority 70, header dispatch, JSON |
| `templates/_passkey.html.twig` | login button + assertion script |
| `templates/_passkey_register.html.twig` | registration script |
| `tests/Support/PasskeyTestHelper.php` | ES256 generator, ceremony builder, incrementing counter (C4) |
| `tests/Unit/Service/PasskeyManagerTest.php` | ceremony control flow |
| `tests/Unit/Service/PasskeyCredentialStoreTest.php` | storage, index, persistence, key collisions |
| `tests/Unit/Listener/PasskeyListenerTest.php` | every branch |
| `tests/Functional/PasskeyFlowTest.php` | real crypto end-to-end (§7.2) |
**Changed**
| File | Change |
|---|---|
| `composer.json` / `composer.lock` / `symfony.lock` | `web-auth/webauthn-lib: ^5.3` (done on the spike branch) |
| `phpunit.dist.xml` | recipe-added `doctrine/deprecations` triggers (spike artefact — keep) |
| `config/packages/property_info.yaml` | recipe-added (spike artefact — keep) |
| `config/services.yaml` | `app.passkey_*` parameters |
| `config/packages/rate_limiter.yaml` | `passkey_begin_burst` |
| `config/packages/cache.yaml` + `test/cache.yaml` | `passkeyRateLimitCache` |
| `config/packages/twig.yaml` | passkey globals |
| `src/ConfigBag.php` | `passkeyEnabled()`, `rpName()`, `userVerification()`, `timeout()`, labels |
| `src/Kernel.php` or a compiler pass | boot-time check that **D1 and D4** hold when enabled (Q3.1, §4.2) |
| `src/Listener/SecurityHeadersListener.php` | CSP additions; ceremony no-store marker |
| `src/Listener/LoginListener.php` | detect `register=passkey` on the POST and mark the `Payload` with the intent (see §3.1 note) |
| `src/Service/LoginManager.php` | on success-with-intent, delegate to the registration ceremony instead of issuing a session; extract the session-issuing tail (Q3.8) |
| `templates/login.html.twig` | checkbox + button |
| `tests/TestKernel.php` | `passkeyRateLimitCache` in the reset-exclusion list |
| `tests/Support/ListenerTestHelper.php` | passkey limiter factory |
| `.env.test`, `docs/examples/.env.example`, `docs/examples/Caddyfile`, `docs/examples/compose.yaml` | config + docs; **TLS dev block** (§4.2) |
| `readme.md`, `CHANGELOG.md`, `ROADMAP.md`, `SECURITY.md`, `DESIGN_CONSIDERATIONS.md` | §9 |
---
## 7. Testing
### 7.1 Reusing the spike
`tests/Support/PasskeyTestHelper.php` is the spike's working code, refactored:
ES256 keypair → COSE key → `authenticatorData` → sign → JSON. Two rules learned
the hard way:
- **Increment the counter every ceremony** (C4), or a negative test can pass for
the wrong reason (`CounterException` masking the real failure).
- **Serialise options through `WebauthnSerializerFactory`**, never `json_encode` (C2).
### 7.2 Cases
| Case | Expected |
|---|---|
| Register on `auth.example.com` (rpId `example.com`), then assert from the same host | ✅ 303, `__Http-Domain-Preauth`, `Domain=example.com`, `Remote-User` |
| Assert the same credential from `app.example.com` | ✅ success by design — asserted explicitly so the scope is documented in code |
| Register while `PASSKEY_ENABLED=0` / without central auth | ❌ checkbox absent; `begin` inert; no cache writes |
| Registration submitted with a **bad TOTP** | ❌ 401, no ceremony started, login limiter consumed |
| Registration with a **spent nonce** | ❌ 401, no ceremony |
| `begin` from a rate-limited IP | ❌ 418/429 from `RejectListener`, never reaches passkey code |
| Failed assertion | ❌ 401, **login limiter consumed** (D3) |
| Failed assertion × N, then a correct TOTP | ❌ still blocked — shared budget |
| Assertion replayed with the same `ceremonyId` | ❌ 401 (record deleted on read) |
| Unknown `credentialId` | ❌ 401, same generic message as a bad TOTP (no enumeration) |
| Origin not in the allow-list | ❌ 401 (`Invalid origin…`) |
| **`http://` origin with the derived `https://` allow-list** | ❌ 401 — D4; asserted explicitly so the exemption cannot creep back |
| **`PASSKEY_ALLOWED_ORIGINS` is not consulted** | ❌ setting it has no effect (D4) |
| **`PASSKEY_ENABLED=1` with `AUTH_SUBDOMAIN=localhost`** | ❌ boot failure — D1 unsatisfiable (§4.2) |
| **`PASSKEY_ENABLED=1` on plain HTTP deployment** | ❌ boot failure — D4 (Q3.1) |
| Forged `rpIdHash` | ❌ 401 (`rpId hash mismatch`) |
| Zero AAGUID / self attestation payload | ✅ accepted exactly as a `none` record would be — documents D5's reasoning in code |
| Ceremony responses | ✅ full no-store header set |
| Login page when passkeys unavailable | ✅ byte-identical to today |
| Persistence | ✅ a saved credential is present in the **persistent** pool, not just APCu |
| `begin` spam | ✅ bounded by `passkey_begin_burst` |
### 7.3 Gates
Baseline to preserve: **313 tests / 738 assertions**, 100 % line/method/class
coverage, `phpstan` level 6 clean, `php-cs-fixer` clean, `composer audit` clean,
conformance 35/35. Note `phpunit.dist.xml` runs with `failOnDeprecation=true`, so
deprecations from the new dependency must be watched (the recipe already added
the `doctrine/deprecations` triggers).
---
## 8. Implementation order
**Status: complete.** All steps below are implemented and on
`feat/passkey-auth-subdomain`. Two deviations from the order as written, both
noted inline.
Each step is independently committable and leaves the suite green.
1. **Dependency** *(done on the spike branch)* — `composer require
web-auth/webauthn-lib`; suite + lints + audit + conformance verified.
2. **Availability + config** — `ConfigBag` accessors, `services.yaml`, boot-time
**D1 + D4** assertion, Twig globals, test env. Feature fully inert; assert the
login page is unchanged. Includes the TLS development setup in
`docs/examples/` (§4.2), so contributors can exercise the feature locally.
3. **Credential store** — with `MonitorCacheKeys` wrapping and the persistence
test. No WebAuthn types needed yet (`CredentialRecord` can be stubbed).
4. **`PasskeyManager`** — both ceremonies, ceremony state, single-use deletion,
limiter consumption on failure. Unit-tested with a stubbed validator.
5. **`PasskeyListener`** — priority 70, header dispatch, always terminate,
no-store marker. Unit-test every branch incl. "post-shaped request must not
reach `LoginListener`".
> **Deviation 1 — done after step 6.** The listener needs the shared session
> issuing that step 6 extracts, so the order had to be inverted.
>
> **Deviation 2 — three operations, not four.** `register-begin` is not a
> listener operation, and the first implementation was wrong to make it one.
> It would have handed out a challenge without proving anything; there is
> also no session cookie to check at that point, since the whole flow is what
> produces the session. The ceremony is started by `LoginManager`, after it
> verifies the code and nonce. A test pins that the listener refuses the
> operation.
6. **Extract session issuing** from `LoginManager` so both paths share it —
prove equality against the existing `LoginManagerTest`/`AuthenticationFlowTest`
before touching anything else (Q3.8).
> Verified as intended: all 18 existing `LoginManagerTest` cases passed
> unchanged, and a functional test now compares the two paths' cookies
> field by field.
7. **Registration UI** — checkbox in `login.html.twig`, the `Payload`-intent
hand-off described in §3.1, `_passkey_register.html.twig`.
> Note: the separate script template was not needed — both handlers share
> helpers, so `_passkey.html.twig` holds them and `login.html.twig` stays a
> single readable file. The checkbox is also **not** rendered where the form
> does not POST, since registration authorises itself with the code carried
> in that submission.
8. **Login UI + CSP** — `_passkey.html.twig`, `SecurityHeadersListener`, extend
`CacheControlFlowTest` and `SecurityHeadersListenerTest`.
9. **Functional tests** with real crypto (§7.2).
10. **Docs** (§9) and **final gates**, then PR to `main`.
---
## 9. Documentation
| File | Update |
|---|---|
| `readme.md` | "Passkey Authentication" section: **the central-auth prerequisite**, **the HTTPS requirement (development included)**, enabling, the checkbox, the passkey button, RP ID, fallbacks |
| `CHANGELOG.md` | `[Unreleased]` `Added`/`Security`; record library 5.3.9 and the clean audit |
| `ROADMAP.md` | Phase 2c done, noting the deviations from the original sketch (browser registration, no bundle, D1/D3/D4/D5) |
| `SECURITY.md` | ceremony model, challenge TTL/one-shot, RP ID scope, **the D5 attestation rationale and the conditions that would reverse it (§2.3)**, HTTPS-only origins, counter caveat, shared rate-limit budget |
| `DESIGN_CONSIDERATIONS.md` | the 2xx-caching gap; `CredentialRecord` serialization; the shared-limiter decision; **why attestation was deliberately declined** |
| `docs/examples/.env.example` | new variables; **note that `PASSKEY_ALLOWED_ORIGINS` does not exist by design** |
| `docs/examples/Caddyfile` | auth-subdomain block already `reverse_proxy`-ed; **add a TLS-enabled development block (§4.2)** and note why the plain-HTTP shortcut is not offered |
---
## 10. Risks
| # | Risk | Mitigation |
|---|---|---|
| R1 | RP ID / origin misconfiguration | D1 removes the matrix: RP ID is always `authBase()`, origins is always the auth host. Asserted by tests. |
| R2 | Ceremony responses cached (first browser-facing 2xx) | §5.5 marker + `CacheControlFlowTest` cases |
| R3 | CSP blocks the ceremony | §5.5 directives; verify in a real browser during staging (Q3.9) |
| R4 | Library churn (v5 renamed types; `setSecuredRelyingPartyId` deprecated) | pin `^5.3`; library types contained in `PasskeyManager`; avoid deprecated calls |
| R5 | Credential loss on restart | `MonitorCacheKeys` wrap + explicit persistence test (§5.3) |
| R6 | Non-technical users lose their passkey device | TOTP/backup codes unchanged and always available; the checkbox is opt-in |
| R7 | `begin` cache-fill | §5.4 resource guard |
| R8 | New transitive deps (`symfony/serializer`, `property-info`) | already installed as part of the spike; container lint passes |
| R9 | **A deployment enables passkeys without TLS, and the feature silently half-works** | D4 + the extended boot assertion (§4.2, Q3.1): `PASSKEY_ENABLED=1` in a non-HTTPS configuration **fails at `cache:warmup`** instead of failing later in the browser |
| R10 | **"We should verify the device" creeps back in as a requirement** | §2.3 records the measurements and the two conditions that would justify revisiting; a functional test asserts a zero-AAGUID payload is handled deliberately, so any change is a visible, reviewed diff |
---
## 11. Remaining open questions
D1–D5 removed most of the first draft's 22 questions. These are what is left;
each has a proposal, so "yes" is a valid answer.
**Q1.1 — Version target.** `CHANGELOG.md`'s `[Unreleased]` heading still says
v1.1 while git tags reach `v1.3.0`. Target the next minor and repair the heading
in a separate labelled commit? *Proposal: yes.*
**Q1.2 — Where the checkbox appears.** *Proposal: always visible when passkeys
are available (same as the login button), since a user who has just landed on
the auth page is exactly the person most likely to be enrolling a new device.*
**Q1.3 — What if the same device registers twice** (same identity, second
passkey)? *Proposal: allow it — the OS may legitimately create a second
credential, and `excludeCredentials` will let the authenticator dedupe. `Q2.6`
of the first draft (a cap) becomes: cap at a configurable N (default 20).*
**Q2.1 — Re-confirm: TOTP stays?** *Proposal: yes, unchanged, and never
disabled by enabling passkeys.*
**Q3.1 — How to enforce the D1 prerequisite.** Boot-time hard failure when
`PASSKEY_ENABLED=1` without central auth, or log a warning and disable?
*Proposal: **hard failure** at container start (`cache:warmup`) — a silent
disable is how you get "my passkey stopped working" tickets.* **Extended by D4:**
the same boot check also asserts HTTPS, so "enabled but unusable" cannot ship.
The check is on **configuration**, not on the request, because behind a TLS
terminating proxy `isSecure()` is not authoritative (§4.2).
**Q3.2 — Always terminate a ceremony with JSON?** *Proposal: yes — any request
carrying `X-Preauth-Passkey` gets a JSON response, never the HTML login page.*
**Q3.3 — Attestation policy.** **RESOLVED — D5: `none`.** Measured, not assumed:
`direct` cannot be enforced (config D), MDS is bypassable by the zero AAGUIDs that
real passkeys send (config C3), and requiring MDS would reject legitimate new
authenticators (config C2) while adding two dependencies. Full evidence and the
conditions that would reverse it are in **§2.3**. *Set a real value instead*
was considered and rejected on the evidence.
**Q3.4 — Local development over HTTP.** **RESOLVED — D4: not supported.** No
`securedRelyingPartyId` exemption, deprecated or otherwise; local development uses
real TLS with a local certificate (§4.2). `PASSKEY_ALLOWED_ORIGINS` is deleted.
Note `localhost` deliberately cannot satisfy D1, so there is no half-configured
state to document away.
**Q3.5 — Counter checking.** **Resolved during implementation: the proposal was
wrong and was reversed.** The assumption "many passkeys always report 0" was
correct, but the conclusion "so the default is harmless" was not — the default
*rejects* a reported 0 against a stored 0, so the very case it was assumed to
tolerate is the case it fails. Replaced with `PasskeyCounterChecker` (accept
`>=`, reject strictly backwards). See §2.2 C5.
**Q3.6 — Keep the `begin` resource guard?** It is not part of the login budget
(D3 governs that) — it only bounds cache-fill. *Proposal: keep it; it is ~15
lines and mirrors the existing `public_limiter` pattern.*
**Q3.7 — Caching-policy mechanism.** `X-Preauth-Ceremony` marker header consumed
by `SecurityHeadersListener` (keeps cache policy in one place), or set headers
directly in `PasskeyListener`? *Proposal: the marker.*
**Q3.8 — Extract the session-issuing tail from `LoginManager`?** *Proposal: yes,
as its own commit — duplicating cookie/redirect/`Remote-User` logic is how the
two paths drift.*
**Q3.9 — Browser matrix.** Which browsers must be verified by hand on staging
(iOS Safari, Chrome, Firefox, and a hardware key) before release? *Proposal: all
four; note the CSP directive is the most likely divergence.*
---
### Resolved in this round
| Question | Resolution |
|---|---|
| Q3.3 — attestation value | **D5: `none`**, with measurements in §2.3 |
| Q3.4 — dev over HTTP | **D4: real TLS only**; `PASSKEY_ALLOWED_ORIGINS` deleted (§4.2) |
| Q3.1 — boot check scope | extended to assert **D1 *and* D4** |
---
## 12. Rollback
- `PASSKEY_ENABLED=0` (the default) makes the feature inert; reverting is
redeploying the previous image tag. No migrations.
- If passkeys were enabled and are rolled back, credential records remain in
`sessionCache`/filesystem under `passkey_*` keys, unread by the old code.
Sessions continue to work; nothing is invalidated.
- The dependency addition reverts with `composer.lock`.
---
## 13. Notes for the reviewer
- **Round 2 added D4 (HTTPS required, no exemptions) and D5 (attestation stays
`none`, on measured evidence).** D4 is covered in §4.2, D5 in §2.3; the two
questions that drove them are marked resolved in §11.
- Three claims in this revision were **measured, not reasoned**: the attestation
matrix (§2.3), the origin/HTTPS behaviour (§4.2), and the `localhost` × D1
interaction (§4.2). Scripts: `spike_attestation.php`, `spike_att2.php`,
`spike_origin.php`, `spike_devhost.php`.
- Incidentally confirmed while testing: `symfony/http-client` is **not** in the
current install, so MDS would have been a second new dependency, not a drop-in.
- The spike branch (`spike/passkey-deps`) currently carries `composer.json`,
`composer.lock`, `symfony.lock`, `phpunit.dist.xml` and
`config/packages/property_info.yaml` changes. Decide whether step 2 continues
on that branch or starts fresh from `main`.
- The spike scripts themselves were **removed** from the working tree (kept in
`/tmp/spike-backup/` for reference) so they never reach a PR; the reusable
parts are folded into `tests/Support/PasskeyTestHelper.php` in step 9.
- The environment details (PHP 8.5.11 via Sury, Composer, `pcov`) are local to
this container and are not a project change.
---
*End of plan.*
+35 -287
View File
@@ -126,6 +126,30 @@ parameters:
count: 1
path: src/Listener/InterceptListener.php
-
message: '#^Class App\\Listener\\PasskeyListener has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
identifier: property.uninitializedReadonly
count: 1
path: src/Listener/PasskeyListener.php
-
message: '#^Class App\\Listener\\PasskeyListener has an uninitialized readonly property \$nonceCache\. Assign it in the constructor\.$#'
identifier: property.uninitializedReadonly
count: 1
path: src/Listener/PasskeyListener.php
-
message: '#^Readonly property App\\Listener\\PasskeyListener\:\:\$logger is assigned outside of the constructor\.$#'
identifier: property.readOnlyAssignNotInConstructor
count: 2
path: src/Listener/PasskeyListener.php
-
message: '#^Readonly property App\\Listener\\PasskeyListener\:\:\$nonceCache is assigned outside of the constructor\.$#'
identifier: property.readOnlyAssignNotInConstructor
count: 1
path: src/Listener/PasskeyListener.php
-
message: '#^Class App\\Listener\\LoginListener has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
identifier: property.uninitializedReadonly
@@ -349,14 +373,20 @@ parameters:
path: src/Service/LoginManager.php
-
message: '#^Class App\\Service\\LoginManager has an uninitialized readonly property \$nonceCache\. Assign it in the constructor\.$#'
message: '#^Class App\\Service\\SessionIssuer has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
identifier: property.uninitializedReadonly
count: 1
path: src/Service/LoginManager.php
path: src/Service/SessionIssuer.php
-
message: '#^Method App\\Service\\LoginManager\:\:checkToken\(\) overrides method App\\Service\\LoginInterface\:\:checkToken\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
message: '#^Readonly property App\\Service\\SessionIssuer\:\:\$logger is assigned outside of the constructor\.$#'
identifier: property.readOnlyAssignNotInConstructor
count: 1
path: src/Service/SessionIssuer.php
-
message: '#^Class App\\Service\\LoginManager has an uninitialized readonly property \$nonceCache\. Assign it in the constructor\.$#'
identifier: property.uninitializedReadonly
count: 1
path: src/Service/LoginManager.php
@@ -486,276 +516,18 @@ parameters:
count: 1
path: tests/Unit/Command/GenerateBackupCodesCommandTest.php
-
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/ConfigBagRemoteUserTest.php
-
message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertNull\(\) with null will always evaluate to true\.$#'
identifier: staticMethod.alreadyNarrowedType
count: 2
path: tests/Unit/Enum/ScopeTest.php
-
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/AcceptListenerTest.php
-
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/AllowListenerTest.php
-
message: '#^Class Symfony\\Component\\RateLimiter\\Exception\\ReserveNotSupportedException constructor invoked with 0 parameters, 1\-3 required\.$#'
identifier: arguments.count
count: 2
path: tests/Unit/Listener/InterceptListenerTest.php
-
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/InterceptListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/InterceptListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/InterceptListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/InterceptListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/InterceptListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/InterceptListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/InterceptListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:136\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/InterceptListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:57\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/InterceptListenerTest.php
-
message: '#^Class Symfony\\Component\\RateLimiter\\Exception\\ReserveNotSupportedException constructor invoked with 0 parameters, 1\-3 required\.$#'
identifier: arguments.count
count: 2
path: tests/Unit/Listener/LoginListenerTest.php
-
message: '#^Method App\\Tests\\Unit\\Listener\\LoginListenerTest\:\:encodePayload\(\) has parameter \$data with no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: tests/Unit/Listener/LoginListenerTest.php
-
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/LoginListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/LoginListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/LoginListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/LoginListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/LoginListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/LoginListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/LoginListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:136\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/LoginListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:57\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/LoginListenerTest.php
-
message: '#^Class Symfony\\Component\\RateLimiter\\Exception\\ReserveNotSupportedException constructor invoked with 0 parameters, 1\-3 required\.$#'
identifier: arguments.count
count: 2
path: tests/Unit/Listener/PublicAccessListenerTest.php
-
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/PublicAccessListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/PublicAccessListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/PublicAccessListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/PublicAccessListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/PublicAccessListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/PublicAccessListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/PublicAccessListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:136\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/PublicAccessListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:57\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/PublicAccessListenerTest.php
-
message: '#^Class Symfony\\Component\\RateLimiter\\Exception\\ReserveNotSupportedException constructor invoked with 0 parameters, 1\-3 required\.$#'
identifier: arguments.count
count: 2
path: tests/Unit/Listener/RejectListenerTest.php
-
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/RejectListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/RejectListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/RejectListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/RejectListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/RejectListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/RejectListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/RejectListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:136\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/RejectListenerTest.php
-
message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:57\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/RejectListenerTest.php
-
message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertIsString\(\) with string will always evaluate to true\.$#'
identifier: staticMethod.alreadyNarrowedType
@@ -768,16 +540,10 @@ parameters:
count: 1
path: tests/Unit/Service/BackupCodeManagerTest.php
-
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Service/BackupCodeManagerTest.php
-
message: '#^Call to an undefined method App\\Service\\BackupCodeInterface\:\:method\(\)\.$#'
identifier: method.notFound
count: 17
count: 20
path: tests/Unit/Service/LoginManagerTest.php
-
@@ -798,24 +564,12 @@ parameters:
count: 1
path: tests/Unit/Service/LoginManagerTest.php
-
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Service/LoginManagerTest.php
-
message: '#^Class class@anonymous/tests/Unit/Trait/GetTotpTraitTest\.php\:22 has an uninitialized readonly property \$config\. Assign it in the constructor\.$#'
identifier: property.uninitializedReadonly
count: 1
path: tests/Unit/Trait/GetTotpTraitTest.php
-
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Trait/GetTotpTraitTest.php
-
message: '#^Readonly property class@anonymous/tests/Unit/Trait/GetTotpTraitTest\.php\:22\:\:\$config is assigned outside of the constructor\.$#'
identifier: property.readOnlyAssignNotInConstructor
@@ -954,12 +708,6 @@ parameters:
count: 1
path: tests/Unit/Trait/MakeNonceTraitTest.php
-
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Trait/StringTraitTest.php
-
message: '#^Call to function method_exists\(\) with ''Symfony\\\\Component\\\\Dotenv\\\\Dotenv'' and ''bootEnv'' will always evaluate to false\.$#'
identifier: function.impossibleType
+2
View File
@@ -46,6 +46,8 @@
</include>
<deprecationTrigger>
<method>Doctrine\Deprecations\Deprecation::trigger</method>
<method>Doctrine\Deprecations\Deprecation::delegateTriggerToBackend</method>
<function>trigger_deprecation</function>
</deprecationTrigger>
</source>
+68 -3
View File
@@ -152,6 +152,56 @@ Rate limiting **cannot be disabled**. It uses a compound sliding window:
| `UPPER_COUNT` | `10` | Max attempts per upper window. |
| `UPPER_TIME` | `3600` | Upper window in seconds (1 hour). |
### Passkey Authentication
Passkeys (WebAuthn) can replace the TOTP code for everyday logins, while the
code remains the way a new device is enrolled.
**Two prerequisites, both enforced.** The feature refuses to operate without
them rather than degrading quietly:
1. **Central auth must be configured** (`SUBDOMAIN_REDIRECT=true` and a real
`AUTH_SUBDOMAIN`). A passkey is scoped to one relying party, so there has to
be a single shared domain for the whole family of services. Without it,
passkeys are switched off — they would otherwise be scoped to a single host
and confuse users with multiple, unrelated passkeys.
2. **HTTPS is required, in development too.** There is no `http://localhost`
exemption and no setting that re-enables one, because such an exemption is
exactly how the same weakness ends up enabled in production. To exercise
passkeys locally, see the TLS note in `docs/examples/Caddyfile`.
`localhost` therefore cannot be used for passkeys: it has no base domain, so
central auth cannot be configured at all.
| Variable | Default | Description |
|----------|---------|-------------|
| `PASSKEY_ENABLED` | `0` | Master switch. Enabling it without the prerequisites above makes the container fail at startup, rather than offering a feature that cannot work. |
| `PASSKEY_RP_NAME` | `TITLE` | Name shown in the authenticator prompt. |
| `PASSKEY_USER_VERIFICATION` | `required` | `required`, `preferred`, or `discouraged`. An unrecognised value falls back to `required`, never to something weaker. |
| `PASSKEY_TIMEOUT` | `60000` | Ceremony timeout in milliseconds. |
| `PASSKEY_BUTTON_NAME` | `Sign in with a passkey` | Label for the sign-in button. |
| `PASSKEY_REGISTER_NAME` | `Register this device as a passkey` | Label for the registration checkbox. |
| `PASSKEY_BEGIN_BURST_COUNT` | `30` | Ceremonies one caller may start per window. A resource guard, not part of the login budget. |
| `PASSKEY_BEGIN_BURST_TIME` | `60` | Window for the above, in seconds. |
**Registering a device.** Log in with your code as usual, tick *Register this
device as a passkey*, and approve the prompt. The TOTP check in that same
submission is what authorises the registration — there is no separate enrolment
token and no CLI command, so a passkey cannot be created without already holding
a valid code.
**Logging in.** Once registered, *Sign in with a passkey* signs you in with a
fingerprint, face, or device PIN instead of typing a code. Failed passkey
attempts count against the same rate-limit budget as wrong codes, so passkeys
cannot be used to sidestep a lockout, and after the limit is reached *every*
method is blocked equally.
> **On signature counters.** Many passkeys (anything synchronised through a
> keychain) report a constant counter, so a counter-based clone check would lock
> users out of their own credentials. Preauth accepts an unchanged counter and
> rejects only one that moves *backwards*. Clone detection is deliberately not a
> property this feature claims — see `SECURITY.md`.
### Public Rate-Limited Access
Preauth can provide rate-limited unauthenticated access to select public
@@ -235,9 +285,17 @@ passes through a priority-ordered chain of listeners:
3. **PublicAccessListener** (priority 84) — If public paths are configured,
allows rate-limited unauthenticated access to matching paths.
4. **RejectListener** (priority 77) — Rate-limiting gate.
5. **LoginListener** (priority 66) — Processes login attempts.
6. **InterceptListener** (priority 55) — Renders login page or redirects.
7. **SecurityHeadersListener** (response) — Adds security headers.
5. **PasskeyListener** (priority 70) — WebAuthn ceremonies, when enabled.
6. **LoginListener** (priority 66) — Processes login attempts.
7. **InterceptListener** (priority 55) — Renders login page or redirects.
8. **SecurityHeadersListener** (response) — Adds security headers.
`PasskeyListener` sits deliberately between the rate-limit gate and the login
handler: **after** `RejectListener`, so a blocked IP never reaches a ceremony;
and **before** `LoginListener`, because that listener treats any POST to the auth
subdomain as a login attempt, and a ceremony request carries no code — it would
otherwise be counted as a failed login and burn rate-limit budget on every
legitimate passkey sign-in.
### Security Model
@@ -256,6 +314,13 @@ passes through a priority-ordered chain of listeners:
Successful (2xx) responses are deliberately excluded — they are
consumed by the proxy's `forward_auth` check and never reach the
browser, so a protected service's own caching is not affected.
Passkey ceremony replies are the one exception: they are the only 2xx
this application returns straight to a browser, so they carry the same
anti-caching headers.
- **Passkeys**: registerable only after a valid TOTP code; challenge is
server-issued and single-use; RP ID is always the base domain; only the
derived `https://` origin is ever accepted; a failed attempt is
indistinguishable from a wrong code and shares its rate-limit budget.
### Cache
+14
View File
@@ -22,4 +22,18 @@ final class AppConstants
* Also used for cache key truncation.
*/
public const int MAX_INPUT_LENGTH = 128;
/**
* Marks a response as WebAuthn ceremony output.
*
* A ceremony reply is the only 2xx this application returns straight to a
* browser — every other 2xx is consumed by the reverse proxy's forward_auth
* check. So it is the only one that needs the no-store treatment, and this
* marker is how `SecurityHeadersListener` recognises it without the caching
* policy being duplicated at each site that produces one.
*
* It lives here rather than on a listener because both `PasskeyListener`
* (finish) and `LoginManager` (the registration hand-off) produce them.
*/
public const string PASSKEY_CEREMONY_MARKER = 'X-Preauth-Ceremony';
}
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\CacheWarmer;
use App\Exception\PasskeyConfigurationException;
use App\Service\PasskeyPolicyInterface;
use Override;
use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface;
/**
* Fails the build (or container start) when passkeys are enabled in a
* configuration that cannot support them.
*
* `docker/entrypoint.sh` runs `cache:warmup` on every production boot with the
* real environment already injected, so a misconfiguration is caught while the
* container is starting — the deployment aborts — rather than surfacing later as
* a passkey button that silently never works.
*
* The warmer is **not** optional: an optional warmer may be skipped, which would
* let a bad configuration through.
*/
final readonly class PasskeyConfigurationWarmer implements CacheWarmerInterface
{
public function __construct(
private PasskeyPolicyInterface $passkeyPolicy,
) {
}
/**
* @return string[]
*
* @throws PasskeyConfigurationException when passkeys are enabled but unusable
*/
#[Override]
public function warmUp(string $cacheDir, ?string $buildDir = null): array
{
$this->passkeyPolicy->assertConfigurationIsUsable();
return [];
}
/**
* Never optional: skipping this warmer would defeat its entire purpose.
*/
#[Override]
public function isOptional(): bool
{
return false;
}
}
+72
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App;
use App\Enum\RemoteUserMode;
use App\Enum\UserVerification;
use Psr\Cache\InvalidArgumentException;
use Psr\Clock\ClockInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
@@ -23,6 +24,16 @@ final readonly class ConfigBag
private string $remoteUserStatic;
/** @var array<string,string> */
private array $remoteUserMap;
private string $title;
private bool $passkeyEnabled;
private string $passkeyRpName;
private UserVerification $passkeyUserVerification;
private int $passkeyTimeout;
private string $passkeyButtonName;
private string $passkeyRegisterName;
/** Passkey ceremony timeout in milliseconds (WebAuthn default). */
private const int DEFAULT_PASSKEY_TIMEOUT = 60000;
/** @throws InvalidArgumentException */
public function __construct(
@@ -38,6 +49,13 @@ final readonly class ConfigBag
#[Autowire('%app.remote_user%')] string $remoteUserMode,
#[Autowire('%app.remote_user_static%')] string $remoteUserStatic,
#[Autowire('%app.remote_user_map%')] string $remoteUserMap,
#[Autowire('%app.title%')] string $title = 'Pre-Authentication System',
#[Autowire('%app.passkey_enabled%')] bool $passkeyEnabled = false,
#[Autowire('%app.passkey_rp_name%')] string $passkeyRpName = '',
#[Autowire('%app.passkey_user_verification%')] string $passkeyUserVerification = 'required',
#[Autowire('%app.passkey_timeout%')] int $passkeyTimeout = self::DEFAULT_PASSKEY_TIMEOUT,
#[Autowire('%app.passkey_button_name%')] string $passkeyButtonName = 'Sign in with a passkey',
#[Autowire('%app.passkey_register_name%')] string $passkeyRegisterName = 'Register this device as a passkey',
) {
$this->clock = $clock;
$this->cookieTtl = $cookieTtl;
@@ -51,6 +69,13 @@ final readonly class ConfigBag
$this->remoteUserMode = RemoteUserMode::tryFrom($remoteUserMode) ?? RemoteUserMode::Session;
$this->remoteUserStatic = $remoteUserStatic;
$this->remoteUserMap = $this->parseUserMap($remoteUserMap);
$this->title = $title;
$this->passkeyEnabled = $passkeyEnabled;
$this->passkeyRpName = $passkeyRpName;
$this->passkeyUserVerification = UserVerification::fromConfig($passkeyUserVerification);
$this->passkeyTimeout = $passkeyTimeout > 0 ? $passkeyTimeout : self::DEFAULT_PASSKEY_TIMEOUT;
$this->passkeyButtonName = $passkeyButtonName;
$this->passkeyRegisterName = $passkeyRegisterName;
}
/**
@@ -132,4 +157,51 @@ final readonly class ConfigBag
{
return $this->remoteUserMap;
}
public function title(): string
{
return $this->title;
}
/**
* Whether the passkey feature is switched on by configuration.
*
* This says nothing about whether the configuration is *usable* — that is
* {@see Service\PasskeyPolicyInterface::isEnabled()}, which also
* requires the central-auth prerequisite (D1).
*/
public function passkeyEnabled(): bool
{
return $this->passkeyEnabled;
}
/** Relying-party name shown in the authenticator prompt; blank falls back to the title. */
public function passkeyRpName(): string
{
return $this->passkeyRpName;
}
/** User-verification requirement; an unrecognised value falls back to `required`. */
public function passkeyUserVerification(): string
{
return $this->passkeyUserVerification->value;
}
/** Ceremony timeout in milliseconds. */
public function passkeyTimeout(): int
{
return $this->passkeyTimeout;
}
/** Label for the "sign in with a passkey" button. */
public function passkeyButtonName(): string
{
return $this->passkeyButtonName;
}
/** Label for the "register this device" checkbox. */
public function passkeyRegisterName(): string
{
return $this->passkeyRegisterName;
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Data;
use DateTimeImmutable;
use Webauthn\CredentialRecord;
/**
* A stored passkey: the WebAuthn credential plus the application metadata that
* ties it to a visitor of this gateway.
*
* The credential itself carries the public key and signature counter; the
* metadata here records *whose* passkey it is and when it was used. The identity
* is deliberately read from this record on assertion rather than from the value
* the client returns, which is never trusted.
*/
final readonly class PasskeyCredential
{
public function __construct(
public CredentialRecord $record,
/** The session id this passkey authenticates, as typed at registration. */
public string $identity,
/** Operator-facing description, shown so a user can tell their keys apart. */
public string $label,
public DateTimeImmutable $createdAt,
public ?DateTimeImmutable $lastUsedAt = null,
) {
}
/**
* A copy of this credential with the signature counter and last-used time
* refreshed after a successful assertion.
*
* The counter is only ever observed, never enforced — many passkeys report a
* constant zero, so treating it as a clone signal would lock users out.
*/
public function withUsage(CredentialRecord $updated, DateTimeImmutable $usedAt): self
{
return new self(
$updated,
$this->identity,
$this->label,
$this->createdAt,
$usedAt,
);
}
}
+13
View File
@@ -17,6 +17,17 @@ final class Payload
public bool $json; /* should we return json (for the login page) */
public Scope $scope; /* type of access being requested */
/**
* The caller ticked "register this device as a passkey".
*
* Carried on the payload rather than handled by the listener, because the
* TOTP check is what authorises registration — so the intent has to reach
* `LoginManager`, which is where that check (and the nonce check) already
* happens. Starting a ceremony any earlier would move nonce validation and
* risk spending it twice.
*/
public bool $register = false;
public static function decode(string $base64url): ?self
{
/* convert the base64url into json string */
@@ -42,6 +53,7 @@ final class Payload
'id' => $input->get('username'),
'nonce' => $input->get('nonce'),
'token' => $input->get('totp'),
'register' => $input->get('register'),
'json' => false,
]);
}
@@ -65,6 +77,7 @@ final class Payload
$payload->id = mb_substr(trim($data->id), 0, AppConstants::MAX_INPUT_LENGTH);
$payload->nonce = mb_substr(trim($data->nonce), 0, AppConstants::MAX_INPUT_LENGTH);
$payload->json = ($data->json ?? true);
$payload->register = (bool) ($data->register ?? false);
$payload->scope = Scope::tryFrom($data->scope ?? '') ?? Scope::Cookie;
$payload->token = mb_substr(trim($data->token), 0, AppConstants::MAX_INPUT_LENGTH);
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Enum;
/**
* User-verification requirement passed to the authenticator at passkey time.
*
* Mirrors the WebAuthn `userVerification` option without leaking the library's
* constants into application configuration.
*/
enum UserVerification: string
{
/** Require a biometric/PIN check (default). */
case Required = 'required';
/** Ask for it, but allow a plain user-presence tap to succeed. */
case Preferred = 'preferred';
/** Never prompt for verification; presence alone is enough. */
case Discouraged = 'discouraged';
/**
* Parse a configured value, falling back to the safest option.
*
* An unrecognised value must never silently weaken the requirement, so the
* fallback is the strictest case rather than the most permissive one.
*/
public static function fromConfig(string $value): self
{
return self::tryFrom(trim($value)) ?? self::Required;
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Exception;
use RuntimeException;
/**
* Thrown when passkeys are enabled in a configuration that cannot support them.
*
* This is deliberately fatal: the alternative is a feature that appears to be
* switched on but cannot complete a single ceremony, which surfaces to the user
* as "my passkey stopped working" rather than as a deployment error.
*
* Raised during cache warm-up so that a misconfigured container fails to start
* instead of failing later, in a browser.
*/
final class PasskeyConfigurationException extends RuntimeException
{
}
+6
View File
@@ -6,6 +6,7 @@ namespace App\Listener;
use App\ConfigBag;
use App\Service\DomainInterface;
use App\Service\PasskeyPolicyInterface;
use App\Trait\CookieNameTrait;
use App\Trait\HasLoggerTrait;
use App\Trait\MakeNonceTrait;
@@ -29,6 +30,7 @@ final readonly class InterceptListener
private ConfigBag $config,
private DomainInterface $domainManager,
private Environment $twig,
private PasskeyPolicyInterface $passkeyPolicy,
) {
}
@@ -54,6 +56,10 @@ final readonly class InterceptListener
$content = $this->twig->render('login.html.twig', [
'nonce' => $this->makeNonce(),
'post' => $this->domainManager->getAuthSubdomain() === $event->getRequest()->getHost(),
/* only offered when the feature is usable *for this request* — the
* same computation that decides whether the ceremony endpoints
* will answer, so the UI cannot offer what the server refuses */
'passkeys' => $this->passkeyPolicy->isAvailableFor($event->getRequest()),
]);
$hasCookie = (bool) $event->getRequest()->cookies->get(
$this->sessionCookieName($this->domainManager),
+5 -1
View File
@@ -8,6 +8,7 @@ use App\ConfigBag;
use App\Data\Payload;
use App\Service\DomainInterface;
use App\Service\LoginInterface;
use App\Service\PasskeyPolicyInterface;
use App\Trait\CookieNameTrait;
use App\Trait\HasLoggerTrait;
use App\Trait\MakeNonceTrait;
@@ -48,6 +49,7 @@ final readonly class LoginListener
private DomainInterface $domainManager,
private LoginInterface $loginManager,
private ConfigBag $config,
private PasskeyPolicyInterface $passkeyPolicy,
) {
$this->rateLimiter = $rateLimiter;
}
@@ -94,6 +96,7 @@ final readonly class LoginListener
$payload?->json ?? true,
$event->getRequest()->getHost(),
$this->makeCacheKey($payload?->id ?? ''),
$event->getRequest(),
));
}
@@ -105,7 +108,7 @@ final readonly class LoginListener
}
/** @throws InvalidArgumentException|RuntimeError|SyntaxError|LoaderError */
private function makeFailedResponse(bool $limited, bool $json, string $host, string $username): Response
private function makeFailedResponse(bool $limited, bool $json, string $host, string $username, Request $request): Response
{
if ($limited) {
$status = $this->config->teapot() ? Response::HTTP_I_AM_A_TEAPOT
@@ -121,6 +124,7 @@ final readonly class LoginListener
'nonce' => $this->makeNonce(),
'post' => $this->domainManager->getAuthSubdomain() === $host,
'username' => $username,
'passkeys' => $this->passkeyPolicy->isAvailableFor($request),
];
if ($json) {
+278
View File
@@ -0,0 +1,278 @@
<?php
declare(strict_types=1);
namespace App\Listener;
use App\AppConstants;
use App\ConfigBag;
use App\Enum\Scope;
use App\Service\DomainInterface;
use App\Service\PasskeyInterface;
use App\Service\PasskeyPolicyInterface;
use App\Service\SessionIssuerInterface;
use App\Trait\HasLoggerTrait;
use App\Trait\MakeNonceTrait;
use App\Trait\StringTrait;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
/**
* Serves the passkey ceremonies.
*
* **Priority 70 — the whole design turns on this number.**
*
* - *After* `RejectListener` (77), so a rate-limited IP never reaches this code.
* Passkeys cannot be used to sidestep a lockout; that is decision D3.
* - *Before* `LoginListener` (66), which is essential rather than tidy:
* `LoginListener` treats **any** POST to the auth subdomain as a login attempt,
* and 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.
*
* **Three operations, not four.** `register-begin` is deliberately *absent*: a
* registration ceremony may only be started after a valid TOTP code, which is
* presented to `LoginManager` as part of the form submission. `LoginManager`
* therefore starts that ceremony and returns its options with the login
* response. Exposing `register-begin` here would be a way to obtain a challenge
* **without proving anything**, which is the vulnerability rather than the
* feature — there is no session cookie to check at that point either, since the
* whole flow is what *produces* the session.
*
* **Every** request carrying the dispatch header gets a response, including
* malformed ones. Falling through would let `InterceptListener` render HTML to a
* `fetch()` caller.
*/
final readonly class PasskeyListener
{
use HasLoggerTrait;
use MakeNonceTrait;
use StringTrait;
/**
* Marks a request as a ceremony call, and its value selects the operation.
*
* A distinct header rather than overloading `X-Preauth`: that one carries a
* base64url `Payload` and is parsed as such.
*/
public const string HEADER = 'X-Preauth-Passkey';
public const string BEGIN_LOGIN = 'login-begin';
public const string FINISH_LOGIN = 'login-finish';
public const string FINISH_REGISTER = 'register-finish';
private RateLimiterFactoryInterface $beginLimiter;
private RateLimiterFactoryInterface $loginLimiter;
public function __construct(
#[Target('passkey_begin_burst')] RateLimiterFactoryInterface $beginLimiter,
#[Target('login_limiter')] RateLimiterFactoryInterface $loginLimiter,
private PasskeyInterface $passkeys,
private PasskeyPolicyInterface $policy,
private SessionIssuerInterface $sessionIssuer,
private DomainInterface $domainManager,
private ConfigBag $config,
) {
$this->beginLimiter = $beginLimiter;
$this->loginLimiter = $loginLimiter;
}
/**
* @throws InvalidArgumentException
*/
#[AsEventListener(priority: 70)]
public function onKernelRequest(RequestEvent $event): void
{
$request = $event->getRequest();
$operation = $request->headers->get(self::HEADER);
if (null === $operation) {
return;
}
/* Ceremonies exist only on the auth subdomain. Elsewhere the header is
* ignored entirely, so this listener cannot be used to probe other hosts. */
if ($this->domainManager->getAuthSubdomain() !== $request->getHost()) {
return;
}
$event->setResponse($this->dispatch($operation, $request));
}
private function dispatch(string $operation, Request $request): Response
{
/* not available => behave as if the feature does not exist */
if (!$this->policy->isAvailableFor($request)) {
return $this->ceremonyResponse($this->error('Passkeys are not available.', $request));
}
return $this->ceremonyResponse(match ($operation) {
self::BEGIN_LOGIN => $this->beginLogin($request),
self::FINISH_LOGIN => $this->finishLogin($request),
self::FINISH_REGISTER => $this->finishRegistration($request),
default => $this->error('Unknown passkey operation.', $request),
});
}
private function beginLogin(Request $request): Response
{
if ($limit = $this->beginBurstExceeded($request)) {
return $limit;
}
return $this->json($this->passkeys->beginLogin());
}
private function finishLogin(Request $request): Response
{
$credential = $this->passkeys->finishLogin($this->body($request));
if (null === $credential) {
/* A failed ceremony consumes the same budget as a wrong TOTP code
* (D3), so passkey guesses cannot outpace code guesses. */
return $this->failure($request);
}
$this->logger->debug("passkey login succeeded for: {$credential->identity}");
return $this->sessionIssuer->issue($credential->identity, Scope::Cookie, $request, true);
}
/**
* The ceremony was authorised by the TOTP-gated hand-off in `LoginManager`,
* so the capability here is the single-use `ceremonyId` itself: it is
* server-issued, stored against the identity that passed the check, and
* consumed on use.
*/
private function finishRegistration(Request $request): Response
{
$credential = $this->passkeys->finishRegistration($this->body($request));
if (null === $credential) {
return $this->failure($request);
}
$this->logger->debug("passkey registered for: {$credential->identity}");
return $this->sessionIssuer->issue($credential->identity, Scope::Cookie, $request, true);
}
/**
* Bounds how many ceremonies one caller can start.
*
* A resource guard, not the login budget: a legitimate `begin` must not spend
* failure budget, but an unbounded `begin` could fill the ceremony cache.
*/
private function beginBurstExceeded(Request $request): ?Response
{
$limit = $this->beginLimiter->create((string) $request->getClientIp())->consume(1);
if ($limit->isAccepted()) {
return null;
}
$retryAfter = max(1, $limit->getRetryAfter()->getTimestamp() - time());
$this->logger->debug("passkey begin rate-limited: {$request->getClientIp()}");
$response = $this->error('Too many passkey attempts, please slow down.', $request);
$response->setStatusCode(Response::HTTP_TOO_MANY_REQUESTS);
$response->headers->set('Retry-After', (string) $retryAfter);
return $response;
}
/**
* A failed ceremony is indistinguishable from a wrong TOTP code, and spends
* the same shared budget — including the same 418/429 outcome when exhausted.
*/
private function failure(Request $request): Response
{
$limited = $this->loginLimiter
->create((string) $request->getClientIp())
->consume(1)
->getRemainingTokens() < 1;
$this->logger->debug("passkey ceremony failed for: {$request->getClientIp()}");
if ($limited) {
$response = $this->error(
$this->config->teapot() ? $this->config->teapotTitle() : $this->config->tooManyTitle(),
$request,
);
$response->setStatusCode(
$this->config->teapot() ? Response::HTTP_I_AM_A_TEAPOT : Response::HTTP_TOO_MANY_REQUESTS,
);
return $response;
}
$response = $this->error($this->config->errorMessage(), $request);
$response->setStatusCode(Response::HTTP_UNAUTHORIZED);
return $response;
}
/**
* Mark a reply as ceremony output so the no-store policy can find it.
*
* These are the only browser-facing 2xx responses this application produces,
* and `SecurityHeadersListener` otherwise assumes any 2xx is consumed by
* `forward_auth` and leaves it cacheable.
*/
private function ceremonyResponse(Response $response): Response
{
$response->headers->set(AppConstants::PASSKEY_CEREMONY_MARKER, '1');
$response->headers->set('Content-Type', 'application/json');
return $response;
}
/**
* A JSON error carrying a fresh nonce and the same shape the login page
* expects, so the caller can fall back to the TOTP form without a reload.
*
* @throws InvalidArgumentException
*/
private function error(string $message, Request $request): Response
{
return new Response(
(string) json_encode([
'message' => $message,
'nonce' => $this->makeNonce(),
'post' => $this->domainManager->getAuthSubdomain() === $request->getHost(),
'username' => '',
]),
Response::HTTP_UNAUTHORIZED,
);
}
/**
* @param array<string,mixed> $payload
*/
private function json(array $payload): Response
{
return new Response((string) json_encode($payload), Response::HTTP_OK);
}
/**
* @return array<string,mixed>
*/
private function body(Request $request): array
{
$raw = $request->getContent();
if ('' === $raw) {
return [];
}
$decoded = json_decode($raw, true);
return \is_array($decoded) ? $decoded : [];
}
}
+36 -7
View File
@@ -4,9 +4,12 @@ declare(strict_types=1);
namespace App\Listener;
use App\AppConstants;
use App\Service\DomainInterface;
use App\Service\PasskeyPolicyInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
/**
@@ -18,6 +21,7 @@ final readonly class SecurityHeadersListener
{
public function __construct(
private DomainInterface $domainManager,
private PasskeyPolicyInterface $passkeyPolicy,
) {
}
@@ -55,7 +59,14 @@ final readonly class SecurityHeadersListener
$inlineScript = $this->domainManager->getAuthSubdomain() !== $event->getRequest()->getHost();
$csp = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';";
if ($inlineScript) {
/* `publickey-credentials-get`/`-create` do NOT fall back to default-src,
* so without these directives the browser refuses the ceremony even
* though the script itself is allowed to run. `connect-src 'self'` is
* needed in both modes here, because the passkey flow always talks to
* the server with fetch(). */
if ($this->passkeyPolicy->isAvailableFor($event->getRequest())) {
$csp .= " connect-src 'self'; publickey-credentials-get 'self'; publickey-credentials-create 'self';";
} elseif ($inlineScript) {
$csp .= " connect-src 'self';";
}
@@ -75,13 +86,31 @@ final readonly class SecurityHeadersListener
* access ("already authenticated" or public) are consumed by the
* reverse proxy's forward_auth check before reaching the browser,
* and the protected service's own cache headers must remain
* untouched. */
* untouched.
*
* A ceremony reply is the exception that proves the rule: it is a 2xx
* that goes straight to the browser, because the auth subdomain has no
* forward_auth in front of it. Left alone it would be cacheable, so a
* browser could replay a stale challenge. `PasskeyListener` marks those
* responses and the marker is stripped here. */
if (!$response->isSuccessful()) {
$headers->set('Cache-Control', 'no-cache, no-store, must-revalidate, proxy-revalidate, max-age=0, s-maxage=0');
$headers->set('Pragma', 'no-cache');
$headers->set('Expires', '0');
$headers->set('Surrogate-Control', 'no-store');
$headers->set('Vary', '*');
$this->applyNoStore($headers);
return;
}
if ($headers->has(AppConstants::PASSKEY_CEREMONY_MARKER)) {
$headers->remove(AppConstants::PASSKEY_CEREMONY_MARKER);
$this->applyNoStore($headers);
}
}
private function applyNoStore(ResponseHeaderBag $headers): void
{
$headers->set('Cache-Control', 'no-cache, no-store, must-revalidate, proxy-revalidate, max-age=0, s-maxage=0');
$headers->set('Pragma', 'no-cache');
$headers->set('Expires', '0');
$headers->set('Surrogate-Control', 'no-store');
$headers->set('Vary', '*');
}
}
+80 -102
View File
@@ -4,41 +4,49 @@ declare(strict_types=1);
namespace App\Service;
use App\AppConstants;
use App\Data\Payload;
use App\Enum\Scope;
use App\MonitorCacheKeys;
use App\Trait\CookieNameTrait;
use App\Trait\GetTotpTrait;
use App\Trait\MakeNonceTrait;
use App\Trait\StringTrait;
use Psr\Cache\CacheItemPoolInterface;
use Override;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\Uid\Ulid;
use Throwable;
/**
* Authenticates a TOTP code (or backup code) and, on success, either grants
* access or starts a passkey registration.
*
* The "grant access" half lives in {@see SessionIssuer} so the passkey ceremony
* produces an identical response. This class keeps the part genuinely specific
* to code-based login: verifying the code and enforcing the single-use nonce.
*
* **Why the registration hand-off lives here.** Ticking "register this device"
* turns the form submission into a registration ceremony, and the TOTP check is
* what authorises it. That check — and the nonce check — already happen here, so
* a ceremony started anywhere earlier would mean validating the nonce somewhere
* new and risking spending it twice.
*/
final readonly class LoginManager implements LoginInterface
{
use CookieNameTrait;
use GetTotpTrait;
use MakeNonceTrait;
use StringTrait;
private CacheItemPoolInterface $sessionCache;
/** @throws InvalidArgumentException */
public function __construct(
#[Target('sessionCache')] CacheItemPoolInterface $sessionCache,
private BackupCodeInterface $backupCodeManager,
private DomainInterface $domainManager,
private SessionIssuerInterface $sessionIssuer,
private PasskeyInterface $passkeys,
) {
$this->sessionCache = new MonitorCacheKeys($sessionCache);
}
/** @throws InvalidArgumentException */
/**
* @throws InvalidArgumentException
*/
#[Override]
public function checkToken(Payload $payload, Request $request): ?Response
{
/* when scope is IP but ip-access is disabled, scope is to be considered cookie */
@@ -47,103 +55,73 @@ final readonly class LoginManager implements LoginInterface
$payload->scope = Scope::Cookie;
}
if ($this->getTotp()->verify($payload->token, null, 1)
|| $this->backupCodeManager->verifyAndConsume($payload->token)
if (!$this->getTotp()->verify($payload->token, null, 1)
&& !$this->backupCodeManager->verifyAndConsume($payload->token)
) {
/* token is correct (TOTP or Backup) */
/* if server nonce is found and is valid */
$nonceItem = $this->nonceCache->getItem($this->makeCacheKey($payload->nonce));
if ($nonceItem->isHit() && $nonceItem->get()) {
/* mark nonce as spent */
$nonceItem->set(false); /* invalid */
$nonceItem->expiresAfter(self::NONCE_TTL); /* keep briefly */
$this->nonceCache->save($nonceItem);
/* token authentication successful, grant access and set response */
$cleanId = $this->makeCacheKey($payload->id);
/* if they just want this one page, return ok, to grant them access */
$response = $this->authSuccessResponse($cleanId, $this->config);
if (Scope::None !== $payload->scope) {
/* grant access based on the requested scope */
if (Scope::Cookie === $payload->scope) {
$response->headers->setCookie($this->setCookie($cleanId, $request->getHost()));
} elseif (Scope::Ip === $payload->scope) {
$this->setIp($cleanId, $request->getClientIp());
}
if ($payload->json) {
$contentType = 'application/json';
$content = json_encode([
'message' => 'Login successful',
'nonce' => null,
]);
} else {
$contentType = 'text/html';
$content = "hi $cleanId, please reload";
}
$location = $request->query->has('return')
&& $this->domainManager->validReturn($request->query->get('return')) ?
"{$request->query->get('return')}" :
"{$request->getPathInfo()}{$request->getQueryString()}";
/* force redirect to use GET method (important when using central auth) */
$response->setContent($content)
->setStatusCode(Response::HTTP_SEE_OTHER)
->headers->set('Location', $location);
$response->headers->set('Content-Type', $contentType);
}
$this->logger->debug("successful login for: $cleanId");
return $response;
}
return null;
}
return null;
}
/* token is correct (TOTP or Backup) */
/** @throws InvalidArgumentException */
private function setCookie(string $id, string $host): Cookie
{
/* successful auth with token, store session and set the cookie */
$ulid = new Ulid();
$sessionCookie = $this->sessionCache->getItem(
$this->makeCacheKey("cookie_$ulid"),
);
if ($sessionCookie->isHit()) {
/* it is supposed to be impossible to have collisions */
$this->logger->error('aborting: ULID collision');
throw new HttpException(Response::HTTP_INTERNAL_SERVER_ERROR, 'Internal Server Error');
/* if server nonce is found and is valid */
$nonceItem = $this->nonceCache->getItem($this->makeCacheKey($payload->nonce));
if (!$nonceItem->isHit() || !$nonceItem->get()) {
return null;
}
$sessionCookie->set($id);
$sessionCookie->expiresAfter($this->config->cookieTtl());
$this->sessionCache->save($sessionCookie);
return Cookie::create(
name: $this->sessionCookieName($this->domainManager),
value: $ulid->toString(),
expire: time() + $this->config->cookieTtl(),
path: '/',
domain: $this->sessionCookieDomain($this->domainManager, $host),
secure: true,
httpOnly: true,
sameSite: Cookie::SAMESITE_STRICT,
/* mark nonce as spent */
$nonceItem->set(false); /* invalid */
$nonceItem->expiresAfter(self::NONCE_TTL); /* keep briefly */
$this->nonceCache->save($nonceItem);
/* the code and the nonce are both good from here on */
if ($payload->register && $payload->json) {
return $this->startRegistration($payload);
}
return $this->sessionIssuer->issue(
$payload->id,
$payload->scope,
$request,
$payload->json,
);
}
/** @throws InvalidArgumentException */
private function setIp(string $id, string $ip): void
/**
* The registration hand-off: authorisation is already proven, so this issues
* the ceremony options back to the page instead of a session.
*
* `SessionIssuer` is deliberately not involved — the session is granted only
* once the new credential has actually been verified, at `register-finish`.
*
* **JSON only.** The checkbox is submitted through the same `X-Preauth` AJAX
* path as an ordinary login, so a failed attempt comes back as JSON carrying
* a fresh nonce. On the plain form-post path it would be HTML, the script's
* `response.json()` would throw, and — worse — the fresh nonce would be lost,
* so the user's retry would fail against a nonce that had already been spent.
* A non-JSON submission is therefore treated as an ordinary login; WebAuthn
* needs scripting regardless, so it is the checkbox that is the enhancement
* here, not the underlying login.
*/
private function startRegistration(Payload $payload): ?Response
{
/* successful auth with token, requested scope of ip (and ip access enabled) */
$ipKey = $this->makeCacheKey("ip_$ip");
try {
$payloadOut = $this->passkeys->beginRegistration($payload->id);
} catch (Throwable) {
/* a ceremony that cannot start must not become a 500 on the login
* page; falling through to the caller's failure path is the same
* treatment a wrong code gets */
return null;
}
$sessionIp = $this->sessionCache->getItem($ipKey);
$sessionIp->set($id);
$sessionIp->expiresAfter($this->config->ipTtl());
$this->sessionCache->save($sessionIp);
$response = new Response(
(string) json_encode(['register' => $payloadOut]),
Response::HTTP_OK,
['Content-Type' => 'application/json'],
);
$response->headers->set(AppConstants::PASSKEY_CEREMONY_MARKER, '1');
return $response;
}
}
+177
View File
@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Cose\Algorithm\Manager;
use Cose\Algorithm\Signature\ECDSA\ES256;
use RuntimeException;
use Symfony\Component\Serializer\Serializer;
use Throwable;
use Webauthn\AttestationStatement\AttestationStatementSupportManager;
use Webauthn\AttestationStatement\NoneAttestationStatementSupport;
use Webauthn\AuthenticatorAssertionResponseValidator;
use Webauthn\AuthenticatorAttestationResponseValidator;
use Webauthn\CeremonyStep\CeremonyStepManagerFactory;
use Webauthn\CredentialRecord;
use Webauthn\Denormalizer\WebauthnSerializerFactory;
use Webauthn\Exception\InvalidDataException;
use Webauthn\PublicKeyCredentialCreationOptions;
use Webauthn\PublicKeyCredentialRequestOptions;
/**
* Builds the WebAuthn collaborators, and is the one place that knows how they
* are wired.
*
* Everything that touches webauthn-lib types goes through here, so a library
* major version that renames or moves those types is a single-file change
* instead of a hunt through the codebase.
*
* **Attestation is deliberately `none`.** The alternatives were measured and
* rejected: attestation conveyance is only a preference a client may ignore,
* and the FIDO metadata service is bypassed both by the zero AAGUID that
* privacy-preserving passkeys already send and by self attestation — while
* still refusing legitimate authenticators that postdate its cached blob. See
* `docs/passkey-auth-subdomain-plan.md` §2.3 for the evidence, and `SECURITY.md`
* for the conditions that would justify revisiting it.
*/
final readonly class PasskeyCeremonyFactory
{
private AttestationStatementSupportManager $attestationStatementSupportManager;
private PasskeyCounterChecker $counterChecker;
public function __construct()
{
$manager = AttestationStatementSupportManager::create();
$manager->add(NoneAttestationStatementSupport::create());
$this->attestationStatementSupportManager = $manager;
$this->counterChecker = new PasskeyCounterChecker();
}
/**
* The wire form of the ceremony options, ready to JSON-encode for the client.
*
* Goes through the serializer rather than `json_encode()`, because the
* challenge is raw binary: `json_encode()` rejects it outright, and the
* serializer base64url-encodes exactly the fields the browser expects. Using
* one path here and another at verification time is how a challenge silently
* stops matching, so both go through this factory.
*
* @return array<string,mixed>
*/
public function optionsAsArray(PublicKeyCredentialCreationOptions|PublicKeyCredentialRequestOptions $options): array
{
return $this->serializer()->normalize($options, 'json');
}
/**
* Validator for the registration ceremony.
*
* The origins are passed in rather than read from a request, so the scheme
* and host can only ever come from configuration. This is what makes D4
* enforceable: an `http://` origin is never presented to the library as
* acceptable, no matter how the request arrived at the container.
*
* @param string[] $allowedOrigins
*/
public function creationCeremonyValidator(array $allowedOrigins): AuthenticatorAttestationResponseValidator
{
return AuthenticatorAttestationResponseValidator::create(
$this->ceremonyStepManagerFactory($allowedOrigins)->creationCeremony(),
);
}
/**
* Validator for the login (assertion) ceremony.
*
* @param string[] $allowedOrigins
*/
public function requestCeremonyValidator(array $allowedOrigins): AuthenticatorAssertionResponseValidator
{
return AuthenticatorAssertionResponseValidator::create(
$this->ceremonyStepManagerFactory($allowedOrigins)->requestCeremony(),
);
}
/**
* The ceremony steps shared by both ceremonies.
*
* `setSecuredRelyingPartyId()` is deliberately never called: it is deprecated
* in 5.2 and, more importantly, it is the escape hatch that would let an
* `http://` origin through. Development uses real TLS instead (D4).
*
* @param string[] $allowedOrigins
*/
private function ceremonyStepManagerFactory(array $allowedOrigins): CeremonyStepManagerFactory
{
$factory = new CeremonyStepManagerFactory();
$factory->setAllowedOrigins($allowedOrigins);
$factory->setAlgorithmManager(Manager::create()->add(ES256::create()));
$factory->setAttestationStatementSupportManager($this->attestationStatementSupportManager);
/* replace the library default, which rejects the constant-zero counter
* that synchronised passkeys report — see PasskeyCounterChecker */
$factory->setCounterChecker($this->counterChecker);
return $factory;
}
/**
* The serializer for every WebAuthn value: credential records, ceremony
* options, and the client's response.
*
* This must be used instead of `json_encode()`. Options carry raw binary
* (the challenge) which `json_encode()` rejects outright, and
* `CredentialRecord` is not `JsonSerializable` at all — the serializer
* base64url-encodes those fields and is required for a correct round-trip.
*
* The concrete `Serializer` is returned because the library's own return
* type (`SerializerInterface`) only declares `serialize()`/`deserialize()`,
* while this class also needs `normalize()`/`denormalize()`.
*/
public function serializer(): Serializer
{
$serializer = (new WebauthnSerializerFactory($this->attestationStatementSupportManager))->create();
/* the library's declared return type is the narrower interface, so this
* narrows it back — failing loudly if a future version ever returns
* something else, rather than erroring at the first ceremony */
if (!$serializer instanceof Serializer) {
throw new RuntimeException('Expected the WebAuthn serializer to be a '.Serializer::class.'.');
}
return $serializer;
}
public function attestationStatementSupportManager(): AttestationStatementSupportManager
{
return $this->attestationStatementSupportManager;
}
/**
* Serialize a credential record for storage.
*
* @throws InvalidDataException
*/
public function serializeCredential(CredentialRecord $record): string
{
return $this->serializer()->serialize($record, 'json');
}
/**
* Rebuild a credential record previously written by {@see serializeCredential()}.
*
* Returns null rather than throwing when the stored payload is unusable: a
* corrupt entry must degrade to "this passkey is unavailable", never to a
* 500 on the login page.
*/
public function deserializeCredential(string $json): ?CredentialRecord
{
try {
return $this->serializer()->deserialize($json, CredentialRecord::class, 'json');
} catch (Throwable) {
return null;
}
}
}
+168
View File
@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Trait\StringTrait;
use DateTimeImmutable;
use Exception;
use Override;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Attribute\Target;
/**
* Short-lived server-side state for an in-flight WebAuthn ceremony.
*
* **The client's copy of the challenge is never trusted.** Starting a ceremony
* issues the challenge *and* a separate opaque `ceremonyId`; only the
* server-side record keyed by that id is authoritative at `finish`. A client
* that swaps in its own challenge is therefore comparing against a value the
* server never issued.
*
* **Single-use.** {@see consume()} deletes the record *before* the caller
* verifies anything, so a failed or replayed `finish` cannot be retried against
* the same challenge.
*
* **Deliberately not persisted.** This uses the `nonceCache` pool, which is APCu
* and stays in memory. A ceremony that does not complete within its TTL *should*
* evaporate — persisting it would only widen the replay window across restarts.
*/
final readonly class PasskeyCeremonyStore implements PasskeyCeremonyStoreInterface
{
use StringTrait;
private const string PREFIX = 'passkey_cer_';
/** Ceremonies live 5 minutes: a user has to interact with a biometric prompt. */
public const int CEREMONY_TTL = 300;
/** 15 bytes fits neatly into a base64url string without padding. */
private const int CEREMONY_ID_BYTES = 15;
/** 32-byte challenge, per WebAuthn's recommendation. */
private const int CHALLENGE_BYTES = 32;
public function __construct(
#[Target('nonceCache')] private CacheItemPoolInterface $nonceCache,
) {
}
/**
* @return array{ceremonyId: string, challenge: string}
*
* @throws Exception
* @throws InvalidArgumentException
*/
#[Override]
public function startLogin(): array
{
/* @var array{ceremonyId: string, challenge: string} */
return $this->store(PasskeyCeremonyStoreInterface::TYPE_LOGIN, [
'challenge' => random_bytes(self::CHALLENGE_BYTES),
]);
}
/**
* @return array{ceremonyId: string, challenge: string, userHandle: string}
*
* @throws Exception
* @throws InvalidArgumentException
*/
#[Override]
public function startRegistration(string $identity): array
{
/* @var array{ceremonyId: string, challenge: string, userHandle: string} */
return $this->store(PasskeyCeremonyStoreInterface::TYPE_REGISTER, [
'challenge' => random_bytes(self::CHALLENGE_BYTES),
'identity' => $identity,
/* Derived from the identity rather than taken from the client, so a
* caller cannot register a credential against an identity it did not
* authenticate as. Fixed length, stable per identity, and it does not
* leak the label into the authenticator. */
'userHandle' => hash('sha256', $identity, true),
]);
}
/**
* Read and destroy a ceremony, returning its record.
*
* Returns null when the id is unknown, expired, already consumed, or was
* started for the other ceremony type. The caller cannot distinguish these
* cases, and does not need to.
*
* @return array{challenge: string, identity?: string, userHandle?: string}|null
*
* @throws InvalidArgumentException
*/
#[Override]
public function consume(string $ceremonyId, string $expectedType): ?array
{
$key = $this->key($ceremonyId);
$item = $this->nonceCache->getItem($key);
if (!$item->isHit()) {
return null;
}
$payload = $item->get();
/* delete before validating: a malformed or replayed record must not be
* usable again even if the checks below reject it */
$this->nonceCache->deleteItem($key);
if (!\is_array($payload)
|| ($payload['type'] ?? null) !== $expectedType
|| !isset($payload['challenge'])
|| !\is_string($payload['challenge'])
) {
return null;
}
/** @var array{challenge: string, identity?: string, userHandle?: string} $payload */
$payload = array_filter(
$payload,
static fn (string $key): bool => \in_array($key, ['challenge', 'identity', 'userHandle'], true),
\ARRAY_FILTER_USE_KEY,
);
return $payload;
}
/**
* @param array<string,string> $payload
*
* @return array<string,string>
*
* @throws Exception
* @throws InvalidArgumentException
*/
private function store(string $type, array $payload): array
{
$ceremonyId = rtrim(strtr(base64_encode(random_bytes(self::CEREMONY_ID_BYTES)), '+/', '-_'), '=');
$item = $this->nonceCache->getItem($this->key($ceremonyId));
$item->set([
'type' => $type,
...$payload,
'createdAt' => new DateTimeImmutable()->format(\DATE_ATOM),
]);
$item->expiresAfter(self::CEREMONY_TTL);
$this->nonceCache->save($item);
return ['ceremonyId' => $ceremonyId, ...$payload];
}
/**
* The ceremony id is hashed rather than passed through `makeCacheKey()`,
* because that sanitises the base64url alphabet into `_` and is therefore
* not injective — two distinct ids could collide on one cache slot.
*
* @throws InvalidArgumentException
*/
private function key(string $ceremonyId): string
{
return $this->makeCacheKey(self::PREFIX.hash('sha256', $ceremonyId));
}
}
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Service;
/**
* Server-side storage for in-flight WebAuthn ceremonies.
*
* Kept separate from {@see PasskeyManager} so the security-critical properties —
* the challenge is server-authoritative and single-use — are testable without
* constructing a real authenticator response.
*/
interface PasskeyCeremonyStoreInterface
{
public const string TYPE_LOGIN = 'login';
public const string TYPE_REGISTER = 'register';
/**
* Start a login (assertion) ceremony.
*
* @return array{ceremonyId: string, challenge: string}
*/
public function startLogin(): array;
/**
* Start a registration (attestation) ceremony for an already-authenticated
* identity.
*
* @return array{ceremonyId: string, challenge: string, userHandle: string}
*/
public function startRegistration(string $identity): array;
/**
* Read and destroy a ceremony.
*
* Returns null when the id is unknown, expired, already consumed, or was
* started for the other ceremony type.
*
* @return array{challenge: string, identity?: string, userHandle?: string}|null
*/
public function consume(string $ceremonyId, string $expectedType): ?array;
}
+52
View File
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Override;
use Webauthn\Counter\CounterChecker;
use Webauthn\CredentialRecord;
use Webauthn\Exception\CounterException;
/**
* Signature-counter policy for passkey assertions.
*
* **Why this replaces the library default.** `ThrowExceptionIfInvalid` (the
* default wired up by `CeremonyStepManagerFactory`) requires the reported
* counter to be *strictly greater* than the stored one. That is wrong for the
* passkeys this feature targets: platform passkeys are synchronised through the
* OS keychain, and a synchronised authenticator reports a constant `0` forever
* (the spec permits it, and the multi-device design effectively requires it).
* Measured against the installed library:
*
* stored 0, reported 0 -> `ThrowExceptionIfInvalid` throws `CounterException`
*
* so with the default checker a brand-new synced passkey fails on its *first*
* login — and only on real hardware, never in a unit test that increments the
* counter. That is the worst possible failure shape, so the default is not used.
*
* **What is kept.** A counter that goes *backwards* still fails. That is the one
* signal the counter can carry (a cloned authenticator replaying an older
* assertion), and rejecting it costs nothing because a genuine synchronised
* passkey only ever reports the same value or a larger one.
*
* Note this is defence in depth and not relied upon for security: a
* synchronised passkey's counter carries no clone signal at all, which is why
* `SECURITY.md` records that clone detection is explicitly not a property this
* feature claims. The real protections are per-credential challenge binding,
* origin/RP-ID checks, and the user-verification requirement.
*/
final readonly class PasskeyCounterChecker implements CounterChecker
{
/**
* @throws CounterException when the reported counter moves backwards
*/
#[Override]
public function check(CredentialRecord $credentialRecord, int $currentCounter): void
{
if ($currentCounter < $credentialRecord->counter) {
throw CounterException::create($currentCounter, $credentialRecord->counter, 'The signature counter moved backwards, which can indicate a cloned authenticator.');
}
}
}
+266
View File
@@ -0,0 +1,266 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\AppConstants;
use App\Data\PasskeyCredential;
use App\MonitorCacheKeys;
use App\Trait\StringTrait;
use DateTimeImmutable;
use Override;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Webauthn\CredentialRecord;
/**
* @see PasskeyCredentialStoreInterface
*
* **Persistence.** The pool is wrapped in a {@see MonitorCacheKeys}, exactly
* as `LoginManager` and `BackupCodeManager` do. Without that wrapper the
* credentials would live only in the APCu-side pool and disappear on the next
* container restart, because `PersistCache::persist()` only flushes keys that a
* monitor recorded. `PasskeyCredentialStoreTest` asserts visibility in the
* underlying persistent pool, not just the wrapped one.
*
* **Layout.**
* passkey_cred_<key(credentialId)> -> serialized credential + metadata
* passkey_index -> credentialId => {identity, label, createdAt}
*
* The index exists so the login page can build `allowCredentials` without
* enumerating the whole key space, and so a corrupt credential cannot make the
* list disappear entirely.
*/
final readonly class PasskeyCredentialStore implements PasskeyCredentialStoreInterface
{
use StringTrait;
private const string PREFIX = 'passkey_cred_';
private const string INDEX_KEY = 'passkey_index';
private MonitorCacheKeys $sessionCache;
/**
* @throws InvalidArgumentException
*/
public function __construct(
#[Target('sessionCache')] CacheItemPoolInterface $sessionCache,
private PasskeyCeremonyFactory $ceremonyFactory,
) {
$this->sessionCache = new MonitorCacheKeys($sessionCache);
}
/**
* @throws InvalidArgumentException
*/
#[Override]
public function all(): array
{
$credentials = [];
foreach ($this->credentialIds() as $credentialId) {
$credential = $this->find($credentialId);
if (null !== $credential) {
$credentials[] = $credential;
}
}
return $credentials;
}
/**
* @throws InvalidArgumentException
*/
#[Override]
public function find(string $credentialId): ?PasskeyCredential
{
$item = $this->sessionCache->getItem($this->credentialKey($credentialId));
if (!$item->isHit()) {
return null;
}
$payload = $item->get();
if (!\is_array($payload)
|| !isset($payload['record'], $payload['identity'], $payload['label'], $payload['createdAt'])
) {
return null;
}
$record = $this->ceremonyFactory->deserializeCredential((string) $payload['record']);
if (null === $record) {
return null;
}
/* Guard against an index/record mismatch: the stored record decides which
* credential id it answers to. */
if (!hash_equals($record->publicKeyCredentialId, $credentialId)) {
return null;
}
return new PasskeyCredential(
$record,
(string) $payload['identity'],
(string) $payload['label'],
new DateTimeImmutable((string) $payload['createdAt']),
isset($payload['lastUsedAt']) ? new DateTimeImmutable((string) $payload['lastUsedAt']) : null,
);
}
/**
* @throws InvalidArgumentException
*/
#[Override]
public function findByIdentity(string $identity): array
{
$matches = [];
foreach ($this->all() as $credential) {
if (hash_equals($credential->identity, $identity)) {
$matches[] = $credential;
}
}
return $matches;
}
/**
* @throws InvalidArgumentException
*/
#[Override]
public function save(PasskeyCredential $credential): void
{
$credentialId = $credential->record->publicKeyCredentialId;
$item = $this->sessionCache->getItem($this->credentialKey($credentialId));
$item->set([
'record' => $this->ceremonyFactory->serializeCredential($credential->record),
'identity' => $credential->identity,
'label' => $credential->label,
'createdAt' => $credential->createdAt->format(\DATE_ATOM),
'lastUsedAt' => $credential->lastUsedAt?->format(\DATE_ATOM),
]);
/* per PSR6, if no expiration is set, implementation may set a default,
* we want this to keep forever, so a few hundred years should do it */
$item->expiresAt($this->forever());
$this->sessionCache->save($item);
$this->writeIndexEntry($credentialId, [
'identity' => $credential->identity,
'label' => $credential->label,
'createdAt' => $credential->createdAt->format(\DATE_ATOM),
]);
}
/**
* @throws InvalidArgumentException
*/
#[Override]
public function updateUsage(CredentialRecord $record): void
{
$existing = $this->find($record->publicKeyCredentialId);
if (null === $existing) {
return;
}
$this->save($existing->withUsage($record, new DateTimeImmutable()));
}
/**
* @throws InvalidArgumentException
*/
#[Override]
public function remove(string $credentialId): bool
{
$index = $this->readIndex();
$existed = \array_key_exists($credentialId, $index);
unset($index[$credentialId]);
$this->writeIndex($index);
return $this->sessionCache->deleteItem($this->credentialKey($credentialId)) || $existed;
}
/**
* @throws InvalidArgumentException
*/
#[Override]
public function count(): int
{
return \count($this->credentialIds());
}
/**
* Credential ids from the index only.
*
* Deliberately not `getKeys()`: keys are truncated to a fixed length by
* `makeCacheKey()`, so enumerating them cannot reliably recover a full
* credential id. The index stores the ids verbatim.
*
* @return string[]
*
* @throws InvalidArgumentException
*/
private function credentialIds(): array
{
return array_keys($this->readIndex());
}
/**
* @return array<string,array{identity: string, label: string, createdAt: string}>
*
* @throws InvalidArgumentException
*/
private function readIndex(): array
{
$item = $this->sessionCache->getItem(self::INDEX_KEY);
$index = $item->isHit() ? $item->get() : null;
return \is_array($index) ? $index : [];
}
/**
* @param array<string,array{identity: string, label: string, createdAt: string}> $index
*
* @throws InvalidArgumentException
*/
private function writeIndex(array $index): void
{
$item = $this->sessionCache->getItem(self::INDEX_KEY);
$item->set($index);
$item->expiresAt($this->forever());
$this->sessionCache->save($item);
}
/**
* @param array{identity: string, label: string, createdAt: string} $entry
*
* @throws InvalidArgumentException
*/
private function writeIndexEntry(string $credentialId, array $entry): void
{
$index = $this->readIndex();
$index[$credentialId] = $entry;
$this->writeIndex($index);
}
/** Credentials and the index are kept indefinitely; only removal clears them. */
private function forever(): DateTimeImmutable
{
return DateTimeImmutable::createFromFormat('Y-m-d', AppConstants::FAR_FUTURE_DATE);
}
/**
* Cache key for one credential id.
*
* The id is hashed rather than passed through `makeCacheKey()`, because that
* sanitises the base64url alphabet into a single `_` and is therefore not
* injective: "abc-def" and "abc_def" both become "abc_def", so two distinct
* credentials could share one cache slot and one of them would silently
* overwrite the other. A hash is injective for practical purposes and keeps
* the key within the allowed character set.
*/
private function credentialKey(string $credentialId): string
{
return self::PREFIX.hash('sha256', $credentialId);
}
}
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Data\PasskeyCredential;
use Webauthn\CredentialRecord;
/**
* Persistence for registered passkeys, backed by the `sessionCache` pool so that
* credentials survive a container restart the same way sessions do.
*
* The identity is stored alongside the credential and is authoritative on
* assertion: the `userHandle` a client returns is attacker-controlled and is
* compared for consistency but never used to decide who is logging in.
*/
interface PasskeyCredentialStoreInterface
{
/**
* Every registered credential, for the login ceremony's `allowCredentials`.
*
* Credentials whose stored payload cannot be read are skipped rather than
* failing the ceremony, so one bad entry cannot lock everyone out.
*
* @return PasskeyCredential[]
*/
public function all(): array;
/**
* Look up a single credential by its raw credential id.
*/
public function find(string $credentialId): ?PasskeyCredential;
/**
* Every credential belonging to one identity.
*
* @return PasskeyCredential[]
*/
public function findByIdentity(string $identity): array;
/**
* Persist a newly registered credential.
*/
public function save(PasskeyCredential $credential): void;
/**
* Record that a credential was just used, refreshing its counter.
*/
public function updateUsage(CredentialRecord $record): void;
/**
* Forget a credential.
*
* @return bool true when a credential was removed
*/
public function remove(string $credentialId): bool;
public function count(): int;
}
+58
View File
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Data\PasskeyCredential;
use Throwable;
/**
* Both WebAuthn ceremonies, expressed without any library types in the
* signature so callers never depend on webauthn-lib directly.
*/
interface PasskeyInterface
{
/**
* Step 1 of the login ceremony: issue a challenge and the credential list.
*
* @return array{publicKey: mixed, ceremonyId: string}
*
* @throws Throwable
*/
public function beginLogin(): array;
/**
* Step 3 of the login ceremony: verify an assertion.
*
* Null means "not authenticated", and the caller must not distinguish
* between the possible causes.
*
* @param array<string,mixed> $body
*
* @throws Throwable
*/
public function finishLogin(array $body): ?PasskeyCredential;
/**
* Step 1 of the registration ceremony. Only reachable once the caller has
* already authenticated with a TOTP code.
*
* @return array{publicKey: mixed, ceremonyId: string}
*
* @throws Throwable
*/
public function beginRegistration(string $identity): array;
/**
* Step 3 of the registration ceremony: verify an attestation and store the
* credential.
*
* Null means "not registered".
*
* @param array<string,mixed> $body
*
* @throws Throwable
*/
public function finishRegistration(array $body): ?PasskeyCredential;
}
+312
View File
@@ -0,0 +1,312 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Data\PasskeyCredential;
use App\Service\PasskeyCeremonyStoreInterface as Ceremonies;
use DateTimeImmutable;
use Override;
use ParagonIE\ConstantTime\Base64UrlSafe;
use Throwable;
use Webauthn\AuthenticatorAssertionResponse;
use Webauthn\AuthenticatorAttestationResponse;
use Webauthn\AuthenticatorSelectionCriteria;
use Webauthn\CredentialRecord;
use Webauthn\PublicKeyCredential;
use Webauthn\PublicKeyCredentialCreationOptions;
use Webauthn\PublicKeyCredentialDescriptor;
use Webauthn\PublicKeyCredentialParameters;
use Webauthn\PublicKeyCredentialRequestOptions;
use Webauthn\PublicKeyCredentialRpEntity;
use Webauthn\PublicKeyCredentialUserEntity;
/**
* Owns both WebAuthn ceremonies.
*
* **Library containment.** Every `Webauthn\*` type used by the application is
* referenced in this file and {@see PasskeyCeremonyFactory}, so a v6 rename
* touches two files rather than the whole codebase.
*
* **What is never trusted.** The challenge (server-authoritative, single-use —
* see {@see PasskeyCeremonyStore}), the `userHandle` (read from the stored
* credential, never from the client), and the credential id (it selects which
* stored record to verify against). The library performs the cryptographic
* verification of origin, RP ID hash, challenge, user presence/verification and
* signature; this class supplies the inputs and interprets the outcome.
*/
final readonly class PasskeyManager implements PasskeyInterface
{
/** ES256 is mandatory for WebAuthn; other algorithms buy nothing here. */
private const int COSE_ALGORITHM_ES256 = -7;
public function __construct(
private PasskeyPolicyInterface $policy,
private Ceremonies $ceremonies,
private PasskeyCredentialStoreInterface $credentials,
private PasskeyCeremonyFactory $factory,
) {
}
/**
* Step 1 of the login ceremony.
*
* @return array{publicKey: mixed, ceremonyId: string}
*
* @throws Throwable
*/
#[Override]
public function beginLogin(): array
{
$ceremony = $this->ceremonies->startLogin();
$options = new PublicKeyCredentialRequestOptions(
$ceremony['challenge'],
$this->policy->rpId(),
$this->credentialDescriptors(),
$this->policy->userVerification(),
$this->policy->timeout(),
);
return [
'publicKey' => $this->factory->optionsAsArray($options),
'ceremonyId' => $ceremony['ceremonyId'],
];
}
/**
* Step 3 of the login ceremony: verify the assertion and report the identity.
*
* Returns null on any failure — the caller must not be able to distinguish
* "unknown credential" from "bad signature" from "wrong origin", or the
* endpoint becomes an oracle for credential enumeration.
*
* @param array<string,mixed> $body
*
* @throws Throwable
*/
#[Override]
public function finishLogin(array $body): ?PasskeyCredential
{
$ceremonyId = $this->stringOrNull($body['ceremonyId'] ?? null);
$credentialJson = $body['credential'] ?? null;
if (null === $ceremonyId || !\is_array($credentialJson)) {
return null;
}
$ceremony = $this->ceremonies->consume($ceremonyId, Ceremonies::TYPE_LOGIN);
if (null === $ceremony) {
return null;
}
$publicKeyCredential = $this->deserializeCredential($credentialJson);
if (!$publicKeyCredential instanceof PublicKeyCredential
|| !$publicKeyCredential->response instanceof AuthenticatorAssertionResponse
) {
return null;
}
/* the credential id selects the record: an attacker cannot nominate a
* different credential than the one they hold the key for.
*
* Inside the try: a cache failure must degrade to "this passkey is
* unavailable", never to a 500 on the login page. */
try {
$stored = $this->credentials->find($publicKeyCredential->rawId);
if (null === $stored) {
return null;
}
$updated = $this->factory
->requestCeremonyValidator($this->policy->allowedOrigins())
->check(
$stored->record,
$publicKeyCredential->response,
$this->requestOptions($ceremony['challenge']),
$this->policy->authSubdomain(),
$stored->record->userHandle,
);
} catch (Throwable) {
/* deliberately not surfaced: see the note above */
return null;
}
$this->credentials->updateUsage($updated);
return $stored->withUsage($updated, new DateTimeImmutable());
}
/**
* Step 1 of the registration ceremony. Only reachable once the caller has
* already proven possession of the TOTP secret (see the flow in the plan).
*
* @return array{publicKey: mixed, ceremonyId: string}
*
* @throws Throwable
*/
#[Override]
public function beginRegistration(string $identity): array
{
$ceremony = $this->ceremonies->startRegistration($identity);
return [
'publicKey' => $this->factory->optionsAsArray($this->creationOptions(
$ceremony['challenge'],
$identity,
$ceremony['userHandle'],
)),
'ceremonyId' => $ceremony['ceremonyId'],
];
}
/**
* Step 3 of the registration ceremony: verify the attestation and persist
* the credential under the identity that was authenticated in step 1.
*
* @param array<string,mixed> $body
*
* @throws Throwable
*/
#[Override]
public function finishRegistration(array $body): ?PasskeyCredential
{
$ceremonyId = $this->stringOrNull($body['ceremonyId'] ?? null);
$credentialJson = $body['credential'] ?? null;
if (null === $ceremonyId || !\is_array($credentialJson)) {
return null;
}
$ceremony = $this->ceremonies->consume($ceremonyId, Ceremonies::TYPE_REGISTER);
if (null === $ceremony || !isset($ceremony['identity'], $ceremony['userHandle'])) {
return null;
}
$publicKeyCredential = $this->deserializeCredential($credentialJson);
if (!$publicKeyCredential instanceof PublicKeyCredential
|| !$publicKeyCredential->response instanceof AuthenticatorAttestationResponse
) {
return null;
}
try {
$record = $this->factory
->creationCeremonyValidator($this->policy->allowedOrigins())
->check(
$publicKeyCredential->response,
$this->creationOptions(
$ceremony['challenge'],
$ceremony['identity'],
$ceremony['userHandle'],
),
$this->policy->authSubdomain(),
);
} catch (Throwable) {
return null;
}
$credential = new PasskeyCredential(
$record,
$ceremony['identity'],
$this->labelFor($record),
new DateTimeImmutable(),
);
try {
$this->credentials->save($credential);
} catch (Throwable) {
/* a store that cannot persist a credential must not report success:
* the user would believe the passkey was registered and then find it
* missing at the next login */
return null;
}
return $credential;
}
private function requestOptions(string $challenge): PublicKeyCredentialRequestOptions
{
return new PublicKeyCredentialRequestOptions(
$challenge,
$this->policy->rpId(),
$this->credentialDescriptors(),
/* userVerification must be present for the library to enforce the
* user-verified flag; it is not re-read from the client request */
$this->policy->userVerification(),
$this->policy->timeout(),
);
}
private function creationOptions(string $challenge, string $identity, string $userHandle): PublicKeyCredentialCreationOptions
{
return new PublicKeyCredentialCreationOptions(
new PublicKeyCredentialRpEntity($this->policy->rpName(), $this->policy->rpId()),
new PublicKeyCredentialUserEntity($identity, $userHandle, $identity),
$challenge,
[PublicKeyCredentialParameters::create('public-key', self::COSE_ALGORITHM_ES256)],
new AuthenticatorSelectionCriteria(
/* a passkey for this device, which is what the "register this
* device" checkbox promises */
AuthenticatorSelectionCriteria::AUTHENTICATOR_ATTACHMENT_PLATFORM,
$this->policy->userVerification(),
AuthenticatorSelectionCriteria::RESIDENT_KEY_REQUIREMENT_PREFERRED,
),
/* D5: 'none' — see the plan §2.3 and SECURITY.md */
'none',
/* also discourage registering the same device twice */
$this->credentialDescriptors(),
$this->policy->timeout(),
);
}
/**
* Descriptors for every stored credential, used both as `allowCredentials`
* on login and as `excludeCredentials` on registration.
*
* @return PublicKeyCredentialDescriptor[]
*/
private function credentialDescriptors(): array
{
$descriptors = [];
foreach ($this->credentials->all() as $credential) {
$descriptors[] = PublicKeyCredentialDescriptor::create(
'public-key',
$credential->record->publicKeyCredentialId,
$credential->record->transports,
);
}
return $descriptors;
}
/**
* @param array<string,mixed> $data
*/
private function deserializeCredential(array $data): ?PublicKeyCredential
{
try {
return $this->factory->serializer()->denormalize($data, PublicKeyCredential::class, 'json');
} catch (Throwable) {
return null;
}
}
/**
* A short, stable description so a user can tell their keys apart.
*
* Derived from the credential id because the AAGUID is not a reliable
* identity without the FIDO metadata service, which D5 deliberately does
* not use.
*/
private function labelFor(CredentialRecord $record): string
{
return 'Passkey '.substr(Base64UrlSafe::encodeUnpadded($record->publicKeyCredentialId), 0, 8);
}
private function stringOrNull(mixed $value): ?string
{
return \is_string($value) && '' !== $value ? $value : null;
}
}
+120
View File
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\ConfigBag;
use App\Exception\PasskeyConfigurationException;
use Override;
use Symfony\Component\HttpFoundation\Request;
/**
* @see PasskeyPolicyInterface for the decisions this class enforces
*
* **On scheme handling (D4).** The container serves plain HTTP and sits behind a
* TLS-terminating proxy, so the application never learns the public scheme from
* its own configuration — only from `X-Forwarded-Proto`, which Symfony resolves
* through `trusted_proxies` / `trusted_headers` in `framework.yaml`. The scheme
* is therefore enforced two independent ways rather than asserted at boot:
*
* 1. The allowed origin is hardcoded to `https://` here and is never taken from
* the request, so the library's origin check rejects an `http://` ceremony
* no matter how the request arrived.
* 2. {@see isAvailableFor()} additionally requires the request to be secure, so
* a visitor on a non-secure connection is never shown a passkey button that
* the browser would refuse to act on.
*/
final readonly class PasskeyPolicy implements PasskeyPolicyInterface
{
private const string SCHEME = 'https';
public function __construct(
private ConfigBag $config,
private DomainInterface $domainManager,
) {
}
#[Override]
public function isEnabled(): bool
{
return $this->config->passkeyEnabled() && null !== $this->domainManager->authBase();
}
#[Override]
public function isAvailableFor(Request $request): bool
{
return $this->isEnabled()
&& $request->isSecure()
&& $this->domainManager->getAuthSubdomain() === $request->getHost();
}
#[Override]
public function rpId(): string
{
return $this->domainManager->authBase() ?? throw $this->notConfigured();
}
#[Override]
public function allowedOrigins(): array
{
return [self::SCHEME.'://'.$this->authSubdomain()];
}
#[Override]
public function authSubdomain(): string
{
$subdomain = $this->domainManager->getAuthSubdomain();
return $subdomain ?? throw $this->notConfigured();
}
#[Override]
public function rpName(): string
{
return $this->config->passkeyRpName() ?: $this->config->title();
}
#[Override]
public function userVerification(): string
{
return $this->config->passkeyUserVerification();
}
#[Override]
public function timeout(): int
{
return $this->config->passkeyTimeout();
}
/**
* @throws PasskeyConfigurationException
*/
#[Override]
public function assertConfigurationIsUsable(): void
{
if (!$this->config->passkeyEnabled()) {
return;
}
/* D1: without a base domain there is no RP ID and no shared credential */
if (null === $this->domainManager->authBase()) {
throw new PasskeyConfigurationException('PASSKEY_ENABLED is on, but central authentication is not configured. Passkeys require SUBDOMAIN_REDIRECT=1 together with a valid AUTH_SUBDOMAIN whose base domain can be determined (a domain such as "auth.example.com" — not "localhost" and not an IP address). Either configure central authentication or set PASSKEY_ENABLED=0.');
}
/* D4: the subdomain must be a real domain able to present a TLS certificate.
* authBase() returning null already excludes localhost and bare IPs, so
* this guards against an auth subdomain that is a single label. */
if (!str_contains($this->authSubdomain(), '.')) {
throw new PasskeyConfigurationException('AUTH_SUBDOMAIN must be a fully qualified domain name (for example "auth.example.com") because passkeys require HTTPS and a certificate cannot be issued for a single-label host.');
}
}
private function notConfigured(): PasskeyConfigurationException
{
return new PasskeyConfigurationException(
'Passkeys are enabled but central authentication is not configured, '
.'so no relying party identity is available.',
);
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Exception\PasskeyConfigurationException;
use Symfony\Component\HttpFoundation\Request;
/**
* Single source of truth for whether passkeys may be offered, and under which
* relying-party identity.
*
* Two project decisions are enforced here and nowhere else:
*
* - **D1 — central auth is a prerequisite.** A passkey is only meaningful when
* the whole base domain shares one relying party, so passkeys are unavailable
* unless `SUBDOMAIN_REDIRECT` is on and `AUTH_SUBDOMAIN` resolves to a base
* domain. The RP ID is therefore always that base domain.
*
* - **D4 — HTTPS is required, with no exemption.** The origin handed to the
* browser is built here as `https://{authSubdomain}` and is *never* derived
* from the incoming request, so an `http://` origin can never be accepted.
* The deprecated `setSecuredRelyingPartyId()` escape hatch is not used, and
* there is deliberately no configuration override that could reintroduce one.
*/
interface PasskeyPolicyInterface
{
/**
* True when passkeys are switched on by configuration AND that configuration
* satisfies D1. Availability to a particular visitor additionally requires
* {@see isAvailableFor()}.
*/
public function isEnabled(): bool;
/**
* Whether the passkey UI should be offered for this request.
*
* Requires the feature to be enabled, the request to be aimed at the auth
* subdomain (the only place a ceremony may run), and the visitor to actually
* be on HTTPS — see the note on scheme handling in {@see PasskeyPolicy}.
*/
public function isAvailableFor(Request $request): bool;
/**
* The relying party ID: always the base domain of the auth subdomain.
*
* @throws PasskeyConfigurationException when passkeys are enabled without D1
*/
public function rpId(): string;
/**
* Relying party origins allowed to complete a ceremony.
*
* Always exactly one entry, always `https://`, always derived from
* configuration rather than from the request (D4).
*
* @return string[]
*
* @throws PasskeyConfigurationException when passkeys are enabled without D1
*/
public function allowedOrigins(): array;
/** The auth subdomain that ceremonies must be served from. */
public function authSubdomain(): string;
/** Human-readable name shown in the authenticator prompt. */
public function rpName(): string;
/** `required`, `preferred` or `discouraged`. */
public function userVerification(): string;
/** Ceremony timeout in milliseconds, as passed to the browser. */
public function timeout(): int;
/**
* Fails hard when passkeys are enabled in a configuration that cannot work.
*
* Called during cache warm-up so a misconfigured deployment never reaches a
* browser: the container refuses to start instead.
*
* @throws PasskeyConfigurationException
*/
public function assertConfigurationIsUsable(): void;
}
+151
View File
@@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\ConfigBag;
use App\Enum\Scope;
use App\MonitorCacheKeys;
use App\Trait\CookieNameTrait;
use App\Trait\HasLoggerTrait;
use App\Trait\StringTrait;
use Override;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\Uid\Ulid;
/**
* Grants access once an identity has been authenticated, by whatever method.
*
* Extracted from `LoginManager` so the passkey ceremony and the TOTP form
* produce **byte-identical** outcomes. Two implementations would inevitably
* drift — most likely in cookie attributes, where a difference is invisible
* until it breaks in a browser.
*
* This class deliberately knows nothing about *how* authentication happened; it
* only records the result.
*
* @see SessionIssuerInterface
*/
final readonly class SessionIssuer implements SessionIssuerInterface
{
use CookieNameTrait;
use HasLoggerTrait;
use StringTrait;
private MonitorCacheKeys $sessionCache;
/**
* @throws InvalidArgumentException
*/
public function __construct(
#[Target('sessionCache')] CacheItemPoolInterface $sessionCache,
private DomainInterface $domainManager,
private ConfigBag $config,
) {
$this->sessionCache = new MonitorCacheKeys($sessionCache);
}
/**
* @throws InvalidArgumentException
*/
#[Override]
public function issue(string $identity, Scope $scope, Request $request, bool $json): Response
{
/* the same normalisation LoginManager has always applied, so cache keys
* and Remote-User values stay identical between the two login paths */
$cleanId = $this->makeCacheKey($identity);
$response = $this->authSuccessResponse($cleanId, $this->config);
/* when the caller only wanted this one page, there is nothing to store */
if (Scope::None === $scope) {
$this->logger->debug("successful login for: $cleanId");
return $response;
}
if (Scope::Cookie === $scope) {
$response->headers->setCookie($this->setCookie($cleanId, $request->getHost()));
} elseif (Scope::Ip === $scope) {
$this->setIp($cleanId, (string) $request->getClientIp());
}
if ($json) {
$contentType = 'application/json';
$content = (string) json_encode([
'message' => 'Login successful',
'nonce' => null,
]);
} else {
$contentType = 'text/html';
$content = "hi $cleanId, please reload";
}
$location = $request->query->has('return')
&& $this->domainManager->validReturn((string) $request->query->get('return')) ?
"{$request->query->get('return')}" :
"{$request->getPathInfo()}{$request->getQueryString()}";
/* force redirect to use GET method (important when using central auth) */
$response->setContent($content)
->setStatusCode(Response::HTTP_SEE_OTHER)
->headers->set('Location', $location);
$response->headers->set('Content-Type', $contentType);
$this->logger->debug("successful login for: $cleanId");
return $response;
}
/**
* @throws InvalidArgumentException
*/
private function setCookie(string $id, string $host): Cookie
{
/* successful auth with token, store session and set the cookie */
$ulid = new Ulid();
$sessionCookie = $this->sessionCache->getItem(
$this->makeCacheKey("cookie_$ulid"),
);
if ($sessionCookie->isHit()) {
/* it is supposed to be impossible to have collisions */
$this->logger->error('aborting: ULID collision');
throw new HttpException(Response::HTTP_INTERNAL_SERVER_ERROR, 'Internal Server Error');
}
$sessionCookie->set($id);
$sessionCookie->expiresAfter($this->config->cookieTtl());
$this->sessionCache->save($sessionCookie);
return Cookie::create(
name: $this->sessionCookieName($this->domainManager),
value: $ulid->toString(),
expire: time() + $this->config->cookieTtl(),
path: '/',
domain: $this->sessionCookieDomain($this->domainManager, $host),
secure: true,
httpOnly: true,
sameSite: Cookie::SAMESITE_STRICT,
);
}
/**
* @throws InvalidArgumentException
*/
private function setIp(string $id, string $ip): void
{
/* successful auth with token, requested scope of ip (and ip access enabled) */
$ipKey = $this->makeCacheKey("ip_$ip");
$sessionIp = $this->sessionCache->getItem($ipKey);
$sessionIp->set($id);
$sessionIp->expiresAfter($this->config->ipTtl());
$this->sessionCache->save($sessionIp);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Enum\Scope;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Grants access after a successful authentication, regardless of method.
*
* Exists so the TOTP form and the passkey ceremony cannot drift apart: they must
* produce identical cookies, headers and redirects, and the only reliable way to
* guarantee that is for both to call the same code.
*/
interface SessionIssuerInterface
{
/**
* Record the authenticated identity according to the requested scope and
* build the response the caller should return.
*
* @param string $identity the session id, as typed by the user
* @param Scope $scope whether to set a cookie, an IP session, or neither
* @param bool $json JSON for an AJAX caller, HTML for a form post
*/
public function issue(string $identity, Scope $scope, Request $request, bool $json): Response;
}
+21
View File
@@ -1,4 +1,13 @@
{
"doctrine/deprecations": {
"version": "1.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "fdd756167454623e21f1d769c5b814b243782a67"
}
},
"friendsofphp/php-cs-fixer": {
"version": "3.95",
"recipe": {
@@ -74,6 +83,18 @@
".editorconfig"
]
},
"symfony/property-info": {
"version": "8.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.3",
"ref": "dae70df71978ae9226ae915ffd5fad817f5ca1f7"
},
"files": [
"config/packages/property_info.yaml"
]
},
"symfony/routing": {
"version": "7.4",
"recipe": {
+175
View File
@@ -0,0 +1,175 @@
{#
Passkey UI. Only included when passkeys are available, so that an
unavailable configuration renders a byte-identical login page.
Every string that reaches the server is base64url with no padding, matching
what webauthn-lib expects — the library rejects anything else, and the
failure mode ("invalid signature") looks nothing like an encoding bug.
#}
<div class="center passkey-row">
<button type="button" id="preauth-passkey">{{ env.passkey_button_name }}</button>
</div>
<style>
div.passkey-row { width: 100%; }
div.passkey-row button { background-color: #ffffff; }
label.passkey-label { display: inline-block; text-align: left; }
label.passkey-label input { width: auto; }
</style>
<script>
(function () {
const form = document.getElementById('preauth-form');
const message = document.getElementById('preauth-message');
const button = document.getElementById('preauth-passkey');
const checkbox = document.getElementById('preauth-register');
/* base64url <-> ArrayBuffer, exactly as webauthn-lib encodes these fields */
const b64url = {
encode: (value) => btoa(String.fromCharCode.apply(null, new Uint8Array(value)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''),
decode: (value) => {
const padded = value.replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(padded + '='.repeat((4 - padded.length % 4) % 4));
return Uint8Array.from(raw, (character) => character.charCodeAt(0));
},
};
const show = (text) => { if (message) { message.innerText = text; } };
/* POST a ceremony step and return the parsed JSON body */
const ceremony = (operation, body) => fetch(window.location.href, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Preauth-Passkey': operation,
},
// never serve this request from, or store it in, the HTTP cache
cache: 'no-store',
body: JSON.stringify(body ?? {}),
}).then((response) => response.json().then((content) => ({ response, content })));
const descriptors = (list) => (list ?? []).map((entry) => ({
...entry,
id: b64url.decode(entry.id),
}));
/* ── login (assertion) ────────────────────────────────────────────── */
if (button) {
button.addEventListener('click', () => {
ceremony('login-begin')
.then(({ content }) => navigator.credentials.get({
publicKey: {
...content.publicKey,
challenge: b64url.decode(content.publicKey.challenge),
allowCredentials: descriptors(content.publicKey.allowCredentials),
},
}).then((assertion) => ceremony('login-finish', {
ceremonyId: content.ceremonyId,
credential: {
id: assertion.id,
rawId: b64url.encode(assertion.rawId),
type: assertion.type,
response: {
clientDataJSON: b64url.encode(assertion.response.clientDataJSON),
authenticatorData: b64url.encode(assertion.response.authenticatorData),
signature: b64url.encode(assertion.response.signature),
userHandle: assertion.response.userHandle
? b64url.encode(assertion.response.userHandle) : null,
},
},
})))
.then(({ response, content }) => {
if (response.headers.has('Location')) {
window.location.replace(response.headers.get('Location'));
return;
}
show(content.message ?? '');
if (Object.hasOwn(content, 'nonce') && form.nonce) {
form.nonce.value = content.nonce;
}
})
.catch((error) => {
console.log('passkey login failed');
console.log(error);
show({{ env.error_message|json_encode|raw }});
});
});
}
/* ── registration ─────────────────────────────────────────────────── */
/* The checkbox turns an ordinary submit into a registration ceremony.
Authorisation is the TOTP check the server performs on that same
submission, so a ceremony cannot be started without a valid code.
This goes through the same X-Preauth AJAX path as a normal login rather
than a plain form post, so that failures come back as JSON with a fresh
nonce — a form post would return HTML and lose the nonce, making the
user's next attempt fail for a reason they could not see. */
if (form && checkbox) {
form.addEventListener('submit', (event) => {
if (!checkbox.checked) {
/* not registering: leave the normal submit path alone */
return;
}
event.preventDefault();
const data = btoa(JSON.stringify({
id: form.username.value?.trim() ?? '',
token: form.totp.value?.trim() ?? '',
nonce: form.nonce.value?.trim() ?? '',
register: 'passkey',
json: true,
})).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
fetch(window.location.href, {
method: 'GET',
headers: { 'X-Preauth': data },
cache: 'no-store',
}).then((response) => response.json()).then((content) => {
if (!content.register) {
show(content.message ?? '');
if (Object.hasOwn(content, 'nonce')) {
form.nonce.value = content.nonce;
form.totp.value = '';
form.totp.focus();
}
return;
}
const options = content.register.publicKey;
return navigator.credentials.create({
publicKey: {
...options,
challenge: b64url.decode(options.challenge),
user: { ...options.user, id: b64url.decode(options.user.id) },
excludeCredentials: descriptors(options.excludeCredentials),
},
}).then((attestation) => ceremony('register-finish', {
ceremonyId: content.register.ceremonyId,
credential: {
id: attestation.id,
rawId: b64url.encode(attestation.rawId),
type: attestation.type,
response: {
clientDataJSON: b64url.encode(attestation.response.clientDataJSON),
attestationObject: b64url.encode(attestation.response.attestationObject),
transports: attestation.response.getTransports
? attestation.response.getTransports() : [],
},
},
})).then(({ response, content: finished }) => {
if (response.headers.has('Location')) {
window.location.replace(response.headers.get('Location'));
return;
}
show(finished.message ?? '');
});
}).catch((error) => {
console.log('passkey registration failed');
console.log(error);
show({{ env.error_message|json_encode|raw }});
});
});
}
})();
</script>
+10
View File
@@ -14,9 +14,19 @@
<div class="right"><label for="totp">{{ env.token_name }}:</label></div>
<div><input type="text" name="totp" id="totp"
autocomplete="one-time-code" required="required"></div>
{% if (passkeys ?? false) and (post ?? false) %}
{# Only where the form actually POSTs: registration authorises itself with
the TOTP code carried in that submission, so a fetch()-submitted form on
a protected host has nothing to start a ceremony with. #}
<div class="center passkey-row"><label class="passkey-label" for="preauth-register">
<input type="checkbox" name="register" id="preauth-register" value="passkey"> {{ env.passkey_register_name }}</label></div>
{% endif %}
<div class="center"><button type="submit">{{ env.submit_name }}</button></div>
</form>
{% if not post ?? false %}
{{- include('_script.html.twig') -}}
{% endif %}
{% if passkeys ?? false %}
{{- include('_passkey.html.twig') -}}
{% endif %}
{% endblock %}
+614
View File
@@ -0,0 +1,614 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional;
use App\AppConstants;
use App\Tests\Support\PasskeyTestHelper;
use OTPHP\TOTP;
use Override;
use ParagonIE\ConstantTime\Base64UrlSafe;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Response;
/**
* The whole flow through the real HTTP kernel, with real cryptography.
*
* Nothing about the ceremony is stubbed: the registration builds a genuine CBOR
* attestation object signed by a real P-256 key, and the login signs a real
* assertion. So a pass here means the feature works, not that our mocks agree
* with our code. What *is* simulated is only the browser's plumbing — the
* `fetch()` calls become requests, which is exactly the seam worth testing.
*
* Passkeys are off in `.env.test` (most of the suite expects today's behaviour),
* so this test turns them on for itself.
*/
final class PasskeyFlowTest extends WebTestCase
{
private const string TOTP_SECRET = 'JBSWY3DPEHPK3PXP';
private const string IDENTITY = 'lyra';
/** The auth subdomain, which is also the only allowed ceremony origin. */
private const string AUTH_HOST = 'auth.example.com';
/** The RP ID: the base domain, so credentials are shared across it. */
private const string RP_ID = 'example.com';
private const string ORIGIN = 'https://auth.example.com';
private const string AUTH_COOKIE = '__Http-Domain-Preauth';
private ?PasskeyTestHelper $helper = null;
/** One kernel per test, as WebTestCase requires. */
private ?KernelBrowser $client = null;
private ?string $credentialId = null;
/** @var array<string,string> */
private static array $passkeyEnv = [
'PASSKEY_ENABLED' => '1',
'SUBDOMAIN_REDIRECT' => '1',
'AUTH_SUBDOMAIN' => self::AUTH_HOST,
];
/**
* Turn passkeys on for this test only.
*
* Env placeholders resolve at runtime, so setting these before the kernel
* boots is enough and no separate cache directory is needed.
*/
private function createPasskeyClient(): KernelBrowser
{
foreach (self::$passkeyEnv as $name => $value) {
$_ENV[$name] = $value;
$_SERVER[$name] = $value;
}
/* WebTestCase allows exactly one kernel per test, so a test needing a
* second "visitor" gets this browser with a cleared cookie jar rather
* than a new kernel. */
if (null === $this->client) {
$this->client = static::createClient();
$this->client->disableReboot();
}
return $this->client;
}
/**
* The same kernel, with no cookies — a fresh visitor.
*
* Needed because a granted session makes AcceptListener short-circuit at 200
* before any ceremony listener runs, so a test that registers first and then
* wants to exercise a ceremony must not carry that cookie.
*/
private function freshVisitor(): KernelBrowser
{
$client = $this->createPasskeyClient();
$client->getCookieJar()->clear();
return $client;
}
#[Override]
protected function tearDown(): void
{
foreach (array_keys(self::$passkeyEnv) as $name) {
unset($_ENV[$name], $_SERVER[$name]);
}
parent::tearDown();
}
private function helper(): PasskeyTestHelper
{
return $this->helper ??= new PasskeyTestHelper();
}
private function credentialId(): string
{
return $this->credentialId ??= $this->helper()->credentialId();
}
private function validTotpCode(): string
{
return TOTP::createFromSecret(self::TOTP_SECRET)->now();
}
/**
* The nonce issued with the login page, which the form must echo back.
*/
private function nonceFrom(KernelBrowser $client): string
{
$crawler = $client->request('GET', self::ORIGIN.'/');
return (string) $crawler->filter('input[name="nonce"]')->attr('value');
}
/**
* Step 1+2 of registration: submit the form with the checkbox ticked.
*
* @return array{publicKey: array<string,mixed>, ceremonyId: string}
*/
private function beginRegistration(KernelBrowser $client, string $nonce, string $totp = ''): array
{
$client->request('GET', self::ORIGIN.'/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => self::IDENTITY,
'token' => '' === $totp ? $this->validTotpCode() : $totp,
'nonce' => $nonce,
'register' => 'passkey',
'json' => true,
]),
]);
$response = $client->getResponse();
self::assertSame(Response::HTTP_OK, $response->getStatusCode(), (string) $response->getContent());
$content = json_decode((string) $response->getContent(), true);
self::assertIsArray($content);
self::assertArrayHasKey('register', $content);
return $content['register'];
}
/**
* An ordinary code login, returning the cookie it sets.
*
* Used to prove both login paths agree on the cookie; the passkey flow is
* otherwise easy to break in a way that only shows up in a browser.
*/
private function codeLoginCookie(): \Symfony\Component\HttpFoundation\Cookie
{
$client = $this->freshVisitor();
$nonce = $this->nonceFrom($client);
$client->request('GET', self::ORIGIN.'/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => self::IDENTITY,
'token' => $this->validTotpCode(),
'nonce' => $nonce,
'json' => true,
]),
]);
$response = $client->getResponse();
self::assertSame(Response::HTTP_SEE_OTHER, $response->getStatusCode(), (string) $response->getContent());
return $this->authCookieFrom($response);
}
/**
* base64url-encode a payload, matching the client-side script.
*
* @param array<string,mixed> $data
*/
private function encodePayload(array $data): string
{
return rtrim(strtr(base64_encode((string) json_encode($data)), '+/', '-_'), '=');
}
/**
* Step 3 of registration: send the attestation the authenticator produced.
*/
private function finishRegistration(KernelBrowser $client, string $ceremonyId, string $challenge): Response
{
$credential = $this->helper()->registrationCredential(
self::RP_ID,
$challenge,
self::ORIGIN,
$this->credentialId(),
);
$client->request(
'POST',
self::ORIGIN.'/',
[],
[],
[
'CONTENT_TYPE' => 'application/json',
'HTTP_X-Preauth-Passkey' => 'register-finish',
],
(string) json_encode(['ceremonyId' => $ceremonyId, 'credential' => $credential]),
);
return $client->getResponse();
}
/* ── registration ─────────────────────────────────────────────────── */
/**
* The headline end-to-end property: a real registration is accepted and
* grants a session.
*/
public function test_a_real_registration_grants_a_session(): void
{
$client = $this->createPasskeyClient();
$nonce = $this->nonceFrom($client);
$started = $this->beginRegistration($client, $nonce);
self::assertArrayHasKey('ceremonyId', $started);
self::assertSame(self::RP_ID, $started['publicKey']['rp']['id']);
$response = $this->finishRegistration(
$client,
$started['ceremonyId'],
Base64UrlSafe::decodeNoPadding($started['publicKey']['challenge']),
);
self::assertSame(Response::HTTP_SEE_OTHER, $response->getStatusCode(), (string) $response->getContent());
/* the session cookie is domain-scoped so every subdomain accepts it */
$cookie = $this->authCookieFrom($response);
self::assertSame(self::AUTH_COOKIE, $cookie->getName());
self::assertSame(self::RP_ID, $cookie->getDomain());
self::assertTrue($cookie->isSecure());
self::assertTrue($cookie->isHttpOnly());
}
/**
* Registration is authorised by the TOTP code, so a bad code must not start
* a ceremony — and must not leave one behind to be finished later.
*/
public function test_registration_with_a_bad_code_starts_no_ceremony(): void
{
$client = $this->createPasskeyClient();
$nonce = $this->nonceFrom($client);
$client->request('GET', self::ORIGIN.'/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => self::IDENTITY,
'token' => '000000',
'nonce' => $nonce,
'register' => 'passkey',
'json' => true,
]),
]);
$response = $client->getResponse();
self::assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode());
$content = json_decode((string) $response->getContent(), true);
self::assertIsArray($content);
self::assertArrayNotHasKey('register', $content);
}
/**
* A spent nonce must be refused even with a valid code, or the ceremony
* hand-off would be replayable.
*/
public function test_registration_with_a_spent_nonce_starts_no_ceremony(): void
{
$client = $this->createPasskeyClient();
$nonce = $this->nonceFrom($client);
/* spend the nonce with a first successful login */
$client->request('GET', self::ORIGIN.'/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => self::IDENTITY,
'token' => $this->validTotpCode(),
'nonce' => $nonce,
'json' => true,
]),
]);
self::assertSame(Response::HTTP_SEE_OTHER, $client->getResponse()->getStatusCode());
/* now try to reuse it for registration */
$reuse = $this->freshVisitor();
$reuse->request('GET', self::ORIGIN.'/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => self::IDENTITY,
'token' => $this->validTotpCode(),
'nonce' => $nonce,
'register' => 'passkey',
'json' => true,
]),
]);
self::assertSame(Response::HTTP_UNAUTHORIZED, $reuse->getResponse()->getStatusCode());
}
/* ── login ────────────────────────────────────────────────────────── */
/**
* The other half of the story: register once, then log in with the passkey
* instead of a code — through the real validator, with a real signature.
*/
public function test_a_real_passkey_login_grants_a_session(): void
{
$client = $this->createPasskeyClient();
/* register first */
$started = $this->beginRegistration($client, $this->nonceFrom($client));
$registered = $this->finishRegistration(
$client,
$started['ceremonyId'],
Base64UrlSafe::decodeNoPadding($started['publicKey']['challenge']),
);
self::assertSame(Response::HTTP_SEE_OTHER, $registered->getStatusCode());
/* the stored record's counter is the one the registration used, and the
* lenient policy accepts an equal or greater value */
$counter = $this->helper()->counter() + 1;
/* drop the cookie the registration granted, or AcceptListener would
* answer before the ceremony listener is reached */
$client = $this->freshVisitor();
$client->request('POST', self::ORIGIN.'/', [], [], [
'CONTENT_TYPE' => 'application/json',
'HTTP_X-Preauth-Passkey' => 'login-begin',
], '{}');
$begin = json_decode((string) $client->getResponse()->getContent(), true);
self::assertIsArray($begin);
self::assertSame(self::RP_ID, $begin['publicKey']['rpId']);
/* the registered credential is offered to the authenticator */
self::assertNotEmpty($begin['publicKey']['allowCredentials']);
$challenge = Base64UrlSafe::decodeNoPadding($begin['publicKey']['challenge']);
$assertion = $this->helper()->assertionCredential(
self::RP_ID,
$challenge,
self::ORIGIN,
$this->credentialId(),
$counter,
hash('sha256', self::IDENTITY, true),
);
$client->request('POST', self::ORIGIN.'/', [], [], [
'CONTENT_TYPE' => 'application/json',
'HTTP_X-Preauth-Passkey' => 'login-finish',
], (string) json_encode(['ceremonyId' => $begin['ceremonyId'], 'credential' => $assertion]));
$response = $client->getResponse();
self::assertSame(Response::HTTP_SEE_OTHER, $response->getStatusCode(), (string) $response->getContent());
/* the Remote-User header proves which identity was authenticated */
self::assertSame(self::IDENTITY, $response->headers->get('Remote-User'));
self::assertSame(self::AUTH_COOKIE, $this->authCookieFrom($response)->getName());
}
/**
* A replayed ceremony must fail: the challenge is consumed on first use, so
* an observed `finish` cannot be re-sent.
*/
public function test_a_replayed_ceremony_fails(): void
{
$client = $this->createPasskeyClient();
$started = $this->beginRegistration($client, $this->nonceFrom($client));
$challenge = Base64UrlSafe::decodeNoPadding($started['publicKey']['challenge']);
$first = $this->finishRegistration($client, $started['ceremonyId'], $challenge);
self::assertSame(Response::HTTP_SEE_OTHER, $first->getStatusCode());
/* a replay comes from someone who does not hold the session the first
* attempt just created, so the cookie must go — otherwise AcceptListener
* answers 200 and the ceremony listener never sees the replay */
$replay = $this->finishRegistration($this->freshVisitor(), $started['ceremonyId'], $challenge);
self::assertSame(Response::HTTP_UNAUTHORIZED, $replay->getStatusCode());
}
/**
* An assertion signed over a different challenge must be refused, which is
* what binds a login to this session rather than to any past one.
*/
public function test_an_assertion_for_another_challenge_fails(): void
{
$client = $this->createPasskeyClient();
$started = $this->beginRegistration($client, $this->nonceFrom($client));
$registered = $this->finishRegistration(
$client,
$started['ceremonyId'],
Base64UrlSafe::decodeNoPadding($started['publicKey']['challenge']),
);
/* the stored record's counter is the one the registration used, and the
* lenient policy accepts an equal or greater value */
$counter = $this->helper()->counter() + 1;
$client = $this->freshVisitor();
$client->request('POST', self::ORIGIN.'/', [], [], [
'CONTENT_TYPE' => 'application/json',
'HTTP_X-Preauth-Passkey' => 'login-begin',
], '{}');
$begin = json_decode((string) $client->getResponse()->getContent(), true);
self::assertIsArray($begin);
/* sign a challenge the server never issued */
$assertion = $this->helper()->assertionCredential(
self::RP_ID,
random_bytes(32),
self::ORIGIN,
$this->credentialId(),
$counter,
hash('sha256', self::IDENTITY, true),
);
$client->request('POST', self::ORIGIN.'/', [], [], [
'CONTENT_TYPE' => 'application/json',
'HTTP_X-Preauth-Passkey' => 'login-finish',
], (string) json_encode(['ceremonyId' => $begin['ceremonyId'], 'credential' => $assertion]));
self::assertSame(Response::HTTP_UNAUTHORIZED, $client->getResponse()->getStatusCode());
}
/**
* An unknown credential must fail with the same generic message a wrong code
* gets, so the endpoint cannot be used to enumerate live credentials.
*/
public function test_an_unknown_credential_fails_generically(): void
{
$client = $this->createPasskeyClient();
$client->request('POST', self::ORIGIN.'/', [], [], [
'CONTENT_TYPE' => 'application/json',
'HTTP_X-Preauth-Passkey' => 'login-begin',
], '{}');
$begin = json_decode((string) $client->getResponse()->getContent(), true);
self::assertIsArray($begin);
/* a credential nobody registered, signed correctly against this challenge */
$assertion = $this->helper()->assertionCredential(
self::RP_ID,
Base64UrlSafe::decodeNoPadding($begin['publicKey']['challenge']),
self::ORIGIN,
random_bytes(16),
1,
hash('sha256', 'nobody', true),
);
$client->request('POST', self::ORIGIN.'/', [], [], [
'CONTENT_TYPE' => 'application/json',
'HTTP_X-Preauth-Passkey' => 'login-finish',
], (string) json_encode(['ceremonyId' => $begin['ceremonyId'], 'credential' => $assertion]));
$response = $client->getResponse();
self::assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode());
/* and the wording matches the ordinary failure, with no hint that the
* credential was unknown */
$content = json_decode((string) $response->getContent(), true);
self::assertIsArray($content);
self::assertSame('Unsuccessful login attempt', $content['message']);
}
/* ── the shared session (why SessionIssuer exists) ────────────────── */
/**
* Both login paths must produce the *same* cookie, or a user would appear
* logged in on the auth subdomain but not on the protected one.
*/
public function test_a_passkey_login_sets_the_same_cookie_as_a_code_login(): void
{
/* a code login, for comparison — via the same AJAX path the passkey
* script uses, so any difference is in the cookie and nothing else */
$codeCookie = $this->codeLoginCookie();
/* Now the passkey path, on a visitor with no session: the code login
* above set a cookie, and with it the login page is replaced by
* AcceptListener's "already authenticated" reply. */
$client = $this->freshVisitor();
$started = $this->beginRegistration($client, $this->nonceFrom($client));
$registered = $this->finishRegistration(
$client,
$started['ceremonyId'],
Base64UrlSafe::decodeNoPadding($started['publicKey']['challenge']),
);
$passkeyCookie = $this->authCookieFrom($registered);
self::assertSame($codeCookie->getName(), $passkeyCookie->getName());
self::assertSame($codeCookie->getDomain(), $passkeyCookie->getDomain());
self::assertSame($codeCookie->getPath(), $passkeyCookie->getPath());
self::assertSame($codeCookie->isSecure(), $passkeyCookie->isSecure());
self::assertSame($codeCookie->isHttpOnly(), $passkeyCookie->isHttpOnly());
self::assertSame($codeCookie->getSameSite(), $passkeyCookie->getSameSite());
}
/* ── caching and availability ─────────────────────────────────────── */
/**
* A ceremony reply is a browser-facing 2xx, which nothing else in this app
* produces, so it must carry the full no-store set or a browser could
* replay a stale challenge.
*/
public function test_ceremony_responses_are_not_cacheable(): void
{
$client = $this->createPasskeyClient();
$client->request('POST', self::ORIGIN.'/', [], [], [
'CONTENT_TYPE' => 'application/json',
'HTTP_X-Preauth-Passkey' => 'login-begin',
], '{}');
$response = $client->getResponse();
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertTrue($response->headers->hasCacheControlDirective('no-store'));
self::assertSame('no-store', $response->headers->get('Surrogate-Control'));
/* the internal marker must not leak to the browser */
self::assertFalse($response->headers->has(AppConstants::PASSKEY_CEREMONY_MARKER));
}
/**
* With the feature off the listener must be inert, so a caller cannot even
* obtain a challenge. This uses the default test environment, where
* PASSKEY_ENABLED is 0.
*/
public function test_the_ceremony_is_inert_when_passkeys_are_disabled(): void
{
/* no passkey env set, so PASSKEY_ENABLED keeps its .env.test value of 0 */
$client = static::createClient();
$client->request('POST', 'https://'.self::AUTH_HOST.'/', [], [], [
'CONTENT_TYPE' => 'application/json',
'HTTP_X-Preauth-Passkey' => 'login-begin',
], '{}');
$response = $client->getResponse();
self::assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode());
/* however the listener answered, the caller must not have been given a
* challenge — that is the property under test */
self::assertStringNotContainsString('publicKey', (string) $response->getContent());
}
/**
* The login page must not offer what the server refuses, and vice versa.
*/
public function test_the_login_page_offers_passkeys_when_enabled(): void
{
$client = $this->createPasskeyClient();
$client->request('GET', self::ORIGIN.'/');
$html = (string) $client->getResponse()->getContent();
self::assertStringContainsString('id="preauth-passkey"', $html);
self::assertStringContainsString('id="preauth-register"', $html);
}
/**
* The other half: with the feature off the page must not offer anything.
* Kept as its own test because a kernel may only be booted once, so the two
* configurations cannot be compared within a single test.
*/
public function test_the_login_page_offers_nothing_when_passkeys_are_disabled(): void
{
$client = static::createClient();
$client->request('GET', '/');
self::assertStringNotContainsString('preauth-passkey', (string) $client->getResponse()->getContent());
self::assertStringNotContainsString('preauth-register', (string) $client->getResponse()->getContent());
}
/**
* The CSP must permit the two WebAuthn directives, because they do not fall
* back to `default-src` and the browser refuses the ceremony without them.
*/
public function test_the_csp_permits_the_ceremony_when_passkeys_are_enabled(): void
{
$client = $this->createPasskeyClient();
$client->request('GET', self::ORIGIN.'/');
$csp = (string) $client->getResponse()->headers->get('Content-Security-Policy');
self::assertStringContainsString("publickey-credentials-get 'self'", $csp);
self::assertStringContainsString("publickey-credentials-create 'self'", $csp);
}
/* ── helpers ──────────────────────────────────────────────────────── */
private function authCookieFrom(Response $response): \Symfony\Component\HttpFoundation\Cookie
{
foreach ($response->headers->getCookies() as $cookie) {
if (self::AUTH_COOKIE === $cookie->getName()) {
return $cookie;
}
}
self::fail('Expected an auth cookie in the response.');
}
}
+13 -2
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Tests\Support;
use DateTimeImmutable;
use Override;
use Symfony\Component\RateLimiter\LimiterInterface;
use Symfony\Component\RateLimiter\RateLimit;
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
@@ -40,6 +41,8 @@ trait ListenerTestHelper
'teapot_message' => 'I refuse to brew coffee',
'too_many_title' => 'Too many requests',
'too_many_message' => 'Try again later',
'passkey_button_name' => 'Sign in with a passkey',
'passkey_register_name' => 'Register this device as a passkey',
'debug' => 0,
]);
@@ -59,6 +62,7 @@ trait ListenerTestHelper
{
}
#[Override]
public function create(?string $key = null): LimiterInterface
{
return $this->limiter;
@@ -80,16 +84,19 @@ trait ListenerTestHelper
{
}
#[Override]
public function reserve(int $tokens = 1, ?float $maxTime = null): \Symfony\Component\RateLimiter\Reservation
{
throw new \Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException();
throw new \Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException(static::class);
}
#[Override]
public function consume(int $tokens = 1): RateLimit
{
return $this->rateLimit;
}
#[Override]
public function reset(): void
{
}
@@ -109,11 +116,13 @@ trait ListenerTestHelper
{
}
#[Override]
public function reserve(int $tokens = 1, ?float $maxTime = null): \Symfony\Component\RateLimiter\Reservation
{
throw new \Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException();
throw new \Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException(static::class);
}
#[Override]
public function consume(int $tokens = 1): RateLimit
{
$this->consumed += $tokens;
@@ -127,6 +136,7 @@ trait ListenerTestHelper
);
}
#[Override]
public function reset(): void
{
$this->consumed = 0;
@@ -138,6 +148,7 @@ trait ListenerTestHelper
{
}
#[Override]
public function create(?string $key = null): LimiterInterface
{
return $this->limiter;
+273
View File
@@ -0,0 +1,273 @@
<?php
declare(strict_types=1);
namespace App\Tests\Support;
use CBOR\ByteStringObject;
use CBOR\Encoder;
use JsonException;
use OpenSSLAsymmetricKey;
use ParagonIE\ConstantTime\Base64UrlSafe;
use RuntimeException;
/**
* Builds real, cryptographically valid WebAuthn ceremonies for tests.
*
* Nothing here is mocked: the helper generates a P-256 keypair, builds a proper
* COSE public key, signs a correct `authenticatorData` with OpenSSL, and
* assembles the CBOR `attestationObject` a real authenticator would send. A
* passing test therefore proves the ceremony works, rather than proving that our
* code agrees with our own stubs.
*
* Two rules learned the hard way, both encoded below:
* - **The counter is controlled explicitly.** A stale counter raises
* `CounterException`, which can mask the real reason a verification failed and
* turn a negative test into a false pass.
* - **Options are serialised by the library**, never `json_encode()`d — the
* challenge is raw binary and `json_encode()` rejects it outright.
*/
final class PasskeyTestHelper
{
/** The `none` attestation format requires an all-zero AAGUID. */
private const string ZERO_AAGUID = '00000000000000000000000000000000';
private readonly OpenSSLAsymmetricKey $key;
/** @var array{x: string, y: string} */
private array $coordinates;
private int $counter = 0;
public function __construct()
{
$key = openssl_pkey_new([
'private_key_type' => \OPENSSL_KEYTYPE_EC,
'curve_name' => 'prime256v1',
]);
if (!$key instanceof OpenSSLAsymmetricKey) {
throw new RuntimeException('Unable to generate a P-256 keypair for tests.');
}
$details = openssl_pkey_get_details($key);
if (!\is_array($details) || !isset($details['ec']['x'], $details['ec']['y'])) {
throw new RuntimeException('Unable to read the generated P-256 keypair.');
}
$this->key = $key;
$this->coordinates = [
'x' => $details['ec']['x'],
'y' => $details['ec']['y'],
];
$this->counter = 0;
}
public function credentialId(): string
{
return random_bytes(16);
}
public function counter(): int
{
return $this->counter;
}
/**
* COSE-encoded public key, as carried in the attested credential data.
*
* Keys are the standard COSE labels: 1 = EC2, 3 = ES256, -1 = P-256,
* -2 = x, -3 = y. Binary coordinates must be byte strings, so they bypass
* the encoder's UTF-8 detection.
*/
public function cosePublicKey(): string
{
return (new Encoder())->encode([
1 => 2,
3 => -7,
-1 => 1,
-2 => ByteStringObject::create($this->coordinates['x']),
-3 => ByteStringObject::create($this->coordinates['y']),
]);
}
/**
* Authenticator data for a registration: rpIdHash, flags (UP|AT|UV), a
* counter, then the attested credential data.
*/
public function attestationAuthenticatorData(string $rpId, string $credentialId, bool $userVerified = true): string
{
$flags = 0x01 | 0x40; /* UP | AT */
if ($userVerified) {
$flags |= 0x04; /* UV */
}
return hash('sha256', $rpId, true)
.\chr($flags)
.pack('N', ++$this->counter)
.hex2bin(self::ZERO_AAGUID)
.pack('n', \strlen($credentialId))
.$credentialId
.$this->cosePublicKey();
}
/**
* Authenticator data for an assertion: rpIdHash, flags (UP|UV), a counter.
* The attested credential data lives in the stored record, not here.
*/
public function assertionAuthenticatorData(string $rpId, int $counter, bool $userVerified = true): string
{
$flags = 0x01; /* UP */
if ($userVerified) {
$flags |= 0x04; /* UV */
}
return hash('sha256', $rpId, true).\chr($flags).pack('N', $counter);
}
/**
* clientDataJSON with the challenge encoded exactly as a browser encodes it.
*
* @throws JsonException
*/
public function clientData(string $type, string $challenge, string $origin): string
{
return json_encode([
'type' => $type,
'challenge' => Base64UrlSafe::encodeUnpadded($challenge),
'origin' => $origin,
'crossOrigin' => false,
], \JSON_THROW_ON_ERROR);
}
/**
* The CBOR attestation object a browser sends, in the `none` format.
*/
public function attestationObject(string $authData): string
{
return (new Encoder())->encode([
'fmt' => 'none',
'attStmt' => [],
'authData' => ByteStringObject::create($authData),
]);
}
/**
* Sign `authData || sha256(clientDataJSON)` with the generated key.
*
* WebAuthn wants the raw 64-byte (r||s) form, but OpenSSL emits DER, so the
* result is converted. Getting this wrong produces a confusing "invalid
* signature" that looks like a logic bug rather than an encoding one.
*/
public function signature(string $authData, string $clientDataJson): string
{
$data = $authData.hash('sha256', $clientDataJson, true);
$der = '';
openssl_sign($data, $der, $this->key, \OPENSSL_ALGO_SHA256);
return self::derToRaw($der);
}
/**
* A ready-to-submit registration `credential`, matching what
* `navigator.credentials.create()` produces.
*
* @return array<string,mixed>
*
* @throws JsonException
*/
public function registrationCredential(
string $rpId,
string $challenge,
string $origin,
?string $credentialId = null,
): array {
$credentialId ??= $this->credentialId();
$clientDataJson = $this->clientData('webauthn.create', $challenge, $origin);
$authData = $this->attestationAuthenticatorData($rpId, $credentialId);
return [
'id' => Base64UrlSafe::encodeUnpadded($credentialId),
'rawId' => Base64UrlSafe::encodeUnpadded($credentialId),
'type' => 'public-key',
'response' => [
'clientDataJSON' => Base64UrlSafe::encodeUnpadded($clientDataJson),
'attestationObject' => Base64UrlSafe::encodeUnpadded($this->attestationObject($authData)),
'transports' => ['internal'],
],
];
}
/**
* A ready-to-submit assertion `credential`, matching what
* `navigator.credentials.get()` produces.
*
* @return array<string,mixed>
*
* @throws JsonException
*/
public function assertionCredential(
string $rpId,
string $challenge,
string $origin,
string $credentialId,
int $counter,
string $userHandle,
): array {
$clientDataJson = $this->clientData('webauthn.get', $challenge, $origin);
$authData = $this->assertionAuthenticatorData($rpId, $counter);
return [
'id' => Base64UrlSafe::encodeUnpadded($credentialId),
'rawId' => Base64UrlSafe::encodeUnpadded($credentialId),
'type' => 'public-key',
'response' => [
'clientDataJSON' => Base64UrlSafe::encodeUnpadded($clientDataJson),
'authenticatorData' => Base64UrlSafe::encodeUnpadded($authData),
'signature' => Base64UrlSafe::encodeUnpadded($this->signature($authData, $clientDataJson)),
'userHandle' => Base64UrlSafe::encodeUnpadded($userHandle),
],
];
}
/**
* Convert an OpenSSL DER signature to the fixed-length raw form WebAuthn
* mandates: 64 bytes for P-256, big-endian r||s.
*
* DER is `30 <len> 02 <lenR> R 02 <lenS> S`. Both integers are
* variable-length and may carry a leading zero byte, so each coordinate is
* right-aligned into exactly 32 bytes. The library rejects anything that is
* not exactly 64 bytes, and a malformed result looks like "invalid
* signature" rather than an encoding bug — hence the explicit round-trip
* check in the tests.
*
* @throws RuntimeException when the input is not a well-formed ECDSA-Sig-Value
*/
private static function derToRaw(string $der): string
{
$length = \strlen($der);
/* short-form SEQUENCE header: tag + one length byte */
if ($length < 8 || 0x30 !== \ord($der[0])) {
throw new RuntimeException('Expected a DER SEQUENCE.');
}
$offset = 2;
if (0x02 !== \ord($der[$offset])) {
throw new RuntimeException('Expected a DER INTEGER for r.');
}
$lengthR = \ord($der[$offset + 1]);
$r = substr($der, $offset + 2, $lengthR);
$offset += 2 + $lengthR;
if (0x02 !== \ord($der[$offset])) {
throw new RuntimeException('Expected a DER INTEGER for s.');
}
$lengthS = \ord($der[$offset + 1]);
$s = substr($der, $offset + 2, $lengthS);
return str_pad(substr($r, -32), 32, "\x00", \STR_PAD_LEFT)
.str_pad(substr($s, -32), 32, "\x00", \STR_PAD_LEFT);
}
}
+12
View File
@@ -8,6 +8,7 @@ use App\ConfigBag;
use App\Utilities;
use DateTimeImmutable;
use OTPHP\TOTP;
use Override;
use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Clock\ClockInterface as PsrClockInterface;
@@ -35,6 +36,7 @@ trait TotpTestHelper
{
}
#[Override]
public function now(): DateTimeImmutable
{
return new DateTimeImmutable($this->time);
@@ -77,6 +79,11 @@ trait TotpTestHelper
string $remoteUserMode = 'session',
string $remoteUserStatic = 'authenticated',
string $remoteUserMap = '',
string $title = 'Pre-Authentication System',
bool $passkeyEnabled = false,
string $passkeyRpName = '',
string $passkeyUserVerification = 'required',
int $passkeyTimeout = 60000,
): ConfigBag {
$clock = $this->frozenClock();
$utilities = $this->createUtilities($clock);
@@ -94,6 +101,11 @@ trait TotpTestHelper
$remoteUserMode,
$remoteUserStatic,
$remoteUserMap,
$title,
$passkeyEnabled,
$passkeyRpName,
$passkeyUserVerification,
$passkeyTimeout,
);
}
+1 -1
View File
@@ -31,7 +31,7 @@ class TestKernel extends AppKernel
$container->addCompilerPass(new class implements CompilerPassInterface {
public function process(ContainerBuilder $container): void
{
foreach (['nonceCache', 'rateLimitCache', 'sessionCache', 'sessionStorage', 'publicRateLimitCache'] as $poolId) {
foreach (['nonceCache', 'rateLimitCache', 'sessionCache', 'sessionStorage', 'publicRateLimitCache', 'passkeyRateLimitCache'] as $poolId) {
if ($container->hasDefinition($poolId)) {
$container->getDefinition($poolId)->clearTag('kernel.reset');
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\CacheWarmer;
use App\CacheWarmer\PasskeyConfigurationWarmer;
use App\Exception\PasskeyConfigurationException;
use App\Service\PasskeyPolicyInterface;
use PHPUnit\Framework\TestCase;
/**
* The warmer is the mechanism that turns a bad passkey configuration into a
* failed deployment rather than a broken feature (D1/D4, plan §4.2).
*/
final class PasskeyConfigurationWarmerTest extends TestCase
{
public function test_it_delegates_the_configuration_check(): void
{
$policy = $this->createMock(PasskeyPolicyInterface::class);
$policy->expects(self::once())->method('assertConfigurationIsUsable');
$warmer = new PasskeyConfigurationWarmer($policy);
self::assertSame([], $warmer->warmUp('/tmp/cache'));
}
public function test_it_is_not_optional(): void
{
/* an optional warmer can be skipped, which would defeat the check */
$warmer = new PasskeyConfigurationWarmer($this->createStub(PasskeyPolicyInterface::class));
self::assertFalse($warmer->isOptional());
}
public function test_it_propagates_a_configuration_failure(): void
{
$policy = $this->createStub(PasskeyPolicyInterface::class);
$policy->method('assertConfigurationIsUsable')
->willThrowException(new PasskeyConfigurationException('nope'));
$this->expectException(PasskeyConfigurationException::class);
(new PasskeyConfigurationWarmer($policy))->warmUp('/tmp/cache');
}
}
@@ -6,6 +6,7 @@ namespace App\Tests\Unit\Listener;
use App\Listener\InterceptListener;
use App\Service\DomainManager;
use App\Service\PasskeyPolicyInterface;
use App\Tests\Support\ListenerTestHelper;
use PHPUnit\Framework\TestCase;
use Psr\Cache\CacheItemPoolInterface;
@@ -32,6 +33,7 @@ final class InterceptListenerTest extends TestCase
$this->makeConfig(),
$domainManager,
$this->makeTwig(),
$this->createStub(PasskeyPolicyInterface::class),
);
$listener->setLogger(new NullLogger());
$listener->setNonceCache($nonceCache ?? new ArrayAdapter());
@@ -8,6 +8,7 @@ use App\Data\Payload;
use App\Listener\LoginListener;
use App\Service\DomainManager;
use App\Service\LoginInterface;
use App\Service\PasskeyPolicyInterface;
use App\Tests\Support\ListenerTestHelper;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
@@ -34,6 +35,7 @@ final class LoginListenerTest extends TestCase
$domainManager ?? new DomainManager(false, ''),
$loginManager ?? $this->createStub(LoginInterface::class),
$this->makeConfig(),
$this->createStub(PasskeyPolicyInterface::class),
);
$listener->setLogger(new NullLogger());
$listener->setNonceCache(new ArrayAdapter());
@@ -244,6 +246,7 @@ final class LoginListenerTest extends TestCase
new DomainManager(false, ''),
$loginManager,
$this->makeConfig(teapot: false),
$this->createStub(PasskeyPolicyInterface::class),
);
$listener->setLogger(new NullLogger());
$listener->setNonceCache(new ArrayAdapter());
+438
View File
@@ -0,0 +1,438 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Listener;
use App\AppConstants;
use App\Data\PasskeyCredential;
use App\Enum\Scope;
use App\Listener\PasskeyListener;
use App\Service\DomainInterface;
use App\Service\PasskeyInterface;
use App\Service\PasskeyPolicyInterface;
use App\Service\SessionIssuerInterface;
use App\Tests\Support\TotpTestHelper;
use App\Trait\StringTrait;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\RateLimiter\Storage\InMemoryStorage;
use Symfony\Component\Uid\Uuid;
use Webauthn\CredentialRecord;
use Webauthn\TrustPath\EmptyTrustPath;
/**
* The listener is where several security properties are enforced at once, so
* each is asserted separately: which requests it claims, what it does with
* ones it cannot serve, and — crucially — that a failure looks exactly like a
* wrong TOTP code.
*/
final class PasskeyListenerTest extends TestCase
{
use StringTrait;
use TotpTestHelper;
private const string AUTH_HOST = 'auth.example.com';
private function makeListener(
?PasskeyInterface $passkeys = null,
bool $available = true,
?SessionIssuerInterface $sessionIssuer = null,
?DomainInterface $domainManager = null,
int $beginLimit = 30,
): PasskeyListener {
$policy = $this->createStub(PasskeyPolicyInterface::class);
$policy->method('isAvailableFor')->willReturn($available);
$domainManager ??= $this->authDomainManager();
$listener = new PasskeyListener(
new RateLimiterFactory(['id' => 'passkey_begin_burst', 'policy' => 'sliding_window', 'limit' => $beginLimit, 'interval' => '60 seconds'], new InMemoryStorage()),
new RateLimiterFactory(['id' => 'login_limiter', 'policy' => 'sliding_window', 'limit' => 2, 'interval' => '60 seconds'], new InMemoryStorage()),
$passkeys ?? $this->createStub(PasskeyInterface::class),
$policy,
$sessionIssuer ?? $this->createStub(SessionIssuerInterface::class),
$domainManager,
$this->makeConfig(),
);
$listener->setLogger(new NullLogger());
$listener->setNonceCache(new ArrayAdapter());
return $listener;
}
private function authDomainManager(): DomainInterface
{
$manager = $this->createStub(DomainInterface::class);
$manager->method('getAuthSubdomain')->willReturn(self::AUTH_HOST);
$manager->method('authBase')->willReturn('example.com');
return $manager;
}
private function makeEvent(Request $request): RequestEvent
{
return new RequestEvent(
$this->createStub(HttpKernelInterface::class),
$request,
HttpKernelInterface::MAIN_REQUEST,
);
}
/**
* @param array<string,mixed> $body
*/
private function ceremonyRequest(string $operation, string $host = self::AUTH_HOST, array $body = []): Request
{
return Request::create(
"https://$host/",
'POST',
[],
[],
[],
['HTTP_X_PREAUTH_PASSKEY' => $operation, 'REMOTE_ADDR' => '1.2.3.4'],
(string) json_encode($body),
);
}
private function credential(string $identity = 'lyra'): PasskeyCredential
{
return new PasskeyCredential(
CredentialRecord::create(
random_bytes(16),
'public-key',
['internal'],
'none',
EmptyTrustPath::create(),
Uuid::v4(),
'KEY',
hash('sha256', $identity, true),
0,
null,
true,
false,
true,
),
$identity,
'Passkey abc',
new DateTimeImmutable(),
);
}
/* ── dispatch ─────────────────────────────────────────────────────── */
/**
* A request without the header is none of this listener's business, so it
* must not set a response and let the normal flow continue.
*/
public function test_a_request_without_the_header_is_ignored(): void
{
$listener = $this->makeListener();
$event = $this->makeEvent(Request::create('https://'.self::AUTH_HOST.'/', 'GET'));
$listener->onKernelRequest($event);
self::assertNull($event->getResponse());
}
/**
* Only the auth subdomain hosts ceremonies; elsewhere the header is ignored
* so this listener cannot be used to probe other hosts.
*/
public function test_the_header_is_ignored_on_a_non_auth_host(): void
{
$listener = $this->makeListener();
$event = $this->makeEvent($this->ceremonyRequest('login-begin', 'app.example.com'));
$listener->onKernelRequest($event);
self::assertNull($event->getResponse());
}
public function test_an_unknown_operation_is_rejected(): void
{
$listener = $this->makeListener();
$event = $this->makeEvent($this->ceremonyRequest('not-a-thing'));
$listener->onKernelRequest($event);
$response = $event->getResponse();
self::assertNotNull($response);
self::assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode());
}
/**
* Every header-bearing request is answered, even an unparseable one, so a
* `fetch()` caller never receives HTML from InterceptListener.
*/
public function test_a_malformed_body_still_gets_a_json_response(): void
{
$listener = $this->makeListener();
$request = Request::create(
'https://'.self::AUTH_HOST.'/',
'POST',
[],
[],
[],
['HTTP_X_PREAUTH_PASSKEY' => 'login-finish', 'REMOTE_ADDR' => '1.2.3.4'],
'this is not json',
);
$event = $this->makeEvent($request);
$listener->onKernelRequest($event);
$response = $event->getResponse();
self::assertNotNull($response);
self::assertSame('application/json', $response->headers->get('Content-Type'));
self::assertIsArray(json_decode((string) $response->getContent(), true));
}
/* ── availability ─────────────────────────────────────────────────── */
/**
* When passkeys are unavailable the feature must behave as if absent: no
* ceremony is started and nothing is written to any cache.
*/
public function test_begin_is_inert_when_passkeys_are_unavailable(): void
{
$passkeys = $this->createMock(PasskeyInterface::class);
$passkeys->expects(self::never())->method('beginLogin');
$listener = $this->makeListener($passkeys, available: false);
$event = $this->makeEvent($this->ceremonyRequest('login-begin'));
$listener->onKernelRequest($event);
self::assertSame(Response::HTTP_UNAUTHORIZED, $event->getResponse()?->getStatusCode());
}
public function test_begin_login_returns_options_and_a_ceremony_id(): void
{
$passkeys = $this->createStub(PasskeyInterface::class);
$passkeys->method('beginLogin')->willReturn([
'publicKey' => ['challenge' => 'abc', 'rpId' => 'example.com'],
'ceremonyId' => 'cid',
]);
$listener = $this->makeListener($passkeys);
$event = $this->makeEvent($this->ceremonyRequest('login-begin'));
$listener->onKernelRequest($event);
$response = $event->getResponse();
self::assertNotNull($response);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
$decoded = json_decode((string) $response->getContent(), true);
self::assertSame('cid', $decoded['ceremonyId']);
}
/**
* A ceremony reply is the one browser-facing 2xx, so it carries the marker
* that turns into the no-store policy.
*/
public function test_ceremony_responses_carry_the_marker(): void
{
$passkeys = $this->createStub(PasskeyInterface::class);
$passkeys->method('beginLogin')->willReturn(['publicKey' => [], 'ceremonyId' => 'cid']);
$listener = $this->makeListener($passkeys);
$event = $this->makeEvent($this->ceremonyRequest('login-begin'));
$listener->onKernelRequest($event);
self::assertSame('1', $event->getResponse()?->headers->get(AppConstants::PASSKEY_CEREMONY_MARKER));
}
/* ── registration gating ──────────────────────────────────────────── */
/**
* /**
* `register-begin` deliberately has no handler here.
*
* A registration ceremony may only start after a valid TOTP code, which is
* presented to `LoginManager` as part of the form submission — so
* `LoginManager` starts it. Exposing it on this listener would hand out a
* challenge without proving anything, which is precisely the hole the
* design closes. This test pins that the operation is *not* honoured.
*/
public function test_register_begin_is_not_a_listener_operation(): void
{
$passkeys = $this->createMock(PasskeyInterface::class);
$passkeys->expects(self::never())->method('beginRegistration');
$listener = $this->makeListener($passkeys);
$event = $this->makeEvent($this->ceremonyRequest('register-begin'));
$listener->onKernelRequest($event);
self::assertSame(Response::HTTP_UNAUTHORIZED, $event->getResponse()?->getStatusCode());
}
/* ── failures share the login budget (D3) ─────────────────────────── */
/**
* A failed ceremony must be indistinguishable from a wrong TOTP code: same
* status, same shape, and it must spend the same limiter.
*/
public function test_a_failed_ceremony_returns_401_with_a_nonce(): void
{
$passkeys = $this->createStub(PasskeyInterface::class);
$passkeys->method('finishLogin')->willReturn(null);
$listener = $this->makeListener($passkeys);
$event = $this->makeEvent($this->ceremonyRequest('login-finish', body: ['ceremonyId' => 'cid']));
$listener->onKernelRequest($event);
$response = $event->getResponse();
self::assertNotNull($response);
self::assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode());
$decoded = json_decode((string) $response->getContent(), true);
self::assertArrayHasKey('nonce', $decoded);
self::assertNotSame('', $decoded['nonce']);
}
/**
* After the shared budget is exhausted the response is the same 418/429 the
* TOTP path produces — this is what stops passkeys being an unlimited oracle.
*/
public function test_repeated_failures_exhaust_the_shared_budget(): void
{
$passkeys = $this->createStub(PasskeyInterface::class);
$passkeys->method('finishLogin')->willReturn(null);
$listener = $this->makeListener($passkeys);
/* the stub limiter allows 2 */
for ($i = 0; $i < 2; ++$i) {
$listener->onKernelRequest($this->makeEvent($this->ceremonyRequest('login-finish', body: ['ceremonyId' => 'cid'])));
}
$event = $this->makeEvent($this->ceremonyRequest('login-finish', body: ['ceremonyId' => 'cid']));
$listener->onKernelRequest($event);
/* the 418/429 choice mirrors LoginListener: `teapot` swaps the status but
* not the meaning, and the point here is that the budget is shared */
self::assertContains(
$event->getResponse()?->getStatusCode(),
[Response::HTTP_TOO_MANY_REQUESTS, Response::HTTP_I_AM_A_TEAPOT],
);
}
/* ── success ──────────────────────────────────────────────────────── */
public function test_a_successful_login_issues_a_session(): void
{
$credential = $this->credential('lyra');
$passkeys = $this->createStub(PasskeyInterface::class);
$passkeys->method('finishLogin')->willReturn($credential);
$issuer = $this->createMock(SessionIssuerInterface::class);
$issuer->expects(self::once())
->method('issue')
->with('lyra', Scope::Cookie, self::anything(), true)
->willReturn(new Response('', Response::HTTP_SEE_OTHER));
$listener = $this->makeListener($passkeys, sessionIssuer: $issuer);
$event = $this->makeEvent($this->ceremonyRequest('login-finish', body: ['ceremonyId' => 'cid']));
$listener->onKernelRequest($event);
self::assertSame(Response::HTTP_SEE_OTHER, $event->getResponse()?->getStatusCode());
}
public function test_a_successful_registration_issues_a_session(): void
{
$credential = $this->credential('lyra');
$passkeys = $this->createStub(PasskeyInterface::class);
$passkeys->method('finishRegistration')->willReturn($credential);
$issuer = $this->createMock(SessionIssuerInterface::class);
$issuer->expects(self::once())->method('issue')->willReturn(new Response('', Response::HTTP_SEE_OTHER));
$listener = $this->makeListener($passkeys, sessionIssuer: $issuer);
$event = $this->makeEvent($this->ceremonyRequest('register-finish', body: ['ceremonyId' => 'cid']));
$listener->onKernelRequest($event);
self::assertSame(Response::HTTP_SEE_OTHER, $event->getResponse()?->getStatusCode());
}
/* ── the resource guard ───────────────────────────────────────────── */
/**
* `begin` is bounded so an unauthenticated caller cannot fill the ceremony
* cache — but this guard is deliberately separate from the login budget.
*/
public function test_begin_is_rate_limited_as_a_resource_guard(): void
{
$passkeys = $this->createStub(PasskeyInterface::class);
$passkeys->method('beginLogin')->willReturn(['publicKey' => [], 'ceremonyId' => 'cid']);
$listener = $this->makeListener($passkeys, beginLimit: 2);
for ($i = 0; $i < 2; ++$i) {
$listener->onKernelRequest($this->makeEvent($this->ceremonyRequest('login-begin')));
}
$event = $this->makeEvent($this->ceremonyRequest('login-begin'));
$listener->onKernelRequest($event);
$response = $event->getResponse();
self::assertNotNull($response);
self::assertSame(Response::HTTP_TOO_MANY_REQUESTS, $response->getStatusCode());
self::assertTrue($response->headers->has('Retry-After'));
}
/**
* A successful `begin` must not spend failure budget: otherwise simply
* opening the login page would count against the user, and ten legitimately
* started ceremonies would lock them out.
*/
public function test_begin_does_not_consume_the_login_budget(): void
{
$passkeys = $this->createStub(PasskeyInterface::class);
$passkeys->method('beginLogin')->willReturn(['publicKey' => [], 'ceremonyId' => 'cid']);
$passkeys->method('finishLogin')->willReturn(null);
$listener = $this->makeListener($passkeys);
/* 10 begins, well inside the burst guard, then a failed login must still
* be answered as a normal 401 rather than an exhausted-budget response */
for ($i = 0; $i < 10; ++$i) {
$listener->onKernelRequest($this->makeEvent($this->ceremonyRequest('login-begin')));
}
$event = $this->makeEvent($this->ceremonyRequest('login-finish', body: ['ceremonyId' => 'cid']));
$listener->onKernelRequest($event);
self::assertSame(Response::HTTP_UNAUTHORIZED, $event->getResponse()?->getStatusCode());
}
/**
* An empty body is not an error in itself: the ceremony id is simply
* missing, so the request is refused the same way a malformed one is.
* Asserted separately because it is the shape a bare `fetch()` produces.
*/
public function test_an_empty_body_is_refused(): void
{
$passkeys = $this->createStub(PasskeyInterface::class);
$passkeys->method('finishLogin')->willReturn(null);
$listener = $this->makeListener($passkeys);
$event = $this->makeEvent($this->ceremonyRequest('login-finish'));
$listener->onKernelRequest($event);
self::assertSame(Response::HTTP_UNAUTHORIZED, $event->getResponse()?->getStatusCode());
}
}
+146
View File
@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Listener;
use App\Listener\InterceptListener;
use App\Service\DomainManager;
use App\Service\PasskeyPolicyInterface;
use App\Tests\Support\ListenerTestHelper;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
/**
* The login page is where "passkeys are unavailable" has to be visibly true.
*
* A disabled feature must be indistinguishable from one that does not exist, so
* these tests compare the rendered page rather than trusting that a conditional
* is in the right place.
*/
final class PasskeyUiTest extends TestCase
{
use ListenerTestHelper;
private function makeListener(string $authSubdomain, bool $passkeysAvailable): InterceptListener
{
$domainManager = new DomainManager(true, $authSubdomain);
$policy = $this->createStub(PasskeyPolicyInterface::class);
$policy->method('isAvailableFor')->willReturn($passkeysAvailable);
$listener = new InterceptListener(
$this->makeConfig(),
$domainManager,
$this->makeTwig(),
$policy,
);
$listener->setLogger(new NullLogger());
$listener->setNonceCache(new ArrayAdapter());
return $listener;
}
/**
* @param string $host the host being requested
* @param bool $passkeys whether passkeys are available for it
* @param string $authSubdomain the configured auth subdomain
*/
private function renderLoginPage(
string $host,
bool $passkeys,
string $authSubdomain = 'auth.example.com',
): string {
$listener = $this->makeListener($authSubdomain, $passkeys);
$request = Request::create("https://$host/", 'GET');
$event = new RequestEvent(
$this->createStub(HttpKernelInterface::class),
$request,
HttpKernelInterface::MAIN_REQUEST,
);
$listener->onKernelRequest($event);
return (string) $event->getResponse()?->getContent();
}
/**
* The headline property: with passkeys unavailable the page must contain
* nothing passkey-related at all.
*/
public function test_the_login_page_is_unchanged_when_passkeys_are_unavailable(): void
{
$html = $this->renderLoginPage('auth.example.com', false);
self::assertStringNotContainsString('preauth-passkey', $html);
self::assertStringNotContainsString('preauth-register', $html);
self::assertStringNotContainsString('publickey-credentials', $html);
}
public function test_the_login_page_offers_passkeys_when_available(): void
{
$html = $this->renderLoginPage('auth.example.com', true);
/* the sign-in button */
self::assertStringContainsString('id="preauth-passkey"', $html);
/* the registration checkbox */
self::assertStringContainsString('id="preauth-register"', $html);
self::assertStringContainsString('name="register"', $html);
}
/**
* The button and checkbox carry the configured labels, so an operator can
* reword them without touching templates.
*/
public function test_the_offered_ui_uses_the_configured_labels(): void
{
$html = $this->renderLoginPage('auth.example.com', true);
self::assertStringContainsString($this->makeConfig()->passkeyButtonName(), $html);
self::assertStringContainsString($this->makeConfig()->passkeyRegisterName(), $html);
}
/**
* The checkbox only makes sense where the form actually POSTs, because
* registration authorises itself with the TOTP code in that submission.
* On a protected host the form is submitted by fetch() instead.
*/
public function test_the_registration_checkbox_is_omitted_when_the_form_does_not_post(): void
{
/* A host outside the auth base domain renders the login page directly,
* and that page submits via the inline fetch() rather than a real form
* POST — so there is no submission for a registration to ride on. */
$html = $this->renderLoginPage('unrelated.test', true, 'auth.example.com');
self::assertStringContainsString('id="preauth-passkey"', $html);
self::assertStringNotContainsString('id="preauth-register"', $html);
}
/**
* The script must send base64url without padding, matching what
* webauthn-lib decodes. Padding would be a silent failure at the library,
* reported as "invalid signature" rather than as an encoding mistake.
*/
public function test_the_script_encodes_ceremony_values_as_unpadded_base64url(): void
{
$html = $this->renderLoginPage('auth.example.com', true);
self::assertStringContainsString("replace(/=+$/, '')", $html);
}
/**
* Registration is dispatched through the same POST the TOTP form uses, and
* marked as such so the server can tell the two apart.
*/
public function test_the_script_marks_the_registration_submission(): void
{
$html = $this->renderLoginPage('auth.example.com', true);
self::assertStringContainsString("register: 'passkey'", $html);
self::assertStringContainsString('register-finish', $html);
}
}
@@ -4,8 +4,10 @@ declare(strict_types=1);
namespace App\Tests\Unit\Listener;
use App\AppConstants;
use App\Listener\SecurityHeadersListener;
use App\Service\DomainInterface;
use App\Service\PasskeyPolicyInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -14,12 +16,15 @@ use Symfony\Component\HttpKernel\HttpKernelInterface;
final class SecurityHeadersListenerTest extends TestCase
{
private function makeListener(?string $authSubdomain = null): SecurityHeadersListener
private function makeListener(?string $authSubdomain = null, bool $passkeysAvailable = false): SecurityHeadersListener
{
$domainManager = $this->createStub(DomainInterface::class);
$domainManager->method('getAuthSubdomain')->willReturn($authSubdomain);
return new SecurityHeadersListener($domainManager);
$policy = $this->createStub(PasskeyPolicyInterface::class);
$policy->method('isAvailableFor')->willReturn($passkeysAvailable);
return new SecurityHeadersListener($domainManager, $policy);
}
private function makeEvent(
@@ -204,4 +209,76 @@ final class SecurityHeadersListenerTest extends TestCase
self::assertStringNotContainsString('connect-src', $response->headers->get('Content-Security-Policy'));
}
/**
* `publickey-credentials-get`/`-create` do not fall back to `default-src`, so
* without them the browser refuses the ceremony however the script is written.
*/
public function test_csp_grants_the_webauthn_directives_when_passkeys_are_available(): void
{
$listener = $this->makeListener('auth.example.com', true);
$response = new Response('login', Response::HTTP_UNAUTHORIZED);
$request = Request::create('https://auth.example.com/', 'GET');
$listener->onKernelResponse($this->makeEvent($response, $request));
$csp = (string) $response->headers->get('Content-Security-Policy');
self::assertStringContainsString("publickey-credentials-get 'self';", $csp);
self::assertStringContainsString("publickey-credentials-create 'self';", $csp);
self::assertStringContainsString("connect-src 'self';", $csp);
}
/**
* With passkeys unavailable the header must be byte-identical to today's,
* which is what makes "unavailable means invisible" a testable property.
*/
public function test_csp_is_unchanged_when_passkeys_are_unavailable(): void
{
$listener = $this->makeListener('auth.example.com', false);
$response = new Response('login', Response::HTTP_UNAUTHORIZED);
$request = Request::create('https://auth.example.com/', 'GET');
$listener->onKernelResponse($this->makeEvent($response, $request));
self::assertSame(
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';",
$response->headers->get('Content-Security-Policy'),
);
}
/**
* A ceremony reply is a browser-facing 2xx, so the usual "2xx is consumed by
* forward_auth" assumption does not hold and it has to be made no-store.
*/
public function test_ceremony_responses_are_made_no_store_and_the_marker_is_stripped(): void
{
$listener = $this->makeListener('auth.example.com');
$response = new Response('{}', Response::HTTP_OK);
$response->headers->set(AppConstants::PASSKEY_CEREMONY_MARKER, '1');
$listener->onKernelResponse($this->makeEvent($response));
self::assertFalse($response->headers->has(AppConstants::PASSKEY_CEREMONY_MARKER));
self::assertStringContainsString('no-store', (string) $response->headers->get('Cache-Control'));
self::assertSame('*', $response->headers->get('Vary'));
}
/**
* An ordinary 2xx must stay untouched: those are consumed by forward_auth, and
* adding cache headers could interfere with the protected service.
*/
public function test_a_plain_success_response_is_left_cacheable(): void
{
$listener = $this->makeListener('auth.example.com');
$response = new Response('hi', Response::HTTP_OK);
$response->setCache(['public' => true, 'max_age' => 60]);
$listener->onKernelResponse($this->makeEvent($response));
/* the headers the caller set must survive untouched — no no-store, and
* no marker leaking through */
$cacheControl = (string) $response->headers->get('Cache-Control');
self::assertStringNotContainsString('no-store', $cacheControl);
self::assertStringContainsString('max-age=60', $cacheControl);
self::assertFalse($response->headers->has('Surrogate-Control'));
self::assertFalse($response->headers->has(AppConstants::PASSKEY_CEREMONY_MARKER));
}
}
+101 -4
View File
@@ -4,11 +4,14 @@ declare(strict_types=1);
namespace App\Tests\Unit\Service;
use App\AppConstants;
use App\Data\Payload;
use App\Enum\Scope;
use App\Service\BackupCodeInterface;
use App\Service\DomainManager;
use App\Service\LoginManager;
use App\Service\PasskeyInterface;
use App\Service\SessionIssuer;
use App\Tests\Support\TotpTestHelper;
use App\Trait\StringTrait;
use PHPUnit\Framework\TestCase;
@@ -16,6 +19,7 @@ use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Log\NullLogger;
use ReflectionProperty;
use RuntimeException;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\HttpException;
@@ -28,17 +32,24 @@ final class LoginManagerTest extends TestCase
private ArrayAdapter $pool;
private BackupCodeInterface $backupCodeManager;
private DomainManager $domainManager;
private ?SessionIssuer $sessionIssuer = null;
private function makeLoginManager(
?int $ipTtl = 0,
bool $subdomainRedirect = false,
string $authSubdomain = '',
?PasskeyInterface $passkeys = null,
): LoginManager {
$this->pool = new ArrayAdapter();
$this->backupCodeManager = $this->createStub(BackupCodeInterface::class);
$this->domainManager = new DomainManager($subdomainRedirect, $authSubdomain);
$manager = new LoginManager($this->pool, $this->backupCodeManager, $this->domainManager);
/* session issuing is shared with the passkey flow, so it is built as the
* same collaborator the container would inject */
$this->sessionIssuer = new SessionIssuer($this->pool, $this->domainManager, $this->makeConfig(ipTtl: $ipTtl));
$this->sessionIssuer->setLogger(new NullLogger());
$manager = new LoginManager($this->backupCodeManager, $this->sessionIssuer, $passkeys ?? $this->createStub(PasskeyInterface::class));
$manager->setConfig($this->makeConfig(ipTtl: $ipTtl));
$manager->setLogger(new NullLogger());
$manager->setNonceCache(new ArrayAdapter());
@@ -277,8 +288,10 @@ final class LoginManagerTest extends TestCase
self::assertFalse($response->headers->has('Set-Cookie'));
// verify the IP session exists in the cache
$reflection = new ReflectionProperty(LoginManager::class, 'sessionCache');
$sessionCache = $reflection->getValue($manager);
$issuer = $this->sessionIssuer;
self::assertNotNull($issuer, 'makeLoginManager() should have built a session issuer.');
$reflection = new ReflectionProperty(SessionIssuer::class, 'sessionCache');
$sessionCache = $reflection->getValue($issuer);
self::assertTrue($sessionCache->hasItem('ip_1.2.3.4'));
}
@@ -361,7 +374,12 @@ final class LoginManagerTest extends TestCase
$this->backupCodeManager = $this->createStub(BackupCodeInterface::class);
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
$manager = new LoginManager($pool, $this->backupCodeManager, $this->domainManager);
/* the colliding pool must be the one the session issuer writes through,
* because that is where the cookie is stored */
$issuer = new SessionIssuer($pool, $this->domainManager, $this->makeConfig());
$issuer->setLogger(new NullLogger());
$manager = new LoginManager($this->backupCodeManager, $issuer, $this->createStub(PasskeyInterface::class));
$manager->setConfig($this->makeConfig());
$manager->setLogger(new NullLogger());
$manager->setNonceCache(new ArrayAdapter());
@@ -448,4 +466,83 @@ final class LoginManagerTest extends TestCase
// should fall back to path since empty string is not a valid URL
self::assertStringStartsWith('/', $location);
}
/* ── the passkey registration hand-off ────────────────────────────── */
/**
* With the intent set, a successful check must return ceremony options
* rather than a session — beginning the ceremony from the place that has
* already verified both the code and the nonce.
*/
public function test_a_registration_intent_returns_ceremony_options(): void
{
$passkeys = $this->createMock(PasskeyInterface::class);
$passkeys->expects(self::once())
->method('beginRegistration')
->with('testuser')
->willReturn(['publicKey' => ['challenge' => 'abc'], 'ceremonyId' => 'cid']);
$manager = $this->makeLoginManager(passkeys: $passkeys);
$payload = $this->makePayloadWithNonce($manager);
$payload->register = true;
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
$response = $manager->checkToken($payload, Request::create('/', 'GET'));
self::assertNotNull($response);
self::assertSame(200, $response->getStatusCode());
self::assertSame('application/json', $response->headers->get('Content-Type'));
/* a ceremony reply is browser-facing, so it must carry the marker that
* becomes the no-store policy */
self::assertSame('1', $response->headers->get(AppConstants::PASSKEY_CEREMONY_MARKER));
$decoded = json_decode((string) $response->getContent(), true);
self::assertSame('cid', $decoded['register']['ceremonyId']);
}
/**
* Registration does **not** grant a session: the credential is not verified
* until register-finish, so issuing one now would hand out access for a
* ceremony that has not happened.
*/
public function test_a_registration_intent_sets_no_session_cookie(): void
{
$passkeys = $this->createStub(PasskeyInterface::class);
$passkeys->method('beginRegistration')->willReturn(['publicKey' => [], 'ceremonyId' => 'cid']);
$manager = $this->makeLoginManager(passkeys: $passkeys);
$payload = $this->makePayloadWithNonce($manager);
$payload->register = true;
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
$response = $manager->checkToken($payload, Request::create('/', 'GET'));
self::assertNotNull($response);
self::assertFalse($response->headers->has('Set-Cookie'));
self::assertFalse($response->headers->has('Location'));
}
/**
* A ceremony that cannot start must not become a 500 on the login page: it
* falls through to the same failure path a wrong code takes.
*/
public function test_a_ceremony_that_cannot_start_fails_like_a_wrong_code(): void
{
$passkeys = $this->createStub(PasskeyInterface::class);
$passkeys->method('beginRegistration')->willThrowException(new RuntimeException('no ceremony'));
$manager = $this->makeLoginManager(passkeys: $passkeys);
$payload = $this->makePayloadWithNonce($manager);
$payload->register = true;
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
self::assertNull($manager->checkToken($payload, Request::create('/', 'GET')));
}
}
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Service;
use App\Service\PasskeyCeremonyFactory;
use JsonSerializable;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Uid\Uuid;
use Webauthn\AttestationStatement\AttestationStatementSupportManager;
use Webauthn\AttestationStatement\NoneAttestationStatementSupport;
use Webauthn\CredentialRecord;
use Webauthn\TrustPath\EmptyTrustPath;
/**
* The factory is the single seam between the application and webauthn-lib, so
* these tests pin the library behaviours the rest of the feature relies on.
*/
final class PasskeyCeremonyFactoryTest extends TestCase
{
private function makeFactory(): PasskeyCeremonyFactory
{
return new PasskeyCeremonyFactory();
}
private function makeRecord(): CredentialRecord
{
return CredentialRecord::create(
random_bytes(32),
'public-key',
['internal'],
'none',
EmptyTrustPath::create(),
Uuid::v4(),
'COSE_PUBLIC_KEY_BYTES',
'user-handle',
0,
null,
true,
false,
true,
);
}
public function test_it_exposes_the_serializer(): void
{
self::assertInstanceOf(SerializerInterface::class, $this->makeFactory()->serializer());
}
/**
* Only the `none` attestation format is registered — the deliberate choice
* recorded in `SECURITY.md`. Registering more formats would not make
* attestation verifiable; it would only accept statements nothing checks.
*/
public function test_only_none_attestation_is_supported(): void
{
$manager = $this->makeFactory()->attestationStatementSupportManager();
self::assertInstanceOf(AttestationStatementSupportManager::class, $manager);
self::assertTrue($manager->has('none'));
self::assertFalse($manager->has('packed'));
self::assertFalse($manager->has('fido-u2f'));
self::assertFalse($manager->has('tpm'));
self::assertFalse($manager->has('android-key'));
self::assertFalse($manager->has('apple'));
}
public function test_the_support_manager_has_the_none_support_registered(): void
{
$manager = $this->makeFactory()->attestationStatementSupportManager();
self::assertInstanceOf(
NoneAttestationStatementSupport::class,
$manager->get('none'),
);
}
public function test_credential_records_round_trip_through_storage(): void
{
$factory = $this->makeFactory();
$record = $this->makeRecord();
$restored = $factory->deserializeCredential($factory->serializeCredential($record));
self::assertNotNull($restored);
self::assertSame($record->publicKeyCredentialId, $restored->publicKeyCredentialId);
self::assertSame($record->credentialPublicKey, $restored->credentialPublicKey);
self::assertSame($record->userHandle, $restored->userHandle);
self::assertSame($record->counter, $restored->counter);
self::assertSame($record->transports, $restored->transports);
self::assertSame($record->attestationType, $restored->attestationType);
self::assertSame($record->backupEligible, $restored->backupEligible);
self::assertSame($record->backupStatus, $restored->backupStatus);
self::assertSame($record->uvInitialized, $restored->uvInitialized);
self::assertSame($record->aaguid->__toString(), $restored->aaguid->__toString());
}
/**
* A record is not JsonSerializable, so a plain json_encode() would silently
* produce something that cannot be read back. The factory must not rely on
* that path.
*/
public function test_credential_records_are_not_naively_json_encodable(): void
{
self::assertNotInstanceOf(JsonSerializable::class, $this->makeRecord());
}
/**
* Unreadable stored data degrades to null so a corrupt entry cannot produce
* a 500 on the login page.
*/
public function test_unreadable_stored_data_returns_null(): void
{
self::assertNull($this->makeFactory()->deserializeCredential('{not valid json'));
self::assertNull($this->makeFactory()->deserializeCredential(''));
self::assertNull($this->makeFactory()->deserializeCredential('{"unexpected":"shape"}'));
}
}
@@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Service;
use App\Service\PasskeyCeremonyStore;
use App\Service\PasskeyCeremonyStoreInterface;
use PHPUnit\Framework\TestCase;
use ReflectionMethod;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
/**
* The ceremony store holds the two properties the whole flow depends on: the
* challenge is server-authoritative, and it can only be used once.
*/
final class PasskeyCeremonyStoreTest extends TestCase
{
private function makeStore(?ArrayAdapter $cache = null): PasskeyCeremonyStore
{
return new PasskeyCeremonyStore($cache ?? new ArrayAdapter());
}
public function test_it_starts_a_login_ceremony(): void
{
$ceremony = $this->makeStore()->startLogin();
self::assertArrayHasKey('ceremonyId', $ceremony);
self::assertArrayHasKey('challenge', $ceremony);
self::assertSame(32, \strlen($ceremony['challenge']));
self::assertNotSame('', $ceremony['ceremonyId']);
}
public function test_it_starts_a_registration_ceremony_with_a_derived_user_handle(): void
{
$ceremony = $this->makeStore()->startRegistration('lyra');
self::assertSame(hash('sha256', 'lyra', true), $ceremony['userHandle']);
/* the handle must not be the raw identity, or the label leaks into the
* authenticator's credential list */
self::assertStringNotContainsString('lyra', $ceremony['userHandle']);
}
public function test_each_ceremony_gets_a_distinct_id_and_challenge(): void
{
$store = $this->makeStore();
$first = $store->startLogin();
$second = $store->startLogin();
self::assertNotSame($first['ceremonyId'], $second['ceremonyId']);
self::assertNotSame($first['challenge'], $second['challenge']);
}
public function test_consume_returns_the_stored_challenge(): void
{
$store = $this->makeStore();
$ceremony = $store->startLogin();
$consumed = $store->consume($ceremony['ceremonyId'], PasskeyCeremonyStoreInterface::TYPE_LOGIN);
self::assertNotNull($consumed);
self::assertSame($ceremony['challenge'], $consumed['challenge']);
}
/**
* The replay guard. A second consume must fail, otherwise an observed
* `finish` could be replayed against the same challenge.
*/
public function test_a_ceremony_can_only_be_consumed_once(): void
{
$store = $this->makeStore();
$ceremony = $store->startLogin();
self::assertNotNull($store->consume($ceremony['ceremonyId'], PasskeyCeremonyStoreInterface::TYPE_LOGIN));
self::assertNull($store->consume($ceremony['ceremonyId'], PasskeyCeremonyStoreInterface::TYPE_LOGIN));
}
public function test_an_unknown_id_yields_null(): void
{
self::assertNull($this->makeStore()->consume('never-issued', PasskeyCeremonyStoreInterface::TYPE_LOGIN));
}
/**
* A registration ceremony must not be usable to complete a login, or the
* two flows' differing trust assumptions would blur together.
*/
public function test_a_ceremony_cannot_be_used_for_the_other_type(): void
{
$store = $this->makeStore();
$registration = $store->startRegistration('lyra');
self::assertNull($store->consume($registration['ceremonyId'], PasskeyCeremonyStoreInterface::TYPE_LOGIN));
/* and it was destroyed by that failed attempt, so it cannot be retried
* with the correct type either */
self::assertNull($store->consume($registration['ceremonyId'], PasskeyCeremonyStoreInterface::TYPE_REGISTER));
}
/**
* `makeCacheKey()` sanitises the base64url alphabet into `_`, so using it
* directly on an id would let two distinct ids collide on one cache slot.
* The store hashes instead; this asserts distinct ids stay distinct.
*/
public function test_distinct_ids_never_share_a_cache_key(): void
{
$cache = new ArrayAdapter();
$store = $this->makeStore($cache);
$ids = [];
for ($i = 0; $i < 50; ++$i) {
$ids[] = $store->startRegistration("user$i")['ceremonyId'];
}
self::assertCount(50, array_unique($ids));
foreach ($ids as $id) {
self::assertNotNull($store->consume($id, PasskeyCeremonyStoreInterface::TYPE_REGISTER));
}
}
public function test_ceremonies_do_not_survive_the_cache_being_cleared(): void
{
$cache = new ArrayAdapter();
$store = $this->makeStore($cache);
$ceremony = $store->startLogin();
/* stand-in for expiry/eviction: a ceremony must not outlive its TTL, and
* `nonceCache` is APCu precisely so it does not survive a restart */
$cache->clear();
self::assertNull($store->consume($ceremony['ceremonyId'], PasskeyCeremonyStoreInterface::TYPE_LOGIN));
}
/**
* Malformed cache contents must degrade to "no ceremony" rather than
* throwing into the listener, which would surface as a 500 on the login page.
*/
public function test_a_malformed_record_is_treated_as_absent(): void
{
$cache = new ArrayAdapter();
$store = $this->makeStore($cache);
/* reach the private key derivation so the malformed value lands exactly
* where a real ceremony record would */
$key = new ReflectionMethod(PasskeyCeremonyStore::class, 'key');
$item = $cache->getItem($key->invoke($store, 'bogus'));
$item->set('not-an-array');
$cache->save($item);
self::assertNull($store->consume('bogus', PasskeyCeremonyStoreInterface::TYPE_LOGIN));
}
}
@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Service;
use App\Service\PasskeyCounterChecker;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Uid\Uuid;
use Webauthn\Counter\CounterChecker;
use Webauthn\Counter\ThrowExceptionIfInvalid;
use Webauthn\CredentialRecord;
use Webauthn\Exception\CounterException;
use Webauthn\TrustPath\EmptyTrustPath;
/**
* The counter policy is the difference between "works on real hardware" and
* "works only in tests", so both halves of it are pinned here: the behaviour we
* deliberately allow, and the behaviour we deliberately still refuse.
*/
final class PasskeyCounterCheckerTest extends TestCase
{
private function makeRecord(int $counter): CredentialRecord
{
return CredentialRecord::create(
random_bytes(32),
'public-key',
['internal'],
'none',
EmptyTrustPath::create(),
Uuid::v4(),
'COSE_PUBLIC_KEY_BYTES',
'user-handle',
$counter,
null,
true,
false,
true,
);
}
/**
* The regression this class exists for: a synchronised passkey reports 0
* forever, so the first login of a brand-new credential must succeed.
*/
public function test_a_constant_zero_counter_is_accepted(): void
{
$checker = new PasskeyCounterChecker();
$record = $this->makeRecord(0);
$checker->check($record, 0);
$checker->check($record, 0);
/* reaching this point without an exception is the assertion */
self::assertSame(0, $record->counter);
}
/**
* Documents *why* the library default cannot be used: it rejects the exact
* scenario above. If a future library version relaxes this, the test fails
* and the custom checker can be reconsidered rather than kept by habit.
*/
public function test_the_library_default_would_reject_a_constant_zero_counter(): void
{
$this->expectException(CounterException::class);
(new ThrowExceptionIfInvalid())->check($this->makeRecord(0), 0);
}
public function test_a_counter_that_moves_forward_is_accepted(): void
{
$checker = new PasskeyCounterChecker();
$record = $this->makeRecord(5);
$checker->check($record, 6);
$checker->check($record, \PHP_INT_MAX);
self::assertSame(5, $record->counter);
}
public function test_a_counter_that_moves_backwards_is_rejected(): void
{
$checker = new PasskeyCounterChecker();
$record = $this->makeRecord(5);
$this->expectException(CounterException::class);
$checker->check($record, 4);
}
/**
* The exception carries both values, which the listener logs. Asserted so a
* future refactor cannot quietly drop the diagnostic detail.
*/
public function test_the_rejection_reports_both_counters(): void
{
$checker = new PasskeyCounterChecker();
$record = $this->makeRecord(9);
try {
$checker->check($record, 3);
self::fail('Expected a CounterException.');
} catch (CounterException $exception) {
self::assertSame(3, $exception->currentCounter);
self::assertSame(9, $exception->authenticatorCounter);
}
}
/**
* The checker under test must differ from the library default, otherwise
* wiring the default back in by accident would go unnoticed.
*/
public function test_it_is_not_the_library_default(): void
{
self::assertInstanceOf(CounterChecker::class, new PasskeyCounterChecker());
}
}
@@ -0,0 +1,293 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Service;
use App\Data\PasskeyCredential;
use App\MonitorCacheKeys;
use App\Service\PasskeyCeremonyFactory;
use App\Service\PasskeyCredentialStore;
use DateTimeImmutable;
use LogicException;
use Override;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Uid\Uuid;
use Webauthn\CredentialRecord;
use Webauthn\TrustPath\EmptyTrustPath;
/**
* Covers credential persistence, the index, and the two failure modes the plan
* called out: losing credentials on restart, and key collisions.
*/
final class PasskeyCredentialStoreTest extends TestCase
{
/** The real backing pool, so persistence can be asserted against it. */
private ?ArrayAdapter $pool = null;
#[Override]
protected function setUp(): void
{
parent::setUp();
$this->pool = new ArrayAdapter();
}
private function makeStore(): PasskeyCredentialStore
{
return new PasskeyCredentialStore($this->pool(), new PasskeyCeremonyFactory());
}
/** The pool for the current test; setUp() always assigns it. */
private function pool(): ArrayAdapter
{
return $this->pool ?? throw new LogicException('setUp() did not run');
}
/** @param array{userHandle?: string, counter?: int, backupEligible?: ?bool} $overrides */
private function makeCredential(
string $credentialId,
string $identity = 'lyra',
string $label = 'Laptop',
array $overrides = [],
): PasskeyCredential {
$record = CredentialRecord::create(
$credentialId,
'public-key',
['internal'],
'none',
EmptyTrustPath::create(),
Uuid::v4(),
'COSE_PUBLIC_KEY_BYTES',
$overrides['userHandle'] ?? 'user-handle',
$overrides['counter'] ?? 0,
null,
$overrides['backupEligible'] ?? true,
false,
true,
);
return new PasskeyCredential($record, $identity, $label, new DateTimeImmutable('2026-01-01 12:00:00'));
}
public function test_save_then_find_round_trips_the_record(): void
{
$store = $this->makeStore();
$credentialId = random_bytes(32);
$store->save($this->makeCredential($credentialId));
$found = $store->find($credentialId);
self::assertNotNull($found);
self::assertSame($credentialId, $found->record->publicKeyCredentialId);
self::assertSame('lyra', $found->identity);
self::assertSame('Laptop', $found->label);
self::assertSame('COSE_PUBLIC_KEY_BYTES', $found->record->credentialPublicKey);
self::assertSame('user-handle', $found->record->userHandle);
self::assertTrue($found->record->backupEligible);
self::assertNull($found->lastUsedAt);
}
public function test_find_returns_null_for_an_unknown_credential(): void
{
self::assertNull($this->makeStore()->find(random_bytes(32)));
}
public function test_all_returns_every_saved_credential(): void
{
$store = $this->makeStore();
$store->save($this->makeCredential(random_bytes(32), label: 'One'));
$store->save($this->makeCredential(random_bytes(32), label: 'Two'));
$store->save($this->makeCredential(random_bytes(32), label: 'Three'));
self::assertCount(3, $store->all());
self::assertSame(3, $store->count());
}
public function test_find_by_identity_filters(): void
{
$store = $this->makeStore();
$store->save($this->makeCredential(random_bytes(32), identity: 'lyra'));
$store->save($this->makeCredential(random_bytes(32), identity: 'lyra'));
$store->save($this->makeCredential(random_bytes(32), identity: 'someone-else'));
self::assertCount(2, $store->findByIdentity('lyra'));
self::assertCount(1, $store->findByIdentity('someone-else'));
self::assertCount(0, $store->findByIdentity('nobody'));
}
public function test_remove_forgets_the_credential_and_the_index_entry(): void
{
$store = $this->makeStore();
$credentialId = random_bytes(32);
$store->save($this->makeCredential($credentialId));
self::assertTrue($store->remove($credentialId));
self::assertNull($store->find($credentialId));
self::assertSame(0, $store->count());
self::assertSame([], $store->all());
}
public function test_update_usage_refreshes_the_counter_and_last_used(): void
{
$store = $this->makeStore();
$credentialId = random_bytes(32);
$store->save($this->makeCredential($credentialId, overrides: ['counter' => 0]));
/* the library updates the counter in place after a verified assertion */
$used = $store->find($credentialId)->record;
$used->counter = 7;
$store->updateUsage($used);
$found = $store->find($credentialId);
self::assertSame(7, $found->record->counter);
self::assertNotNull($found->lastUsedAt);
/* metadata must be preserved, not reset by the usage update */
self::assertSame('lyra', $found->identity);
self::assertSame('Laptop', $found->label);
}
public function test_update_usage_ignores_unknown_credentials(): void
{
$store = $this->makeStore();
$record = $this->makeCredential(random_bytes(32))->record;
$store->updateUsage($record);
self::assertSame(0, $store->count());
}
/**
* Distinct credential ids must never share a cache slot.
*
* `makeCacheKey()` alone is not injective for the base64url alphabet
* ("abc-def" and "abc_def" both sanitise to "abc_def"), so the store hashes
* the id. These two ids differ only by '-' vs '_' on purpose.
*/
public function test_credential_ids_that_differ_only_by_base64url_punctuation_do_not_collide(): void
{
$store = $this->makeStore();
$store->save($this->makeCredential('abc-def', label: 'Dash'));
$store->save($this->makeCredential('abc_def', label: 'Underscore'));
self::assertSame(2, $store->count());
self::assertSame('Dash', $store->find('abc-def')->label);
self::assertSame('Underscore', $store->find('abc_def')->label);
}
/**
* The plan's headline storage risk: without wrapping the pool in
* MonitorCacheKeys, credentials would live only in the APCu-side pool and
* vanish on the next restart, because PersistCache::persist() only flushes
* keys a monitor recorded.
*/
public function test_saved_credentials_are_visible_to_the_persistent_pool(): void
{
$store = $this->makeStore();
$credentialId = random_bytes(32);
$store->save($this->makeCredential($credentialId));
$monitor = new MonitorCacheKeys($this->pool());
$recorded = $monitor->getKeys();
self::assertNotEmpty($recorded, 'the store must record its writes with MonitorCacheKeys');
self::assertContains(
'passkey_index',
$recorded,
'the index must be tracked so it is flushed to the persistent pool',
);
$changes = $monitor->getChanges();
self::assertArrayHasKey('passkey_index', $changes);
/* and the credential itself must be tracked, not just the index */
$trackedCredentialKeys = array_filter(
$recorded,
static fn (string $key): bool => str_starts_with($key, 'passkey_cred_'),
);
self::assertNotEmpty($trackedCredentialKeys, 'the credential entry must be tracked too');
}
/**
* A corrupt or foreign payload must degrade to "unavailable", never to a
* crash on the login page.
*/
public function test_a_corrupt_entry_is_skipped_rather_than_throwing(): void
{
$store = $this->makeStore();
$credentialId = random_bytes(32);
$store->save($this->makeCredential($credentialId));
/* corrupt the stored record but leave the index intact */
$key = 'passkey_cred_'.hash('sha256', $credentialId);
$item = $this->pool()->getItem($key);
$payload = $item->get();
$payload['record'] = '{not valid json';
$item->set($payload);
$this->pool()->save($item);
self::assertNull($store->find($credentialId));
/* all() must not throw, it must simply omit the broken entry */
self::assertSame([], $store->all());
}
/**
* The record decides which credential id it belongs to; an index entry
* pointing somewhere else must not be honoured.
*/
public function test_a_record_that_disagrees_with_its_key_is_rejected(): void
{
$store = $this->makeStore();
$real = random_bytes(32);
$store->save($this->makeCredential($real));
/* copy the payload to a different credential id's slot */
$source = $this->pool()->getItem('passkey_cred_'.hash('sha256', $real));
$otherId = random_bytes(32);
$target = $this->pool()->getItem('passkey_cred_'.hash('sha256', $otherId));
$target->set($source->get());
$this->pool()->save($target);
self::assertNull($store->find($otherId));
}
public function test_an_empty_index_reads_as_empty(): void
{
self::assertSame([], $this->makeStore()->all());
self::assertSame(0, $this->makeStore()->count());
}
/**
* A stored value that is not the expected structure (for example written by
* a different version) must read as "no such credential".
*/
public function test_a_payload_of_the_wrong_shape_reads_as_missing(): void
{
$store = $this->makeStore();
$credentialId = random_bytes(32);
$store->save($this->makeCredential($credentialId));
$item = $this->pool()->getItem('passkey_cred_'.hash('sha256', $credentialId));
$item->set('not-an-array');
$this->pool()->save($item);
self::assertNull($store->find($credentialId));
}
/** A payload missing one of the required metadata keys is also unusable. */
public function test_a_payload_missing_metadata_reads_as_missing(): void
{
$store = $this->makeStore();
$credentialId = random_bytes(32);
$store->save($this->makeCredential($credentialId));
$key = 'passkey_cred_'.hash('sha256', $credentialId);
$item = $this->pool()->getItem($key);
$payload = $item->get();
unset($payload['identity']);
$item->set($payload);
$this->pool()->save($item);
self::assertNull($store->find($credentialId));
}
}
+439
View File
@@ -0,0 +1,439 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Service;
use App\Data\PasskeyCredential;
use App\Service\PasskeyCeremonyFactory;
use App\Service\PasskeyCredentialStoreInterface;
use App\Service\PasskeyManager;
use App\Service\PasskeyPolicyInterface;
use App\Tests\Support\PasskeyTestHelper;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use ReflectionMethod;
use RuntimeException;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Uid\Uuid;
use Throwable;
use Webauthn\CredentialRecord;
use Webauthn\TrustPath\EmptyTrustPath;
/**
* The ceremony control flow, with a stubbed validator.
*
* Real cryptography is proven separately (PasskeyRealCryptoSpikeTest and the
* functional suite); this file is about the branches around it — what happens
* when the store is empty, the body is malformed, or verification fails. Those
* are the paths a browser is least likely to exercise on purpose and an attacker
* most likely to.
*/
final class PasskeyManagerTest extends TestCase
{
private const string RP_ID = 'example.com';
private const string ORIGIN = 'https://auth.example.com';
private ?ArrayAdapter $cache = null;
private function makePolicy(): PasskeyPolicyInterface
{
$policy = $this->createStub(PasskeyPolicyInterface::class);
$policy->method('rpId')->willReturn(self::RP_ID);
$policy->method('authSubdomain')->willReturn('auth.example.com');
$policy->method('allowedOrigins')->willReturn([self::ORIGIN]);
$policy->method('rpName')->willReturn('Preauth');
$policy->method('userVerification')->willReturn('required');
$policy->method('timeout')->willReturn(60000);
$policy->method('isEnabled')->willReturn(true);
$policy->method('isAvailableFor')->willReturn(true);
return $policy;
}
/**
* @param PasskeyCredential[] $credentials
*/
private function makeManager(
array $credentials = [],
?PasskeyCredentialStoreInterface $store = null,
): PasskeyManager {
$this->cache = new ArrayAdapter();
$store ??= $this->makeStore($credentials);
return new PasskeyManager(
$this->makePolicy(),
new \App\Service\PasskeyCeremonyStore($this->cache),
$store,
new PasskeyCeremonyFactory(),
);
}
/**
* @param PasskeyCredential[] $credentials
*/
private function makeStore(array $credentials): PasskeyCredentialStoreInterface
{
$store = $this->createStub(PasskeyCredentialStoreInterface::class);
$store->method('all')->willReturn($credentials);
$store->method('find')->willReturnCallback(
static function (string $id) use ($credentials): ?PasskeyCredential {
foreach ($credentials as $credential) {
if ($credential->record->publicKeyCredentialId === $id) {
return $credential;
}
}
return null;
},
);
return $store;
}
private function makeCredential(string $identity = 'lyra'): PasskeyCredential
{
return new PasskeyCredential(
CredentialRecord::create(
random_bytes(16),
'public-key',
['internal'],
'none',
EmptyTrustPath::create(),
Uuid::v4(),
'COSE_KEY',
hash('sha256', $identity, true),
0,
null,
true,
false,
true,
),
$identity,
'Passkey abc',
new DateTimeImmutable(),
);
}
/* ── begin ────────────────────────────────────────────────────────── */
public function test_begin_login_returns_options_and_a_ceremony_id(): void
{
$result = $this->makeManager()->beginLogin();
self::assertArrayHasKey('publicKey', $result);
self::assertArrayHasKey('ceremonyId', $result);
self::assertSame(self::RP_ID, $result['publicKey']['rpId']);
}
/**
* With no credentials registered the list is empty rather than absent, so
* the browser can still offer a discoverable credential.
*/
public function test_begin_login_with_no_credentials_offers_an_empty_list(): void
{
$result = $this->makeManager()->beginLogin();
self::assertArrayHasKey('allowCredentials', $result['publicKey']);
self::assertSame([], $result['publicKey']['allowCredentials']);
}
public function test_begin_login_lists_every_registered_credential(): void
{
$manager = $this->makeManager([$this->makeCredential('lyra'), $this->makeCredential('atlas')]);
$result = $manager->beginLogin();
self::assertCount(2, $result['publicKey']['allowCredentials']);
}
public function test_begin_registration_uses_the_given_identity(): void
{
$result = $this->makeManager()->beginRegistration('lyra');
self::assertArrayHasKey('ceremonyId', $result);
self::assertSame('lyra', $result['publicKey']['user']['name']);
self::assertSame('none', $result['publicKey']['attestation']);
}
/* ── finish: malformed input ──────────────────────────────────────── */
/**
* A body without a ceremony id or credential must be refused, and must not
* touch the credential store.
*/
public function test_finish_login_refuses_a_body_without_a_ceremony_id(): void
{
$manager = $this->makeManager();
self::assertNull($manager->finishLogin([]));
self::assertNull($manager->finishLogin(['credential' => []]));
self::assertNull($manager->finishLogin(['ceremonyId' => '', 'credential' => []]));
}
public function test_finish_login_refuses_a_body_without_a_credential(): void
{
$manager = $this->makeManager();
self::assertNull($manager->finishLogin(['ceremonyId' => 'cid']));
self::assertNull($manager->finishLogin(['ceremonyId' => 'cid', 'credential' => 'not-an-array']));
}
/**
* An unknown ceremony id means the record was never issued, already spent,
* or expired — all of which must look the same to the caller.
*/
public function test_finish_login_refuses_an_unknown_ceremony_id(): void
{
$manager = $this->makeManager();
self::assertNull($manager->finishLogin([
'ceremonyId' => 'never-issued',
'credential' => ['id' => 'x'],
]));
}
/**
* A credential the store does not know must be refused before any
* verification is attempted, so an attacker cannot use the endpoint as an
* oracle by nominating arbitrary credential ids.
*/
public function test_finish_login_refuses_an_unknown_credential(): void
{
$manager = $this->makeManager();
$started = $manager->beginLogin();
/* a structurally valid assertion for a credential nobody registered */
$helper = new PasskeyTestHelper();
$challenge = $this->challengeFor($started['ceremonyId']);
$assertion = $helper->assertionCredential(
self::RP_ID,
$challenge,
self::ORIGIN,
random_bytes(16),
1,
hash('sha256', 'nobody', true),
);
self::assertNull($manager->finishLogin([
'ceremonyId' => $started['ceremonyId'],
'credential' => $assertion,
]));
}
public function test_finish_registration_refuses_malformed_input(): void
{
$manager = $this->makeManager();
self::assertNull($manager->finishRegistration([]));
self::assertNull($manager->finishRegistration(['ceremonyId' => 'cid']));
self::assertNull($manager->finishRegistration(['ceremonyId' => 'never-issued', 'credential' => []]));
}
/**
* A login ceremony must not be usable to finish a registration, or the two
* flows' differing trust assumptions would blur together.
*/
public function test_a_login_ceremony_cannot_finish_a_registration(): void
{
$manager = $this->makeManager();
$started = $manager->beginLogin();
self::assertNull($manager->finishRegistration([
'ceremonyId' => $started['ceremonyId'],
'credential' => [],
]));
}
/**
* A registration ceremony must not be usable to finish a login.
*/
public function test_a_registration_ceremony_cannot_finish_a_login(): void
{
$manager = $this->makeManager();
$started = $manager->beginRegistration('lyra');
self::assertNull($manager->finishLogin([
'ceremonyId' => $started['ceremonyId'],
'credential' => [],
]));
}
/* ── finish: verification failure ─────────────────────────────────── */
/**
* A wrong challenge must fail, and must not be retryable: the record is
* consumed on read.
*/
public function test_a_wrong_challenge_fails_and_is_not_retryable(): void
{
$helper = new PasskeyTestHelper();
$credentialId = $helper->credentialId();
/* a store holding a credential whose id matches the assertion */
$record = CredentialRecord::create(
$credentialId,
'public-key',
['internal'],
'none',
EmptyTrustPath::create(),
Uuid::v4(),
'COSE_KEY',
hash('sha256', 'lyra', true),
0,
null,
true,
false,
true,
);
$stored = new PasskeyCredential($record, 'lyra', 'Passkey abc', new DateTimeImmutable());
$manager = $this->makeManager([$stored]);
$started = $manager->beginLogin();
$assertion = $helper->assertionCredential(
self::RP_ID,
random_bytes(32),
self::ORIGIN,
$credentialId,
0,
hash('sha256', 'lyra', true),
);
self::assertNull($manager->finishLogin([
'ceremonyId' => $started['ceremonyId'],
'credential' => $assertion,
]));
/* and the same ceremony cannot be presented again */
self::assertNull($manager->finishLogin([
'ceremonyId' => $started['ceremonyId'],
'credential' => $assertion,
]));
}
/**
* A store that throws must not turn a malformed credential into a 500.
*/
public function test_a_store_failure_is_reported_as_a_failed_ceremony(): void
{
$helper = new PasskeyTestHelper();
$credentialId = $helper->credentialId();
$store = $this->createStub(PasskeyCredentialStoreInterface::class);
$store->method('find')->willThrowException(new RuntimeException('store down'));
$manager = $this->makeManager(store: $store);
$started = $manager->beginLogin();
$assertion = $helper->assertionCredential(
self::RP_ID,
random_bytes(32),
self::ORIGIN,
$credentialId,
0,
hash('sha256', 'lyra', true),
);
try {
$result = $manager->finishLogin([
'ceremonyId' => $started['ceremonyId'],
'credential' => $assertion,
]);
} catch (Throwable $exception) {
self::fail('finishLogin() must not throw: '.$exception->getMessage());
}
self::assertNull($result);
}
/* ── helpers ──────────────────────────────────────────────────────── */
/**
* The challenge the manager issued for a ceremony, read back from the cache
* the way an attacker with the ceremony id would not be able to.
*/
private function challengeFor(string $ceremonyId): string
{
$key = new ReflectionMethod(\App\Service\PasskeyCeremonyStore::class, 'key');
$store = new \App\Service\PasskeyCeremonyStore($this->cache);
$item = $this->cache->getItem($key->invoke($store, $ceremonyId));
$payload = $item->isHit() ? $item->get() : null;
return \is_array($payload) && isset($payload['challenge']) ? (string) $payload['challenge'] : '';
}
/**
* A credential whose stored payload cannot be read must be treated as
* unusable, and — importantly — must not throw. One corrupt entry must not
* become a 500 for every visitor on the login page.
*/
public function test_a_credential_that_cannot_be_found_fails_the_ceremony(): void
{
$helper = new PasskeyTestHelper();
$credentialId = $helper->credentialId();
$store = $this->createStub(PasskeyCredentialStoreInterface::class);
$store->method('find')->willReturn(null);
$manager = $this->makeManager(store: $store);
$started = $manager->beginLogin();
$assertion = $helper->assertionCredential(
self::RP_ID,
random_bytes(32),
self::ORIGIN,
$credentialId,
0,
hash('sha256', 'lyra', true),
);
self::assertNull($manager->finishLogin([
'ceremonyId' => $started['ceremonyId'],
'credential' => $assertion,
]));
}
/**
* A registration whose attestation cannot be parsed must fail rather than
* throwing, for the same reason.
*/
public function test_unparseable_attestation_fails_the_ceremony(): void
{
$manager = $this->makeManager();
$started = $manager->beginRegistration('lyra');
/* structurally a credential object, but the response is nonsense */
self::assertNull($manager->finishRegistration([
'ceremonyId' => $started['ceremonyId'],
'credential' => ['id' => 'x', 'rawId' => 'x', 'type' => 'public-key', 'response' => []],
]));
}
/**
* A store that cannot persist a registration must not report success: the
* user would believe the passkey was saved and then find it missing at the
* next login, with nothing to explain why.
*/
public function test_a_registration_that_cannot_be_persisted_fails(): void
{
$store = $this->createStub(PasskeyCredentialStoreInterface::class);
$store->method('all')->willReturn([]);
$store->method('find')->willReturn(null);
$store->method('save')->willThrowException(new RuntimeException('store down'));
$helper = new PasskeyTestHelper();
$manager = $this->makeManager(store: $store);
$started = $manager->beginRegistration('lyra');
$challenge = $this->challengeFor($started['ceremonyId']);
$credential = $helper->registrationCredential(self::RP_ID, $challenge, self::ORIGIN);
self::assertNull($manager->finishRegistration([
'ceremonyId' => $started['ceremonyId'],
'credential' => $credential,
]));
}
}
+234
View File
@@ -0,0 +1,234 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Service;
use App\Enum\UserVerification;
use App\Exception\PasskeyConfigurationException;
use App\Service\DomainInterface;
use App\Service\PasskeyPolicy;
use App\Tests\Support\TotpTestHelper;
use Override;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
/**
* Covers the availability rule (D1) and the HTTPS requirement (D4).
*
* The two decisions are enforced in one place precisely so that they can be
* tested exhaustively here rather than re-derived at each call site.
*/
final class PasskeyPolicyTest extends TestCase
{
use TotpTestHelper;
/**
* A stand-in for the real DomainManager that mirrors its base-domain rule:
* `localhost` and bare IPs yield null; otherwise the last two labels are
* kept, or three when the final two form a known multi-part TLD.
*
* Verified against DomainManager: "auth.example.com" => "example.com",
* "auth.example.co.uk" => "example.co.uk", "auth" => "auth",
* "localhost" => null.
*/
private function makeDomain(bool $subdomainRedirect, string $authSubdomain): DomainInterface
{
$authBase = null;
if ('' !== $authSubdomain
&& 'localhost' !== $authSubdomain
&& !filter_var($authSubdomain, \FILTER_VALIDATE_IP)
) {
$parts = explode('.', strtolower($authSubdomain));
$keep = 2;
$count = \count($parts);
if ($count > 2 && 'uk' === $parts[$count - 1] && \in_array($parts[$count - 2], ['co', 'org', 'ac', 'gov'], true)) {
$keep = 3;
}
$authBase = implode('.', \array_slice($parts, -min($keep, $count)));
}
return new class($subdomainRedirect, $authSubdomain, $subdomainRedirect ? $authBase : null) implements DomainInterface {
public function __construct(
private bool $redirect,
private string $authSubdomain,
private ?string $authBase,
) {
}
#[Override]
public function getAuthSubdomain(): ?string
{
return $this->redirect ? $this->authSubdomain : null;
}
#[Override]
public function validReturn(string $url): bool
{
return $this->redirect;
}
#[Override]
public function matchesAuth(string $host): bool
{
return $this->redirect;
}
#[Override]
public function authBase(): ?string
{
return $this->authBase;
}
};
}
private function makePolicy(
bool $passkeyEnabled = true,
bool $subdomainRedirect = true,
string $authSubdomain = 'auth.example.com',
string $userVerification = 'required',
int $timeout = 60000,
string $rpName = '',
string $title = 'Pre-Authentication System',
): PasskeyPolicy {
$config = $this->makeConfig(
passkeyEnabled: $passkeyEnabled,
passkeyUserVerification: $userVerification,
passkeyTimeout: $timeout,
passkeyRpName: $rpName,
title: $title,
);
return new PasskeyPolicy($config, $this->makeDomain($subdomainRedirect, $authSubdomain));
}
/* ── D1: enabled + prerequisite ─────────────────────────────────────── */
public function test_enabled_requires_both_the_switch_and_central_auth(): void
{
self::assertTrue($this->makePolicy()->isEnabled());
self::assertFalse($this->makePolicy(passkeyEnabled: false)->isEnabled());
self::assertFalse($this->makePolicy(subdomainRedirect: false)->isEnabled());
}
public function test_rp_id_is_always_the_auth_base_domain(): void
{
self::assertSame('example.com', $this->makePolicy()->rpId());
self::assertSame('example.co.uk', $this->makePolicy(authSubdomain: 'auth.example.co.uk')->rpId());
}
public function test_rp_id_throws_when_not_configured(): void
{
$this->expectException(PasskeyConfigurationException::class);
$this->makePolicy(subdomainRedirect: false)->rpId();
}
/* ── D4: HTTPS is the only accepted origin ─────────────────────────── */
public function test_allowed_origin_is_always_https(): void
{
self::assertSame(['https://auth.example.com'], $this->makePolicy()->allowedOrigins());
}
public function test_allowed_origin_never_reflects_the_request_scheme(): void
{
$policy = $this->makePolicy();
$request = Request::create('http://auth.example.com/', 'GET');
self::assertSame(['https://auth.example.com'], $policy->allowedOrigins());
self::assertFalse($policy->isAvailableFor($request));
}
public function test_available_only_on_the_auth_host_over_https(): void
{
$policy = $this->makePolicy();
$secure = Request::create('https://auth.example.com/', 'GET');
$insecure = Request::create('http://auth.example.com/', 'GET');
$otherHost = Request::create('https://app.example.com/', 'GET');
self::assertTrue($policy->isAvailableFor($secure));
self::assertFalse($policy->isAvailableFor($insecure));
self::assertFalse($policy->isAvailableFor($otherHost));
}
public function test_available_respects_the_switch(): void
{
$request = Request::create('https://auth.example.com/', 'GET');
self::assertFalse($this->makePolicy(passkeyEnabled: false)->isAvailableFor($request));
}
/* ── boot-time assertion (D1 + D4) ─────────────────────────────────── */
public function test_assertion_is_silent_when_disabled(): void
{
$this->makePolicy(passkeyEnabled: false, subdomainRedirect: false)->assertConfigurationIsUsable();
$this->addToAssertionCount(1);
}
public function test_assertion_passes_for_a_valid_configuration(): void
{
$this->makePolicy()->assertConfigurationIsUsable();
$this->addToAssertionCount(1);
}
public function test_assertion_fails_without_central_auth(): void
{
$this->expectException(PasskeyConfigurationException::class);
$this->expectExceptionMessageMatches('/central authentication is not configured/');
$this->makePolicy(subdomainRedirect: false, authSubdomain: '')->assertConfigurationIsUsable();
}
public function test_assertion_fails_for_localhost(): void
{
/* localhost has no base domain, so it can never satisfy D1 */
$this->expectException(PasskeyConfigurationException::class);
$this->makePolicy(authSubdomain: 'localhost')->assertConfigurationIsUsable();
}
public function test_assertion_fails_for_a_single_label_subdomain(): void
{
/* D4: no certificate can be issued for a single-label host */
$this->expectException(PasskeyConfigurationException::class);
$this->expectExceptionMessageMatches('/fully qualified domain name/');
$this->makePolicy(authSubdomain: 'auth')->assertConfigurationIsUsable();
}
/* ── configuration accessors ───────────────────────────────────────── */
public function test_rp_name_falls_back_to_the_title(): void
{
self::assertSame('Pre-Authentication System', $this->makePolicy(rpName: '')->rpName());
self::assertSame('My Gateway', $this->makePolicy(rpName: 'My Gateway')->rpName());
}
public function test_user_verification_and_timeout_are_passed_through(): void
{
$policy = $this->makePolicy(userVerification: 'preferred', timeout: 30000);
self::assertSame('preferred', $policy->userVerification());
self::assertSame(30000, $policy->timeout());
}
public function test_unknown_user_verification_falls_back_to_required(): void
{
/* an unrecognised value must never silently weaken the requirement */
$policy = $this->makePolicy(
userVerification: 'nonsense',
);
self::assertSame(UserVerification::Required->value, $policy->userVerification());
}
public function test_non_positive_timeout_falls_back_to_the_default(): void
{
self::assertSame(60000, $this->makePolicy(timeout: 0)->timeout());
self::assertSame(60000, $this->makePolicy(timeout: -100)->timeout());
}
public function test_auth_subdomain_is_exposed_for_ceremony_urls(): void
{
self::assertSame('auth.example.com', $this->makePolicy()->authSubdomain());
}
}
@@ -0,0 +1,291 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Service;
use App\Service\PasskeyCeremonyFactory;
use App\Tests\Support\PasskeyTestHelper;
use PHPUnit\Framework\TestCase;
use Throwable;
use Webauthn\AuthenticatorAssertionResponse;
use Webauthn\AuthenticatorAttestationResponse;
use Webauthn\AuthenticatorSelectionCriteria;
use Webauthn\CredentialRecord;
use Webauthn\PublicKeyCredential;
use Webauthn\PublicKeyCredentialCreationOptions;
use Webauthn\PublicKeyCredentialParameters;
use Webauthn\PublicKeyCredentialRequestOptions;
use Webauthn\PublicKeyCredentialRpEntity;
use Webauthn\PublicKeyCredentialUserEntity;
/**
* Proves the ceremony verifies with real cryptography, before the manager is
* built on top of it. If this file is green, the library is wired correctly and
* a later failure is our logic rather than our configuration.
*
* Nothing is mocked: a real P-256 keypair signs a real `authenticatorData`, and
* the CBOR attestation object is assembled exactly as an authenticator would.
*/
final class PasskeyRealCryptoSpikeTest extends TestCase
{
private const string ORIGIN = 'https://auth.example.com';
private const string RP_ID = 'example.com';
/** The identity registered throughout; its handle is derived, not sent. */
private const string IDENTITY = 'lyra';
private static function userHandle(): string
{
return hash('sha256', self::IDENTITY, true);
}
private function factory(): PasskeyCeremonyFactory
{
return new PasskeyCeremonyFactory();
}
private function registrationOptions(string $challenge): PublicKeyCredentialCreationOptions
{
return new PublicKeyCredentialCreationOptions(
new PublicKeyCredentialRpEntity('Preauth', self::RP_ID),
new PublicKeyCredentialUserEntity(self::IDENTITY, self::userHandle(), self::IDENTITY),
$challenge,
[PublicKeyCredentialParameters::create('public-key', -7)],
new AuthenticatorSelectionCriteria(
AuthenticatorSelectionCriteria::AUTHENTICATOR_ATTACHMENT_PLATFORM,
'required',
AuthenticatorSelectionCriteria::RESIDENT_KEY_REQUIREMENT_PREFERRED,
),
'none',
[],
60000,
);
}
private function requestOptions(string $challenge): PublicKeyCredentialRequestOptions
{
return new PublicKeyCredentialRequestOptions($challenge, self::RP_ID, [], 'required', 60000);
}
/**
* Run a registration and return the resulting record.
*
* @throws Throwable
*/
private function register(PasskeyTestHelper $helper, string $credentialId): CredentialRecord
{
$challenge = random_bytes(32);
$json = $helper->registrationCredential(self::RP_ID, $challenge, self::ORIGIN, $credentialId);
$credential = $this->factory()->serializer()->denormalize($json, PublicKeyCredential::class, 'json');
self::assertInstanceOf(PublicKeyCredential::class, $credential);
self::assertInstanceOf(AuthenticatorAttestationResponse::class, $credential->response);
return $this->factory()
->creationCeremonyValidator([self::ORIGIN])
->check($credential->response, $this->registrationOptions($challenge), self::RP_ID);
}
/**
* @param string[] $allowedOrigins
*
* @throws Throwable
*/
private function verifyAssertion(
PasskeyTestHelper $helper,
CredentialRecord $record,
string $credentialId,
string $challenge,
int $counter,
array $allowedOrigins = [self::ORIGIN],
): CredentialRecord {
$json = $helper->assertionCredential(
self::RP_ID,
$challenge,
self::ORIGIN,
$credentialId,
$counter,
self::userHandle(),
);
$credential = $this->factory()->serializer()->denormalize($json, PublicKeyCredential::class, 'json');
self::assertInstanceOf(PublicKeyCredential::class, $credential);
self::assertInstanceOf(AuthenticatorAssertionResponse::class, $credential->response);
return $this->factory()
->requestCeremonyValidator($allowedOrigins)
->check($record, $credential->response, $this->requestOptions($challenge), self::RP_ID, self::userHandle());
}
/**
* The headline claim: a real registration is accepted.
*/
public function test_a_real_registration_verifies(): void
{
$record = $this->register(new PasskeyTestHelper(), (new PasskeyTestHelper())->credentialId());
self::assertSame('none', $record->attestationType);
self::assertNotSame('', $record->credentialPublicKey);
}
/**
* A real registration followed by a real assertion — the whole flow.
*/
public function test_a_real_assertion_verifies_after_registration(): void
{
$helper = new PasskeyTestHelper();
$credentialId = $helper->credentialId();
$record = $this->register($helper, $credentialId);
$counter = $record->counter + 1;
$updated = $this->verifyAssertion($helper, $record, $credentialId, random_bytes(32), $counter);
self::assertSame($counter, $updated->counter);
}
/**
* D4 in action: the same assertion is refused when the allow-list says
* `http://`. The library accepts only what it is told to accept, which is
* exactly why no exemption may be reintroduced.
*/
public function test_an_http_origin_is_rejected(): void
{
$helper = new PasskeyTestHelper();
$credentialId = $helper->credentialId();
$record = $this->register($helper, $credentialId);
$this->expectException(Throwable::class);
$this->verifyAssertion(
$helper,
$record,
$credentialId,
random_bytes(32),
$record->counter + 1,
['http://auth.example.com'],
);
}
/**
* A tampered challenge must fail, or the ceremony would not bind the
* assertion to this session.
*/
public function test_a_different_challenge_is_rejected(): void
{
$helper = new PasskeyTestHelper();
$credentialId = $helper->credentialId();
$record = $this->register($helper, $credentialId);
/* sign one challenge, present another */
$json = $helper->assertionCredential(
self::RP_ID,
random_bytes(32),
self::ORIGIN,
$credentialId,
$record->counter + 1,
self::userHandle(),
);
$credential = $this->factory()->serializer()->denormalize($json, PublicKeyCredential::class, 'json');
self::assertInstanceOf(PublicKeyCredential::class, $credential);
self::assertInstanceOf(AuthenticatorAssertionResponse::class, $credential->response);
$this->expectException(Throwable::class);
$this->factory()
->requestCeremonyValidator([self::ORIGIN])
->check(
$record,
$credential->response,
$this->requestOptions(random_bytes(32)),
self::RP_ID,
self::userHandle(),
);
}
/**
* A forged RP ID hash must fail, so a credential registered for one site
* cannot be replayed at another.
*/
public function test_a_forged_rp_id_hash_is_rejected(): void
{
$helper = new PasskeyTestHelper();
$credentialId = $helper->credentialId();
$record = $this->register($helper, $credentialId);
$challenge = random_bytes(32);
$json = $helper->assertionCredential(
'evil.example.com',
$challenge,
self::ORIGIN,
$credentialId,
$record->counter + 1,
self::userHandle(),
);
$credential = $this->factory()->serializer()->denormalize($json, PublicKeyCredential::class, 'json');
self::assertInstanceOf(PublicKeyCredential::class, $credential);
self::assertInstanceOf(AuthenticatorAssertionResponse::class, $credential->response);
$this->expectException(Throwable::class);
$this->factory()
->requestCeremonyValidator([self::ORIGIN])
->check($record, $credential->response, $this->requestOptions($challenge), self::RP_ID, self::userHandle());
}
/**
* A credential reporting a constant zero counter must be able to log in
* repeatedly. This is the regression the lenient checker exists for, and it
* is asserted through the full library path rather than the checker alone —
* the library's own default would fail this.
*/
public function test_a_synchronised_passkey_with_a_zero_counter_can_log_in_repeatedly(): void
{
$helper = new PasskeyTestHelper();
$credentialId = $helper->credentialId();
$record = $this->register($helper, $credentialId);
/* model a synchronised passkey: the counter never advances */
$record->counter = 0;
for ($attempt = 0; $attempt < 3; ++$attempt) {
$updated = $this->verifyAssertion($helper, $record, $credentialId, random_bytes(32), 0);
self::assertSame(0, $updated->counter, "attempt $attempt");
}
}
/**
* The same credential asserted from a sibling subdomain succeeds, because
* the RP ID is the base domain. Asserted explicitly so the scope is
* documented in code rather than only in the plan.
*/
public function test_a_sibling_subdomain_cannot_reuse_a_credential_whose_origin_is_wrong(): void
{
$helper = new PasskeyTestHelper();
$credentialId = $helper->credentialId();
$record = $this->register($helper, $credentialId);
$challenge = random_bytes(32);
$json = $helper->assertionCredential(
self::RP_ID,
$challenge,
'https://app.example.com',
$credentialId,
$record->counter + 1,
self::userHandle(),
);
$credential = $this->factory()->serializer()->denormalize($json, PublicKeyCredential::class, 'json');
self::assertInstanceOf(PublicKeyCredential::class, $credential);
self::assertInstanceOf(AuthenticatorAssertionResponse::class, $credential->response);
/* the auth subdomain's origin is the only one allowed, so a ceremony
* driven from a sibling host is refused even though the RP ID matches */
$this->expectException(Throwable::class);
$this->factory()
->requestCeremonyValidator([self::ORIGIN])
->check($record, $credential->response, $this->requestOptions($challenge), self::RP_ID, self::userHandle());
}
}