omniauth / passkey
Omniauth's passkeys over web-auth/webauthn-lib, under any PHP host: 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 rec
Requires
- php: >=8.2
- ext-openssl: *
- omniscient/omniauth: ^1.0@dev
- symfony/serializer: ^6.4|^7.0|^8.0
- symfony/uid: ^6.4|^7.0|^8.0
- web-auth/webauthn-lib: ^5.3
Requires (Dev)
- doctrine/dbal: ^4.0
- phpunit/phpunit: ^11.0
- web-auth/webauthn-symfony-bundle: ^5.3
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-20 22:54:51 UTC
README
Passkeys for omniscient/omniauth, under any PHP
host, over web-auth/webauthn-lib: the sign-in (request options, then the assertion checked) and
the registration from an account's settings (creation options, then the attestation checked), on
the credential record as omnibase's userPasskey table holds it - so a passkey registered by
web-auth/webauthn-symfony-bundle signs in here, and one registered here is read by the bundle.
use Omniauth\{ArrayState, Context, Factor, Omniauth}; use Omniauth\Method\Methods; use Omniauth\Passkey\{PasskeyMethod, RelyingParty}; $omniauth = new Omniauth( accounts: $accounts, // your accounts, behind Omniauth\Contract\Accounts methods: new Methods([ new PasskeyMethod($accounts, $passkeys, new RelyingParty(id: 'example.org')), // $passkeys: your table, behind Contract\Passkeys ]), state: new ArrayState($_SESSION), ); $context = new Context(origin: 'https://example.org', host: 'example.org', ip: $_SERVER['REMOTE_ADDR']); $options = $omniauth->begin('passkey', $context); // $options->payload: the JSON for parseRequestOptionsFromJSON() // ... the browser signs: navigator.credentials.get(), then credential.toJSON(), posted back $result = $omniauth->verify('passkey', $context, ['json' => $posted]); // Authenticated, $result->passed === [Factor::Passkey]: no second factor after a passkey $enrolment = $omniauth->enrol(Factor::Passkey, $account); // from the settings $options = $enrolment->begin($context); // the JSON for parseCreationOptionsFromJSON() $enrolment->confirm($context, ['json' => $posted, 'label' => 'MacBook Touch ID']);
It requires omniscient/omniauth and web-auth/webauthn-lib ^5.3 - framework-free, with its CBOR, COSE and PKI libraries and the standalone Symfony components it is written on (serializer, property-info, property-access, string, uid, clock). No bundle, no kernel, no container.
The shape
PasskeyMethod |
a first factor (Challenging, Enrolling): begin()/verify() sign in, enrolBegin()/enrolConfirm() register |
PasskeyRecord |
a passkey as the table holds it: the 13 fields of WebAuthn's CredentialRecord, same names, the credential id and the public key in base64; fromRow()/toRow() for the table's text, toCredentialRecord()/fromCredentialRecord() for webauthn-lib, after() once a sign-in moved its counter and flags |
Contract\Passkeys |
the store: of($account), byCredentialId($base64), add($account, $record, $label), used($record) (omnibase fills it on userPasskey, step AU4) |
RelyingParty |
the relying party's id (a registrable domain), its name, the origins allowed, the ids allowed over plain http (localhost) |
Testing\SoftwareAuthenticator |
a P-256 authenticator in PHP that answers the options with the browser's JSON: tests sign in with no browser |
Testing\InMemoryPasskeys |
the store in memory |
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::$originsnames 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 device and the
gesture or the code that unlocks it; that is why no second factor follows a passkey (Marco's
decision QA1:
EnrolledSecondFactors::AFTERleaves it out); - 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: attestation
"none"asked and accepted (what the bundle does without metadata), ES256 or RS256, a discoverable credential (residentKey: required), the account's passkeys excluded, a credential already known refused.
A refusal says bad-credentials with a stable reason (unknown-passkey, invalid-assertion,
invalid-attestation, known-passkey, unreadable-credential); webauthn-lib's own message is for
the server's log, not the page.
One relying party for two hosts (QA9)
A passkey belongs to a registrable domain. Marco's decision: the Symfony site and its Laravel twin
share one in production - new RelyingParty(id: 'example.org', origins: ['https://www.example.org', 'https://twin.example.org']), and a passkey registered on one host signs in on the other. With no
id, each request's host is the id (the port never enters it: localhost serves
https://localhost:8443 and http://localhost:8132 alike).
Compatible both ways with web-auth/webauthn-symfony-bundle
The bundle writes userPasskey through its DBAL types (base64, aaguid, trust_path, json),
whose text omnibase's own types reproduce since AU1. PasskeyRecord keeps that text: fromRow()
reads a row the bundle wrote, toRow() writes the row the bundle reads. Tests/BundleCompatibilityTest
proves it both ways - see "Verified".
Documentation
Verified, and not
- Verified (omniauth's harness,
docker compose run --rm omniauth testin the core'sdocker/, PHP 8.4; every file linted on PHP 8.2):- a registration from the settings then a sign-in, through the conductor, by the software
authenticator; no second factor after it for an account a password asks one of (QA1); the
options' JSON (challenge,
rpId,allowCredentials: [],userVerification: required; the creation options' relying party, user - the handle as its id, as omnibase'sWebauthnUserEntityRepositorygives it -, algorithms, selection, exclusions); - 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 (sign-in and registration), a counter gone back, the same authenticator
registered twice, an unreadable credential; the backup flags saved; one relying party for two
hosts (QA9), and
localhostover http; - a passkey the bundle registered signs in here: the row lesringostars' sign-in suite saw
the bundle write at a registration (AU1), recorded as it is, read by
fromRow()and signed in with, its counter written back; and live, the row the bundle's DBAL types (5.3.9, doctrine/dbal 4, MySQL's platform) write for the record webauthn-lib's attestation check makes, as the bundle's creation controller does - the same row, and the same asfromCredentialRecord()->toRow(); - a passkey registered here is read and verified by the bundle: its row through the bundle's
DBAL types is the
CredentialRecordits repository hands its authenticator, webauthn-lib's assertion check configured as the bundle configures it (noallowed_origins, profile "default") signs in with it, and what the bundle would save back is the row omniauth writes after the same sign-in; a certificate trust path written and read as the bundle does; docs/installation.mdrun as written; in plain PHP (BareTest), a registration and a sign-in by hand loading nothing but omniauth, webauthn-lib and the libraries it requires - no class of a framework, of the bundle, of Doctrine or of an application, though the harness has them.
- a registration from the settings then a sign-in, through the conductor, by the software
authenticator; no second factor after it for an account a password asks one of (QA1); the
options' JSON (challenge,
- Not verified: a real browser and a real authenticator (Chrome's virtual authenticator, at AU7 and
AU8); attestation formats other than
"none"and metadata statements (not asked, as today); a host's store (Base\Auth\UserPasskeys, AU4) and the bridges' answers around the JSON (AU6, AU8); a row written by the bundle in a running site after AU1 read by this package in the same database (the text is compared, not the database).
Licence
MIT.