Add passkey configuration and availability policy (inert)

Groundwork for passkey authentication, with the feature switched off by
default and no behaviour change when it is off.

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

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

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

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

Suite: 333 tests / 770 assertions (was 313 / 738), 100% coverage on new
files. phpstan level 6 clean, php-cs-fixer clean, conformance 35/35.
This commit is contained in:
2026-09-27 02:37:07 +00:00
parent 0458d9b8d2
commit 108e9623e6
21 changed files with 2760 additions and 68 deletions
+6
View File
@@ -12,6 +12,12 @@ 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'
PUBLIC_PATHS=''
PUBLIC_BURST_COUNT=100
PUBLIC_BURST_TIME=60
+2 -1
View File
@@ -18,7 +18,8 @@
"symfony/runtime": "8.1.*",
"symfony/twig-bundle": "8.1.*",
"symfony/uid": "8.1.*",
"symfony/yaml": "8.1.*"
"symfony/yaml": "8.1.*",
"web-auth/webauthn-lib": "^5.3"
},
"config": {
"allow-plugins": {
Generated
+1166 -1
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
framework:
property_info:
with_constructor_extractor: true
+6
View File
@@ -15,4 +15,10 @@ twig:
teapot_message: '%env(TEAPOT_MESSAGE)%'
too_many_title: '%env(TOO_MANY_TITLE)%'
too_many_message: '%env(TOO_MANY_MESSAGE)%'
passkey_button_name: '%env(PASSKEY_BUTTON_NAME)%'
passkey_register_name: '%env(PASSKEY_REGISTER_NAME)%'
debug: '%env(SHELL_VERBOSITY)%'
# `passkeys` is computed per request by the controller-facing templates via
# PasskeyPolicyInterface, never from an env var, so that availability and the
# D1/D4 prerequisites cannot drift apart.
+25
View File
@@ -55,6 +55,18 @@ 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'
# --- styling options ---
env(TITLE): 'Pre-Authentication System'
env(BG_COLOR): '#029386' # teal
@@ -96,6 +108,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 +131,7 @@ 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'
+20
View File
@@ -15,6 +15,26 @@
#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'
# --- extra options ---
# how long do we allow *ALL* traffic from an ip address after successful login
+33
View File
@@ -49,10 +49,43 @@ protected.example.com {
# optionally, if you want to use a subdomain for central preauth
# set SUBDOMAIN_REDIRECT to true
# and AUTH_SUBDOMAIN to match the subdomain you use here
#
# Passkeys (PASSKEY_ENABLED) require this block AND HTTPS: the ceremony runs
# here and the credential is scoped to the base domain. Caddy provisions a
# certificate automatically for a real hostname, so nothing extra is needed in
# production. This block is also deliberately NOT behind forward_auth — the
# browser talks to it directly during a ceremony.
auth.example.com {
reverse_proxy preauth
}
# --- local development with passkeys ---
# Browsers only allow a WebAuthn ceremony over HTTPS, and preauth does not offer
# an exemption for http://localhost (that would be a way to run passkeys
# insecurely in production). So to exercise passkeys locally, give yourself a
# real hostname and a locally-trusted certificate:
#
# 1. Point the names at your machine:
# # /etc/hosts
# 127.0.0.1 auth.preauthtest.local app.preauthtest.local
# 2. Trust a certificate for them (mkcert installs a local CA):
# mkcert auth.preauthtest.local app.preauthtest.local
#
# 3. In preauth's .env:
# SUBDOMAIN_REDIRECT=true
# AUTH_SUBDOMAIN=auth.preauthtest.local
# PASSKEY_ENABLED=true
#
# 4. Terminate TLS here and proxy to the container:
#
# auth.preauthtest.local, "*.preauthtest.local" {
# tls /path/to/auth.preauthtest.local+1.pem /path/to/auth.preauthtest.local+1-key.pem
# reverse_proxy preauth
# }
#
# Note "localhost" itself cannot be used: it has no base domain, so central
# auth cannot be configured and passkeys stay disabled.
# --- public rate-limited access (v1.1) ---
# Configure PUBLIC_PATHS env var to specify which paths are public.
# Example: PUBLIC_PATHS=/public/**
+819
View File
@@ -0,0 +1,819 @@
# 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** | 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: keep the library default; document that clone detection is not relied
upon (Q3.5). Test helpers must 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
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`".
6. **Extract session issuing** from `LoginManager` so both paths share it —
prove equality against the existing `LoginManagerTest`/`AuthenticationFlowTest`
before touching anything else (Q3.8).
7. **Registration UI** — checkbox in `login.html.twig`, the `Payload`-intent
hand-off described in §3.1, `_passkey_register.html.twig`.
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.** *Proposal: keep the library default; document that
counter-based clone detection is not relied upon (many passkeys always report 0).*
**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.*
-66
View File
@@ -486,42 +486,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
@@ -582,12 +558,6 @@ parameters:
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
@@ -642,12 +612,6 @@ parameters:
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
@@ -702,12 +666,6 @@ parameters:
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
@@ -768,12 +726,6 @@ 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
@@ -798,24 +750,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 +894,6 @@ parameters:
count: 1
path: tests/Unit/Trait/MakeNonceTraitTest.php
-
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Trait/StringTraitTest.php
-
message: '#^Call to function method_exists\(\) with ''Symfony\\\\Component\\\\Dotenv\\\\Dotenv'' and ''bootEnv'' will always evaluate to false\.$#'
identifier: function.impossibleType
+2
View File
@@ -46,6 +46,8 @@
</include>
<deprecationTrigger>
<method>Doctrine\Deprecations\Deprecation::trigger</method>
<method>Doctrine\Deprecations\Deprecation::delegateTriggerToBackend</method>
<function>trigger_deprecation</function>
</deprecationTrigger>
</source>
@@ -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;
}
}
+54
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App;
use App\Enum\RemoteUserMode;
use App\Enum\UserVerification;
use Psr\Cache\InvalidArgumentException;
use Psr\Clock\ClockInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
@@ -23,6 +24,14 @@ 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;
/** Passkey ceremony timeout in milliseconds (WebAuthn default). */
private const int DEFAULT_PASSKEY_TIMEOUT = 60000;
/** @throws InvalidArgumentException */
public function __construct(
@@ -38,6 +47,11 @@ 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,
) {
$this->clock = $clock;
$this->cookieTtl = $cookieTtl;
@@ -51,6 +65,11 @@ 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;
}
/**
@@ -132,4 +151,39 @@ 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;
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Enum;
/**
* User-verification requirement passed to the authenticator at passkey time.
*
* Mirrors the WebAuthn `userVerification` option without leaking the library's
* constants into application configuration.
*/
enum UserVerification: string
{
/** Require a biometric/PIN check (default). */
case Required = 'required';
/** Ask for it, but allow a plain user-presence tap to succeed. */
case Preferred = 'preferred';
/** Never prompt for verification; presence alone is enough. */
case Discouraged = 'discouraged';
/**
* Parse a configured value, falling back to the safest option.
*
* An unrecognised value must never silently weaken the requirement, so the
* fallback is the strictest case rather than the most permissive one.
*/
public static function fromConfig(string $value): self
{
return self::tryFrom(trim($value)) ?? self::Required;
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Exception;
use RuntimeException;
/**
* Thrown when passkeys are enabled in a configuration that cannot support them.
*
* This is deliberately fatal: the alternative is a feature that appears to be
* switched on but cannot complete a single ceremony, which surfaces to the user
* as "my passkey stopped working" rather than as a deployment error.
*
* Raised during cache warm-up so that a misconfigured container fails to start
* instead of failing later, in a browser.
*/
final class PasskeyConfigurationException extends RuntimeException
{
}
+120
View File
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\ConfigBag;
use App\Exception\PasskeyConfigurationException;
use Override;
use Symfony\Component\HttpFoundation\Request;
/**
* @see PasskeyPolicyInterface for the decisions this class enforces
*
* **On scheme handling (D4).** The container serves plain HTTP and sits behind a
* TLS-terminating proxy, so the application never learns the public scheme from
* its own configuration — only from `X-Forwarded-Proto`, which Symfony resolves
* through `trusted_proxies` / `trusted_headers` in `framework.yaml`. The scheme
* is therefore enforced two independent ways rather than asserted at boot:
*
* 1. The allowed origin is hardcoded to `https://` here and is never taken from
* the request, so the library's origin check rejects an `http://` ceremony
* no matter how the request arrived.
* 2. {@see isAvailableFor()} additionally requires the request to be secure, so
* a visitor on a non-secure connection is never shown a passkey button that
* the browser would refuse to act on.
*/
final readonly class PasskeyPolicy implements PasskeyPolicyInterface
{
private const string SCHEME = 'https';
public function __construct(
private ConfigBag $config,
private DomainInterface $domainManager,
) {
}
#[Override]
public function isEnabled(): bool
{
return $this->config->passkeyEnabled() && null !== $this->domainManager->authBase();
}
#[Override]
public function isAvailableFor(Request $request): bool
{
return $this->isEnabled()
&& $request->isSecure()
&& $this->domainManager->getAuthSubdomain() === $request->getHost();
}
#[Override]
public function rpId(): string
{
return $this->domainManager->authBase() ?? throw $this->notConfigured();
}
#[Override]
public function allowedOrigins(): array
{
return [self::SCHEME.'://'.$this->authSubdomain()];
}
#[Override]
public function authSubdomain(): string
{
$subdomain = $this->domainManager->getAuthSubdomain();
return $subdomain ?? throw $this->notConfigured();
}
#[Override]
public function rpName(): string
{
return $this->config->passkeyRpName() ?: $this->config->title();
}
#[Override]
public function userVerification(): string
{
return $this->config->passkeyUserVerification();
}
#[Override]
public function timeout(): int
{
return $this->config->passkeyTimeout();
}
/**
* @throws PasskeyConfigurationException
*/
#[Override]
public function assertConfigurationIsUsable(): void
{
if (!$this->config->passkeyEnabled()) {
return;
}
/* D1: without a base domain there is no RP ID and no shared credential */
if (null === $this->domainManager->authBase()) {
throw new PasskeyConfigurationException('PASSKEY_ENABLED is on, but central authentication is not configured. Passkeys require SUBDOMAIN_REDIRECT=1 together with a valid AUTH_SUBDOMAIN whose base domain can be determined (a domain such as "auth.example.com" — not "localhost" and not an IP address). Either configure central authentication or set PASSKEY_ENABLED=0.');
}
/* D4: the subdomain must be a real domain able to present a TLS certificate.
* authBase() returning null already excludes localhost and bare IPs, so
* this guards against an auth subdomain that is a single label. */
if (!str_contains($this->authSubdomain(), '.')) {
throw new PasskeyConfigurationException('AUTH_SUBDOMAIN must be a fully qualified domain name (for example "auth.example.com") because passkeys require HTTPS and a certificate cannot be issued for a single-label host.');
}
}
private function notConfigured(): PasskeyConfigurationException
{
return new PasskeyConfigurationException(
'Passkeys are enabled but central authentication is not configured, '
.'so no relying party identity is available.',
);
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Exception\PasskeyConfigurationException;
use Symfony\Component\HttpFoundation\Request;
/**
* Single source of truth for whether passkeys may be offered, and under which
* relying-party identity.
*
* Two project decisions are enforced here and nowhere else:
*
* - **D1 — central auth is a prerequisite.** A passkey is only meaningful when
* the whole base domain shares one relying party, so passkeys are unavailable
* unless `SUBDOMAIN_REDIRECT` is on and `AUTH_SUBDOMAIN` resolves to a base
* domain. The RP ID is therefore always that base domain.
*
* - **D4 — HTTPS is required, with no exemption.** The origin handed to the
* browser is built here as `https://{authSubdomain}` and is *never* derived
* from the incoming request, so an `http://` origin can never be accepted.
* The deprecated `setSecuredRelyingPartyId()` escape hatch is not used, and
* there is deliberately no configuration override that could reintroduce one.
*/
interface PasskeyPolicyInterface
{
/**
* True when passkeys are switched on by configuration AND that configuration
* satisfies D1. Availability to a particular visitor additionally requires
* {@see isAvailableFor()}.
*/
public function isEnabled(): bool;
/**
* Whether the passkey UI should be offered for this request.
*
* Requires the feature to be enabled, the request to be aimed at the auth
* subdomain (the only place a ceremony may run), and the visitor to actually
* be on HTTPS — see the note on scheme handling in {@see PasskeyPolicy}.
*/
public function isAvailableFor(Request $request): bool;
/**
* The relying party ID: always the base domain of the auth subdomain.
*
* @throws PasskeyConfigurationException when passkeys are enabled without D1
*/
public function rpId(): string;
/**
* Relying party origins allowed to complete a ceremony.
*
* Always exactly one entry, always `https://`, always derived from
* configuration rather than from the request (D4).
*
* @return string[]
*
* @throws PasskeyConfigurationException when passkeys are enabled without D1
*/
public function allowedOrigins(): array;
/** The auth subdomain that ceremonies must be served from. */
public function authSubdomain(): string;
/** Human-readable name shown in the authenticator prompt. */
public function rpName(): string;
/** `required`, `preferred` or `discouraged`. */
public function userVerification(): string;
/** Ceremony timeout in milliseconds, as passed to the browser. */
public function timeout(): int;
/**
* Fails hard when passkeys are enabled in a configuration that cannot work.
*
* Called during cache warm-up so a misconfigured deployment never reaches a
* browser: the container refuses to start instead.
*
* @throws PasskeyConfigurationException
*/
public function assertConfigurationIsUsable(): void;
}
+21
View File
@@ -1,4 +1,13 @@
{
"doctrine/deprecations": {
"version": "1.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "fdd756167454623e21f1d769c5b814b243782a67"
}
},
"friendsofphp/php-cs-fixer": {
"version": "3.95",
"recipe": {
@@ -74,6 +83,18 @@
".editorconfig"
]
},
"symfony/property-info": {
"version": "8.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.3",
"ref": "dae70df71978ae9226ae915ffd5fad817f5ca1f7"
},
"files": [
"config/packages/property_info.yaml"
]
},
"symfony/routing": {
"version": "7.4",
"recipe": {
+12
View File
@@ -8,6 +8,7 @@ use App\ConfigBag;
use App\Utilities;
use DateTimeImmutable;
use OTPHP\TOTP;
use Override;
use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Clock\ClockInterface as PsrClockInterface;
@@ -35,6 +36,7 @@ trait TotpTestHelper
{
}
#[Override]
public function now(): DateTimeImmutable
{
return new DateTimeImmutable($this->time);
@@ -77,6 +79,11 @@ trait TotpTestHelper
string $remoteUserMode = 'session',
string $remoteUserStatic = 'authenticated',
string $remoteUserMap = '',
string $title = 'Pre-Authentication System',
bool $passkeyEnabled = false,
string $passkeyRpName = '',
string $passkeyUserVerification = 'required',
int $passkeyTimeout = 60000,
): ConfigBag {
$clock = $this->frozenClock();
$utilities = $this->createUtilities($clock);
@@ -94,6 +101,11 @@ trait TotpTestHelper
$remoteUserMode,
$remoteUserStatic,
$remoteUserMap,
$title,
$passkeyEnabled,
$passkeyRpName,
$passkeyUserVerification,
$passkeyTimeout,
);
}
@@ -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');
}
}
+234
View File
@@ -0,0 +1,234 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Service;
use App\Enum\UserVerification;
use App\Exception\PasskeyConfigurationException;
use App\Service\DomainInterface;
use App\Service\PasskeyPolicy;
use App\Tests\Support\TotpTestHelper;
use Override;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
/**
* Covers the availability rule (D1) and the HTTPS requirement (D4).
*
* The two decisions are enforced in one place precisely so that they can be
* tested exhaustively here rather than re-derived at each call site.
*/
final class PasskeyPolicyTest extends TestCase
{
use TotpTestHelper;
/**
* A stand-in for the real DomainManager that mirrors its base-domain rule:
* `localhost` and bare IPs yield null; otherwise the last two labels are
* kept, or three when the final two form a known multi-part TLD.
*
* Verified against DomainManager: "auth.example.com" => "example.com",
* "auth.example.co.uk" => "example.co.uk", "auth" => "auth",
* "localhost" => null.
*/
private function makeDomain(bool $subdomainRedirect, string $authSubdomain): DomainInterface
{
$authBase = null;
if ('' !== $authSubdomain
&& 'localhost' !== $authSubdomain
&& !filter_var($authSubdomain, \FILTER_VALIDATE_IP)
) {
$parts = explode('.', strtolower($authSubdomain));
$keep = 2;
$count = \count($parts);
if ($count > 2 && 'uk' === $parts[$count - 1] && \in_array($parts[$count - 2], ['co', 'org', 'ac', 'gov'], true)) {
$keep = 3;
}
$authBase = implode('.', \array_slice($parts, -min($keep, $count)));
}
return new class($subdomainRedirect, $authSubdomain, $subdomainRedirect ? $authBase : null) implements DomainInterface {
public function __construct(
private bool $redirect,
private string $authSubdomain,
private ?string $authBase,
) {
}
#[Override]
public function getAuthSubdomain(): ?string
{
return $this->redirect ? $this->authSubdomain : null;
}
#[Override]
public function validReturn(string $url): bool
{
return $this->redirect;
}
#[Override]
public function matchesAuth(string $host): bool
{
return $this->redirect;
}
#[Override]
public function authBase(): ?string
{
return $this->authBase;
}
};
}
private function makePolicy(
bool $passkeyEnabled = true,
bool $subdomainRedirect = true,
string $authSubdomain = 'auth.example.com',
string $userVerification = 'required',
int $timeout = 60000,
string $rpName = '',
string $title = 'Pre-Authentication System',
): PasskeyPolicy {
$config = $this->makeConfig(
passkeyEnabled: $passkeyEnabled,
passkeyUserVerification: $userVerification,
passkeyTimeout: $timeout,
passkeyRpName: $rpName,
title: $title,
);
return new PasskeyPolicy($config, $this->makeDomain($subdomainRedirect, $authSubdomain));
}
/* ── D1: enabled + prerequisite ─────────────────────────────────────── */
public function test_enabled_requires_both_the_switch_and_central_auth(): void
{
self::assertTrue($this->makePolicy()->isEnabled());
self::assertFalse($this->makePolicy(passkeyEnabled: false)->isEnabled());
self::assertFalse($this->makePolicy(subdomainRedirect: false)->isEnabled());
}
public function test_rp_id_is_always_the_auth_base_domain(): void
{
self::assertSame('example.com', $this->makePolicy()->rpId());
self::assertSame('example.co.uk', $this->makePolicy(authSubdomain: 'auth.example.co.uk')->rpId());
}
public function test_rp_id_throws_when_not_configured(): void
{
$this->expectException(PasskeyConfigurationException::class);
$this->makePolicy(subdomainRedirect: false)->rpId();
}
/* ── D4: HTTPS is the only accepted origin ─────────────────────────── */
public function test_allowed_origin_is_always_https(): void
{
self::assertSame(['https://auth.example.com'], $this->makePolicy()->allowedOrigins());
}
public function test_allowed_origin_never_reflects_the_request_scheme(): void
{
$policy = $this->makePolicy();
$request = Request::create('http://auth.example.com/', 'GET');
self::assertSame(['https://auth.example.com'], $policy->allowedOrigins());
self::assertFalse($policy->isAvailableFor($request));
}
public function test_available_only_on_the_auth_host_over_https(): void
{
$policy = $this->makePolicy();
$secure = Request::create('https://auth.example.com/', 'GET');
$insecure = Request::create('http://auth.example.com/', 'GET');
$otherHost = Request::create('https://app.example.com/', 'GET');
self::assertTrue($policy->isAvailableFor($secure));
self::assertFalse($policy->isAvailableFor($insecure));
self::assertFalse($policy->isAvailableFor($otherHost));
}
public function test_available_respects_the_switch(): void
{
$request = Request::create('https://auth.example.com/', 'GET');
self::assertFalse($this->makePolicy(passkeyEnabled: false)->isAvailableFor($request));
}
/* ── boot-time assertion (D1 + D4) ─────────────────────────────────── */
public function test_assertion_is_silent_when_disabled(): void
{
$this->makePolicy(passkeyEnabled: false, subdomainRedirect: false)->assertConfigurationIsUsable();
$this->addToAssertionCount(1);
}
public function test_assertion_passes_for_a_valid_configuration(): void
{
$this->makePolicy()->assertConfigurationIsUsable();
$this->addToAssertionCount(1);
}
public function test_assertion_fails_without_central_auth(): void
{
$this->expectException(PasskeyConfigurationException::class);
$this->expectExceptionMessageMatches('/central authentication is not configured/');
$this->makePolicy(subdomainRedirect: false, authSubdomain: '')->assertConfigurationIsUsable();
}
public function test_assertion_fails_for_localhost(): void
{
/* localhost has no base domain, so it can never satisfy D1 */
$this->expectException(PasskeyConfigurationException::class);
$this->makePolicy(authSubdomain: 'localhost')->assertConfigurationIsUsable();
}
public function test_assertion_fails_for_a_single_label_subdomain(): void
{
/* D4: no certificate can be issued for a single-label host */
$this->expectException(PasskeyConfigurationException::class);
$this->expectExceptionMessageMatches('/fully qualified domain name/');
$this->makePolicy(authSubdomain: 'auth')->assertConfigurationIsUsable();
}
/* ── configuration accessors ───────────────────────────────────────── */
public function test_rp_name_falls_back_to_the_title(): void
{
self::assertSame('Pre-Authentication System', $this->makePolicy(rpName: '')->rpName());
self::assertSame('My Gateway', $this->makePolicy(rpName: 'My Gateway')->rpName());
}
public function test_user_verification_and_timeout_are_passed_through(): void
{
$policy = $this->makePolicy(userVerification: 'preferred', timeout: 30000);
self::assertSame('preferred', $policy->userVerification());
self::assertSame(30000, $policy->timeout());
}
public function test_unknown_user_verification_falls_back_to_required(): void
{
/* an unrecognised value must never silently weaken the requirement */
$policy = $this->makePolicy(
userVerification: 'nonsense',
);
self::assertSame(UserVerification::Required->value, $policy->userVerification());
}
public function test_non_positive_timeout_falls_back_to_the_default(): void
{
self::assertSame(60000, $this->makePolicy(timeout: 0)->timeout());
self::assertSame(60000, $this->makePolicy(timeout: -100)->timeout());
}
public function test_auth_subdomain_is_exposed_for_ceremony_urls(): void
{
self::assertSame('auth.example.com', $this->makePolicy()->authSubdomain());
}
}