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:
@@ -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
@@ -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
@@ -0,0 +1,3 @@
|
||||
framework:
|
||||
property_info:
|
||||
with_constructor_extractor: true
|
||||
@@ -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,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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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.*
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user