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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -12,3 +12,5 @@ framework:
|
||||
adapters: cache.adapter.array
|
||||
publicRateLimitCache:
|
||||
adapters: cache.adapter.array
|
||||
passkeyRateLimitCache:
|
||||
adapters: cache.adapter.array
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 : [];
|
||||
}
|
||||
}
|
||||
@@ -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', '*');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user