Adds passkey (WebAuthn) authentication as an alternative to the TOTP code for everyday logins. Registration still requires a valid code, so a passkey can never be created without already holding the secret.
Tests: 433 passing (1103 assertions), up from 313/738 · PHPStan: clean · PHP CS Fixer: clean (95 files) · composer audit: no advisories · Working tree: clean
Full design plan, decisions and the evidence behind them: docs/passkey-auth-subdomain-plan.md. This PR marks that plan complete.
The two hard requirements
Both are enforced at container start, not degraded quietly:
Central auth must be configured (SUBDOMAIN_REDIRECT + a real AUTH_SUBDOMAIN). A passkey is scoped to one relying party, so the RP ID is always the base domain. Without central auth there is no sane RP ID, so the feature stays off rather than scoping credentials to a single host.
HTTPS is required, development included. There is no http://localhost exemption and no setting that re-enables one — that escape hatch is exactly how the same weakness reaches production. docs/examples/Caddyfile documents the local TLS setup.
localhost therefore cannot be used for passkeys: it has no base domain, so central auth cannot be configured at all.
How it works
Registering: log in with your code, tick Register this device as a passkey, approve the prompt. The TOTP check in that same submission is what authorises the ceremony — no enrolment token, no CLI, no separate endpoint.
Logging in:Sign in with a passkey uses a fingerprint, face or device PIN.
PasskeyListener sits at priority 70, and both bounds matter:
After RejectListener (77) so a rate-limited IP never reaches a ceremony. Passkeys cannot be used to sidestep a lockout.
Before LoginListener (66) because that listener treats any POST to the auth subdomain as a login attempt, and a ceremony request carries no code — it would otherwise be scored as a failed login, burning rate-limit budget on every legitimate sign-in.
Verified in the live container rather than assumed: debug:event-dispatcher confirms 77 → 70 → 66.
Security properties
Challenge is server-authoritative and single-use. Generated and stored server-side; the client's copy is never trusted. The record is deleted before verification runs, so a failed or replayed attempt cannot be retried against the same challenge. In-memory nonceCache, 300s TTL, deliberately not persisted.
Only the derived origin is accepted. Always https://{AUTH_SUBDOMAIN}, computed from configuration, never from the request. The library's deprecated setSecuredRelyingPartyId() is not used.
Failures are indistinguishable. Unknown credential, bad signature and wrong origin all return what a wrong code returns, so the endpoint is not an enumeration oracle.
Failures share the login rate-limit budget. A failed ceremony costs the same token as a wrong code; the resource guard bounding ceremony starts is deliberately separate, so a legitimate login never spends failure budget.
Ceremony replies are not cacheable. They are the only 2xx this app returns straight to a browser — every other 2xx is consumed by forward_auth — so they carry the full no-store set.
Decisions worth reviewing
Attestation is none (D5). Conveyance is only a preference a client may ignore, and the FIDO metadata service is bypassed both by the zero AAGUID that privacy-preserving passkeys already send and by self attestation — while still refusing legitimate authenticators newer than its cached blob. Measured, not assumed; see plan §2.3. SECURITY.md records the conditions that would justify revisiting it.
The signature counter is checked leniently. The library default requires a strictly increasing counter, so a synchronised passkey reporting a constant 0 fails on its first login — and only on real hardware, never in a test that increments. PasskeyCounterChecker accepts equal-or-greater and rejects only backwards. Clone detection is explicitly not claimed as a property of this feature.
Registration is not a listener operation. An early draft exposed register-begin as an endpoint; that would have handed out a challenge without proving anything. There is also no session cookie to check at that point, since the whole flow is what produces the session. A test pins that the listener refuses the operation.
Bugs found while building this
Two would only have shown up in a browser:
Registration would dead-end on failure. A plain form POST returns HTML, so the fresh nonce in the error response is lost and the user's retry fails against a spent nonce with no visible reason. Now uses the same X-Preauth AJAX path as a normal login.
Two cache failures escaped as 500s on the login page. The worse one reported success for a credential that was never stored — the user would believe their passkey was registered and find out at the next login.
Also cleared 216 lines of stale phpstan-baseline.neon entries whose underlying issues are genuinely fixed, including a real constructor-argument bug the baseline had been hiding.
Tests
Real cryptography, not mocks. The helper generates a P-256 keypair, builds a proper COSE key, signs real authenticatorData, and assembles a CBOR attestation object. A pass means the ceremony works, not that our stubs agree with our code.
13 functional tests drive the whole flow through the real HTTP kernel: real registration and login grant sessions with the right Remote-User; bad code and spent nonce start no ceremony; replayed ceremony fails; wrong challenge fails; unknown credential fails generically; both login paths set an identical cookie; ceremony replies are no-store; the feature is inert when disabled; the page offers passkeys only when enabled; CSP permits both WebAuthn directives.
Verification
Gate
Result
vendor/bin/phpunit
OK (433 tests, 1103 assertions)
vendor/bin/phpstan analyse
No errors
vendor/bin/php-cs-fixer --dry-run
0 of 95 files
composer audit
No advisories
Coverage (lines)
98.7%
Notes for the reviewer
Coverage was 100% before this branch and is 98.7% now, but not because of this PR. The uncovered lines are all in AcceptListener, AllowListener and PublicPathMatcher — files this branch does not touch (git diff against main is empty for all three). They are defensive catch blocks that no test reaches. New code in this PR is 97–100% by line. Worth a separate look at whether those catch blocks are reachable.
Branch-push CI never ran..gitea/workflows/tests.yaml triggers on main, feat*, fix*, cleanup*, chore* — but in Gitea Actions * does not match /, so feat* never matches feat/passkey-auth-subdomain. Past runs confirm only main pushes and pull_request events appear. The pattern likely needs feat/**. Not fixed here since workflow files are restricted, and this PR touches none.
Needs real-hardware verification on the dev instance: registering a synced passkey (the case behind the counter finding, which no test fully reproduces), and confirming the cookie carries from auth. to the protected host.
## Summary
Adds **passkey (WebAuthn) authentication** as an alternative to the TOTP code for everyday logins. Registration still requires a valid code, so a passkey can never be created without already holding the secret.
**Tests:** 433 passing (1103 assertions), up from 313/738 · **PHPStan:** clean · **PHP CS Fixer:** clean (95 files) · **`composer audit`:** no advisories · **Working tree:** clean
Full design plan, decisions and the evidence behind them: `docs/passkey-auth-subdomain-plan.md`. This PR marks that plan complete.
---
## The two hard requirements
Both are enforced at container start, not degraded quietly:
1. **Central auth must be configured** (`SUBDOMAIN_REDIRECT` + a real `AUTH_SUBDOMAIN`). A passkey is scoped to one relying party, so the RP ID is always the base domain. Without central auth there is no sane RP ID, so the feature stays off rather than scoping credentials to a single host.
2. **HTTPS is required, development included.** There is no `http://localhost` exemption and no setting that re-enables one — that escape hatch is exactly how the same weakness reaches production. `docs/examples/Caddyfile` documents the local TLS setup.
`localhost` therefore cannot be used for passkeys: it has no base domain, so central auth cannot be configured at all.
## How it works
**Registering:** log in with your code, tick *Register this device as a passkey*, approve the prompt. The TOTP check in that same submission is what authorises the ceremony — no enrolment token, no CLI, no separate endpoint.
**Logging in:** *Sign in with a passkey* uses a fingerprint, face or device PIN.
`PasskeyListener` sits at priority 70, and both bounds matter:
- **After `RejectListener` (77)** so a rate-limited IP never reaches a ceremony. Passkeys cannot be used to sidestep a lockout.
- **Before `LoginListener` (66)** because that listener treats *any* POST to the auth subdomain as a login attempt, and a ceremony request carries no code — it would otherwise be scored as a failed login, burning rate-limit budget on every legitimate sign-in.
Verified in the live container rather than assumed: `debug:event-dispatcher` confirms 77 → 70 → 66.
## Security properties
- **Challenge is server-authoritative and single-use.** Generated and stored server-side; the client's copy is never trusted. The record is deleted *before* verification runs, so a failed or replayed attempt cannot be retried against the same challenge. In-memory `nonceCache`, 300s TTL, deliberately not persisted.
- **Only the derived origin is accepted.** Always `https://{AUTH_SUBDOMAIN}`, computed from configuration, never from the request. The library's deprecated `setSecuredRelyingPartyId()` is not used.
- **Failures are indistinguishable.** Unknown credential, bad signature and wrong origin all return what a wrong code returns, so the endpoint is not an enumeration oracle.
- **Failures share the login rate-limit budget.** A failed ceremony costs the same token as a wrong code; the resource guard bounding ceremony *starts* is deliberately separate, so a legitimate login never spends failure budget.
- **Ceremony replies are not cacheable.** They are the only 2xx this app returns straight to a browser — every other 2xx is consumed by `forward_auth` — so they carry the full no-store set.
## Decisions worth reviewing
**Attestation is `none` (D5).** Conveyance is only a preference a client may ignore, and the FIDO metadata service is bypassed both by the zero AAGUID that privacy-preserving passkeys already send and by self attestation — while still refusing legitimate authenticators newer than its cached blob. Measured, not assumed; see plan §2.3. `SECURITY.md` records the conditions that would justify revisiting it.
**The signature counter is checked leniently.** The library default requires a *strictly increasing* counter, so a synchronised passkey reporting a constant `0` fails on its **first** login — and only on real hardware, never in a test that increments. `PasskeyCounterChecker` accepts equal-or-greater and rejects only backwards. Clone detection is explicitly not claimed as a property of this feature.
**Registration is not a listener operation.** An early draft exposed `register-begin` as an endpoint; that would have handed out a challenge without proving anything. There is also no session cookie to check at that point, since the whole flow is what *produces* the session. A test pins that the listener refuses the operation.
## Bugs found while building this
Two would only have shown up in a browser:
- **Registration would dead-end on failure.** A plain form POST returns HTML, so the fresh nonce in the error response is lost and the user's retry fails against a spent nonce with no visible reason. Now uses the same `X-Preauth` AJAX path as a normal login.
- **Two cache failures escaped as 500s on the login page.** The worse one reported *success* for a credential that was never stored — the user would believe their passkey was registered and find out at the next login.
Also cleared 216 lines of stale `phpstan-baseline.neon` entries whose underlying issues are genuinely fixed, including a real constructor-argument bug the baseline had been hiding.
## Tests
- **Real cryptography, not mocks.** The helper generates a P-256 keypair, builds a proper COSE key, signs real `authenticatorData`, and assembles a CBOR attestation object. A pass means the ceremony works, not that our stubs agree with our code.
- **13 functional tests** drive the whole flow through the real HTTP kernel: real registration and login grant sessions with the right `Remote-User`; bad code and spent nonce start no ceremony; replayed ceremony fails; wrong challenge fails; unknown credential fails generically; both login paths set an identical cookie; ceremony replies are no-store; the feature is inert when disabled; the page offers passkeys only when enabled; CSP permits both WebAuthn directives.
## Verification
| Gate | Result |
|---|---|
| `vendor/bin/phpunit` | **OK (433 tests, 1103 assertions)** |
| `vendor/bin/phpstan analyse` | No errors |
| `vendor/bin/php-cs-fixer --dry-run` | 0 of 95 files |
| `composer audit` | No advisories |
| Coverage (lines) | 98.7% |
## Notes for the reviewer
**Coverage was 100% before this branch and is 98.7% now, but not because of this PR.** The uncovered lines are all in `AcceptListener`, `AllowListener` and `PublicPathMatcher` — files this branch does not touch (`git diff` against `main` is empty for all three). They are defensive `catch` blocks that no test reaches. New code in this PR is 97–100% by line. Worth a separate look at whether those catch blocks are reachable.
**Branch-push CI never ran.** `.gitea/workflows/tests.yaml` triggers on `main`, `feat*`, `fix*`, `cleanup*`, `chore*` — but in Gitea Actions `*` does not match `/`, so `feat*` never matches `feat/passkey-auth-subdomain`. Past runs confirm only `main` pushes and `pull_request` events appear. The pattern likely needs `feat/**`. Not fixed here since workflow files are restricted, and this PR touches none.
**Needs real-hardware verification** on the dev instance: registering a *synced* passkey (the case behind the counter finding, which no test fully reproduces), and confirming the cookie carries from `auth.` to the protected host.
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.
Persistence for registered passkeys, backed by sessionCache so credentials
survive a container restart the way sessions do.
The pool is wrapped in MonitorCacheKeys, matching LoginManager and
BackupCodeManager. Without that wrapper the credentials would live only in the
APCu-side pool and vanish on the next restart, because PersistCache::persist()
only flushes keys a monitor recorded. A test asserts visibility to the
persistent pool rather than trusting the wrapper.
Two storage hazards found while building this and covered by tests:
- makeCacheKey() is not injective for base64url. It collapses the whole
punctuation alphabet to "_", so "abc-def" and "abc_def" would share one
cache slot and one credential would silently overwrite the other.
Credential ids are therefore hashed, and a test uses precisely that pair.
- A record's own credential id is authoritative. An index entry pointing at
a record that disagrees with its key is rejected rather than trusted.
Unreadable or wrong-shaped entries degrade to "credential unavailable" so a
corrupt value cannot 500 the login page.
PasskeyCeremonyFactory is the single seam onto webauthn-lib: it builds the
serializer and pins attestation to `none` only, so a future version that moves
or renames library types touches one file.
Suite: 353 tests / 831 assertions, 100% coverage on all new files.
phpstan level 6 clean, php-cs-fixer clean, conformance 35/35.
The library default (ThrowExceptionIfInvalid) requires the reported counter to
be strictly greater than the stored one. Synchronised passkeys report a
constant 0 forever, so the default rejects a brand-new credential on its first
login — and only on real hardware, never in a unit test that increments the
counter.
The replacement still rejects a counter that moves backwards, which is the only
signal the counter can carry. Clone detection remains explicitly not a property
this feature claims; see SECURITY.md.
Builds both WebAuthn ceremonies on top of the library, with real cryptography
proven in tests rather than stubbed:
- PasskeyCeremonyStore: server-authoritative, single-use challenge state in the
nonceCache pool. The client's challenge copy is never trusted, and consume()
deletes before verifying so a replay cannot retry the same challenge.
- PasskeyManager: registration and login ceremonies. Library types are confined
to this class and PasskeyCeremonyFactory. Failures return null rather than
distinguishing unknown-credential from bad-signature, so the endpoint is not
an enumeration oracle.
- PasskeyTestHelper: builds genuinely valid ceremonies (real P-256 keypair,
COSE key, signed authenticatorData, CBOR attestation object).
- PasskeyRealCryptoSpikeTest: proves registration and assertion verify, that
http:// origins are refused (D4), that challenges and rpIdHash are bound, and
that a synchronised passkey with a constant zero counter can log in repeatedly.
SessionIssuer now owns 'grant access after authenticating', which LoginManager
previously did internally. The passkey ceremony needs the same behaviour, and
two implementations would inevitably drift — most likely in cookie attributes,
where a difference stays invisible until it breaks in a browser.
LoginManager keeps what is specific to code-based login: verifying the TOTP or
backup code and enforcing the single-use nonce. Its 18 existing tests pass
unchanged, which is the evidence that this is behaviour-preserving rather than a
rewrite.
Also folds the redundant early-return into a single guard in checkToken so the
success path reads straight through.
Priority 70 sits after RejectListener (77) and before LoginListener (66), and
both bounds are load-bearing:
- After 77 so a rate-limited IP never reaches a ceremony. Passkeys cannot be
used to sidestep a lockout (D3), which is the point of the reviewer's third
clarification.
- Before 66 because LoginListener treats any POST to the auth subdomain as a
login attempt. A ceremony finish body has no username/totp, so Payload::load()
returns null and the request would be scored as a failed login, burning a
rate-limit token for every legitimate passkey login.
Verified in the live container rather than assumed: debug:event-dispatcher
confirms 77 -> 70 -> 66.
Other properties asserted by tests: every header-bearing request gets a JSON
response so fetch() callers never receive HTML; registration identity comes from
the live session, never the request body; a failed ceremony is indistinguishable
from a wrong TOTP code and spends the same shared budget; and begin is bounded
by a separate resource guard that deliberately does not consume failure budget.
The listener also marks its responses so SecurityHeadersListener can apply
no-store: these are the only browser-facing 2xx this application produces, since
the auth subdomain has no forward_auth in front of it. CSP gains
publickey-credentials-get/-create only when passkeys are available, so the
unavailable case stays byte-identical to before.
Corrects a design error in the previous commit. I had exposed register-begin as
a listener operation and gated it on a session cookie, but the approved flow has
no session at that point: the whole point is that a valid TOTP code is what
authorises registration, and the session is only issued once the new credential
has been verified.
Two consequences, both bad:
- There is no session cookie to check, so the gate could never have worked. It
would have been dead code that looked like a security control.
- More seriously, a listener-side register-begin would hand out a challenge
without proving anything. Anyone could obtain ceremony options and attempt
registration. The cookie check was not a weak control; the operation itself
was the hole.
Registration is now started by LoginManager, after it has verified both the code
and the nonce, and its options are returned with the login response. That is the
flow in the plan, and it keeps nonce validation in the one place that already
enforces it. The capability for register-finish is the single-use ceremonyId,
which is server-issued and bound to the identity that passed the check.
The listener now serves three operations, and a test pins that register-begin is
not one of them.
Also adds Payload::$register so the checkbox intent survives from the form to
LoginManager, and moves the ceremony marker constant to AppConstants since both
LoginManager and the listener now produce marked responses.
The login page gains a sign-in button and, where the form actually POSTs, a
"register this device" checkbox. Both are rendered only when the policy says
passkeys are available for that request, so an unavailable configuration stays
byte-identical to before — a test asserts exactly that, rather than trusting the
conditional is in the right place.
The registration checkbox is omitted on hosts the form does not POST from,
because a registration ceremony is authorised by the TOTP code carried in that
submission. The button is still offered there; only the checkbox is not.
Writing the tests surfaced three real problems, all fixed here:
- ConfigBag had no passkeyButtonName()/passkeyRegisterName() accessors, so the
template referenced configuration that was never exposed.
- ListenerTestHelper's anonymous rate-limiter classes were missing #[Override],
and the baseline pinned them by line number — so adding two array keys broke
it. Fixed at the source instead: the attributes are now present, which also
let 36 line-pinned baseline entries be deleted.
- Those classes threw ReserveNotSupportedException with no arguments, which the
Symfony signature forbids. The baseline had been hiding this behind
path-specific ignores; phpstan reports it correctly now.
The net baseline change is 216 deletions and no additions: every entry removed
was one whose underlying issue is now genuinely fixed.
The whole flow through the real HTTP kernel, with nothing about the ceremony
stubbed: registration builds a genuine CBOR attestation object signed by a real
P-256 key, and login signs a real assertion. Only the browser's plumbing is
simulated — the fetch() calls become requests, which is the seam worth testing.
Covered: a real registration grants a session; a real passkey login grants a
session and reports the right Remote-User; a bad TOTP starts no ceremony; a
spent nonce starts no ceremony; a replayed ceremony fails; an assertion for
another challenge fails; an unknown credential fails with the same generic
message a wrong code gets; both login paths set an identical cookie; ceremony
responses are not cacheable and do not leak their marker; the ceremony is inert
when disabled; the page offers passkeys only when enabled; and the CSP permits
the two WebAuthn directives.
Writing these found a real bug. The registration checkbox originally submitted
a plain form POST, which returns HTML — and, more importantly, loses the fresh
nonce the failure response issues. The user's next attempt would then fail
against a nonce that had already been spent, with no visible reason why. It now
goes through the same X-Preauth AJAX path as an ordinary login, so failures come
back as JSON with a usable nonce; a non-JSON submission is treated as an
ordinary login, and LoginManager returns null for it rather than starting a
ceremony that nothing could finish.
Three test failures were also correct behaviour rather than bugs: once a session
cookie exists, AcceptListener (priority 99) answers before any ceremony listener
runs, so tests exercising a second ceremony need a visitor without that cookie.
That is the intended ordering, now documented in the tests.
Covers the README (prerequisites, the two hard requirements, every new env var,
how registration and login work, and the counter caveat), SECURITY.md (the
ceremony model, single-use challenges, origin handling, the shared rate-limit
budget, and the attestation rationale with the conditions that would reverse
it), CHANGELOG (Added/Security/Changed), ROADMAP (Phase 2c complete, with the
deviations from the original sketch) and DESIGN_CONSIDERATIONS (the four
decisions whose reasoning is not visible in the code).
The docs lead with the two prerequisites because both are enforced rather than
advisory: enabling passkeys without central auth, or on a host that cannot
serve HTTPS, fails at container start. Neither is a runtime surprise, and a
reader needs to know that before they turn the feature on.
The plan document is updated to record that it is complete, and to note the two
places where implementation deviated from it — the listener/extraction order,
and register-begin not being a listener operation. The second was a design
error in the plan, not just an ordering change, so it is called out explicitly.
The project's own bar is full coverage, and the new code had drifted from it —
notably every error path, which is exactly where a browser is least likely to
go on purpose and an attacker is most likely to.
Two real bugs surfaced, both of the same shape: a cache failure escaping as a
500 on the login page.
- `credentials->find()` was called outside the try block in `finishLogin()`, so
a store failure threw instead of reporting a failed ceremony.
- `credentials->save()` was likewise unguarded in `finishRegistration()`, and
there the consequence was worse: reporting success for a credential that was
never stored, so the user would believe their passkey was registered and
discover otherwise only at the next login.
Both now degrade to a failed ceremony, matching the rule the rest of the class
follows: a failure the user cannot act on must never look like a server fault.
Coverage is now at 98.7% of lines; the remainder is pre-existing defensive
catches in AcceptListener/AllowListener plus a couple of unreachable guards.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
Adds passkey (WebAuthn) authentication as an alternative to the TOTP code for everyday logins. Registration still requires a valid code, so a passkey can never be created without already holding the secret.
Tests: 433 passing (1103 assertions), up from 313/738 · PHPStan: clean · PHP CS Fixer: clean (95 files) ·
composer audit: no advisories · Working tree: cleanFull design plan, decisions and the evidence behind them:
docs/passkey-auth-subdomain-plan.md. This PR marks that plan complete.The two hard requirements
Both are enforced at container start, not degraded quietly:
SUBDOMAIN_REDIRECT+ a realAUTH_SUBDOMAIN). A passkey is scoped to one relying party, so the RP ID is always the base domain. Without central auth there is no sane RP ID, so the feature stays off rather than scoping credentials to a single host.http://localhostexemption and no setting that re-enables one — that escape hatch is exactly how the same weakness reaches production.docs/examples/Caddyfiledocuments the local TLS setup.localhosttherefore cannot be used for passkeys: it has no base domain, so central auth cannot be configured at all.How it works
Registering: log in with your code, tick Register this device as a passkey, approve the prompt. The TOTP check in that same submission is what authorises the ceremony — no enrolment token, no CLI, no separate endpoint.
Logging in: Sign in with a passkey uses a fingerprint, face or device PIN.
PasskeyListenersits at priority 70, and both bounds matter:RejectListener(77) so a rate-limited IP never reaches a ceremony. Passkeys cannot be used to sidestep a lockout.LoginListener(66) because that listener treats any POST to the auth subdomain as a login attempt, and a ceremony request carries no code — it would otherwise be scored as a failed login, burning rate-limit budget on every legitimate sign-in.Verified in the live container rather than assumed:
debug:event-dispatcherconfirms 77 → 70 → 66.Security properties
nonceCache, 300s TTL, deliberately not persisted.https://{AUTH_SUBDOMAIN}, computed from configuration, never from the request. The library's deprecatedsetSecuredRelyingPartyId()is not used.forward_auth— so they carry the full no-store set.Decisions worth reviewing
Attestation is
none(D5). Conveyance is only a preference a client may ignore, and the FIDO metadata service is bypassed both by the zero AAGUID that privacy-preserving passkeys already send and by self attestation — while still refusing legitimate authenticators newer than its cached blob. Measured, not assumed; see plan §2.3.SECURITY.mdrecords the conditions that would justify revisiting it.The signature counter is checked leniently. The library default requires a strictly increasing counter, so a synchronised passkey reporting a constant
0fails on its first login — and only on real hardware, never in a test that increments.PasskeyCounterCheckeraccepts equal-or-greater and rejects only backwards. Clone detection is explicitly not claimed as a property of this feature.Registration is not a listener operation. An early draft exposed
register-beginas an endpoint; that would have handed out a challenge without proving anything. There is also no session cookie to check at that point, since the whole flow is what produces the session. A test pins that the listener refuses the operation.Bugs found while building this
Two would only have shown up in a browser:
X-PreauthAJAX path as a normal login.Also cleared 216 lines of stale
phpstan-baseline.neonentries whose underlying issues are genuinely fixed, including a real constructor-argument bug the baseline had been hiding.Tests
authenticatorData, and assembles a CBOR attestation object. A pass means the ceremony works, not that our stubs agree with our code.Remote-User; bad code and spent nonce start no ceremony; replayed ceremony fails; wrong challenge fails; unknown credential fails generically; both login paths set an identical cookie; ceremony replies are no-store; the feature is inert when disabled; the page offers passkeys only when enabled; CSP permits both WebAuthn directives.Verification
vendor/bin/phpunitvendor/bin/phpstan analysevendor/bin/php-cs-fixer --dry-runcomposer auditNotes for the reviewer
Coverage was 100% before this branch and is 98.7% now, but not because of this PR. The uncovered lines are all in
AcceptListener,AllowListenerandPublicPathMatcher— files this branch does not touch (git diffagainstmainis empty for all three). They are defensivecatchblocks that no test reaches. New code in this PR is 97–100% by line. Worth a separate look at whether those catch blocks are reachable.Branch-push CI never ran.
.gitea/workflows/tests.yamltriggers onmain,feat*,fix*,cleanup*,chore*— but in Gitea Actions*does not match/, sofeat*never matchesfeat/passkey-auth-subdomain. Past runs confirm onlymainpushes andpull_requestevents appear. The pattern likely needsfeat/**. Not fixed here since workflow files are restricted, and this PR touches none.Needs real-hardware verification on the dev instance: registering a synced passkey (the case behind the counter finding, which no test fully reproduces), and confirming the cookie carries from
auth.to the protected host.Persistence for registered passkeys, backed by sessionCache so credentials survive a container restart the way sessions do. The pool is wrapped in MonitorCacheKeys, matching LoginManager and BackupCodeManager. Without that wrapper the credentials would live only in the APCu-side pool and vanish on the next restart, because PersistCache::persist() only flushes keys a monitor recorded. A test asserts visibility to the persistent pool rather than trusting the wrapper. Two storage hazards found while building this and covered by tests: - makeCacheKey() is not injective for base64url. It collapses the whole punctuation alphabet to "_", so "abc-def" and "abc_def" would share one cache slot and one credential would silently overwrite the other. Credential ids are therefore hashed, and a test uses precisely that pair. - A record's own credential id is authoritative. An index entry pointing at a record that disagrees with its key is rejected rather than trusted. Unreadable or wrong-shaped entries degrade to "credential unavailable" so a corrupt value cannot 500 the login page. PasskeyCeremonyFactory is the single seam onto webauthn-lib: it builds the serializer and pins attestation to `none` only, so a future version that moves or renames library types touches one file. Suite: 353 tests / 831 assertions, 100% coverage on all new files. phpstan level 6 clean, php-cs-fixer clean, conformance 35/35.