Files
preauth/tests/Functional/PasskeyFlowTest.php
T
lyra ffe6870231 Add end-to-end functional tests with real cryptography
The whole flow through the real HTTP kernel, with nothing about the ceremony
stubbed: registration builds a genuine CBOR attestation object signed by a real
P-256 key, and login signs a real assertion. Only the browser's plumbing is
simulated — the fetch() calls become requests, which is the seam worth testing.

Covered: a real registration grants a session; a real passkey login grants a
session and reports the right Remote-User; a bad TOTP starts no ceremony; a
spent nonce starts no ceremony; a replayed ceremony fails; an assertion for
another challenge fails; an unknown credential fails with the same generic
message a wrong code gets; both login paths set an identical cookie; ceremony
responses are not cacheable and do not leak their marker; the ceremony is inert
when disabled; the page offers passkeys only when enabled; and the CSP permits
the two WebAuthn directives.

Writing these found a real bug. The registration checkbox originally submitted
a plain form POST, which returns HTML — and, more importantly, loses the fresh
nonce the failure response issues. The user's next attempt would then fail
against a nonce that had already been spent, with no visible reason why. It now
goes through the same X-Preauth AJAX path as an ordinary login, so failures come
back as JSON with a usable nonce; a non-JSON submission is treated as an
ordinary login, and LoginManager returns null for it rather than starting a
ceremony that nothing could finish.

Three test failures were also correct behaviour rather than bugs: once a session
cookie exists, AcceptListener (priority 99) answers before any ceremony listener
runs, so tests exercising a second ceremony need a visitor without that cookie.
That is the intended ordering, now documented in the tests.
2026-09-27 11:35:54 +00:00

