Aller au contenu

omniauth/webauthn

Omniauth's WebAuthn over web-auth/webauthn-lib, under any PHP host - passkeys: the sign-in (request options, then the assertion checked: origin, relying party, user verification required, counter and backup flags kept) and the registration (creation options, then the attestation checked), on the credential record as omnibase's userPasskey table holds it - the 13 fields of WebAuthn's CredentialRecord, in their base64 text - so a passkey registered by web-auth/webauthn-symfony-bundle signs in here and one registered here is read by the bundle. A software authenticator (P-256) signs real assertions in tests. Formerly omniauth/passkey, which it replaces (the Omniauth\Passkey\ names stay aliases for one release).

Étage
Fournisseur · Omniauth
Version
1.x
Suite de conformité
non branchée
Vérifié en réel
Vérifié en partie
Licence
MIT
composer require omniauth/webauthn:1.x-dev

Passkeys (WebAuthn) as a first factor of omnisense/omniauth, under any PHP host, over web-auth/webauthn-lib 5: the request and creation options a page's script hands the browser, and the assertion and attestation the browser posts back, checked by webauthn-lib with nothing turned off. User verification is required at every ceremony, so no second factor follows a passkey (Marco's decision QA1). The records are those of web-auth/webauthn-symfony-bundle, both ways: a passkey the bundle registered signs in here, and one registered here is read by the bundle. It calls no service: the ceremonies happen between the server, the browser and the authenticator.

new PasskeyMethod($accounts, $passkeys, new RelyingParty(id: 'example.org'));

This package was omniauth/passkey until 2026-10-23 (renamed: it implements WebAuthn, passkeys are one use of it). It replaces omniauth/passkey, and the former namespace Omniauth\Passkey\ stays an alias of Omniauth\WebAuthn\ for one release (Legacy/, each with a deprecation notice). The factor keeps its name, passkey (Factor::Passkey).

Installation

composer require omnisense/omniauth omniauth/webauthn

