diff --git a/.env.test b/.env.test index 7f9c469..0c348d7 100644 --- a/.env.test +++ b/.env.test @@ -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 diff --git a/config/packages/cache.yaml b/config/packages/cache.yaml index 93125a6..2234e40 100644 --- a/config/packages/cache.yaml +++ b/config/packages/cache.yaml @@ -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 diff --git a/config/packages/rate_limiter.yaml b/config/packages/rate_limiter.yaml index bceb2ef..5e46b4d 100644 --- a/config/packages/rate_limiter.yaml +++ b/config/packages/rate_limiter.yaml @@ -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' diff --git a/config/packages/test/cache.yaml b/config/packages/test/cache.yaml index d25a7bd..226c49d 100644 --- a/config/packages/test/cache.yaml +++ b/config/packages/test/cache.yaml @@ -12,3 +12,5 @@ framework: adapters: cache.adapter.array publicRateLimitCache: adapters: cache.adapter.array + passkeyRateLimitCache: + adapters: cache.adapter.array diff --git a/config/services.yaml b/config/services.yaml index 61c7f8e..80722b5 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -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' diff --git a/docs/examples/.env.example b/docs/examples/.env.example index 614b00b..2778667 100644 --- a/docs/examples/.env.example +++ b/docs/examples/.env.example @@ -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 --- diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index a201bf9..7490ce0 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -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 diff --git a/src/Listener/PasskeyListener.php b/src/Listener/PasskeyListener.php new file mode 100644 index 0000000..1782d28 --- /dev/null +++ b/src/Listener/PasskeyListener.php @@ -0,0 +1,316 @@ +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 $payload + */ + private function json(array $payload): Response + { + return new Response((string) json_encode($payload), Response::HTTP_OK); + } + + /** + * @return array + */ + private function body(Request $request): array + { + $raw = $request->getContent(); + if ('' === $raw) { + return []; + } + + $decoded = json_decode($raw, true); + + return \is_array($decoded) ? $decoded : []; + } +} diff --git a/src/Listener/SecurityHeadersListener.php b/src/Listener/SecurityHeadersListener.php index 4f4d324..7bafa54 100644 --- a/src/Listener/SecurityHeadersListener.php +++ b/src/Listener/SecurityHeadersListener.php @@ -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', '*'); + } } diff --git a/tests/TestKernel.php b/tests/TestKernel.php index 670dbea..83e15de 100644 --- a/tests/TestKernel.php +++ b/tests/TestKernel.php @@ -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'); } diff --git a/tests/Unit/Listener/PasskeyListenerTest.php b/tests/Unit/Listener/PasskeyListenerTest.php new file mode 100644 index 0000000..619394c --- /dev/null +++ b/tests/Unit/Listener/PasskeyListenerTest.php @@ -0,0 +1,446 @@ +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 $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()); + } +} diff --git a/tests/Unit/Listener/SecurityHeadersListenerTest.php b/tests/Unit/Listener/SecurityHeadersListenerTest.php index c5d40e4..633605f 100644 --- a/tests/Unit/Listener/SecurityHeadersListenerTest.php +++ b/tests/Unit/Listener/SecurityHeadersListenerTest.php @@ -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)); + } }