Add the passkey UI and fix issues it exposed

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.
This commit is contained in:
2026-09-27 11:32:16 +00:00
parent 69ee5e99aa
commit 11903bf746
10 changed files with 371 additions and 219 deletions
-216
View File
@@ -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
+18
View File
@@ -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;
}
}
+6
View File
@@ -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),
+5 -1
View File
@@ -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) {
+168
View File
@@ -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.
#}
<div class="center passkey-row">
<button type="button" id="preauth-passkey">{{ env.passkey_button_name }}</button>
</div>
<style>
div.passkey-row { width: 100%; }
div.passkey-row button { background-color: #ffffff; }
label.passkey-label { display: inline-block; text-align: left; }
label.passkey-label input { width: auto; }
</style>
<script>
(function () {
const form = document.getElementById('preauth-form');
const message = document.getElementById('preauth-message');
const button = document.getElementById('preauth-passkey');
const checkbox = document.getElementById('preauth-register');
/* base64url <-> ArrayBuffer, exactly as webauthn-lib encodes these fields */
const b64url = {
encode: (value) => btoa(String.fromCharCode.apply(null, new Uint8Array(value)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''),
decode: (value) => {
const padded = value.replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(padded + '='.repeat((4 - padded.length % 4) % 4));
return Uint8Array.from(raw, (character) => character.charCodeAt(0));
},
};
const show = (text) => { if (message) { message.innerText = text; } };
/* POST a ceremony step and return the parsed JSON body */
const ceremony = (operation, body) => fetch(window.location.href, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Preauth-Passkey': operation,
},
// never serve this request from, or store it in, the HTTP cache
cache: 'no-store',
body: JSON.stringify(body ?? {}),
}).then((response) => response.json().then((content) => ({ response, content })));
const descriptors = (list) => (list ?? []).map((entry) => ({
...entry,
id: b64url.decode(entry.id),
}));
/* ── login (assertion) ────────────────────────────────────────────── */
if (button) {
button.addEventListener('click', () => {
ceremony('login-begin')
.then(({ content }) => navigator.credentials.get({
publicKey: {
...content.publicKey,
challenge: b64url.decode(content.publicKey.challenge),
allowCredentials: descriptors(content.publicKey.allowCredentials),
},
}).then((assertion) => ceremony('login-finish', {
ceremonyId: content.ceremonyId,
credential: {
id: assertion.id,
rawId: b64url.encode(assertion.rawId),
type: assertion.type,
response: {
clientDataJSON: b64url.encode(assertion.response.clientDataJSON),
authenticatorData: b64url.encode(assertion.response.authenticatorData),
signature: b64url.encode(assertion.response.signature),
userHandle: assertion.response.userHandle
? b64url.encode(assertion.response.userHandle) : null,
},
},
})))
.then(({ response, content }) => {
if (response.headers.has('Location')) {
window.location.replace(response.headers.get('Location'));
return;
}
show(content.message ?? '');
if (Object.hasOwn(content, 'nonce') && form.nonce) {
form.nonce.value = content.nonce;
}
})
.catch((error) => {
console.log('passkey login failed');
console.log(error);
show({{ env.error_message|json_encode|raw }});
});
});
}
/* ── registration ─────────────────────────────────────────────────── */
/* The checkbox turns the ordinary submit into a registration ceremony.
Authorisation is the TOTP check the server performs on that same POST,
so the ceremony cannot be started without a valid code. */
if (form && checkbox) {
form.addEventListener('submit', (event) => {
if (!checkbox.checked || form.getAttribute('method') !== 'post') {
/* not registering, or a non-post form: leave the normal flow alone */
return;
}
event.preventDefault();
const payload = new URLSearchParams({
username: form.username.value,
totp: form.totp.value,
nonce: form.nonce.value,
register: 'passkey',
});
fetch(window.location.href, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
cache: 'no-store',
body: payload.toString(),
}).then((response) => response.json()).then((content) => {
if (!content.register) {
show(content.message ?? '');
if (Object.hasOwn(content, 'nonce')) {
form.nonce.value = content.nonce;
}
return;
}
const options = content.register.publicKey;
return navigator.credentials.create({
publicKey: {
...options,
challenge: b64url.decode(options.challenge),
user: { ...options.user, id: b64url.decode(options.user.id) },
excludeCredentials: descriptors(options.excludeCredentials),
},
}).then((attestation) => ceremony('register-finish', {
ceremonyId: content.register.ceremonyId,
credential: {
id: attestation.id,
rawId: b64url.encode(attestation.rawId),
type: attestation.type,
response: {
clientDataJSON: b64url.encode(attestation.response.clientDataJSON),
attestationObject: b64url.encode(attestation.response.attestationObject),
transports: attestation.response.getTransports
? attestation.response.getTransports() : [],
},
},
})).then(({ response, content: finished }) => {
if (response.headers.has('Location')) {
window.location.replace(response.headers.get('Location'));
return;
}
show(finished.message ?? '');
});
}).catch((error) => {
console.log('passkey registration failed');
console.log(error);
show({{ env.error_message|json_encode|raw }});
});
});
}
})();
</script>
+10
View File
@@ -14,9 +14,19 @@
<div class="right"><label for="totp">{{ env.token_name }}:</label></div>
<div><input type="text" name="totp" id="totp"
autocomplete="one-time-code" required="required"></div>
{% 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. #}
<div class="center passkey-row"><label class="passkey-label" for="preauth-register">
<input type="checkbox" name="register" id="preauth-register" value="passkey"> {{ env.passkey_register_name }}</label></div>
{% endif %}
<div class="center"><button type="submit">{{ env.submit_name }}</button></div>
</form>
{% if not post ?? false %}
{{- include('_script.html.twig') -}}
{% endif %}
{% if passkeys ?? false %}
{{- include('_passkey.html.twig') -}}
{% endif %}
{% endblock %}
+13 -2
View File
@@ -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;
@@ -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());
@@ -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());
+146
View File
@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
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\Log\NullLogger;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
/**
* The login page is where "passkeys are unavailable" has to be visibly true.
*
* A disabled feature must be indistinguishable from one that does not exist, so
* these tests compare the rendered page rather than trusting that a conditional
* is in the right place.
*/
final class PasskeyUiTest extends TestCase
{
use ListenerTestHelper;
private function makeListener(string $authSubdomain, bool $passkeysAvailable): InterceptListener
{
$domainManager = new DomainManager(true, $authSubdomain);
$policy = $this->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);
}
}