Add the passkey listener at priority 70

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.
This commit is contained in:
2026-09-27 10:50:09 +00:00
parent fed7b1b48c
commit ffb824c652
12 changed files with 925 additions and 10 deletions
+2
View File
@@ -18,6 +18,8 @@ PASSKEY_USER_VERIFICATION='required'
PASSKEY_TIMEOUT=60000
PASSKEY_BUTTON_NAME='Sign in with a passkey'
PASSKEY_REGISTER_NAME='Register this device as a passkey'
PASSKEY_BEGIN_BURST_COUNT=30
PASSKEY_BEGIN_BURST_TIME=60
PUBLIC_PATHS=''
PUBLIC_BURST_COUNT=100
PUBLIC_BURST_TIME=60
+2
View File
@@ -12,6 +12,8 @@ framework:
adapters: cache.adapter.filesystem
publicRateLimitCache:
adapters: cache.adapter.apcu
passkeyRateLimitCache:
adapters: cache.adapter.apcu
# Unique name of your app: used to compute stable namespaces for cache keys.
prefix_seed: digitaladapt/preauth
+12
View File
@@ -27,3 +27,15 @@ framework:
public_limiter:
policy: compound
limiters: [public_burst, public_upper]
# bounds how many ceremonies one caller can *start*.
#
# This is a resource guard, NOT part of the login budget: D3 makes the
# existing login_limiter the single shared budget for every login method,
# and a legitimate `begin` must not consume failure budget. Without this,
# an unauthenticated caller could fill the ceremony cache with records.
passkey_begin_burst:
policy: 'sliding_window'
limit: '%env(int:PASSKEY_BEGIN_BURST_COUNT)%'
interval: '%env(int:PASSKEY_BEGIN_BURST_TIME)% seconds'
cache_pool: 'passkeyRateLimitCache'
+2
View File
@@ -12,3 +12,5 @@ framework:
adapters: cache.adapter.array
publicRateLimitCache:
adapters: cache.adapter.array
passkeyRateLimitCache:
adapters: cache.adapter.array
+4
View File
@@ -66,6 +66,10 @@ parameters:
# Extra options, custom labels
env(PASSKEY_BUTTON_NAME): 'Sign in with a passkey'
env(PASSKEY_REGISTER_NAME): 'Register this device as a passkey'
# bounds how many ceremonies one caller can start (resource guard, not the
# login budget — see config/packages/rate_limiter.yaml)
env(PASSKEY_BEGIN_BURST_COUNT): 30
env(PASSKEY_BEGIN_BURST_TIME): 60
# --- styling options ---
env(TITLE): 'Pre-Authentication System'
+2
View File
@@ -34,6 +34,8 @@
#PASSKEY_TIMEOUT=60000 # ceremony timeout in milliseconds
#PASSKEY_BUTTON_NAME='Sign in with a passkey'
#PASSKEY_REGISTER_NAME='Register this device as a passkey'
#PASSKEY_BEGIN_BURST_COUNT=30 # ceremonies one caller may start per window
#PASSKEY_BEGIN_BURST_TIME=60 # window for the above, in seconds
# --- extra options ---
+24
View File
@@ -126,6 +126,30 @@ parameters:
count: 1
path: src/Listener/InterceptListener.php
-
message: '#^Class App\\Listener\\PasskeyListener has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
identifier: property.uninitializedReadonly
count: 1
path: src/Listener/PasskeyListener.php
-
message: '#^Class App\\Listener\\PasskeyListener has an uninitialized readonly property \$nonceCache\. Assign it in the constructor\.$#'
identifier: property.uninitializedReadonly
count: 1
path: src/Listener/PasskeyListener.php
-
message: '#^Readonly property App\\Listener\\PasskeyListener\:\:\$logger is assigned outside of the constructor\.$#'
identifier: property.readOnlyAssignNotInConstructor
count: 2
path: src/Listener/PasskeyListener.php
-
message: '#^Readonly property App\\Listener\\PasskeyListener\:\:\$nonceCache is assigned outside of the constructor\.$#'
identifier: property.readOnlyAssignNotInConstructor
count: 1
path: src/Listener/PasskeyListener.php
-
message: '#^Class App\\Listener\\LoginListener has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
identifier: property.uninitializedReadonly
+316
View File
@@ -0,0 +1,316 @@
<?php
declare(strict_types=1);
namespace App\Listener;
use App\ConfigBag;
use App\Enum\Scope;
use App\Service\DomainInterface;
use App\Service\PasskeyInterface;
use App\Service\PasskeyPolicyInterface;
use App\Service\SessionIssuerInterface;
use App\Trait\CookieNameTrait;
use App\Trait\HasLoggerTrait;
use App\Trait\MakeNonceTrait;
use App\Trait\StringTrait;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
/**
* Serves the passkey ceremonies.
*
* **Priority 70 — the whole design turns on this number.**
*
* - *After* `RejectListener` (77), so a rate-limited IP never reaches this code.
* Passkeys cannot be used to sidestep a lockout; that is decision D3.
* - *Before* `LoginListener` (66), which is essential rather than tidy:
* `LoginListener` treats **any** POST to the auth subdomain as a login attempt,
* and a ceremony `finish` body has no `username`/`totp`, so `Payload::load()`
* returns null and the request would be scored as a failed login — burning a
* rate-limit token for every legitimate passkey login.
*
* **Every** request carrying the dispatch header gets a response, including
* malformed ones. Falling through would let `InterceptListener` render HTML to a
* `fetch()` caller.
*/
final readonly class PasskeyListener
{
use CookieNameTrait;
use HasLoggerTrait;
use MakeNonceTrait;
use StringTrait;
/**
* Marks a request as a ceremony call, and its value selects the operation.
*
* A distinct header rather than overloading `X-Preauth`: that one carries a
* base64url `Payload` and is parsed as such.
*/
public const string HEADER = 'X-Preauth-Passkey';
/** Marks a response as ceremony output, so the caching policy can see it. */
public const string CEREMONY_MARKER = 'X-Preauth-Ceremony';
private const string BEGIN_LOGIN = 'login-begin';
private const string FINISH_LOGIN = 'login-finish';
private const string BEGIN_REGISTER = 'register-begin';
private const string FINISH_REGISTER = 'register-finish';
private RateLimiterFactoryInterface $beginLimiter;
private RateLimiterFactoryInterface $loginLimiter;
public function __construct(
#[Target('passkey_begin_burst')] RateLimiterFactoryInterface $beginLimiter,
#[Target('login_limiter')] RateLimiterFactoryInterface $loginLimiter,
#[Target('sessionCache')] private CacheItemPoolInterface $sessionCache,
private PasskeyInterface $passkeys,
private PasskeyPolicyInterface $policy,
private SessionIssuerInterface $sessionIssuer,
private DomainInterface $domainManager,
private ConfigBag $config,
) {
$this->beginLimiter = $beginLimiter;
$this->loginLimiter = $loginLimiter;
}
/**
* @throws InvalidArgumentException
*/
#[AsEventListener(priority: 70)]
public function onKernelRequest(RequestEvent $event): void
{
$request = $event->getRequest();
$operation = $request->headers->get(self::HEADER);
if (null === $operation) {
return;
}
/* Ceremonies exist only on the auth subdomain. Elsewhere the header is
* ignored entirely, so this listener cannot be used to probe other hosts. */
if ($this->domainManager->getAuthSubdomain() !== $request->getHost()) {
return;
}
$event->setResponse($this->dispatch($operation, $request));
}
private function dispatch(string $operation, Request $request): Response
{
/* not available => behave as if the feature does not exist */
if (!$this->policy->isAvailableFor($request)) {
return $this->ceremonyResponse($this->error('Passkeys are not available.', $request));
}
return $this->ceremonyResponse(match ($operation) {
self::BEGIN_LOGIN => $this->beginLogin($request),
self::FINISH_LOGIN => $this->finishLogin($request),
self::BEGIN_REGISTER => $this->beginRegistration($request),
self::FINISH_REGISTER => $this->finishRegistration($request),
default => $this->error('Unknown passkey operation.', $request),
});
}
private function beginLogin(Request $request): Response
{
if ($limit = $this->beginBurstExceeded($request)) {
return $limit;
}
return $this->json($this->passkeys->beginLogin());
}
/**
* Registration is only offered to someone who already authenticated: the
* identity comes from the live session, never from the request body, so a
* caller cannot register a passkey for an identity it does not hold.
*/
private function beginRegistration(Request $request): Response
{
$identity = $this->identityFromRequest($request);
if (null === $identity) {
return $this->error('Registration requires a completed login.', $request);
}
if ($limit = $this->beginBurstExceeded($request)) {
return $limit;
}
return $this->json($this->passkeys->beginRegistration($identity));
}
private function finishLogin(Request $request): Response
{
$credential = $this->passkeys->finishLogin($this->body($request));
if (null === $credential) {
/* A failed ceremony consumes the same budget as a wrong TOTP code
* (D3), so passkey guesses cannot outpace code guesses. */
return $this->failure($request);
}
$this->logger->debug("passkey login succeeded for: {$credential->identity}");
return $this->sessionIssuer->issue($credential->identity, Scope::Cookie, $request, true);
}
private function finishRegistration(Request $request): Response
{
$credential = $this->passkeys->finishRegistration($this->body($request));
if (null === $credential) {
return $this->failure($request);
}
$this->logger->debug("passkey registered for: {$credential->identity}");
return $this->sessionIssuer->issue($credential->identity, Scope::Cookie, $request, true);
}
/**
* The identity of an already-authenticated caller, for registration.
*
* Read from the live session cookie, so `register-begin` is reachable only by
* someone who has just passed the TOTP check. Null when there is no session.
*
* @throws InvalidArgumentException
*/
private function identityFromRequest(Request $request): ?string
{
$cookie = $request->cookies->get($this->sessionCookieName($this->domainManager));
if (!\is_string($cookie) || '' === $cookie) {
return null;
}
$item = $this->sessionCache->getItem($this->makeCacheKey("cookie_$cookie"));
if (!$item->isHit()) {
return null;
}
$identity = $item->get();
return \is_string($identity) && '' !== $identity ? $identity : null;
}
/**
* Bounds how many ceremonies one caller can start.
*
* A resource guard, not the login budget: a legitimate `begin` must not spend
* failure budget, but an unbounded `begin` could fill the ceremony cache.
*/
private function beginBurstExceeded(Request $request): ?Response
{
$limit = $this->beginLimiter->create((string) $request->getClientIp())->consume(1);
if ($limit->isAccepted()) {
return null;
}
$retryAfter = max(1, $limit->getRetryAfter()->getTimestamp() - time());
$this->logger->debug("passkey begin rate-limited: {$request->getClientIp()}");
$response = $this->error('Too many passkey attempts, please slow down.', $request);
$response->setStatusCode(Response::HTTP_TOO_MANY_REQUESTS);
$response->headers->set('Retry-After', (string) $retryAfter);
return $response;
}
/**
* A failed ceremony is indistinguishable from a wrong TOTP code, and spends
* the same shared budget — including the same 418/429 outcome when exhausted.
*/
private function failure(Request $request): Response
{
$limited = $this->loginLimiter
->create((string) $request->getClientIp())
->consume(1)
->getRemainingTokens() < 1;
$this->logger->debug("passkey ceremony failed for: {$request->getClientIp()}");
if ($limited) {
$response = $this->error(
$this->config->teapot() ? $this->config->teapotTitle() : $this->config->tooManyTitle(),
$request,
);
$response->setStatusCode(
$this->config->teapot() ? Response::HTTP_I_AM_A_TEAPOT : Response::HTTP_TOO_MANY_REQUESTS,
);
return $response;
}
$response = $this->error($this->config->errorMessage(), $request);
$response->setStatusCode(Response::HTTP_UNAUTHORIZED);
return $response;
}
/**
* Mark a reply as ceremony output so the no-store policy can find it.
*
* These are the only browser-facing 2xx responses this application produces,
* and `SecurityHeadersListener` otherwise assumes any 2xx is consumed by
* `forward_auth` and leaves it cacheable.
*/
private function ceremonyResponse(Response $response): Response
{
$response->headers->set(self::CEREMONY_MARKER, '1');
$response->headers->set('Content-Type', 'application/json');
return $response;
}
/**
* A JSON error carrying a fresh nonce and the same shape the login page
* expects, so the caller can fall back to the TOTP form without a reload.
*
* @throws InvalidArgumentException
*/
private function error(string $message, Request $request): Response
{
return new Response(
(string) json_encode([
'message' => $message,
'nonce' => $this->makeNonce(),
'post' => $this->domainManager->getAuthSubdomain() === $request->getHost(),
'username' => '',
]),
Response::HTTP_UNAUTHORIZED,
);
}
/**
* @param array<string,mixed> $payload
*/
private function json(array $payload): Response
{
return new Response((string) json_encode($payload), Response::HTTP_OK);
}
/**
* @return array<string,mixed>
*/
private function body(Request $request): array
{
$raw = $request->getContent();
if ('' === $raw) {
return [];
}
$decoded = json_decode($raw, true);
return \is_array($decoded) ? $decoded : [];
}
}
+35 -7
View File
@@ -5,8 +5,10 @@ declare(strict_types=1);
namespace App\Listener;
use App\Service\DomainInterface;
use App\Service\PasskeyPolicyInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
/**
@@ -18,6 +20,7 @@ final readonly class SecurityHeadersListener
{
public function __construct(
private DomainInterface $domainManager,
private PasskeyPolicyInterface $passkeyPolicy,
) {
}
@@ -55,7 +58,14 @@ final readonly class SecurityHeadersListener
$inlineScript = $this->domainManager->getAuthSubdomain() !== $event->getRequest()->getHost();
$csp = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';";
if ($inlineScript) {
/* `publickey-credentials-get`/`-create` do NOT fall back to default-src,
* so without these directives the browser refuses the ceremony even
* though the script itself is allowed to run. `connect-src 'self'` is
* needed in both modes here, because the passkey flow always talks to
* the server with fetch(). */
if ($this->passkeyPolicy->isAvailableFor($event->getRequest())) {
$csp .= " connect-src 'self'; publickey-credentials-get 'self'; publickey-credentials-create 'self';";
} elseif ($inlineScript) {
$csp .= " connect-src 'self';";
}
@@ -75,13 +85,31 @@ final readonly class SecurityHeadersListener
* access ("already authenticated" or public) are consumed by the
* reverse proxy's forward_auth check before reaching the browser,
* and the protected service's own cache headers must remain
* untouched. */
* untouched.
*
* A ceremony reply is the exception that proves the rule: it is a 2xx
* that goes straight to the browser, because the auth subdomain has no
* forward_auth in front of it. Left alone it would be cacheable, so a
* browser could replay a stale challenge. `PasskeyListener` marks those
* responses and the marker is stripped here. */
if (!$response->isSuccessful()) {
$headers->set('Cache-Control', 'no-cache, no-store, must-revalidate, proxy-revalidate, max-age=0, s-maxage=0');
$headers->set('Pragma', 'no-cache');
$headers->set('Expires', '0');
$headers->set('Surrogate-Control', 'no-store');
$headers->set('Vary', '*');
$this->applyNoStore($headers);
return;
}
if ($headers->has(PasskeyListener::CEREMONY_MARKER)) {
$headers->remove(PasskeyListener::CEREMONY_MARKER);
$this->applyNoStore($headers);
}
}
private function applyNoStore(ResponseHeaderBag $headers): void
{
$headers->set('Cache-Control', 'no-cache, no-store, must-revalidate, proxy-revalidate, max-age=0, s-maxage=0');
$headers->set('Pragma', 'no-cache');
$headers->set('Expires', '0');
$headers->set('Surrogate-Control', 'no-store');
$headers->set('Vary', '*');
}
}
+1 -1
View File
@@ -31,7 +31,7 @@ class TestKernel extends AppKernel
$container->addCompilerPass(new class implements CompilerPassInterface {
public function process(ContainerBuilder $container): void
{
foreach (['nonceCache', 'rateLimitCache', 'sessionCache', 'sessionStorage', 'publicRateLimitCache'] as $poolId) {
foreach (['nonceCache', 'rateLimitCache', 'sessionCache', 'sessionStorage', 'publicRateLimitCache', 'passkeyRateLimitCache'] as $poolId) {
if ($container->hasDefinition($poolId)) {
$container->getDefinition($poolId)->clearTag('kernel.reset');
}
+446
View File
@@ -0,0 +1,446 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Listener;
use App\Data\PasskeyCredential;
use App\Enum\Scope;
use App\Listener\PasskeyListener;
use App\MonitorCacheKeys;
use App\Service\DomainInterface;
use App\Service\PasskeyInterface;
use App\Service\PasskeyPolicyInterface;
use App\Service\SessionIssuerInterface;
use App\Tests\Support\TotpTestHelper;
use App\Trait\StringTrait;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\RateLimiter\Storage\InMemoryStorage;
use Symfony\Component\Uid\Uuid;
use Webauthn\CredentialRecord;
use Webauthn\TrustPath\EmptyTrustPath;
/**
* The listener is where several security properties are enforced at once, so
* each is asserted separately: which requests it claims, what it does with
* ones it cannot serve, and — crucially — that a failure looks exactly like a
* wrong TOTP code.
*/
final class PasskeyListenerTest extends TestCase
{
use StringTrait;
use TotpTestHelper;
private const string AUTH_HOST = 'auth.example.com';
private ?ArrayAdapter $sessionCache = null;
private function makeListener(
?PasskeyInterface $passkeys = null,
bool $available = true,
?SessionIssuerInterface $sessionIssuer = null,
?DomainInterface $domainManager = null,
int $beginLimit = 30,
): PasskeyListener {
$this->sessionCache = new ArrayAdapter();
$policy = $this->createStub(PasskeyPolicyInterface::class);
$policy->method('isAvailableFor')->willReturn($available);
$domainManager ??= $this->authDomainManager();
$listener = new PasskeyListener(
new RateLimiterFactory(['id' => 'passkey_begin_burst', 'policy' => 'sliding_window', 'limit' => $beginLimit, 'interval' => '60 seconds'], new InMemoryStorage()),
new RateLimiterFactory(['id' => 'login_limiter', 'policy' => 'sliding_window', 'limit' => 2, 'interval' => '60 seconds'], new InMemoryStorage()),
$this->sessionCache,
$passkeys ?? $this->createStub(PasskeyInterface::class),
$policy,
$sessionIssuer ?? $this->createStub(SessionIssuerInterface::class),
$domainManager,
$this->makeConfig(),
);
$listener->setLogger(new NullLogger());
$listener->setNonceCache(new ArrayAdapter());
return $listener;
}
private function authDomainManager(): DomainInterface
{
$manager = $this->createStub(DomainInterface::class);
$manager->method('getAuthSubdomain')->willReturn(self::AUTH_HOST);
$manager->method('authBase')->willReturn('example.com');
return $manager;
}
private function makeEvent(Request $request): RequestEvent
{
return new RequestEvent(
$this->createStub(HttpKernelInterface::class),
$request,
HttpKernelInterface::MAIN_REQUEST,
);
}
/**
* @param array<string,mixed> $body
*/
private function ceremonyRequest(string $operation, string $host = self::AUTH_HOST, array $body = []): Request
{
return Request::create(
"https://$host/",
'POST',
[],
[],
[],
['HTTP_X_PREAUTH_PASSKEY' => $operation, 'REMOTE_ADDR' => '1.2.3.4'],
(string) json_encode($body),
);
}
private function credential(string $identity = 'lyra'): PasskeyCredential
{
return new PasskeyCredential(
CredentialRecord::create(
random_bytes(16),
'public-key',
['internal'],
'none',
EmptyTrustPath::create(),
Uuid::v4(),
'KEY',
hash('sha256', $identity, true),
0,
null,
true,
false,
true,
),
$identity,
'Passkey abc',
new DateTimeImmutable(),
);
}
/* ── dispatch ─────────────────────────────────────────────────────── */
/**
* A request without the header is none of this listener's business, so it
* must not set a response and let the normal flow continue.
*/
public function test_a_request_without_the_header_is_ignored(): void
{
$listener = $this->makeListener();
$event = $this->makeEvent(Request::create('https://'.self::AUTH_HOST.'/', 'GET'));
$listener->onKernelRequest($event);
self::assertNull($event->getResponse());
}
/**
* Only the auth subdomain hosts ceremonies; elsewhere the header is ignored
* so this listener cannot be used to probe other hosts.
*/
public function test_the_header_is_ignored_on_a_non_auth_host(): void
{
$listener = $this->makeListener();
$event = $this->makeEvent($this->ceremonyRequest('login-begin', 'app.example.com'));
$listener->onKernelRequest($event);
self::assertNull($event->getResponse());
}
public function test_an_unknown_operation_is_rejected(): void
{
$listener = $this->makeListener();
$event = $this->makeEvent($this->ceremonyRequest('not-a-thing'));
$listener->onKernelRequest($event);
$response = $event->getResponse();
self::assertNotNull($response);
self::assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode());
}
/**
* Every header-bearing request is answered, even an unparseable one, so a
* `fetch()` caller never receives HTML from InterceptListener.
*/
public function test_a_malformed_body_still_gets_a_json_response(): void
{
$listener = $this->makeListener();
$request = Request::create(
'https://'.self::AUTH_HOST.'/',
'POST',
[],
[],
[],
['HTTP_X_PREAUTH_PASSKEY' => 'login-finish', 'REMOTE_ADDR' => '1.2.3.4'],
'this is not json',
);
$event = $this->makeEvent($request);
$listener->onKernelRequest($event);
$response = $event->getResponse();
self::assertNotNull($response);
self::assertSame('application/json', $response->headers->get('Content-Type'));
self::assertIsArray(json_decode((string) $response->getContent(), true));
}
/* ── availability ─────────────────────────────────────────────────── */
/**
* When passkeys are unavailable the feature must behave as if absent: no
* ceremony is started and nothing is written to any cache.
*/
public function test_begin_is_inert_when_passkeys_are_unavailable(): void
{
$passkeys = $this->createMock(PasskeyInterface::class);
$passkeys->expects(self::never())->method('beginLogin');
$listener = $this->makeListener($passkeys, available: false);
$event = $this->makeEvent($this->ceremonyRequest('login-begin'));
$listener->onKernelRequest($event);
self::assertSame(Response::HTTP_UNAUTHORIZED, $event->getResponse()?->getStatusCode());
}
public function test_begin_login_returns_options_and_a_ceremony_id(): void
{
$passkeys = $this->createStub(PasskeyInterface::class);
$passkeys->method('beginLogin')->willReturn([
'publicKey' => ['challenge' => 'abc', 'rpId' => 'example.com'],
'ceremonyId' => 'cid',
]);
$listener = $this->makeListener($passkeys);
$event = $this->makeEvent($this->ceremonyRequest('login-begin'));
$listener->onKernelRequest($event);
$response = $event->getResponse();
self::assertNotNull($response);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
$decoded = json_decode((string) $response->getContent(), true);
self::assertSame('cid', $decoded['ceremonyId']);
}
/**
* A ceremony reply is the one browser-facing 2xx, so it carries the marker
* that turns into the no-store policy.
*/
public function test_ceremony_responses_carry_the_marker(): void
{
$passkeys = $this->createStub(PasskeyInterface::class);
$passkeys->method('beginLogin')->willReturn(['publicKey' => [], 'ceremonyId' => 'cid']);
$listener = $this->makeListener($passkeys);
$event = $this->makeEvent($this->ceremonyRequest('login-begin'));
$listener->onKernelRequest($event);
self::assertSame('1', $event->getResponse()?->headers->get(PasskeyListener::CEREMONY_MARKER));
}
/* ── registration gating ──────────────────────────────────────────── */
/**
* Registration requires a live session: the identity comes from the cookie,
* never from the body, so nobody can register a passkey for another identity.
*/
public function test_register_begin_without_a_session_is_refused(): void
{
$passkeys = $this->createMock(PasskeyInterface::class);
$passkeys->expects(self::never())->method('beginRegistration');
$listener = $this->makeListener($passkeys);
$event = $this->makeEvent($this->ceremonyRequest('register-begin'));
$listener->onKernelRequest($event);
self::assertSame(Response::HTTP_UNAUTHORIZED, $event->getResponse()?->getStatusCode());
}
public function test_register_begin_uses_the_identity_from_the_session_cookie(): void
{
$passkeys = $this->createMock(PasskeyInterface::class);
$passkeys->expects(self::once())
->method('beginRegistration')
->with('lyra')
->willReturn(['publicKey' => [], 'ceremonyId' => 'cid']);
$listener = $this->makeListener($passkeys);
/* seed a live session the way SessionIssuer would have; this has to
* happen after makeListener(), which builds the pool the listener holds */
$ulid = 'test-ulid';
$monitor = new MonitorCacheKeys($this->sessionCache);
$item = $monitor->getItem($this->makeCacheKey("cookie_$ulid"));
$item->set('lyra');
$monitor->save($item);
$request = $this->ceremonyRequest('register-begin');
$request->cookies->set('__Http-Domain-Preauth', $ulid);
$event = $this->makeEvent($request);
$listener->onKernelRequest($event);
self::assertSame(Response::HTTP_OK, $event->getResponse()?->getStatusCode());
}
/* ── failures share the login budget (D3) ─────────────────────────── */
/**
* A failed ceremony must be indistinguishable from a wrong TOTP code: same
* status, same shape, and it must spend the same limiter.
*/
public function test_a_failed_ceremony_returns_401_with_a_nonce(): void
{
$passkeys = $this->createStub(PasskeyInterface::class);
$passkeys->method('finishLogin')->willReturn(null);
$listener = $this->makeListener($passkeys);
$event = $this->makeEvent($this->ceremonyRequest('login-finish', body: ['ceremonyId' => 'cid']));
$listener->onKernelRequest($event);
$response = $event->getResponse();
self::assertNotNull($response);
self::assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode());
$decoded = json_decode((string) $response->getContent(), true);
self::assertArrayHasKey('nonce', $decoded);
self::assertNotSame('', $decoded['nonce']);
}
/**
* After the shared budget is exhausted the response is the same 418/429 the
* TOTP path produces — this is what stops passkeys being an unlimited oracle.
*/
public function test_repeated_failures_exhaust_the_shared_budget(): void
{
$passkeys = $this->createStub(PasskeyInterface::class);
$passkeys->method('finishLogin')->willReturn(null);
$listener = $this->makeListener($passkeys);
/* the stub limiter allows 2 */
for ($i = 0; $i < 2; ++$i) {
$listener->onKernelRequest($this->makeEvent($this->ceremonyRequest('login-finish', body: ['ceremonyId' => 'cid'])));
}
$event = $this->makeEvent($this->ceremonyRequest('login-finish', body: ['ceremonyId' => 'cid']));
$listener->onKernelRequest($event);
/* the 418/429 choice mirrors LoginListener: `teapot` swaps the status but
* not the meaning, and the point here is that the budget is shared */
self::assertContains(
$event->getResponse()?->getStatusCode(),
[Response::HTTP_TOO_MANY_REQUESTS, Response::HTTP_I_AM_A_TEAPOT],
);
}
/* ── success ──────────────────────────────────────────────────────── */
public function test_a_successful_login_issues_a_session(): void
{
$credential = $this->credential('lyra');
$passkeys = $this->createStub(PasskeyInterface::class);
$passkeys->method('finishLogin')->willReturn($credential);
$issuer = $this->createMock(SessionIssuerInterface::class);
$issuer->expects(self::once())
->method('issue')
->with('lyra', Scope::Cookie, self::anything(), true)
->willReturn(new Response('', Response::HTTP_SEE_OTHER));
$listener = $this->makeListener($passkeys, sessionIssuer: $issuer);
$event = $this->makeEvent($this->ceremonyRequest('login-finish', body: ['ceremonyId' => 'cid']));
$listener->onKernelRequest($event);
self::assertSame(Response::HTTP_SEE_OTHER, $event->getResponse()?->getStatusCode());
}
public function test_a_successful_registration_issues_a_session(): void
{
$credential = $this->credential('lyra');
$passkeys = $this->createStub(PasskeyInterface::class);
$passkeys->method('finishRegistration')->willReturn($credential);
$issuer = $this->createMock(SessionIssuerInterface::class);
$issuer->expects(self::once())->method('issue')->willReturn(new Response('', Response::HTTP_SEE_OTHER));
$listener = $this->makeListener($passkeys, sessionIssuer: $issuer);
$event = $this->makeEvent($this->ceremonyRequest('register-finish', body: ['ceremonyId' => 'cid']));
$listener->onKernelRequest($event);
self::assertSame(Response::HTTP_SEE_OTHER, $event->getResponse()?->getStatusCode());
}
/* ── the resource guard ───────────────────────────────────────────── */
/**
* `begin` is bounded so an unauthenticated caller cannot fill the ceremony
* cache — but this guard is deliberately separate from the login budget.
*/
public function test_begin_is_rate_limited_as_a_resource_guard(): void
{
$passkeys = $this->createStub(PasskeyInterface::class);
$passkeys->method('beginLogin')->willReturn(['publicKey' => [], 'ceremonyId' => 'cid']);
$listener = $this->makeListener($passkeys, beginLimit: 2);
for ($i = 0; $i < 2; ++$i) {
$listener->onKernelRequest($this->makeEvent($this->ceremonyRequest('login-begin')));
}
$event = $this->makeEvent($this->ceremonyRequest('login-begin'));
$listener->onKernelRequest($event);
$response = $event->getResponse();
self::assertNotNull($response);
self::assertSame(Response::HTTP_TOO_MANY_REQUESTS, $response->getStatusCode());
self::assertTrue($response->headers->has('Retry-After'));
}
/**
* A successful `begin` must not spend failure budget: otherwise simply
* opening the login page would count against the user, and ten legitimately
* started ceremonies would lock them out.
*/
public function test_begin_does_not_consume_the_login_budget(): void
{
$passkeys = $this->createStub(PasskeyInterface::class);
$passkeys->method('beginLogin')->willReturn(['publicKey' => [], 'ceremonyId' => 'cid']);
$passkeys->method('finishLogin')->willReturn(null);
$listener = $this->makeListener($passkeys);
/* 10 begins, well inside the burst guard, then a failed login must still
* be answered as a normal 401 rather than an exhausted-budget response */
for ($i = 0; $i < 10; ++$i) {
$listener->onKernelRequest($this->makeEvent($this->ceremonyRequest('login-begin')));
}
$event = $this->makeEvent($this->ceremonyRequest('login-finish', body: ['ceremonyId' => 'cid']));
$listener->onKernelRequest($event);
self::assertSame(Response::HTTP_UNAUTHORIZED, $event->getResponse()?->getStatusCode());
}
}
@@ -4,8 +4,10 @@ declare(strict_types=1);
namespace App\Tests\Unit\Listener;
use App\Listener\PasskeyListener;
use App\Listener\SecurityHeadersListener;
use App\Service\DomainInterface;
use App\Service\PasskeyPolicyInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -14,12 +16,15 @@ use Symfony\Component\HttpKernel\HttpKernelInterface;
final class SecurityHeadersListenerTest extends TestCase
{
private function makeListener(?string $authSubdomain = null): SecurityHeadersListener
private function makeListener(?string $authSubdomain = null, bool $passkeysAvailable = false): SecurityHeadersListener
{
$domainManager = $this->createStub(DomainInterface::class);
$domainManager->method('getAuthSubdomain')->willReturn($authSubdomain);
return new SecurityHeadersListener($domainManager);
$policy = $this->createStub(PasskeyPolicyInterface::class);
$policy->method('isAvailableFor')->willReturn($passkeysAvailable);
return new SecurityHeadersListener($domainManager, $policy);
}
private function makeEvent(
@@ -204,4 +209,76 @@ final class SecurityHeadersListenerTest extends TestCase
self::assertStringNotContainsString('connect-src', $response->headers->get('Content-Security-Policy'));
}
/**
* `publickey-credentials-get`/`-create` do not fall back to `default-src`, so
* without them the browser refuses the ceremony however the script is written.
*/
public function test_csp_grants_the_webauthn_directives_when_passkeys_are_available(): void
{
$listener = $this->makeListener('auth.example.com', true);
$response = new Response('login', Response::HTTP_UNAUTHORIZED);
$request = Request::create('https://auth.example.com/', 'GET');
$listener->onKernelResponse($this->makeEvent($response, $request));
$csp = (string) $response->headers->get('Content-Security-Policy');
self::assertStringContainsString("publickey-credentials-get 'self';", $csp);
self::assertStringContainsString("publickey-credentials-create 'self';", $csp);
self::assertStringContainsString("connect-src 'self';", $csp);
}
/**
* With passkeys unavailable the header must be byte-identical to today's,
* which is what makes "unavailable means invisible" a testable property.
*/
public function test_csp_is_unchanged_when_passkeys_are_unavailable(): void
{
$listener = $this->makeListener('auth.example.com', false);
$response = new Response('login', Response::HTTP_UNAUTHORIZED);
$request = Request::create('https://auth.example.com/', 'GET');
$listener->onKernelResponse($this->makeEvent($response, $request));
self::assertSame(
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';",
$response->headers->get('Content-Security-Policy'),
);
}
/**
* A ceremony reply is a browser-facing 2xx, so the usual "2xx is consumed by
* forward_auth" assumption does not hold and it has to be made no-store.
*/
public function test_ceremony_responses_are_made_no_store_and_the_marker_is_stripped(): void
{
$listener = $this->makeListener('auth.example.com');
$response = new Response('{}', Response::HTTP_OK);
$response->headers->set(PasskeyListener::CEREMONY_MARKER, '1');
$listener->onKernelResponse($this->makeEvent($response));
self::assertFalse($response->headers->has(PasskeyListener::CEREMONY_MARKER));
self::assertStringContainsString('no-store', (string) $response->headers->get('Cache-Control'));
self::assertSame('*', $response->headers->get('Vary'));
}
/**
* An ordinary 2xx must stay untouched: those are consumed by forward_auth, and
* adding cache headers could interfere with the protected service.
*/
public function test_a_plain_success_response_is_left_cacheable(): void
{
$listener = $this->makeListener('auth.example.com');
$response = new Response('hi', Response::HTTP_OK);
$response->setCache(['public' => true, 'max_age' => 60]);
$listener->onKernelResponse($this->makeEvent($response));
/* the headers the caller set must survive untouched — no no-store, and
* no marker leaking through */
$cacheControl = (string) $response->headers->get('Cache-Control');
self::assertStringNotContainsString('no-store', $cacheControl);
self::assertStringContainsString('max-age=60', $cacheControl);
self::assertFalse($response->headers->has('Surrogate-Control'));
self::assertFalse($response->headers->has(PasskeyListener::CEREMONY_MARKER));
}
}