Requires PHP 8.2 with ext-openssl, omnisense/omniauth (1.x), web-auth/webauthn-lib (^5.3), symfony/serializer and symfony/uid (6.4, 7 or 8: standalone components, webauthn-lib's own ground). No framework. Under omnibridge/symfony and omnibridge/laravel the passkey key turns it on (Hosts).

Options

new PasskeyMethod(...):

Argument Type Default
accounts Omniauth\Contract\Accounts required the handle (the credential's user id), identifier and display name
passkeys Contract\Passkeys required the passkeys, as PasskeyRecord values (omnibase: userPasskey)
rp RelyingParty new RelyingParty() who the passkeys belong to
timeout ?int null milliseconds the browser gives the person; null: the browser's own

new RelyingParty(...), read-only:

Argument Type Default
id ?string null: each request's host, lower case, without its port the registrable domain the passkeys belong to (RELYING_PARTY_ID); a passkey made for it serves every host under it
name ?string null: the id what some authenticators show
origins list<string> []: the request's own origin the origins a ceremony may come from (a site and its twin)
insecure list<string> ['localhost'] the ids allowed over plain HTTP (WebAuthn's own exception)

serves($context) is true when the request's host is the id or under it; a host it does not serve is refused before any challenge (refused, reason relying-party). Constants: PasskeyMethod::CHALLENGE_LENGTH (32 bytes), PasskeyMethod::ALGORITHMS ([-7, -257]: ES256, RS256).

Capabilities

a first factor, begin() then verify() (Method\Challenging) yes: the request options (no credential listed: a discoverable passkey, the person picks it), then the assertion
set up from the settings (Method\Enrolling) yes: the creation options (the account's passkeys excluded), then the attestation, with a label
turned off through revoke() (Method\Revocable) no: removing a passkey is the store's (the settings page's)
a second factor after it no (QA1): EnrolledSecondFactors::AFTER leaves passkey out
attestation formats none only, asked and accepted (what the bundle does without metadata)
algorithms ES256, RS256
user verification required at both ceremonies
the bundle's rows read and written yes (The records and the bundle)

serializer() gives the Symfony serializer webauthn-lib reads and writes its JSON with.

What a ceremony checks

webauthn-lib does the checking; PasskeyMethod asks it for everything and turns nothing off:

  • one challenge per ceremony (32 random bytes), kept in the attempt (never in the answer) and taken at the verification: an assertion posted twice finds nothing to check against (expired);
  • the origin among those allowed (the request's own unless RelyingParty::$origins names them), the relying party's id hashed in the authenticator data, the client data's type;
  • user verification required at the sign-in and at the registration;
  • the signature with the public key kept; the counter: one that goes back (a cloned authenticator) is refused; the backup flags, consistent, and saved after each sign-in with the counter (Passkeys::used());
  • at a registration: ES256 or RS256, a discoverable credential (residentKey: required), the account's passkeys excluded, a credential already known refused.

Hosts

omnibridge/symfony (omniauth.yaml) omnibridge/laravel (config/omniauth.php)
on passkey: ~ 'passkey' => [...]
the relying party passkey.rp_id, rp_name, origins the same keys
timeout passkey.timeout the same
the remember-me after a passkey passkey.remember (true; the posted JSON's remember wins) the same
the request options POST passkey.options_path (/login/passkey/options), route passkey_login_options the same
the assertion POST passkey.path (/login/passkey), route passkey_login the same
the store stores.passkeys (Omniauth\WebAuthn\Contract\Passkeys) stores.passkeys, or omnibase's Base\Auth\UserPasskeys

Both answer in the JSON web-auth/webauthn-symfony-bundle answered, so a page's script stays as it is: the options as {"status": "ok", "errorMessage": "", ...the options}, a sign-in as {"status": "ok", "errorMessage": ""} (with redirect when a second factor follows), a refused sign-in as {"status": "failed", "errorMessage": ...} with HTTP 401, options that could not be given with HTTP 400. Registering a passkey from the settings is the application's page (omnibase's), through $omniauth->enrol(Factor::Passkey, $account).

Errors

The failures and their reasons are listed in The method: expired, bad-credentials (unreadable-credential, unknown-passkey, invalid-assertion, invalid-attestation, known-passkey), refused (relying-party). webauthn-lib's own message is never shown. The exceptions:

Exception When
\UnexpectedValueException a PasskeyRecord whose credential id or public key is not base64 (a store's mistake)
\JsonException PasskeyRecord::fromRow() of a JSON column that is not JSON
\InvalidArgumentException, \LogicException the test doubles: a SoftwareAuthenticator key that is not P-256; InMemoryPasskeys::add() of a record for another account

Verified, and not

Run in omniauth's harness on 2026-10-25 (PHP 8.4, doctrine/dbal 4 and web-auth/webauthn-symfony-bundle 5.3 installed beside): omniauth/webauthn's 34 tests green. They cover a registration from the settings then a sign-in through the conductor, by the software authenticator, and no second factor after it (QA1); the options' JSON; every refusal (a forged signature, an unknown passkey, a challenge used twice, another challenge, another origin, another relying party, a host the relying party does not serve, a person not verified, a counter gone back, the same authenticator registered twice, an unreadable credential); the backup flags saved; one relying party for two hosts (QA9), and localhost over HTTP; both ways with the bundle (Tests/BundleCompatibilityTest: a row the bundle wrote signs in here, a row written here is read and verified by the bundle as it configures webauthn-lib); docs/installation.md run as written; in plain PHP (BareTest), nothing loaded but omniauth, webauthn-lib and what it requires.

Not verified: a real browser and a real authenticator (Chrome's virtual authenticator, at steps AU7 and AU8 of the omniauth plan); attestation formats other than none and metadata statements (not asked); a host's store and the bridges' answers around the JSON, tested in their own suites.

Proposer une modification docs/index.md

Installation

composer require omnisense/omniauth omniauth/webauthn

It requires omnisense/omniauth and web-auth/webauthn-lib (framework-free: its CBOR, COSE and PKI libraries, and the standalone Symfony components it is written on - serializer, property-info, uid, clock). No bundle, no framework: the passkeys stay in your table, behind Omniauth\WebAuthn\Contract\Passkeys, as PasskeyRecord values - the 13 fields of WebAuthn's credential record, as omnibase's userPasskey table holds them.

The whole thing by hand - the in-memory stores of Omniauth\Testing stand in for yours, and Omniauth\WebAuthn\Testing\SoftwareAuthenticator for the person's browser and device:

<?php

require 'vendor/autoload.php';

use Omniauth\ArrayState;
use Omniauth\Context;
use Omniauth\Factor;
use Omniauth\Method\Methods;
use Omniauth\Omniauth;
use Omniauth\WebAuthn\PasskeyMethod;
use Omniauth\WebAuthn\RelyingParty;
use Omniauth\WebAuthn\Testing\InMemoryPasskeys;
use Omniauth\WebAuthn\Testing\SoftwareAuthenticator;
use Omniauth\Testing\InMemoryStore;
use Omniauth\Testing\TestAccount;

$store = new InMemoryStore($ringo = new TestAccount('42', 'ringo', 'ringo@example.org', name: 'Ringo Starr'));
$passkeys = new InMemoryPasskeys($store);                  // yours: the userPasskey table

$session = [];                                             // $_SESSION, in a real page
$omniauth = new Omniauth(
    accounts: $store,
    methods: new Methods([
        new PasskeyMethod($store, $passkeys, new RelyingParty(id: 'example.org')),   // RELYING_PARTY_ID
    ]),
    state: new ArrayState($session),
);
$context = new Context(origin: 'https://example.org', host: 'example.org', ip: '203.0.113.7');
$browser = new SoftwareAuthenticator('https://example.org');

// From the account's settings: the creation options, then what the browser made of them.
$enrolment = $omniauth->enrol(Factor::Passkey, $ringo);
$options = $enrolment->begin($context);                    // JSON for PublicKeyCredential.parseCreationOptionsFromJSON()
$credential = $browser->create($options->payload);         // navigator.credentials.create(), then credential.toJSON()
echo $enrolment->confirm($context, ['json' => json_encode($credential), 'label' => 'MacBook'])->isDone() ? "registered\n" : "refused\n";
// registered

// Signing in: the request options, then the assertion. No account named: the person picks the passkey.
$options = $omniauth->begin('passkey', $context);          // JSON for PublicKeyCredential.parseRequestOptionsFromJSON()
$assertion = $browser->get($options->payload);             // navigator.credentials.get(), then credential.toJSON()
$result = $omniauth->verify('passkey', $context, ['json' => json_encode($assertion)]);
echo $result->status->value, ' as ', $result->account->identifier, ' by ', implode(' + ', array_map(fn ($f) => $f->value, $result->passed)), "\n";
// authenticated as ringo by passkey

What your page does around it is the host's part: it sends $options->payload as it is (the bridges add status: "ok" and errorMessage: "" around it, as web-auth/webauthn-symfony-bundle does, for assets/security/webauthn.js), posts the browser's JSON back, checks the CSRF token of the settings page, and on Authenticated renews its session id and keeps the account's handle.

No second factor follows a passkey (Marco's decision QA1): user verification is required at every ceremony - the device, and the gesture or the code that unlocks it.

Run in omniauth's harness on 2026-10-25 (the block as it is, Tests/DocsTest.php), it prints:

registered
authenticated as ringo by passkey

Next: the method, the records and the bundle.

Proposer une modification docs/installation.md

The method

new PasskeyMethod(
    accounts: $accounts,                 // Omniauth\Contract\Accounts
    passkeys: $passkeys,                 // Omniauth\WebAuthn\Contract\Passkeys
    rp: new RelyingParty(
        id: 'example.org',               // RELYING_PARTY_ID; null: each request's host
        name: 'Les Ringo Stars',         // what some authenticators show; null: the id
        origins: [],                     // the origins allowed; []: the request's own
        insecure: ['localhost'],         // the ids allowed over plain http
    ),
    timeout: null,                       // milliseconds the browser gives the person; null: its own
);

A first factor. No second factor follows it (QA1): user verification is required at every ceremony.

Signing in

Step Call Gives Keeps in the attempt
options $omniauth->begin('passkey', $context) Challenge::json(): challenge, rpId, allowCredentials: [], userVerification: "required" the challenge, the id
assertion $omniauth->verify('passkey', $context, ['json' => $posted]) Result (Authenticated, passed: [passkey]) nothing: the challenge is taken

The proof is what the browser's credential.toJSON() gives, under json - or its keys as the proof itself. The passkey is found by its id (Passkeys::byCredentialId(base64)), its account by its user handle (Accounts::byHandle()); after the check, Passkeys::used() receives the record with its counter and backup flags moved.

Registering, from the settings

Step Call Gives
options $omniauth->enrol(Factor::Passkey, $account)->begin($context) Challenge::json(): rp, user (id: the handle, name: the identifier, displayName), challenge, pubKeyCredParams (ES256, RS256), authenticatorSelection (verification and resident key required), attestation: "none", excludeCredentials (the account's passkeys)
attestation ->confirm($context, ['json' => $posted, 'label' => '...']) Enrolled: done, or failed with its reason; the record added by Passkeys::add($account, $record, $label)

Refusals

Failure Reason When
expired - no options begun in this attempt, or already used
bad-credentials unreadable-credential the JSON is not a credential of the expected kind
unknown-passkey no passkey of that id, or its account is gone
invalid-assertion webauthn-lib refused: challenge, origin, relying party, presence, verification, signature, counter, backup flags
invalid-attestation webauthn-lib refused the registration
known-passkey this credential is already registered
refused relying-party the request's host is not under the relying party's id

The conductor throttles both steps (passkey, passkey.begin) like every method.

What the host does

Send Challenge::$payload as it is (the bridges wrap it as web-auth/webauthn-symfony-bundle did, status: "ok", errorMessage: "", so assets/security/webauthn.js stays as it is); post the browser's JSON back; check the settings page's CSRF token before a registration; on Authenticated, renew the session id.

Proposer une modification docs/webauthn.md

The records and the bundle

A PasskeyRecord is a passkey as omnibase's userPasskey table holds it: the 13 fields of webauthn-lib's CredentialRecord, under the same names, in the values of the columns.

Field Column's text In the record
publicKeyCredentialId base64 (standard, padded) of the id's bytes the same base64; credentialId() gives the bytes
type public-key the same
transports JSON, ["internal"] a list
attestationType none the same
trustPath JSON, [] or {"x5c":[...]} the array webauthn-lib normalizes it to
aaguid a UUID's text the same
credentialPublicKey base64 of the COSE key the same base64; publicKey() gives the bytes
userHandle the account's id, as text the same
counter a number an int
otherUI JSON or null an array or null
backupEligible, backupStatus, uvInitialized 0/1 or null bools or null

That text is what the bundle's DBAL types (base64, aaguid, trust_path, json) write, and what omnibase's own (passkey_base64, passkey_aaguid, passkey_trust_path, step AU1) reproduce:

$record = PasskeyRecord::fromRow($row);          // a row as the database gives it back (other columns ignored)
$row = $record->toRow();                         // the 13 columns as the bundle writes them
$credential = $record->toCredentialRecord();     // webauthn-lib's object, as omnibase's PasskeyRepository::credentialRecord() makes it
$record = PasskeyRecord::fromCredentialRecord($credential);   // as PasskeyRepository::fill() copies it

An application on Doctrine (omnibase) maps its entity's fields instead: the entity holds the bytes (its passkey_base64 type decodes them), so its store gives base64_encode() of them.

Both ways

So that the switch from the bundle (lesringostars at step AU7, then every site) loses no passkey, Tests/BundleCompatibilityTest checks:

  1. registered by the bundle, signed in here: the row lesringostars' sign-in suite saw the bundle write when a passkey was added from the settings (AU1), recorded as it is; and live, the row the bundle's DBAL types write for the record webauthn-lib's attestation check makes - the same row;
  2. registered here, read by the bundle: the row PasskeyMethod made, read through the bundle's DBAL types into the CredentialRecord its repository hands its authenticator, then a sign-in checked by webauthn-lib as the bundle configures it; what the bundle saves back equals what omniauth writes after the same sign-in.

One difference stays visible: the bundle asked userVerification: "preferred" (its default), this method asks "required" at both ceremonies. A passkey registered by the bundle without verification (uvInitialized false) signs in here only if the authenticator verifies the person this time.

Proposer une modification docs/records.md