615 lines
24 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Functional;
use App\AppConstants;
use App\Tests\Support\PasskeyTestHelper;
use OTPHP\TOTP;
use Override;
use ParagonIE\ConstantTime\Base64UrlSafe;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Response;
/**
* The whole flow through the real HTTP kernel, with real cryptography.
*
* Nothing about the ceremony is stubbed: the registration builds a genuine CBOR
* attestation object signed by a real P-256 key, and the login signs a real
* assertion. So a pass here means the feature works, not that our mocks agree
* with our code. What *is* simulated is only the browser's plumbing — the
* `fetch()` calls become requests, which is exactly the seam worth testing.
*
* Passkeys are off in `.env.test` (most of the suite expects today's behaviour),
* so this test turns them on for itself.
*/
final class PasskeyFlowTest extends WebTestCase
{
private const string TOTP_SECRET = 'JBSWY3DPEHPK3PXP';
private const string IDENTITY = 'lyra';
/** The auth subdomain, which is also the only allowed ceremony origin. */
private const string AUTH_HOST = 'auth.example.com';
/** The RP ID: the base domain, so credentials are shared across it. */
private const string RP_ID = 'example.com';
private const string ORIGIN = 'https://auth.example.com';
private const string AUTH_COOKIE = '__Http-Domain-Preauth';
private ?PasskeyTestHelper $helper = null;
/** One kernel per test, as WebTestCase requires. */
private ?KernelBrowser $client = null;
private ?string $credentialId = null;
/** @var array<string,string> */
private static array $passkeyEnv = [
'PASSKEY_ENABLED' => '1',
'SUBDOMAIN_REDIRECT' => '1',
'AUTH_SUBDOMAIN' => self::AUTH_HOST,
];
/**
* Turn passkeys on for this test only.
*
* Env placeholders resolve at runtime, so setting these before the kernel
* boots is enough and no separate cache directory is needed.
*/
private function createPasskeyClient(): KernelBrowser
{
foreach (self::$passkeyEnv as $name => $value) {
$_ENV[$name] = $value;
$_SERVER[$name] = $value;
}
/* WebTestCase allows exactly one kernel per test, so a test needing a
* second "visitor" gets this browser with a cleared cookie jar rather
* than a new kernel. */
if (null === $this->client) {
$this->client = static::createClient();
$this->client->disableReboot();
}
return $this->client;
}
/**
* The same kernel, with no cookies — a fresh visitor.
*
* Needed because a granted session makes AcceptListener short-circuit at 200
* before any ceremony listener runs, so a test that registers first and then
* wants to exercise a ceremony must not carry that cookie.
*/
private function freshVisitor(): KernelBrowser
{
$client = $this->createPasskeyClient();
$client->getCookieJar()->clear();
return $client;
}
#[Override]
protected function tearDown(): void
{
foreach (array_keys(self::$passkeyEnv) as $name) {
unset($_ENV[$name], $_SERVER[$name]);
}
parent::tearDown();
}
private function helper(): PasskeyTestHelper
{
return $this->helper ??= new PasskeyTestHelper();
}
private function credentialId(): string
{
return $this->credentialId ??= $this->helper()->credentialId();
}
private function validTotpCode(): string
{
return TOTP::createFromSecret(self::TOTP_SECRET)->now();
}
/**
* The nonce issued with the login page, which the form must echo back.
*/
private function nonceFrom(KernelBrowser $client): string
{
$crawler = $client->request('GET', self::ORIGIN.'/');
return (string) $crawler->filter('input[name="nonce"]')->attr('value');
}
/**
* Step 1+2 of registration: submit the form with the checkbox ticked.
*
* @return array{publicKey: array<string,mixed>, ceremonyId: string}
*/
private function beginRegistration(KernelBrowser $client, string $nonce, string $totp = ''): array
{
$client->request('GET', self::ORIGIN.'/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => self::IDENTITY,
'token' => '' === $totp ? $this->validTotpCode() : $totp,
'nonce' => $nonce,
'register' => 'passkey',
'json' => true,
]),
]);
$response = $client->getResponse();
self::assertSame(Response::HTTP_OK, $response->getStatusCode(), (string) $response->getContent());
$content = json_decode((string) $response->getContent(), true);
self::assertIsArray($content);
self::assertArrayHasKey('register', $content);
return $content['register'];
}
/**
* An ordinary code login, returning the cookie it sets.
*
* Used to prove both login paths agree on the cookie; the passkey flow is
* otherwise easy to break in a way that only shows up in a browser.
*/
private function codeLoginCookie(): \Symfony\Component\HttpFoundation\Cookie
{
$client = $this->freshVisitor();
$nonce = $this->nonceFrom($client);
$client->request('GET', self::ORIGIN.'/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => self::IDENTITY,
'token' => $this->validTotpCode(),
'nonce' => $nonce,
'json' => true,
]),
]);
$response = $client->getResponse();
self::assertSame(Response::HTTP_SEE_OTHER, $response->getStatusCode(), (string) $response->getContent());
return $this->authCookieFrom($response);
}
/**
* base64url-encode a payload, matching the client-side script.
*
* @param array<string,mixed> $data
*/
private function encodePayload(array $data): string
{
return rtrim(strtr(base64_encode((string) json_encode($data)), '+/', '-_'), '=');
}
/**
* Step 3 of registration: send the attestation the authenticator produced.
*/
private function finishRegistration(KernelBrowser $client, string $ceremonyId, string $challenge): Response
{
$credential = $this->helper()->registrationCredential(
self::RP_ID,
$challenge,
self::ORIGIN,
$this->credentialId(),
);
$client->request(
'POST',
self::ORIGIN.'/',
[],
[],
[
'CONTENT_TYPE' => 'application/json',
'HTTP_X-Preauth-Passkey' => 'register-finish',
],
(string) json_encode(['ceremonyId' => $ceremonyId, 'credential' => $credential]),
);
return $client->getResponse();
}
/* ── registration ─────────────────────────────────────────────────── */
/**
* The headline end-to-end property: a real registration is accepted and
* grants a session.
*/
public function test_a_real_registration_grants_a_session(): void
{
$client = $this->createPasskeyClient();
$nonce = $this->nonceFrom($client);
$started = $this->beginRegistration($client, $nonce);
self::assertArrayHasKey('ceremonyId', $started);
self::assertSame(self::RP_ID, $started['publicKey']['rp']['id']);
$response = $this->finishRegistration(
$client,
$started['ceremonyId'],
Base64UrlSafe::decodeNoPadding($started['publicKey']['challenge']),
);
self::assertSame(Response::HTTP_SEE_OTHER, $response->getStatusCode(), (string) $response->getContent());
/* the session cookie is domain-scoped so every subdomain accepts it */
$cookie = $this->authCookieFrom($response);
self::assertSame(self::AUTH_COOKIE, $cookie->getName());
self::assertSame(self::RP_ID, $cookie->getDomain());
self::assertTrue($cookie->isSecure());
self::assertTrue($cookie->isHttpOnly());
}
/**
* Registration is authorised by the TOTP code, so a bad code must not start
* a ceremony — and must not leave one behind to be finished later.
*/
public function test_registration_with_a_bad_code_starts_no_ceremony(): void
{
$client = $this->createPasskeyClient();
$nonce = $this->nonceFrom($client);
$client->request('GET', self::ORIGIN.'/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => self::IDENTITY,
'token' => '000000',
'nonce' => $nonce,
'register' => 'passkey',
'json' => true,
]),
]);
$response = $client->getResponse();
self::assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode());
$content = json_decode((string) $response->getContent(), true);
self::assertIsArray($content);
self::assertArrayNotHasKey('register', $content);
}
/**
* A spent nonce must be refused even with a valid code, or the ceremony
* hand-off would be replayable.
*/
public function test_registration_with_a_spent_nonce_starts_no_ceremony(): void
{
$client = $this->createPasskeyClient();
$nonce = $this->nonceFrom($client);
/* spend the nonce with a first successful login */
$client->request('GET', self::ORIGIN.'/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => self::IDENTITY,
'token' => $this->validTotpCode(),
'nonce' => $nonce,
'json' => true,
]),
]);
self::assertSame(Response::HTTP_SEE_OTHER, $client->getResponse()->getStatusCode());
/* now try to reuse it for registration */
$reuse = $this->freshVisitor();
$reuse->request('GET', self::ORIGIN.'/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([
'id' => self::IDENTITY,
'token' => $this->validTotpCode(),
'nonce' => $nonce,
'register' => 'passkey',
'json' => true,
]),
]);
self::assertSame(Response::HTTP_UNAUTHORIZED, $reuse->getResponse()->getStatusCode());
}
/* ── login ────────────────────────────────────────────────────────── */
/**
* The other half of the story: register once, then log in with the passkey
* instead of a code — through the real validator, with a real signature.
*/
public function test_a_real_passkey_login_grants_a_session(): void
{
$client = $this->createPasskeyClient();
/* register first */
$started = $this->beginRegistration($client, $this->nonceFrom($client));
$registered = $this->finishRegistration(
$client,
$started['ceremonyId'],
Base64UrlSafe::decodeNoPadding($started['publicKey']['challenge']),
);
self::assertSame(Response::HTTP_SEE_OTHER, $registered->getStatusCode());
/* the stored record's counter is the one the registration used, and the
* lenient policy accepts an equal or greater value */
$counter = $this->helper()->counter() + 1;
/* drop the cookie the registration granted, or AcceptListener would
* answer before the ceremony listener is reached */
$client = $this->freshVisitor();
$client->request('POST', self::ORIGIN.'/', [], [], [
'CONTENT_TYPE' => 'application/json',
'HTTP_X-Preauth-Passkey' => 'login-begin',
], '{}');
$begin = json_decode((string) $client->getResponse()->getContent(), true);
self::assertIsArray($begin);
self::assertSame(self::RP_ID, $begin['publicKey']['rpId']);
/* the registered credential is offered to the authenticator */
self::assertNotEmpty($begin['publicKey']['allowCredentials']);
$challenge = Base64UrlSafe::decodeNoPadding($begin['publicKey']['challenge']);
$assertion = $this->helper()->assertionCredential(
self::RP_ID,
$challenge,
self::ORIGIN,
$this->credentialId(),
$counter,
hash('sha256', self::IDENTITY, true),
);
$client->request('POST', self::ORIGIN.'/', [], [], [
'CONTENT_TYPE' => 'application/json',
'HTTP_X-Preauth-Passkey' => 'login-finish',
], (string) json_encode(['ceremonyId' => $begin['ceremonyId'], 'credential' => $assertion]));
$response = $client->getResponse();
self::assertSame(Response::HTTP_SEE_OTHER, $response->getStatusCode(), (string) $response->getContent());
/* the Remote-User header proves which identity was authenticated */
self::assertSame(self::IDENTITY, $response->headers->get('Remote-User'));
self::assertSame(self::AUTH_COOKIE, $this->authCookieFrom($response)->getName());
}
/**
* A replayed ceremony must fail: the challenge is consumed on first use, so
* an observed `finish` cannot be re-sent.
*/
public function test_a_replayed_ceremony_fails(): void
{
$client = $this->createPasskeyClient();
$started = $this->beginRegistration($client, $this->nonceFrom($client));
$challenge = Base64UrlSafe::decodeNoPadding($started['publicKey']['challenge']);
$first = $this->finishRegistration($client, $started['ceremonyId'], $challenge);
self::assertSame(Response::HTTP_SEE_OTHER, $first->getStatusCode());
/* a replay comes from someone who does not hold the session the first
* attempt just created, so the cookie must go — otherwise AcceptListener
* answers 200 and the ceremony listener never sees the replay */
$replay = $this->finishRegistration($this->freshVisitor(), $started['ceremonyId'], $challenge);
self::assertSame(Response::HTTP_UNAUTHORIZED, $replay->getStatusCode());
}
/**
* An assertion signed over a different challenge must be refused, which is
* what binds a login to this session rather than to any past one.
*/
public function test_an_assertion_for_another_challenge_fails(): void
{
$client = $this->createPasskeyClient();
$started = $this->beginRegistration($client, $this->nonceFrom($client));
$registered = $this->finishRegistration(
$client,
$started['ceremonyId'],
Base64UrlSafe::decodeNoPadding($started['publicKey']['challenge']),
);
/* the stored record's counter is the one the registration used, and the
* lenient policy accepts an equal or greater value */
$counter = $this->helper()->counter() + 1;
$client = $this->freshVisitor();
$client->request('POST', self::ORIGIN.'/', [], [], [
'CONTENT_TYPE' => 'application/json',
'HTTP_X-Preauth-Passkey' => 'login-begin',
], '{}');
$begin = json_decode((string) $client->getResponse()->getContent(), true);
self::assertIsArray($begin);
/* sign a challenge the server never issued */
$assertion = $this->helper()->assertionCredential(
self::RP_ID,
random_bytes(32),
self::ORIGIN,
$this->credentialId(),
$counter,
hash('sha256', self::IDENTITY, true),
);
$client->request('POST', self::ORIGIN.'/', [], [], [
'CONTENT_TYPE' => 'application/json',
'HTTP_X-Preauth-Passkey' => 'login-finish',
], (string) json_encode(['ceremonyId' => $begin['ceremonyId'], 'credential' => $assertion]));
self::assertSame(Response::HTTP_UNAUTHORIZED, $client->getResponse()->getStatusCode());
}
/**
* An unknown credential must fail with the same generic message a wrong code
* gets, so the endpoint cannot be used to enumerate live credentials.
*/
public function test_an_unknown_credential_fails_generically(): void
{
$client = $this->createPasskeyClient();
$client->request('POST', self::ORIGIN.'/', [], [], [
'CONTENT_TYPE' => 'application/json',
'HTTP_X-Preauth-Passkey' => 'login-begin',
], '{}');
$begin = json_decode((string) $client->getResponse()->getContent(), true);
self::assertIsArray($begin);
/* a credential nobody registered, signed correctly against this challenge */
$assertion = $this->helper()->assertionCredential(
self::RP_ID,
Base64UrlSafe::decodeNoPadding($begin['publicKey']['challenge']),
self::ORIGIN,
random_bytes(16),
1,
hash('sha256', 'nobody', true),
);
$client->request('POST', self::ORIGIN.'/', [], [], [
'CONTENT_TYPE' => 'application/json',
'HTTP_X-Preauth-Passkey' => 'login-finish',
], (string) json_encode(['ceremonyId' => $begin['ceremonyId'], 'credential' => $assertion]));
$response = $client->getResponse();
self::assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode());
/* and the wording matches the ordinary failure, with no hint that the
* credential was unknown */
$content = json_decode((string) $response->getContent(), true);
self::assertIsArray($content);
self::assertSame('Unsuccessful login attempt', $content['message']);
}
/* ── the shared session (why SessionIssuer exists) ────────────────── */
/**
* Both login paths must produce the *same* cookie, or a user would appear
* logged in on the auth subdomain but not on the protected one.
*/
public function test_a_passkey_login_sets_the_same_cookie_as_a_code_login(): void
{
/* a code login, for comparison — via the same AJAX path the passkey
* script uses, so any difference is in the cookie and nothing else */
$codeCookie = $this->codeLoginCookie();
/* Now the passkey path, on a visitor with no session: the code login
* above set a cookie, and with it the login page is replaced by
* AcceptListener's "already authenticated" reply. */
$client = $this->freshVisitor();
$started = $this->beginRegistration($client, $this->nonceFrom($client));
$registered = $this->finishRegistration(
$client,
$started['ceremonyId'],
Base64UrlSafe::decodeNoPadding($started['publicKey']['challenge']),
);
$passkeyCookie = $this->authCookieFrom($registered);
self::assertSame($codeCookie->getName(), $passkeyCookie->getName());
self::assertSame($codeCookie->getDomain(), $passkeyCookie->getDomain());
self::assertSame($codeCookie->getPath(), $passkeyCookie->getPath());
self::assertSame($codeCookie->isSecure(), $passkeyCookie->isSecure());
self::assertSame($codeCookie->isHttpOnly(), $passkeyCookie->isHttpOnly());
self::assertSame($codeCookie->getSameSite(), $passkeyCookie->getSameSite());
}
/* ── caching and availability ─────────────────────────────────────── */
/**
* A ceremony reply is a browser-facing 2xx, which nothing else in this app
* produces, so it must carry the full no-store set or a browser could
* replay a stale challenge.
*/
public function test_ceremony_responses_are_not_cacheable(): void
{
$client = $this->createPasskeyClient();
$client->request('POST', self::ORIGIN.'/', [], [], [
'CONTENT_TYPE' => 'application/json',
'HTTP_X-Preauth-Passkey' => 'login-begin',
], '{}');
$response = $client->getResponse();
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertTrue($response->headers->hasCacheControlDirective('no-store'));
self::assertSame('no-store', $response->headers->get('Surrogate-Control'));
/* the internal marker must not leak to the browser */
self::assertFalse($response->headers->has(AppConstants::PASSKEY_CEREMONY_MARKER));
}
/**
* With the feature off the listener must be inert, so a caller cannot even
* obtain a challenge. This uses the default test environment, where
* PASSKEY_ENABLED is 0.
*/
public function test_the_ceremony_is_inert_when_passkeys_are_disabled(): void
{
/* no passkey env set, so PASSKEY_ENABLED keeps its .env.test value of 0 */
$client = static::createClient();
$client->request('POST', 'https://'.self::AUTH_HOST.'/', [], [], [
'CONTENT_TYPE' => 'application/json',
'HTTP_X-Preauth-Passkey' => 'login-begin',
], '{}');
$response = $client->getResponse();
self::assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode());
/* however the listener answered, the caller must not have been given a
* challenge — that is the property under test */
self::assertStringNotContainsString('publicKey', (string) $response->getContent());
}
/**
* The login page must not offer what the server refuses, and vice versa.
*/
public function test_the_login_page_offers_passkeys_when_enabled(): void
{
$client = $this->createPasskeyClient();
$client->request('GET', self::ORIGIN.'/');
$html = (string) $client->getResponse()->getContent();
self::assertStringContainsString('id="preauth-passkey"', $html);
self::assertStringContainsString('id="preauth-register"', $html);
}
/**
* The other half: with the feature off the page must not offer anything.
* Kept as its own test because a kernel may only be booted once, so the two
* configurations cannot be compared within a single test.
*/
public function test_the_login_page_offers_nothing_when_passkeys_are_disabled(): void
{
$client = static::createClient();
$client->request('GET', '/');
self::assertStringNotContainsString('preauth-passkey', (string) $client->getResponse()->getContent());
self::assertStringNotContainsString('preauth-register', (string) $client->getResponse()->getContent());
}
/**
* The CSP must permit the two WebAuthn directives, because they do not fall
* back to `default-src` and the browser refuses the ceremony without them.
*/
public function test_the_csp_permits_the_ceremony_when_passkeys_are_enabled(): void
{
$client = $this->createPasskeyClient();
$client->request('GET', self::ORIGIN.'/');
$csp = (string) $client->getResponse()->headers->get('Content-Security-Policy');
self::assertStringContainsString("publickey-credentials-get 'self'", $csp);
self::assertStringContainsString("publickey-credentials-create 'self'", $csp);
}
/* ── helpers ──────────────────────────────────────────────────────── */
private function authCookieFrom(Response $response): \Symfony\Component\HttpFoundation\Cookie
{
foreach ($response->headers->getCookies() as $cookie) {
if (self::AUTH_COOKIE === $cookie->getName()) {
return $cookie;
}
}
self::fail('Expected an auth cookie in the response.');
}
}