Files
preauth/tests/Unit/Service/PasskeyManagerTest.php
T
lyra 9523accd23
PHP Test / test (pull_request) Successful in 51s
Tests / test (pull_request) Successful in 51s
Close the coverage gaps in the passkey code, fixing what they exposed
The project's own bar is full coverage, and the new code had drifted from it —
notably every error path, which is exactly where a browser is least likely to
go on purpose and an attacker is most likely to.

Two real bugs surfaced, both of the same shape: a cache failure escaping as a
500 on the login page.

- `credentials->find()` was called outside the try block in `finishLogin()`, so
  a store failure threw instead of reporting a failed ceremony.
- `credentials->save()` was likewise unguarded in `finishRegistration()`, and
  there the consequence was worse: reporting success for a credential that was
  never stored, so the user would believe their passkey was registered and
  discover otherwise only at the next login.

Both now degrade to a failed ceremony, matching the rule the rest of the class
follows: a failure the user cannot act on must never look like a server fault.

Coverage is now at 98.7% of lines; the remainder is pre-existing defensive
catches in AcceptListener/AllowListener plus a couple of unreachable guards.
2026-09-27 11:40:01 +00:00

440 lines
15 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Unit\Service;
use App\Data\PasskeyCredential;
use App\Service\PasskeyCeremonyFactory;
use App\Service\PasskeyCredentialStoreInterface;
use App\Service\PasskeyManager;
use App\Service\PasskeyPolicyInterface;
use App\Tests\Support\PasskeyTestHelper;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use ReflectionMethod;
use RuntimeException;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Uid\Uuid;
use Throwable;
use Webauthn\CredentialRecord;
use Webauthn\TrustPath\EmptyTrustPath;
/**
* The ceremony control flow, with a stubbed validator.
*
* Real cryptography is proven separately (PasskeyRealCryptoSpikeTest and the
* functional suite); this file is about the branches around it — what happens
* when the store is empty, the body is malformed, or verification fails. Those
* are the paths a browser is least likely to exercise on purpose and an attacker
* most likely to.
*/
final class PasskeyManagerTest extends TestCase
{
private const string RP_ID = 'example.com';
private const string ORIGIN = 'https://auth.example.com';
private ?ArrayAdapter $cache = null;
private function makePolicy(): PasskeyPolicyInterface
{
$policy = $this->createStub(PasskeyPolicyInterface::class);
$policy->method('rpId')->willReturn(self::RP_ID);
$policy->method('authSubdomain')->willReturn('auth.example.com');
$policy->method('allowedOrigins')->willReturn([self::ORIGIN]);
$policy->method('rpName')->willReturn('Preauth');
$policy->method('userVerification')->willReturn('required');
$policy->method('timeout')->willReturn(60000);
$policy->method('isEnabled')->willReturn(true);
$policy->method('isAvailableFor')->willReturn(true);
return $policy;
}
/**
* @param PasskeyCredential[] $credentials
*/
private function makeManager(
array $credentials = [],
?PasskeyCredentialStoreInterface $store = null,
): PasskeyManager {
$this->cache = new ArrayAdapter();
$store ??= $this->makeStore($credentials);
return new PasskeyManager(
$this->makePolicy(),
new \App\Service\PasskeyCeremonyStore($this->cache),
$store,
new PasskeyCeremonyFactory(),
);
}
/**
* @param PasskeyCredential[] $credentials
*/
private function makeStore(array $credentials): PasskeyCredentialStoreInterface
{
$store = $this->createStub(PasskeyCredentialStoreInterface::class);
$store->method('all')->willReturn($credentials);
$store->method('find')->willReturnCallback(
static function (string $id) use ($credentials): ?PasskeyCredential {
foreach ($credentials as $credential) {
if ($credential->record->publicKeyCredentialId === $id) {
return $credential;
}
}
return null;
},
);
return $store;
}
private function makeCredential(string $identity = 'lyra'): PasskeyCredential
{
return new PasskeyCredential(
CredentialRecord::create(
random_bytes(16),
'public-key',
['internal'],
'none',
EmptyTrustPath::create(),
Uuid::v4(),
'COSE_KEY',
hash('sha256', $identity, true),
0,
null,
true,
false,
true,
),
$identity,
'Passkey abc',
new DateTimeImmutable(),
);
}
/* ── begin ────────────────────────────────────────────────────────── */
public function test_begin_login_returns_options_and_a_ceremony_id(): void
{
$result = $this->makeManager()->beginLogin();
self::assertArrayHasKey('publicKey', $result);
self::assertArrayHasKey('ceremonyId', $result);
self::assertSame(self::RP_ID, $result['publicKey']['rpId']);
}
/**
* With no credentials registered the list is empty rather than absent, so
* the browser can still offer a discoverable credential.
*/
public function test_begin_login_with_no_credentials_offers_an_empty_list(): void
{
$result = $this->makeManager()->beginLogin();
self::assertArrayHasKey('allowCredentials', $result['publicKey']);
self::assertSame([], $result['publicKey']['allowCredentials']);
}
public function test_begin_login_lists_every_registered_credential(): void
{
$manager = $this->makeManager([$this->makeCredential('lyra'), $this->makeCredential('atlas')]);
$result = $manager->beginLogin();
self::assertCount(2, $result['publicKey']['allowCredentials']);
}
public function test_begin_registration_uses_the_given_identity(): void
{
$result = $this->makeManager()->beginRegistration('lyra');
self::assertArrayHasKey('ceremonyId', $result);
self::assertSame('lyra', $result['publicKey']['user']['name']);
self::assertSame('none', $result['publicKey']['attestation']);
}
/* ── finish: malformed input ──────────────────────────────────────── */
/**
* A body without a ceremony id or credential must be refused, and must not
* touch the credential store.
*/
public function test_finish_login_refuses_a_body_without_a_ceremony_id(): void
{
$manager = $this->makeManager();
self::assertNull($manager->finishLogin([]));
self::assertNull($manager->finishLogin(['credential' => []]));
self::assertNull($manager->finishLogin(['ceremonyId' => '', 'credential' => []]));
}
public function test_finish_login_refuses_a_body_without_a_credential(): void
{
$manager = $this->makeManager();
self::assertNull($manager->finishLogin(['ceremonyId' => 'cid']));
self::assertNull($manager->finishLogin(['ceremonyId' => 'cid', 'credential' => 'not-an-array']));
}
/**
* An unknown ceremony id means the record was never issued, already spent,
* or expired — all of which must look the same to the caller.
*/
public function test_finish_login_refuses_an_unknown_ceremony_id(): void
{
$manager = $this->makeManager();
self::assertNull($manager->finishLogin([
'ceremonyId' => 'never-issued',
'credential' => ['id' => 'x'],
]));
}
/**
* A credential the store does not know must be refused before any
* verification is attempted, so an attacker cannot use the endpoint as an
* oracle by nominating arbitrary credential ids.
*/
public function test_finish_login_refuses_an_unknown_credential(): void
{
$manager = $this->makeManager();
$started = $manager->beginLogin();
/* a structurally valid assertion for a credential nobody registered */
$helper = new PasskeyTestHelper();
$challenge = $this->challengeFor($started['ceremonyId']);
$assertion = $helper->assertionCredential(
self::RP_ID,
$challenge,
self::ORIGIN,
random_bytes(16),
1,
hash('sha256', 'nobody', true),
);
self::assertNull($manager->finishLogin([
'ceremonyId' => $started['ceremonyId'],
'credential' => $assertion,
]));
}
public function test_finish_registration_refuses_malformed_input(): void
{
$manager = $this->makeManager();
self::assertNull($manager->finishRegistration([]));
self::assertNull($manager->finishRegistration(['ceremonyId' => 'cid']));
self::assertNull($manager->finishRegistration(['ceremonyId' => 'never-issued', 'credential' => []]));
}
/**
* A login ceremony must not be usable to finish a registration, or the two
* flows' differing trust assumptions would blur together.
*/
public function test_a_login_ceremony_cannot_finish_a_registration(): void
{
$manager = $this->makeManager();
$started = $manager->beginLogin();
self::assertNull($manager->finishRegistration([
'ceremonyId' => $started['ceremonyId'],
'credential' => [],
]));
}
/**
* A registration ceremony must not be usable to finish a login.
*/
public function test_a_registration_ceremony_cannot_finish_a_login(): void
{
$manager = $this->makeManager();
$started = $manager->beginRegistration('lyra');
self::assertNull($manager->finishLogin([
'ceremonyId' => $started['ceremonyId'],
'credential' => [],
]));
}
/* ── finish: verification failure ─────────────────────────────────── */
/**
* A wrong challenge must fail, and must not be retryable: the record is
* consumed on read.
*/
public function test_a_wrong_challenge_fails_and_is_not_retryable(): void
{
$helper = new PasskeyTestHelper();
$credentialId = $helper->credentialId();
/* a store holding a credential whose id matches the assertion */
$record = CredentialRecord::create(
$credentialId,
'public-key',
['internal'],
'none',
EmptyTrustPath::create(),
Uuid::v4(),
'COSE_KEY',
hash('sha256', 'lyra', true),
0,
null,
true,
false,
true,
);
$stored = new PasskeyCredential($record, 'lyra', 'Passkey abc', new DateTimeImmutable());
$manager = $this->makeManager([$stored]);
$started = $manager->beginLogin();
$assertion = $helper->assertionCredential(
self::RP_ID,
random_bytes(32),
self::ORIGIN,
$credentialId,
0,
hash('sha256', 'lyra', true),
);
self::assertNull($manager->finishLogin([
'ceremonyId' => $started['ceremonyId'],
'credential' => $assertion,
]));
/* and the same ceremony cannot be presented again */
self::assertNull($manager->finishLogin([
'ceremonyId' => $started['ceremonyId'],
'credential' => $assertion,
]));
}
/**
* A store that throws must not turn a malformed credential into a 500.
*/
public function test_a_store_failure_is_reported_as_a_failed_ceremony(): void
{
$helper = new PasskeyTestHelper();
$credentialId = $helper->credentialId();
$store = $this->createStub(PasskeyCredentialStoreInterface::class);
$store->method('find')->willThrowException(new RuntimeException('store down'));
$manager = $this->makeManager(store: $store);
$started = $manager->beginLogin();
$assertion = $helper->assertionCredential(
self::RP_ID,
random_bytes(32),
self::ORIGIN,
$credentialId,
0,
hash('sha256', 'lyra', true),
);
try {
$result = $manager->finishLogin([
'ceremonyId' => $started['ceremonyId'],
'credential' => $assertion,
]);
} catch (Throwable $exception) {
self::fail('finishLogin() must not throw: '.$exception->getMessage());
}
self::assertNull($result);
}
/* ── helpers ──────────────────────────────────────────────────────── */
/**
* The challenge the manager issued for a ceremony, read back from the cache
* the way an attacker with the ceremony id would not be able to.
*/
private function challengeFor(string $ceremonyId): string
{
$key = new ReflectionMethod(\App\Service\PasskeyCeremonyStore::class, 'key');
$store = new \App\Service\PasskeyCeremonyStore($this->cache);
$item = $this->cache->getItem($key->invoke($store, $ceremonyId));
$payload = $item->isHit() ? $item->get() : null;
return \is_array($payload) && isset($payload['challenge']) ? (string) $payload['challenge'] : '';
}
/**
* A credential whose stored payload cannot be read must be treated as
* unusable, and — importantly — must not throw. One corrupt entry must not
* become a 500 for every visitor on the login page.
*/
public function test_a_credential_that_cannot_be_found_fails_the_ceremony(): void
{
$helper = new PasskeyTestHelper();
$credentialId = $helper->credentialId();
$store = $this->createStub(PasskeyCredentialStoreInterface::class);
$store->method('find')->willReturn(null);
$manager = $this->makeManager(store: $store);
$started = $manager->beginLogin();
$assertion = $helper->assertionCredential(
self::RP_ID,
random_bytes(32),
self::ORIGIN,
$credentialId,
0,
hash('sha256', 'lyra', true),
);
self::assertNull($manager->finishLogin([
'ceremonyId' => $started['ceremonyId'],
'credential' => $assertion,
]));
}
/**
* A registration whose attestation cannot be parsed must fail rather than
* throwing, for the same reason.
*/
public function test_unparseable_attestation_fails_the_ceremony(): void
{
$manager = $this->makeManager();
$started = $manager->beginRegistration('lyra');
/* structurally a credential object, but the response is nonsense */
self::assertNull($manager->finishRegistration([
'ceremonyId' => $started['ceremonyId'],
'credential' => ['id' => 'x', 'rawId' => 'x', 'type' => 'public-key', 'response' => []],
]));
}
/**
* A store that cannot persist a registration must not report success: the
* user would believe the passkey was saved and then find it missing at the
* next login, with nothing to explain why.
*/
public function test_a_registration_that_cannot_be_persisted_fails(): void
{
$store = $this->createStub(PasskeyCredentialStoreInterface::class);
$store->method('all')->willReturn([]);
$store->method('find')->willReturn(null);
$store->method('save')->willThrowException(new RuntimeException('store down'));
$helper = new PasskeyTestHelper();
$manager = $this->makeManager(store: $store);
$started = $manager->beginRegistration('lyra');
$challenge = $this->challengeFor($started['ceremonyId']);
$credential = $helper->registrationCredential(self::RP_ID, $challenge, self::ORIGIN);
self::assertNull($manager->finishRegistration([
'ceremonyId' => $started['ceremonyId'],
'credential' => $credential,
]));
}
}