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.
This commit is contained in:
@@ -76,7 +76,7 @@ final readonly class LoginManager implements LoginInterface
|
||||
|
||||
/* the code and the nonce are both good from here on */
|
||||
|
||||
if ($payload->register) {
|
||||
if ($payload->register && $payload->json) {
|
||||
return $this->startRegistration($payload);
|
||||
}
|
||||
|
||||
@@ -94,6 +94,15 @@ final readonly class LoginManager implements LoginInterface
|
||||
*
|
||||
* `SessionIssuer` is deliberately not involved — the session is granted only
|
||||
* once the new credential has actually been verified, at `register-finish`.
|
||||
*
|
||||
* **JSON only.** The checkbox is submitted through the same `X-Preauth` AJAX
|
||||
* path as an ordinary login, so a failed attempt comes back as JSON carrying
|
||||
* a fresh nonce. On the plain form-post path it would be HTML, the script's
|
||||
* `response.json()` would throw, and — worse — the fresh nonce would be lost,
|
||||
* so the user's retry would fail against a nonce that had already been spent.
|
||||
* A non-JSON submission is therefore treated as an ordinary login; WebAuthn
|
||||
* needs scripting regardless, so it is the checkbox that is the enhancement
|
||||
* here, not the underlying login.
|
||||
*/
|
||||
private function startRegistration(Payload $payload): ?Response
|
||||
{
|
||||
|
||||
@@ -96,35 +96,42 @@ label.passkey-label input { width: auto; }
|
||||
}
|
||||
|
||||
/* ── 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. */
|
||||
/* The checkbox turns an ordinary submit into a registration ceremony.
|
||||
Authorisation is the TOTP check the server performs on that same
|
||||
submission, so a ceremony cannot be started without a valid code.
|
||||
|
||||
This goes through the same X-Preauth AJAX path as a normal login rather
|
||||
than a plain form post, so that failures come back as JSON with a fresh
|
||||
nonce — a form post would return HTML and lose the nonce, making the
|
||||
user's next attempt fail for a reason they could not see. */
|
||||
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 */
|
||||
if (!checkbox.checked) {
|
||||
/* not registering: leave the normal submit path alone */
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const payload = new URLSearchParams({
|
||||
username: form.username.value,
|
||||
totp: form.totp.value,
|
||||
nonce: form.nonce.value,
|
||||
const data = btoa(JSON.stringify({
|
||||
id: form.username.value?.trim() ?? '',
|
||||
token: form.totp.value?.trim() ?? '',
|
||||
nonce: form.nonce.value?.trim() ?? '',
|
||||
register: 'passkey',
|
||||
});
|
||||
json: true,
|
||||
})).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
|
||||
fetch(window.location.href, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
method: 'GET',
|
||||
headers: { 'X-Preauth': data },
|
||||
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;
|
||||
form.totp.value = '';
|
||||
form.totp.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,614 @@
|
||||
<?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.');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user