Passkeys::fake() swaps the relying party for PasskeysFake — a programmable, no-crypto double bound under the PasskeyService contract — and returns it for assertions. You can assert your enrolment and login controllers without reproducing authenticator crypto. It performs no CBOR/COSE decoding, signature verification or challenge check, and is bound only for the test. The facade, an injected PasskeyService and the model verbs all see it:
- Ceremonies are recorded, not performed: register() persists a factory-built credential, authenticate() returns the programmed outcome, and the options calls return canned options that apply the overrides. The canned creation options decode the user handle exactly as the real service does, so a malformed handle is refused there too.
- Calls through the model verbs (registerPasskey() and the rest) are recorded as well — they route through Passkeys::for($this).
- rename() and revoke() still run the real, ownership-checked actions — writes and PasskeyRenamed / PasskeyRevoked included — and are recorded once they succeed.
- Reads (all(), find(), count(), exists()) go to the database; attestationFormats() returns the real registry’s formats.
use RoundlyConsulting\Passkeys\Facades\Passkeys;
$fake = Passkeys::fake();
// The fake skips the crypto, not your controller's parsing: RegistrationResponseData::fromArray()
// still needs the browser's shape with base64url members — any bytes will do.
$this->postJson('/passkeys', [
'id' => 'AAAA',
'rawId' => 'AAAA',
'type' => 'public-key',
'response' => ['clientDataJSON' => 'e30', 'attestationObject' => 'oA'],
])->assertCreated();
$fake->assertRegisteredFor($user);
$this->deleteJson("/passkeys/{$passkey->id}")->assertNoContent();
$fake->assertRevoked($passkey);
$fake->assertNothingRenamed();
// programmable outcomes:
Passkeys::fake()->rejectAuthentication(); // authenticate() throws CredentialNotFound
Passkeys::fake()->authenticatesAs($passkey); // authenticate() returns this exact credential
Passkeys::fake()->failRegistrationWith($exception);Programmable outcomes
| Method | Effect |
|---|---|
acceptRegistration() | register() persists a factory-built ES256 credential for the user (the default). |
failRegistrationWith($e) | register() throws the given PasskeyException instead of persisting. |
acceptAuthentication() | authenticate() succeeds (the default). |
rejectAuthentication() | authenticate() throws CredentialNotFound. |
authenticatesAs($passkey) | authenticate() returns exactly this credential. |
Assertions
| Assertion | Passes when |
|---|---|
assertRegistered() | At least one registration was recorded. |
assertRegisteredFor($user) | A registration was recorded for $user. |
assertNothingRegistered() | No registration was recorded. |
assertRegistrationCount($n) | Exactly $n registrations were recorded. |
assertAuthenticated() | At least one successful authentication was recorded. |
assertAuthenticatedFor($user) | A successful authentication was recorded for $user. |
assertAuthenticationFailed() | At least one authentication failed. |
assertAuthenticationCount($n) | Exactly $n authentications were recorded. |
assertRenamed(?$passkey, ?$name) | A rename was recorded — of this passkey and/or to this name, when given. |
assertNothingRenamed() | No rename was recorded. |
assertRevoked(?$passkey) | A revocation was recorded — of this passkey, when given. |
assertNothingRevoked() | No revocation was recorded. |
Assertions throw PasskeyAssertionFailed — a package exception, not a PHPUnit assertion — so they work under any runner. The fake honours AuthenticationExpectation the way the real verifier does, and for($user)->authenticate() holds the result to that account — a credential of the wrong owner is recorded as a failure and throws CredentialNotFound:
use RoundlyConsulting\Passkeys\Exceptions\CredentialAlreadyRegistered;
use RoundlyConsulting\Passkeys\Models\Passkey;
// A rejected sign-in
$fake = Passkeys::fake()->rejectAuthentication();
$this->postJson('/login/passkey', $payload)->assertUnauthorized();
$fake->assertAuthenticationFailed();
// A registration failure surfaced to the client
$fake = Passkeys::fake()->failRegistrationWith(CredentialAlreadyRegistered::make());
$this->actingAs($user)->postJson('/passkeys', $payload)->assertStatus(422);
$fake->assertNothingRegistered();
// Sign-in resolves to a specific credential
$passkey = Passkey::factory()->es256()->forAuthenticatable($user)->create();
Passkeys::fake()->authenticatesAs($passkey);
$this->postJson('/login/passkey', $payload)->assertOk();Real ceremonies with the virtual authenticator
When you want the real verifier in your suite — challenge, origin, RP ID hash, flags, signature, sign counter, user binding — drive it with VirtualAuthenticator, a software ES256 authenticator that attests with none:
use RoundlyConsulting\Passkeys\DataTransferObjects\AuthenticationOptionsOverrides;
use RoundlyConsulting\Passkeys\Enums\UserVerification;
use RoundlyConsulting\Passkeys\Facades\Passkeys;
use RoundlyConsulting\Passkeys\Testing\VirtualAuthenticator;
$authenticator = VirtualAuthenticator::es256(); // rpId from the options, origin from config
$keys = Passkeys::for($user);
$keys->register($authenticator->register($keys->registrationOptions()));
$passkey = $keys->authenticate($authenticator->assert($keys->authenticationOptions()));
// Prove your step-up really demands user verification:
$options = $keys->authenticationOptions(new AuthenticationOptionsOverrides(userVerification: UserVerification::Required));
$keys->authenticate($authenticator->assert($options, userVerified: false)); // UserVerificationRequired
$authenticator->assert($options, signCount: 3); // explicit counter, e.g. a cloned key
$authenticator->credentialId(); // base64url, as stored in credential_id- register($options, residentKey: false) models a non-discoverable credential, with no userHandle on assertions — so, as with a real browser, it can only answer options minted for its user (Passkeys::for($user)->authenticationOptions()); a usernameless ceremony refuses it.
- The counter advances by one per assert() unless signCount is given; userVerified: false sends presence only.
- It answers any options it is given, so a suite can also play the attacker and prove the server refuses.
- Test-only: its key is throwaway material from crypto-for-laravel’s TestKeys, so never use it in production code. It lives in runtime autoload only so other packages’ suites can reach it — the same posture as Passkeys::fake(). On macOS with Homebrew PHP, key generation may need OPENSSL_CONF pointed at Homebrew’s openssl.cnf.
Factory
Passkey::factory() builds valid stored credentials for your own tests, using the configured model:
use RoundlyConsulting\Passkeys\Models\Passkey;
$passkey = Passkey::factory()->es256()->forAuthenticatable($user)->create();
$rsa = Passkey::factory()->rs256()->neverUsed()->forAuthenticatable($user)->create();| State | Effect |
|---|---|
es256() | A valid ES256 (P-256) COSE public key. |
rs256() | A valid RS256 (RSA-2048) COSE public key. |
withCredentialId($id) | Keys the credential on an explicit ID and its hash. |
forAuthenticatable($model) | Owns the passkey by a specific model. |
neverUsed() | sign_count = 0, last_used_at = null. |
Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.