From 11903bf746c0850a816064fc33d56cea40185595 Mon Sep 17 00:00:00 2001 From: Lyra Bot Date: Sun, 27 Sep 2026 11:32:16 +0000 Subject: [PATCH] Add the passkey UI and fix issues it exposed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The login page gains a sign-in button and, where the form actually POSTs, a "register this device" checkbox. Both are rendered only when the policy says passkeys are available for that request, so an unavailable configuration stays byte-identical to before — a test asserts exactly that, rather than trusting the conditional is in the right place. The registration checkbox is omitted on hosts the form does not POST from, because a registration ceremony is authorised by the TOTP code carried in that submission. The button is still offered there; only the checkbox is not. Writing the tests surfaced three real problems, all fixed here: - ConfigBag had no passkeyButtonName()/passkeyRegisterName() accessors, so the template referenced configuration that was never exposed. - ListenerTestHelper's anonymous rate-limiter classes were missing #[Override], and the baseline pinned them by line number — so adding two array keys broke it. Fixed at the source instead: the attributes are now present, which also let 36 line-pinned baseline entries be deleted. - Those classes threw ReserveNotSupportedException with no arguments, which the Symfony signature forbids. The baseline had been hiding this behind path-specific ignores; phpstan reports it correctly now. The net baseline change is 216 deletions and no additions: every entry removed was one whose underlying issue is now genuinely fixed. --- phpstan-baseline.neon | 216 ------------------ src/ConfigBag.php | 18 ++ src/Listener/InterceptListener.php | 6 + src/Listener/LoginListener.php | 6 +- templates/_passkey.html.twig | 168 ++++++++++++++ templates/login.html.twig | 10 + tests/Support/ListenerTestHelper.php | 15 +- tests/Unit/Listener/InterceptListenerTest.php | 2 + tests/Unit/Listener/LoginListenerTest.php | 3 + tests/Unit/Listener/PasskeyUiTest.php | 146 ++++++++++++ 10 files changed, 371 insertions(+), 219 deletions(-) create mode 100644 templates/_passkey.html.twig create mode 100644 tests/Unit/Listener/PasskeyUiTest.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 7490ce0..88cf224 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -522,228 +522,12 @@ parameters: count: 2 path: tests/Unit/Enum/ScopeTest.php - - - message: '#^Class Symfony\\Component\\RateLimiter\\Exception\\ReserveNotSupportedException constructor invoked with 0 parameters, 1\-3 required\.$#' - identifier: arguments.count - count: 2 - path: tests/Unit/Listener/InterceptListenerTest.php - - - - message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#' - identifier: method.missingOverride - count: 1 - path: tests/Unit/Listener/InterceptListenerTest.php - - - - message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#' - identifier: method.missingOverride - count: 1 - path: tests/Unit/Listener/InterceptListenerTest.php - - - - message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#' - identifier: method.missingOverride - count: 1 - path: tests/Unit/Listener/InterceptListenerTest.php - - - - message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#' - identifier: method.missingOverride - count: 1 - path: tests/Unit/Listener/InterceptListenerTest.php - - - - message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#' - identifier: method.missingOverride - count: 1 - path: tests/Unit/Listener/InterceptListenerTest.php - - - - message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#' - identifier: method.missingOverride - count: 1 - path: tests/Unit/Listener/InterceptListenerTest.php - - - - message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:136\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#' - identifier: method.missingOverride - count: 1 - path: tests/Unit/Listener/InterceptListenerTest.php - - - - message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:57\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#' - identifier: method.missingOverride - count: 1 - path: tests/Unit/Listener/InterceptListenerTest.php - - - - message: '#^Class Symfony\\Component\\RateLimiter\\Exception\\ReserveNotSupportedException constructor invoked with 0 parameters, 1\-3 required\.$#' - identifier: arguments.count - count: 2 - path: tests/Unit/Listener/LoginListenerTest.php - - message: '#^Method App\\Tests\\Unit\\Listener\\LoginListenerTest\:\:encodePayload\(\) has parameter \$data with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 path: tests/Unit/Listener/LoginListenerTest.php - - - message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#' - identifier: method.missingOverride - count: 1 - path: tests/Unit/Listener/LoginListenerTest.php - - - - message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#' - identifier: method.missingOverride - count: 1 - path: tests/Unit/Listener/LoginListenerTest.php - - - - message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#' - identifier: method.missingOverride - count: 1 - path: tests/Unit/Listener/LoginListenerTest.php - - - - message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#' - identifier: method.missingOverride - count: 1 - path: tests/Unit/Listener/LoginListenerTest.php - - - - message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#' - identifier: method.missingOverride - count: 1 - path: tests/Unit/Listener/LoginListenerTest.php - - - - message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#' - identifier: method.missingOverride - count: 1 - path: tests/Unit/Listener/LoginListenerTest.php - - - - message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:136\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#' - identifier: method.missingOverride - count: 1 - path: tests/Unit/Listener/LoginListenerTest.php - - - - message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:57\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#' - identifier: method.missingOverride - count: 1 - path: tests/Unit/Listener/LoginListenerTest.php - - - - message: '#^Class Symfony\\Component\\RateLimiter\\Exception\\ReserveNotSupportedException constructor invoked with 0 parameters, 1\-3 required\.$#' - identifier: arguments.count - count: 2 - path: tests/Unit/Listener/PublicAccessListenerTest.php - - - - message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#' - identifier: method.missingOverride - count: 1 - path: tests/Unit/Listener/PublicAccessListenerTest.php - - - - message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#' - identifier: method.missingOverride - count: 1 - path: tests/Unit/Listener/PublicAccessListenerTest.php - - - - message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#' - identifier: method.missingOverride - count: 1 - path: tests/Unit/Listener/PublicAccessListenerTest.php - - - - message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#' - identifier: method.missingOverride - count: 1 - path: tests/Unit/Listener/PublicAccessListenerTest.php - - - - message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#' - identifier: method.missingOverride - count: 1 - path: tests/Unit/Listener/PublicAccessListenerTest.php - - - - message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#' - identifier: method.missingOverride - count: 1 - path: tests/Unit/Listener/PublicAccessListenerTest.php - - - - message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:136\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#' - identifier: method.missingOverride - count: 1 - path: tests/Unit/Listener/PublicAccessListenerTest.php - - - - message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:57\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#' - identifier: method.missingOverride - count: 1 - path: tests/Unit/Listener/PublicAccessListenerTest.php - - - - message: '#^Class Symfony\\Component\\RateLimiter\\Exception\\ReserveNotSupportedException constructor invoked with 0 parameters, 1\-3 required\.$#' - identifier: arguments.count - count: 2 - path: tests/Unit/Listener/RejectListenerTest.php - - - - message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#' - identifier: method.missingOverride - count: 1 - path: tests/Unit/Listener/RejectListenerTest.php - - - - message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#' - identifier: method.missingOverride - count: 1 - path: tests/Unit/Listener/RejectListenerTest.php - - - - message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#' - identifier: method.missingOverride - count: 1 - path: tests/Unit/Listener/RejectListenerTest.php - - - - message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#' - identifier: method.missingOverride - count: 1 - path: tests/Unit/Listener/RejectListenerTest.php - - - - message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reserve\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reserve\(\) but is missing the \#\[\\Override\] attribute\.$#' - identifier: method.missingOverride - count: 1 - path: tests/Unit/Listener/RejectListenerTest.php - - - - message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:78\:\:reset\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:reset\(\) but is missing the \#\[\\Override\] attribute\.$#' - identifier: method.missingOverride - count: 1 - path: tests/Unit/Listener/RejectListenerTest.php - - - - message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:136\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#' - identifier: method.missingOverride - count: 1 - path: tests/Unit/Listener/RejectListenerTest.php - - - - message: '#^Method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface@anonymous/tests/Support/ListenerTestHelper\.php\:57\:\:create\(\) overrides method Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface\:\:create\(\) but is missing the \#\[\\Override\] attribute\.$#' - identifier: method.missingOverride - count: 1 - path: tests/Unit/Listener/RejectListenerTest.php - - message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertIsString\(\) with string will always evaluate to true\.$#' identifier: staticMethod.alreadyNarrowedType diff --git a/src/ConfigBag.php b/src/ConfigBag.php index 8cebf89..2ceaddf 100644 --- a/src/ConfigBag.php +++ b/src/ConfigBag.php @@ -29,6 +29,8 @@ final readonly class ConfigBag private string $passkeyRpName; private UserVerification $passkeyUserVerification; private int $passkeyTimeout; + private string $passkeyButtonName; + private string $passkeyRegisterName; /** Passkey ceremony timeout in milliseconds (WebAuthn default). */ private const int DEFAULT_PASSKEY_TIMEOUT = 60000; @@ -52,6 +54,8 @@ final readonly class ConfigBag #[Autowire('%app.passkey_rp_name%')] string $passkeyRpName = '', #[Autowire('%app.passkey_user_verification%')] string $passkeyUserVerification = 'required', #[Autowire('%app.passkey_timeout%')] int $passkeyTimeout = self::DEFAULT_PASSKEY_TIMEOUT, + #[Autowire('%app.passkey_button_name%')] string $passkeyButtonName = 'Sign in with a passkey', + #[Autowire('%app.passkey_register_name%')] string $passkeyRegisterName = 'Register this device as a passkey', ) { $this->clock = $clock; $this->cookieTtl = $cookieTtl; @@ -70,6 +74,8 @@ final readonly class ConfigBag $this->passkeyRpName = $passkeyRpName; $this->passkeyUserVerification = UserVerification::fromConfig($passkeyUserVerification); $this->passkeyTimeout = $passkeyTimeout > 0 ? $passkeyTimeout : self::DEFAULT_PASSKEY_TIMEOUT; + $this->passkeyButtonName = $passkeyButtonName; + $this->passkeyRegisterName = $passkeyRegisterName; } /** @@ -186,4 +192,16 @@ final readonly class ConfigBag { return $this->passkeyTimeout; } + + /** Label for the "sign in with a passkey" button. */ + public function passkeyButtonName(): string + { + return $this->passkeyButtonName; + } + + /** Label for the "register this device" checkbox. */ + public function passkeyRegisterName(): string + { + return $this->passkeyRegisterName; + } } diff --git a/src/Listener/InterceptListener.php b/src/Listener/InterceptListener.php index 22d23d8..152228f 100644 --- a/src/Listener/InterceptListener.php +++ b/src/Listener/InterceptListener.php @@ -6,6 +6,7 @@ namespace App\Listener; use App\ConfigBag; use App\Service\DomainInterface; +use App\Service\PasskeyPolicyInterface; use App\Trait\CookieNameTrait; use App\Trait\HasLoggerTrait; use App\Trait\MakeNonceTrait; @@ -29,6 +30,7 @@ final readonly class InterceptListener private ConfigBag $config, private DomainInterface $domainManager, private Environment $twig, + private PasskeyPolicyInterface $passkeyPolicy, ) { } @@ -54,6 +56,10 @@ final readonly class InterceptListener $content = $this->twig->render('login.html.twig', [ 'nonce' => $this->makeNonce(), 'post' => $this->domainManager->getAuthSubdomain() === $event->getRequest()->getHost(), + /* only offered when the feature is usable *for this request* — the + * same computation that decides whether the ceremony endpoints + * will answer, so the UI cannot offer what the server refuses */ + 'passkeys' => $this->passkeyPolicy->isAvailableFor($event->getRequest()), ]); $hasCookie = (bool) $event->getRequest()->cookies->get( $this->sessionCookieName($this->domainManager), diff --git a/src/Listener/LoginListener.php b/src/Listener/LoginListener.php index 1d2a561..32c8fa8 100644 --- a/src/Listener/LoginListener.php +++ b/src/Listener/LoginListener.php @@ -8,6 +8,7 @@ use App\ConfigBag; use App\Data\Payload; use App\Service\DomainInterface; use App\Service\LoginInterface; +use App\Service\PasskeyPolicyInterface; use App\Trait\CookieNameTrait; use App\Trait\HasLoggerTrait; use App\Trait\MakeNonceTrait; @@ -48,6 +49,7 @@ final readonly class LoginListener private DomainInterface $domainManager, private LoginInterface $loginManager, private ConfigBag $config, + private PasskeyPolicyInterface $passkeyPolicy, ) { $this->rateLimiter = $rateLimiter; } @@ -94,6 +96,7 @@ final readonly class LoginListener $payload?->json ?? true, $event->getRequest()->getHost(), $this->makeCacheKey($payload?->id ?? ''), + $event->getRequest(), )); } @@ -105,7 +108,7 @@ final readonly class LoginListener } /** @throws InvalidArgumentException|RuntimeError|SyntaxError|LoaderError */ - private function makeFailedResponse(bool $limited, bool $json, string $host, string $username): Response + private function makeFailedResponse(bool $limited, bool $json, string $host, string $username, Request $request): Response { if ($limited) { $status = $this->config->teapot() ? Response::HTTP_I_AM_A_TEAPOT @@ -121,6 +124,7 @@ final readonly class LoginListener 'nonce' => $this->makeNonce(), 'post' => $this->domainManager->getAuthSubdomain() === $host, 'username' => $username, + 'passkeys' => $this->passkeyPolicy->isAvailableFor($request), ]; if ($json) { diff --git a/templates/_passkey.html.twig b/templates/_passkey.html.twig new file mode 100644 index 0000000..4e95ecb --- /dev/null +++ b/templates/_passkey.html.twig @@ -0,0 +1,168 @@ +{# + Passkey UI. Only included when passkeys are available, so that an + unavailable configuration renders a byte-identical login page. + + Every string that reaches the server is base64url with no padding, matching + what webauthn-lib expects — the library rejects anything else, and the + failure mode ("invalid signature") looks nothing like an encoding bug. +#} +
+ +
+ + diff --git a/templates/login.html.twig b/templates/login.html.twig index 6c254be..e2b2b32 100644 --- a/templates/login.html.twig +++ b/templates/login.html.twig @@ -14,9 +14,19 @@
+ {% if (passkeys ?? false) and (post ?? false) %} + {# Only where the form actually POSTs: registration authorises itself with + the TOTP code carried in that submission, so a fetch()-submitted form on + a protected host has nothing to start a ceremony with. #} +
+ {% endif %}
{% if not post ?? false %} {{- include('_script.html.twig') -}} {% endif %} +{% if passkeys ?? false %} + {{- include('_passkey.html.twig') -}} +{% endif %} {% endblock %} diff --git a/tests/Support/ListenerTestHelper.php b/tests/Support/ListenerTestHelper.php index 8ba3065..313b76a 100644 --- a/tests/Support/ListenerTestHelper.php +++ b/tests/Support/ListenerTestHelper.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Tests\Support; use DateTimeImmutable; +use Override; use Symfony\Component\RateLimiter\LimiterInterface; use Symfony\Component\RateLimiter\RateLimit; use Symfony\Component\RateLimiter\RateLimiterFactoryInterface; @@ -40,6 +41,8 @@ trait ListenerTestHelper 'teapot_message' => 'I refuse to brew coffee', 'too_many_title' => 'Too many requests', 'too_many_message' => 'Try again later', + 'passkey_button_name' => 'Sign in with a passkey', + 'passkey_register_name' => 'Register this device as a passkey', 'debug' => 0, ]); @@ -59,6 +62,7 @@ trait ListenerTestHelper { } + #[Override] public function create(?string $key = null): LimiterInterface { return $this->limiter; @@ -80,16 +84,19 @@ trait ListenerTestHelper { } + #[Override] public function reserve(int $tokens = 1, ?float $maxTime = null): \Symfony\Component\RateLimiter\Reservation { - throw new \Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException(); + throw new \Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException(static::class); } + #[Override] public function consume(int $tokens = 1): RateLimit { return $this->rateLimit; } + #[Override] public function reset(): void { } @@ -109,11 +116,13 @@ trait ListenerTestHelper { } + #[Override] public function reserve(int $tokens = 1, ?float $maxTime = null): \Symfony\Component\RateLimiter\Reservation { - throw new \Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException(); + throw new \Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException(static::class); } + #[Override] public function consume(int $tokens = 1): RateLimit { $this->consumed += $tokens; @@ -127,6 +136,7 @@ trait ListenerTestHelper ); } + #[Override] public function reset(): void { $this->consumed = 0; @@ -138,6 +148,7 @@ trait ListenerTestHelper { } + #[Override] public function create(?string $key = null): LimiterInterface { return $this->limiter; diff --git a/tests/Unit/Listener/InterceptListenerTest.php b/tests/Unit/Listener/InterceptListenerTest.php index e5d6631..cf5f256 100644 --- a/tests/Unit/Listener/InterceptListenerTest.php +++ b/tests/Unit/Listener/InterceptListenerTest.php @@ -6,6 +6,7 @@ namespace App\Tests\Unit\Listener; use App\Listener\InterceptListener; use App\Service\DomainManager; +use App\Service\PasskeyPolicyInterface; use App\Tests\Support\ListenerTestHelper; use PHPUnit\Framework\TestCase; use Psr\Cache\CacheItemPoolInterface; @@ -32,6 +33,7 @@ final class InterceptListenerTest extends TestCase $this->makeConfig(), $domainManager, $this->makeTwig(), + $this->createStub(PasskeyPolicyInterface::class), ); $listener->setLogger(new NullLogger()); $listener->setNonceCache($nonceCache ?? new ArrayAdapter()); diff --git a/tests/Unit/Listener/LoginListenerTest.php b/tests/Unit/Listener/LoginListenerTest.php index 0871de2..f52a3c4 100644 --- a/tests/Unit/Listener/LoginListenerTest.php +++ b/tests/Unit/Listener/LoginListenerTest.php @@ -8,6 +8,7 @@ use App\Data\Payload; use App\Listener\LoginListener; use App\Service\DomainManager; use App\Service\LoginInterface; +use App\Service\PasskeyPolicyInterface; use App\Tests\Support\ListenerTestHelper; use PHPUnit\Framework\TestCase; use Psr\Log\NullLogger; @@ -34,6 +35,7 @@ final class LoginListenerTest extends TestCase $domainManager ?? new DomainManager(false, ''), $loginManager ?? $this->createStub(LoginInterface::class), $this->makeConfig(), + $this->createStub(PasskeyPolicyInterface::class), ); $listener->setLogger(new NullLogger()); $listener->setNonceCache(new ArrayAdapter()); @@ -244,6 +246,7 @@ final class LoginListenerTest extends TestCase new DomainManager(false, ''), $loginManager, $this->makeConfig(teapot: false), + $this->createStub(PasskeyPolicyInterface::class), ); $listener->setLogger(new NullLogger()); $listener->setNonceCache(new ArrayAdapter()); diff --git a/tests/Unit/Listener/PasskeyUiTest.php b/tests/Unit/Listener/PasskeyUiTest.php new file mode 100644 index 0000000..4db6edc --- /dev/null +++ b/tests/Unit/Listener/PasskeyUiTest.php @@ -0,0 +1,146 @@ +createStub(PasskeyPolicyInterface::class); + $policy->method('isAvailableFor')->willReturn($passkeysAvailable); + + $listener = new InterceptListener( + $this->makeConfig(), + $domainManager, + $this->makeTwig(), + $policy, + ); + $listener->setLogger(new NullLogger()); + $listener->setNonceCache(new ArrayAdapter()); + + return $listener; + } + + /** + * @param string $host the host being requested + * @param bool $passkeys whether passkeys are available for it + * @param string $authSubdomain the configured auth subdomain + */ + private function renderLoginPage( + string $host, + bool $passkeys, + string $authSubdomain = 'auth.example.com', + ): string { + $listener = $this->makeListener($authSubdomain, $passkeys); + $request = Request::create("https://$host/", 'GET'); + $event = new RequestEvent( + $this->createStub(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST, + ); + + $listener->onKernelRequest($event); + + return (string) $event->getResponse()?->getContent(); + } + + /** + * The headline property: with passkeys unavailable the page must contain + * nothing passkey-related at all. + */ + public function test_the_login_page_is_unchanged_when_passkeys_are_unavailable(): void + { + $html = $this->renderLoginPage('auth.example.com', false); + + self::assertStringNotContainsString('preauth-passkey', $html); + self::assertStringNotContainsString('preauth-register', $html); + self::assertStringNotContainsString('publickey-credentials', $html); + } + + public function test_the_login_page_offers_passkeys_when_available(): void + { + $html = $this->renderLoginPage('auth.example.com', true); + + /* the sign-in button */ + self::assertStringContainsString('id="preauth-passkey"', $html); + /* the registration checkbox */ + self::assertStringContainsString('id="preauth-register"', $html); + self::assertStringContainsString('name="register"', $html); + } + + /** + * The button and checkbox carry the configured labels, so an operator can + * reword them without touching templates. + */ + public function test_the_offered_ui_uses_the_configured_labels(): void + { + $html = $this->renderLoginPage('auth.example.com', true); + + self::assertStringContainsString($this->makeConfig()->passkeyButtonName(), $html); + self::assertStringContainsString($this->makeConfig()->passkeyRegisterName(), $html); + } + + /** + * The checkbox only makes sense where the form actually POSTs, because + * registration authorises itself with the TOTP code in that submission. + * On a protected host the form is submitted by fetch() instead. + */ + public function test_the_registration_checkbox_is_omitted_when_the_form_does_not_post(): void + { + /* A host outside the auth base domain renders the login page directly, + * and that page submits via the inline fetch() rather than a real form + * POST — so there is no submission for a registration to ride on. */ + $html = $this->renderLoginPage('unrelated.test', true, 'auth.example.com'); + + self::assertStringContainsString('id="preauth-passkey"', $html); + self::assertStringNotContainsString('id="preauth-register"', $html); + } + + /** + * The script must send base64url without padding, matching what + * webauthn-lib decodes. Padding would be a silent failure at the library, + * reported as "invalid signature" rather than as an encoding mistake. + */ + public function test_the_script_encodes_ceremony_values_as_unpadded_base64url(): void + { + $html = $this->renderLoginPage('auth.example.com', true); + + self::assertStringContainsString("replace(/=+$/, '')", $html); + } + + /** + * Registration is dispatched through the same POST the TOTP form uses, and + * marked as such so the server can tell the two apart. + */ + public function test_the_script_marks_the_registration_submission(): void + { + $html = $this->renderLoginPage('auth.example.com', true); + + self::assertStringContainsString("register: 'passkey'", $html); + self::assertStringContainsString('register-finish', $html); + } +}