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
28 changed files with 1931 additions and 374 deletions
+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
+22
View File
@@ -652,6 +652,10 @@ 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
@@ -667,11 +671,29 @@ Each step is independently committable and leaves the suite green.
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).
+1 -217
View File
@@ -522,228 +522,12 @@ parameters:
count: 2
path: tests/Unit/Enum/ScopeTest.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 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 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 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 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
@@ -759,7 +543,7 @@ parameters:
-
message: '#^Call to an undefined method App\\Service\\BackupCodeInterface\:\:method\(\)\.$#'
identifier: method.notFound
count: 17
count: 20
path: tests/Unit/Service/LoginManagerTest.php
-
+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';
}
+18
View File
@@ -29,6 +29,8 @@ final readonly class ConfigBag
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;
@@ -52,6 +54,8 @@ final readonly class ConfigBag
#[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;
@@ -70,6 +74,8 @@ final readonly class ConfigBag
$this->passkeyRpName = $passkeyRpName;
$this->passkeyUserVerification = UserVerification::fromConfig($passkeyUserVerification);
$this->passkeyTimeout = $passkeyTimeout > 0 ? $passkeyTimeout : self::DEFAULT_PASSKEY_TIMEOUT;
$this->passkeyButtonName = $passkeyButtonName;
$this->passkeyRegisterName = $passkeyRegisterName;
}
/**
@@ -186,4 +192,16 @@ final readonly class ConfigBag
{
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;
}
}
+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);
+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) {
+20 -58
View File
@@ -4,17 +4,16 @@ 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\CookieNameTrait;
use App\Trait\HasLoggerTrait;
use App\Trait\MakeNonceTrait;
use App\Trait\StringTrait;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
@@ -36,13 +35,21 @@ use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
* 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 CookieNameTrait;
use HasLoggerTrait;
use MakeNonceTrait;
use StringTrait;
@@ -55,16 +62,11 @@ final readonly class PasskeyListener
*/
public const string HEADER = 'X-Preauth-Passkey';
/** Marks a response as ceremony output, so the caching policy can see it. */
public const string CEREMONY_MARKER = 'X-Preauth-Ceremony';
public const string BEGIN_LOGIN = 'login-begin';
private const string BEGIN_LOGIN = 'login-begin';
public const string FINISH_LOGIN = 'login-finish';
private const string FINISH_LOGIN = 'login-finish';
private const string BEGIN_REGISTER = 'register-begin';
private const string FINISH_REGISTER = 'register-finish';
public const string FINISH_REGISTER = 'register-finish';
private RateLimiterFactoryInterface $beginLimiter;
@@ -73,7 +75,6 @@ final readonly class PasskeyListener
public function __construct(
#[Target('passkey_begin_burst')] RateLimiterFactoryInterface $beginLimiter,
#[Target('login_limiter')] RateLimiterFactoryInterface $loginLimiter,
#[Target('sessionCache')] private CacheItemPoolInterface $sessionCache,
private PasskeyInterface $passkeys,
private PasskeyPolicyInterface $policy,
private SessionIssuerInterface $sessionIssuer,
@@ -116,7 +117,6 @@ final readonly class PasskeyListener
return $this->ceremonyResponse(match ($operation) {
self::BEGIN_LOGIN => $this->beginLogin($request),
self::FINISH_LOGIN => $this->finishLogin($request),
self::BEGIN_REGISTER => $this->beginRegistration($request),
self::FINISH_REGISTER => $this->finishRegistration($request),
default => $this->error('Unknown passkey operation.', $request),
});
@@ -131,25 +131,6 @@ final readonly class PasskeyListener
return $this->json($this->passkeys->beginLogin());
}
/**
* Registration is only offered to someone who already authenticated: the
* identity comes from the live session, never from the request body, so a
* caller cannot register a passkey for an identity it does not hold.
*/
private function beginRegistration(Request $request): Response
{
$identity = $this->identityFromRequest($request);
if (null === $identity) {
return $this->error('Registration requires a completed login.', $request);
}
if ($limit = $this->beginBurstExceeded($request)) {
return $limit;
}
return $this->json($this->passkeys->beginRegistration($identity));
}
private function finishLogin(Request $request): Response
{
$credential = $this->passkeys->finishLogin($this->body($request));
@@ -165,6 +146,12 @@ final readonly class PasskeyListener
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));
@@ -178,31 +165,6 @@ final readonly class PasskeyListener
return $this->sessionIssuer->issue($credential->identity, Scope::Cookie, $request, true);
}
/**
* The identity of an already-authenticated caller, for registration.
*
* Read from the live session cookie, so `register-begin` is reachable only by
* someone who has just passed the TOTP check. Null when there is no session.
*
* @throws InvalidArgumentException
*/
private function identityFromRequest(Request $request): ?string
{
$cookie = $request->cookies->get($this->sessionCookieName($this->domainManager));
if (!\is_string($cookie) || '' === $cookie) {
return null;
}
$item = $this->sessionCache->getItem($this->makeCacheKey("cookie_$cookie"));
if (!$item->isHit()) {
return null;
}
$identity = $item->get();
return \is_string($identity) && '' !== $identity ? $identity : null;
}
/**
* Bounds how many ceremonies one caller can start.
*
@@ -266,7 +228,7 @@ final readonly class PasskeyListener
*/
private function ceremonyResponse(Response $response): Response
{
$response->headers->set(self::CEREMONY_MARKER, '1');
$response->headers->set(AppConstants::PASSKEY_CEREMONY_MARKER, '1');
$response->headers->set('Content-Type', 'application/json');
return $response;
+3 -2
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Listener;
use App\AppConstants;
use App\Service\DomainInterface;
use App\Service\PasskeyPolicyInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
@@ -98,8 +99,8 @@ final readonly class SecurityHeadersListener
return;
}
if ($headers->has(PasskeyListener::CEREMONY_MARKER)) {
$headers->remove(PasskeyListener::CEREMONY_MARKER);
if ($headers->has(AppConstants::PASSKEY_CEREMONY_MARKER)) {
$headers->remove(AppConstants::PASSKEY_CEREMONY_MARKER);
$this->applyNoStore($headers);
}
}
+57 -5
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Service;
use App\AppConstants;
use App\Data\Payload;
use App\Enum\Scope;
use App\Trait\GetTotpTrait;
@@ -13,14 +14,21 @@ use Override;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
/**
* Authenticates a TOTP code (or backup code) and, on success, grants access.
* Authenticates a TOTP code (or backup code) and, on success, either grants
* access or starts a passkey registration.
*
* The "grant access" half now lives in {@see SessionIssuer} so the passkey
* ceremony produces an identical response. This class keeps the part that is
* genuinely specific to code-based login: verifying the code and enforcing the
* single-use nonce.
* 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
{
@@ -31,6 +39,7 @@ final readonly class LoginManager implements LoginInterface
public function __construct(
private BackupCodeInterface $backupCodeManager,
private SessionIssuerInterface $sessionIssuer,
private PasskeyInterface $passkeys,
) {
}
@@ -65,6 +74,12 @@ final readonly class LoginManager implements LoginInterface
$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,
@@ -72,4 +87,41 @@ final readonly class LoginManager implements LoginInterface
$payload->json,
);
}
/**
* 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
{
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;
}
$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;
}
}
-5
View File
@@ -95,11 +95,6 @@ final readonly class PasskeyCeremonyFactory
);
}
public function counterChecker(): PasskeyCounterChecker
{
return $this->counterChecker;
}
/**
* The ceremony steps shared by both ceremonies.
*
+18 -7
View File
@@ -109,13 +109,16 @@ final readonly class PasskeyManager implements PasskeyInterface
}
/* the credential id selects the record: an attacker cannot nominate a
* different credential than the one they hold the key for */
$stored = $this->credentials->find($publicKeyCredential->rawId);
if (null === $stored) {
return null;
}
* 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(
@@ -210,7 +213,15 @@ final readonly class PasskeyManager implements PasskeyInterface
$this->labelFor($record),
new DateTimeImmutable(),
);
$this->credentials->save($credential);
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;
}
+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;
@@ -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());
+29 -37
View File
@@ -4,10 +4,10 @@ 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\MonitorCacheKeys;
use App\Service\DomainInterface;
use App\Service\PasskeyInterface;
use App\Service\PasskeyPolicyInterface;
@@ -41,8 +41,6 @@ final class PasskeyListenerTest extends TestCase
private const string AUTH_HOST = 'auth.example.com';
private ?ArrayAdapter $sessionCache = null;
private function makeListener(
?PasskeyInterface $passkeys = null,
bool $available = true,
@@ -50,8 +48,6 @@ final class PasskeyListenerTest extends TestCase
?DomainInterface $domainManager = null,
int $beginLimit = 30,
): PasskeyListener {
$this->sessionCache = new ArrayAdapter();
$policy = $this->createStub(PasskeyPolicyInterface::class);
$policy->method('isAvailableFor')->willReturn($available);
@@ -60,7 +56,6 @@ final class PasskeyListenerTest extends TestCase
$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()),
$this->sessionCache,
$passkeys ?? $this->createStub(PasskeyInterface::class),
$policy,
$sessionIssuer ?? $this->createStub(SessionIssuerInterface::class),
@@ -252,16 +247,22 @@ final class PasskeyListenerTest extends TestCase
$listener->onKernelRequest($event);
self::assertSame('1', $event->getResponse()?->headers->get(PasskeyListener::CEREMONY_MARKER));
self::assertSame('1', $event->getResponse()?->headers->get(AppConstants::PASSKEY_CEREMONY_MARKER));
}
/* ── registration gating ──────────────────────────────────────────── */
/**
* Registration requires a live session: the identity comes from the cookie,
* never from the body, so nobody can register a passkey for another identity.
* /**
* `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_without_a_session_is_refused(): void
public function test_register_begin_is_not_a_listener_operation(): void
{
$passkeys = $this->createMock(PasskeyInterface::class);
$passkeys->expects(self::never())->method('beginRegistration');
@@ -274,33 +275,6 @@ final class PasskeyListenerTest extends TestCase
self::assertSame(Response::HTTP_UNAUTHORIZED, $event->getResponse()?->getStatusCode());
}
public function test_register_begin_uses_the_identity_from_the_session_cookie(): void
{
$passkeys = $this->createMock(PasskeyInterface::class);
$passkeys->expects(self::once())
->method('beginRegistration')
->with('lyra')
->willReturn(['publicKey' => [], 'ceremonyId' => 'cid']);
$listener = $this->makeListener($passkeys);
/* seed a live session the way SessionIssuer would have; this has to
* happen after makeListener(), which builds the pool the listener holds */
$ulid = 'test-ulid';
$monitor = new MonitorCacheKeys($this->sessionCache);
$item = $monitor->getItem($this->makeCacheKey("cookie_$ulid"));
$item->set('lyra');
$monitor->save($item);
$request = $this->ceremonyRequest('register-begin');
$request->cookies->set('__Http-Domain-Preauth', $ulid);
$event = $this->makeEvent($request);
$listener->onKernelRequest($event);
self::assertSame(Response::HTTP_OK, $event->getResponse()?->getStatusCode());
}
/* ── failures share the login budget (D3) ─────────────────────────── */
/**
@@ -443,4 +417,22 @@ final class PasskeyListenerTest extends TestCase
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,7 +4,7 @@ declare(strict_types=1);
namespace App\Tests\Unit\Listener;
use App\Listener\PasskeyListener;
use App\AppConstants;
use App\Listener\SecurityHeadersListener;
use App\Service\DomainInterface;
use App\Service\PasskeyPolicyInterface;
@@ -252,11 +252,11 @@ final class SecurityHeadersListenerTest extends TestCase
{
$listener = $this->makeListener('auth.example.com');
$response = new Response('{}', Response::HTTP_OK);
$response->headers->set(PasskeyListener::CEREMONY_MARKER, '1');
$response->headers->set(AppConstants::PASSKEY_CEREMONY_MARKER, '1');
$listener->onKernelResponse($this->makeEvent($response));
self::assertFalse($response->headers->has(PasskeyListener::CEREMONY_MARKER));
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'));
}
@@ -279,6 +279,6 @@ final class SecurityHeadersListenerTest extends TestCase
self::assertStringNotContainsString('no-store', $cacheControl);
self::assertStringContainsString('max-age=60', $cacheControl);
self::assertFalse($response->headers->has('Surrogate-Control'));
self::assertFalse($response->headers->has(PasskeyListener::CEREMONY_MARKER));
self::assertFalse($response->headers->has(AppConstants::PASSKEY_CEREMONY_MARKER));
}
}
+85 -2
View File
@@ -4,11 +4,13 @@ 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;
@@ -17,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;
@@ -35,6 +38,7 @@ final class LoginManagerTest extends TestCase
?int $ipTtl = 0,
bool $subdomainRedirect = false,
string $authSubdomain = '',
?PasskeyInterface $passkeys = null,
): LoginManager {
$this->pool = new ArrayAdapter();
$this->backupCodeManager = $this->createStub(BackupCodeInterface::class);
@@ -45,7 +49,7 @@ final class LoginManagerTest extends TestCase
$this->sessionIssuer = new SessionIssuer($this->pool, $this->domainManager, $this->makeConfig(ipTtl: $ipTtl));
$this->sessionIssuer->setLogger(new NullLogger());
$manager = new LoginManager($this->backupCodeManager, $this->sessionIssuer);
$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());
@@ -375,7 +379,7 @@ final class LoginManagerTest extends TestCase
$issuer = new SessionIssuer($pool, $this->domainManager, $this->makeConfig());
$issuer->setLogger(new NullLogger());
$manager = new LoginManager($this->backupCodeManager, $issuer);
$manager = new LoginManager($this->backupCodeManager, $issuer, $this->createStub(PasskeyInterface::class));
$manager->setConfig($this->makeConfig());
$manager->setLogger(new NullLogger());
$manager->setNonceCache(new ArrayAdapter());
@@ -462,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')));
}
}
+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,
]));
}
}