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