Omniauth decides who signs in, and by which factors, under any PHP host. A sign-in is a ceremony
of one or two steps - begin() (send a link or a code, give a passkey's options, send the visitor
to an identity provider), then verify() (check what came back) - and every way in goes through
the same conductor, Omniauth\Omniauth, which runs the same sequence whatever the method: the
throttle, an event a listener may refuse on, the method, the account's own check, the second-factor
rule, the attempt kept between two requests or cleared. It answers with a Result: who, by which
factors, what remains to pass, and what the host does next (open its session, set its remember-me,
set the trusted-device cookie).
$omniauth->verify('password', $context, ['identifier' => 'ringo@example.org', 'password' => '...']);
// Result: SecondFactorRequired, offered [email-code, backup-code]
$omniauth->begin('email-code', $context); // the code goes out through the host's Sender
$omniauth->verify('email-code', $context, ['code' => '481516', 'trust' => true]);
// Result: Authenticated, passed [password, email-code], the trusted_device cookie's value
The host keeps what is its own: the firewall or the guard, the request and the response, the
session cookie and its renewal at each sign-in, signing out, the remember-me, the pages and their
forms. The application keeps its accounts and their proofs in its own tables, behind small store
contracts (The contracts): omniauth has no table, no ORM and no framework. The
core requires PHP 8.2, four PSR interfaces (cache, simple-cache, clock, event-dispatcher) and
symfony/password-hasher, a component that stands alone. The Symfony and Laravel bridges live in
omnibridge (omnibridge/symfony, omnibridge/laravel), where omniauth is only suggested
(Symfony, Laravel).
The name: OmniAuth is also Ruby's well-known sign-in library. This one is PHP, published under
omnisense/, and unrelated.
When to use it
- A site that signs people in by more than a password - a sign-in link, a code by e-mail, an authenticator application, backup codes, passkeys, Google - and wants one rule for the second factor whatever the first one was.
- A site served by two hosts over one database (a Symfony site and its Laravel twin): the same hashes, the same trusted-device cookie (scheb/2fa's format), the same TOTP secrets and the same route names under both.
- Moving off scheb/2fa, web-auth/webauthn-symfony-bundle or hwi/oauth-bundle without losing the browsers already trusted, the passkeys already registered or the secrets already kept: the formats are theirs, byte for byte where it matters.
It is not an identity server (it issues no tokens to other applications), not a user manager (it
creates no account: an unknown identity comes back as IdentityUnknown for the host's sign-up
page), and not an API authentication layer (API tokens, impersonation and switching user stay the
host's).
Methods
One method per factor; a host mounts the ones it serves (Method\Methods). "begin" is a first
step before the verification (Method\Challenging); "set up" a ceremony from the account's
settings (Method\Enrolling, $omniauth->enrol()); "turn off" $omniauth->revoke()
(Method\Revocable). Read from each class's interfaces.
Factor (Factor::) |
Class | Package | Role | begin | Set up | Turn off |
|---|---|---|---|---|---|---|
password (Password) |
Password\PasswordMethod |
omnisense/omniauth |
first | - | yes: a new password | - |
link (Link) |
Link\LinkMethod |
omnisense/omniauth |
first | yes: sends the link | - | - |
oauth (OAuth) |
OAuth\OAuthMethod |
omnisense/omniauth |
first | yes: the provider's address | yes: links a provider | - (the store's Identities::unlink()) |
passkey (Passkey) |
Omniauth\WebAuthn\PasskeyMethod |
omniauth/webauthn |
first | yes: the request options | yes: registers a passkey | - |
email-code (EmailCode) |
EmailCode\EmailCodeMethod |
omnisense/omniauth |
second | yes: sends the code | yes | yes |
totp (Totp) |
Omniauth\Otp\TotpMethod |
omniauth/otp |
second | - | yes: a secret and its QR address | yes |
backup-code (BackupCode) |
BackupCode\BackupCodeMethod |
omnisense/omniauth |
second | - | yes: draws a new set | yes |
trusted-device (TrustedDevice) |
TrustedDevice\TrustedDevices |
omnisense/omniauth |
second, read from the cookie (Method\Ambient) |
- | - (given after a second factor with trust) |
yes: every browser forgotten |
A first factor finds the account; a second one is asked of an account already found. The default rule (The second-factor rule) asks a second factor after a password, a link or an identity provider, never after a passkey.
Identity providers
The sign-in through identity providers (OAuth 2, OpenID Connect) is the core's own, under
Omniauth\OAuth\: the state, PKCE and the nonce, the linking by the provider's subject, the
sign-up of an unknown identity (Identity providers). It requires no OAuth library;
each provider is a package of its own.
| Package | Provider | Class, factory | What it checks |
|---|---|---|---|
omniauth/google |
Google (OpenID Connect, over league/oauth2-google) | Omniauth\Google\GoogleProvider, GoogleFactory (entries named google or of type: google) |
scopes openid email profile, the nonce, the ID token's issuer, audience, authorized party, lifetime, nonce and Workspace domain (hd); email_verified; the userinfo when the ID token has no address |
omnisense/omniauth |
any league/oauth2-client provider, by hand | Omniauth\OAuth\Provider\LeagueProvider |
the state and the S256 challenge in the address, the code exchanged with the verifier, the subject, address and name read from the resource owner's details |
omnisense/omniauth |
none: an authorization server in memory, for tests | Omniauth\OAuth\Testing\FakeProvider |
a code once, for its client and return address, with the verifier of its challenge and the nonce |
| omniauth/oauth | - | - | abandoned since 2026-10-23: its last release holds no code and requires omnisense/omniauth, whose Omniauth\OAuth\ has the same namespace and class names |
Decisions it carries
Marco's decisions of 2026-10-20 (QA1 to QA9 of the omniauth plan), which the code holds:
- QA1, no second factor after a passkey and QA2, a second factor after an identity
provider:
EnrolledSecondFactors::AFTERis[Password, Link, OAuth]. - QA3, the linking rule: an identity is found by the provider's subject first; an address the
provider vouches for links an account only when it has no second factor (
NoSecondFactorLinking). - QA4, the sign-in link: 15 minutes, once, its SHA-256 kept and never its value; a new link at each request.
- QA5, the secrets kept hashed or sealed: the e-mailed code as an HMAC keyed by the host's secret and bound to the account; the TOTP secrets sealed with sodium (omniauth/otp).
- QA8, the bridges in omnibridge, omniauth only suggested there; QA9, one relying party for the passkeys of a site and its twin (omniauth/webauthn).
How this manual is organised
- Installation: requirements, a whole sign-in in plain PHP, then the hosts.
- Symfony and Laravel, Symfony (
omniauth.yaml, the authenticator, the routes) and Laravel (config/omniauth.php, the guard, the routes). - Concepts: the conductor, the attempt, the result and every value it carries; The contracts: the accounts, the stores of each proof, the host's duties.
- The methods: password, sign-in link, code by e-mail, backup codes, trusted devices, identity providers; the second-factor rule; events.
- Configuration reference: every constructor's options, the throttle's limits.
- Errors: what is a failure in the result, and what is an exception.
- Testing, the Docker harness, writing a method or a provider, Verified, and not.
Propose a change docs/index.md
Installation
composer require omnisense/omniauth # password, link, code by e-mail, backup codes, trusted devices, identity providers
composer require omniauth/otp omniauth/webauthn omniauth/google # an authenticator application, passkeys, Google: one package each
Requirements
- PHP 8.2 or later.
omnisense/omniauthrequirespsr/cache(^2.0 or ^3.0),psr/simple-cache(^2.0 or ^3.0),psr/clock(^1.0),psr/event-dispatcher(^1.0) andsymfony/password-hasher(^6.4, ^7.0 or ^8.0), a component that loads no framework. Nothing else: no ORM, no HTTP client, no OAuth library.- It suggests
league/oauth2-client(Omniauth\OAuth\Provider\LeagueProvider: the provider packages require it),symfony/cache(a PSR-6 pool or PSR-16 cache forCacheThrottlein plain PHP),phpunit/phpunit(the providers' conformance suite) andomnisense/omnibridge(the hosts' contracts, among themOmnibridge\Contract\Tokensfor a form's CSRF token). omniauth/otpaddsext-sodium, spomky-labs/otphp and paragonie/constant_time_encoding;omniauth/webauthnaddsext-openssl, web-auth/webauthn-lib, symfony/serializer and symfony/uid;omniauth/googleadds league/oauth2-client and league/oauth2-google (Guzzle through them).
Plain PHP
No framework, no ORM: the accounts and their proofs stay in your tables, behind small store
contracts (The contracts). The whole thing by hand - the in-memory stores of
Omniauth\Testing stand in for yours:
<?php
require 'vendor/autoload.php';
use Omniauth\ArrayState;
use Omniauth\BackupCode\BackupCodeMethod;
use Omniauth\CacheThrottle;
use Omniauth\Context;
use Omniauth\EmailCode\EmailCodeMethod;
use Omniauth\Link\LinkMethod;
use Omniauth\Method\Methods;
use Omniauth\Omniauth;
use Omniauth\Password\PasswordMethod;
use Omniauth\Testing\ArrayCache;
use Omniauth\Testing\InMemoryStore;
use Omniauth\Testing\RecordingSender;
use Omniauth\Testing\TestAccount;
use Omniauth\TrustedDevice\TrustedDevices;
use Symfony\Component\PasswordHasher\Hasher\NativePasswordHasher;
$secret = 'the-application-secret-32-bytes-at-least'; // Symfony's kernel.secret, Laravel's APP_KEY...
$hasher = new NativePasswordHasher(); // bcrypt, cost 13: Symfony's "auto"
// Your stores: here one account with a password and the code by e-mail.
$store = new InMemoryStore(new TestAccount('42', 'ringo', 'ringo@example.org', $hasher->hash('yellow-submarine'), emailCodeEnabled: true));
$sender = new RecordingSender(); // yours sends an e-mail
$session = []; // $_SESSION, in a real page: ArrayState::session()
$omniauth = new Omniauth(
accounts: $store,
methods: new Methods([
new PasswordMethod($store, $store, $hasher),
new LinkMethod($store, $store, $sender),
new EmailCodeMethod($store, $store->emailCodes(), $sender, $secret),
new BackupCodeMethod($store),
new TrustedDevices($store, $store, $secret),
]),
state: new ArrayState($session),
throttle: new CacheThrottle(new ArrayCache()), // any PSR-6 pool or PSR-16 cache
);
// What your page knows of the request.
$context = new Context(origin: 'https://example.org', host: 'example.org', ip: '203.0.113.7', trustedDevice: $_COOKIE['trusted_device'] ?? null);
$result = $omniauth->verify('password', $context, ['identifier' => 'ringo@example.org', 'password' => 'yellow-submarine']);
echo $result->status->value, ': ', implode(', ', array_map(fn ($f) => $f->value, $result->offered)), "\n";
// second-factor-required: email-code
$omniauth->begin('email-code', $context); // the code goes out
$code = $sender->last()->secret; // what the person reads in the mail
$result = $omniauth->verify('email-code', $context, ['code' => $code, 'trust' => true]);
echo $result->status->value, ' as ', $result->account->identifier, ' by ', implode(' + ', array_map(fn ($f) => $f->value, $result->passed)), "\n";
// authenticated as ringo by password + email-code
echo $result->trustDevice ? "trusted_device cookie to set\n" : '';
// setcookie('trusted_device', $result->trustDevice, ...): next time, no code
What your page does with the result is the host's part: Authenticated - renew the session id
(session_regenerate_id(true)) and keep the account's handle in the session; set a
remember-me when $result->remember; set the trusted_device cookie when $result->trustDevice
is given. SecondFactorRequired - show the second-factor page with $result->offered. Failed -
translate $result->failure (a stable key: bad-credentials, throttled, account-refused...).
Check your form's CSRF token before calling (Omnibridge\Contract\Tokens under a bridged host).
docker/harness/bin/bare is a whole script of the same, every method in turn; Tests/DocsTest
runs the block above as it is written here.
Run in the harness on 2026-10-25 (PHP 8.4), the script prints:
second-factor-required: email-code
authenticated as ringo by password + email-code
trusted_device cookie to set
The pieces:
- the methods, one per factor, each over the stores it reads (
PasswordMethodoverPasswords,EmailCodeMethodoverEmailCodesand aSender...), mounted in aMethod\Methods; - the conductor,
Omniauth, over the accounts, the methods, the visitor'sState(here an array; the session under a framework) and optionally aThrottle, a PSR-14 dispatcher, a PSR-20 clock and a second-factor rule (by defaultEnrolledSecondFactors); - a
Contextper request (origin, host, address, browser, thetrusted_devicecookie, the firewall's name) and the proof the visitor sent, as an array.
The same script with Google in place of the password is the identity providers page's; with an authenticator application, omniauth/otp's installation page; with a passkey, omniauth/webauthn's.
In a framework
- Symfony: omnibridge/symfony's
OmnibridgeBundleregisters omniauth's extension where omniauth is installed;config/packages/omniauth.yamlturns each method on, and one authenticator,Omnibridge\Symfony\Auth\OmniauthAuthenticator, signs in by all of them: Symfony. - Laravel: omnibridge/laravel's
Auth\AuthServiceProvider, on withconfig/omnibridge.php's'auth' => true;config/omniauth.phptakes the same keys asomniauth.yaml, and the guard driveromniauthsigns in: Laravel. - Any other host: build the conductor once per request (or once per worker, with a
Statethat reads the current request's session), as the script above does, and answer itsResultas described above.
Propose a change docs/installation.md
Symfony and Laravel
omniauth is the same under every host; the bridges live in omnibridge (Marco's decision QA8), where omniauth is only suggested:
- omnibridge/symfony (
Omnibridge\Symfony\Auth\, step AU6): one authenticator per firewall overOmniauth::verify(), the "second factor in progress" token answeringIS_AUTHENTICATED_2FA_IN_PROGRESS(scheb/2fa's name, soaccess_controldoes not change), the ceremonies' routes at the paths and under the names scheb/2fa, web-auth/webauthn-symfony-bundle and hwi/oauth-bundle had, the session asState, omniauth'sCacheThrottleon a cache pool asThrottle, the hashersecurity.yamlsets for the user class: Symfony. - omnibridge/laravel (
Omnibridge\Laravel\Auth\, step AU8): a guard and a user provider (driveromniauth) over the same conductor, the same addresses and route names, the session asState,CacheThrottleon a Laravel cache store, the gate answering Security's attributes (ROLE_*,IS_AUTHENTICATED_FULLY...): Laravel.
Both read the same keys - config/packages/omniauth.yaml under Symfony, config/omniauth.php
under Laravel - so a site and its twin on one database say the same thing once each. What two
hosts of one database must share for a visitor to cross from one to the other:
| What | Why | Symfony | Laravel |
|---|---|---|---|
| the secret | it keys the e-mailed codes' hashes and signs the trusted devices' tokens | omniauth.secret (default %kernel.secret%) |
omniauth.secret (default: app.key, decoded) |
| the TOTP key | the secrets sealed at rest open under it only | totp.secret_key |
totp.secret_key |
| the trusted devices' cookie and firewall name | the fwl claim of scheb/2fa's tokens |
trusted_device.cookie, trusted_device.firewall (default: the request's firewall) |
trusted_device.cookie, trusted_device.firewall (default main) |
| the password hasher | the same hashes verify, and are not rehashed at each sign-in | password.user_class: the hasher security.yaml sets for it |
password.hasher: security.yaml's keys |
| the relying party's id | a passkey serves the hosts under its id only | passkey.rp_id |
passkey.rp_id |
| the throttle's pool | both count the same tries, and the TOTP codes already used | throttle.pool |
throttle.pool (a PSR-6 binding) |
A host that is neither mounts omniauth by hand, as the installation shows: the
conductor over its session, its answers to the Result.
Propose a change docs/hosts.md
Symfony
omniauth runs without a framework (Installation); in a Symfony application,
omnibridge/symfony (Omnibridge\Symfony\Auth\) does the wiring: the conductor and its methods from
config/packages/omniauth.yaml, one authenticator for every way in, the second factor's token,
voter, trust resolver and gate, the ceremonies' routes and their controller. This page describes
omnibridge/symfony's Auth\ namespace at its 1.x branch.
Registering
composer require omnisense/omniauth omnibridge/symfony
Omnibridge\Symfony\OmnibridgeBundle (omnibridge/symfony's bundle) registers omniauth's container
extension, Omnibridge\Symfony\Auth\DependencyInjection\OmniauthExtension (alias omniauth), when
the class Omniauth\Omniauth exists; an application's own bundle can do the same with
OmniauthExtension::register($container) (omnibase's BaseBundle does). Without an
omniauth.yaml, or with one that holds no key, the extension loads nothing: no service, no route.
# config/packages/security.yaml
security:
firewalls:
main:
custom_authenticators: [Omnibridge\Symfony\Auth\OmniauthAuthenticator]
entry_point: Omnibridge\Symfony\Auth\OmniauthAuthenticator # optional: start() sends to login_route
remember_me: { secret: '%kernel.secret%' } # Security's own, used when omniauth says "remember"
logout: { path: security_logout }
access_control:
- { path: ^/login/2fa, roles: IS_AUTHENTICATED_2FA_IN_PROGRESS }
# config/routes/omniauth.yaml
omniauth: { resource: ., type: omniauth }
# config/packages/omniauth.yaml - each key turns its method on
omniauth:
password: { routes: [security_login] }
link: { route: security_loginWithToken } # 15 minutes, once, hashed
email_code: ~
backup_codes: ~
trusted_device: ~ # scheb/2fa's cookie and tokens
totp: { secret_key: '%env(OMNIAUTH_TOTP_KEY)%' } # omniauth/otp; the secrets sealed at rest
passkey: { rp_id: '%env(RELYING_PARTY_ID)%' } # omniauth/webauthn
oauth:
providers:
google: { client_id: '%env(GOOGLE_CLIENT_ID)%', client_secret: '%env(GOOGLE_CLIENT_SECRET)%' } # omniauth/google
A method whose package is missing cannot be turned on: totp without omniauth/otp, passkey
without omniauth/webauthn, oauth without Omniauth\OAuth\OAuthMethod stop the container's
compilation with a LogicException naming the package to require
(omniauth.yaml turns "totp" on, which needs omniauth/otp: composer require omniauth/otp.).
The configuration tree
password and throttle are on unless set to false (canBeDisabled); link, email_code,
backup_codes, trusted_device, totp, passkey and oauth are off unless their key is present
(canBeEnabled: ~ turns one on with its defaults). The defaults below were read by resolving the
tree with Symfony's Config Processor in omnibridge/symfony's image on 2026-10-25.
| Key | Type | Default | |
|---|---|---|---|
secret |
string | %kernel.secret% |
keys the e-mailed codes' hashes and signs the trusted devices' tokens (scheb/2fa signed them with kernel.secret) |
login_route |
string | security_login |
the sign-in page: where a refused or abandoned attempt goes back to, and the entry point's address |
stores.accounts |
service id | Omniauth\Contract\Accounts |
the application's accounts |
stores.passwords |
service id | Omniauth\Contract\Passwords |
|
stores.link_tokens |
service id | Omniauth\Contract\LinkTokens |
|
stores.email_codes |
service id | Omniauth\Contract\EmailCodes |
|
stores.backup_codes |
service id | Omniauth\Contract\BackupCodes |
|
stores.trusted_version |
service id | Omniauth\Contract\TrustedVersion |
|
stores.totp_secrets |
service id | Omniauth\Contract\TotpSecrets |
decorated by the sealed store when totp.secret_key is set |
stores.identities |
service id | Omniauth\Contract\Identities |
|
stores.passkeys |
service id | Omniauth\WebAuthn\Contract\Passkeys |
|
stores.sender |
service id | Omniauth\Contract\Sender |
sends the links and the codes |
password.enabled |
bool | true |
|
password.routes |
list of route names | [security_login] |
a POST to one of them is a password sign-in |
password.form |
string or null | null |
the form's name, its fields read under it; null: at the top of the request |
password.identifier |
string | _username |
the field of what the person types |
password.password |
string | _password |
|
password.remember |
string | _remember_me |
the "remember me" box |
password.target_path_parameter |
string | _target_path |
a posted (or query) address of this site to go to once signed in |
password.default_target_path |
string | / |
where to go otherwise (after the firewall's saved target path) |
password.user_class |
class or null | null |
the user class whose hasher security.yaml's password_hashers sets; null: NativePasswordHasher (bcrypt, cost 13) |
password.csrf.enabled |
bool | true |
the form's token checked through Omnibridge\Contract\Tokens (when the container has that service) |
password.csrf.field, password.csrf.intent |
string | _csrf_token, authenticate |
|
link.lifetime |
int, at least 60 | 900 |
seconds a link is good for |
link.route |
route name or null | null |
the link's page: a GET of it with a non-empty parameter signs in; null: the link is mounted but no request signs in by it |
link.parameter |
string | token |
the route's parameter that holds the token |
email_code.digits |
int, 4 to 9 | 6 |
|
email_code.lifetime |
int, at least 60 | 600 |
seconds |
backup_codes.count |
int, at least 1 | 8 |
codes drawn at each set-up |
trusted_device.lifetime |
int, at least 60 | 2592000 |
seconds (30 days): the token's exp and the cookie's expiry |
trusted_device.cookie |
string | trusted_device |
the cookie's name |
trusted_device.firewall |
string or null | null |
the tokens' fwl claim; null: the name of the request's firewall (main when none) |
totp.leeway |
int, at least 0 | 0 |
seconds before and after a period (scheb/2fa's leeway) |
totp.period |
int, at least 1 | 30 |
|
totp.digits |
int, 6 to 10 | 6 |
|
totp.issuer, totp.server_name |
string or null | null |
the QR address's issuer and label suffix (scheb's issuer, server_name) |
totp.secret_key |
string or null | null |
base64 of 32 bytes (from the vault or the environment, never in the repository): the secrets sealed at rest; null: kept as the store has them |
passkey.rp_id |
string or null | null |
the relying party's id; null: each request's host |
passkey.rp_name |
string or null | null |
null: the id |
passkey.origins |
list of strings | [] |
the origins allowed; empty: the request's own |
passkey.timeout |
int or null | null |
milliseconds the browser gives the person |
passkey.remember |
bool | true |
Security's remember-me after a passkey (the posted JSON's remember wins) |
passkey.options_path, passkey.options_route |
string | /login/passkey/options, passkey_login_options |
the request options (POST) |
passkey.path, passkey.route |
string | /login/passkey, passkey_login |
the assertion (POST) |
oauth.link_by_email |
bool, "true", "false", service id or null |
null |
link an account by an address its provider vouches for: true, false, or a service (Omniauth\OAuth\Contract\EmailLinking); null: NoSecondFactorLinking |
oauth.sign_up_route |
route name or null | null |
where an unknown identity goes (its PendingIdentity in the session); null: the sign-in page |
oauth.providers.<name>.client_id |
string | '' |
an entry without one is not served |
oauth.providers.<name>.client_secret |
string | '' |
|
oauth.providers.<name>.redirect_uri |
string or null | null |
an absolute address or a path; null: the path of check_route |
oauth.providers.<name>.scopes |
list of strings | [] |
omniauth/google's default when empty: openid email profile |
oauth.providers.<name>.hosted_domain |
string or null | null |
Google Workspace domain, or * for any |
oauth.providers.<name>.type |
string or null | null |
the kind a factory makes; null: the entry's name |
oauth.providers.<name>.service |
service id or null | null |
a service that is the provider (Omniauth\OAuth\Provider\Provider), in place of the keys above |
oauth.providers.<name>.start_path, .start_route |
string or null | /connect/<name>, oauth_<name> |
the trip out (GET) |
oauth.providers.<name>.check_path, .check_route |
string or null | /connect/<name>/check, oauth_<name>_check |
the return (GET) |
oauth.providers.<name>.<any other key> |
any | - | kept and handed to the factories as it is (a factory of the application's reads its own keys) |
second_factor.after |
list of factor names | [password, link, oauth] |
the first factors a second one follows (EnrolledSecondFactors) |
second_factor.form_path, .form_route |
string | /login/2fa, 2fa_login |
the second factor's page (GET) |
second_factor.check_path, .check_route |
string | /login/2fa/check, 2fa_login_check |
where its form posts |
second_factor.template |
string | security/login_2fa.html.twig |
the page's Twig template |
second_factor.code, .trusted |
string | _auth_code, _trusted |
the form's fields: the code, "trust this browser" |
second_factor.csrf.enabled, .field, .intent |
bool, string, string | true, _csrf_token, two_factor |
|
throttle.enabled |
bool | true |
false: no throttle |
throttle.pool |
service id | cache.app |
a PSR-6 pool: the tries counted, and the TOTP steps used |
throttle.limits.<scope> |
[tries, seconds] |
[]: CacheThrottle::LIMITS |
replaces a scope's limit (Configuration) |
messages.<failure> or messages.<failure>:<reason> |
string | [] |
a page's message for a failure, %reason% replaced (below) |
Services
| Service id | Class | |
|---|---|---|
Omniauth\Omniauth |
Omniauth |
the conductor; public |
omniauth.methods (alias Omniauth\Method\Methods) |
Methods |
every service tagged omniauth.method |
omniauth.method.password, .link, .email_code, .backup_codes, .trusted_device, .totp, .passkey, .oauth |
the methods | one per key turned on, tagged omniauth.method; the second factors (email_code, backup_codes, totp) also omniauth.second_factor |
omniauth.second_factors |
Methods |
the second factors alone, for the rule and the address linking (no cycle through the first factors) |
omniauth.policy.default (alias Omniauth\Contract\SecondFactorPolicy) |
EnrolledSecondFactors |
the rule; an application decorates the alias with its own |
Omnibridge\Symfony\Auth\SessionState (alias Omniauth\Contract\State) |
SessionState |
the attempt in the current request's session; nothing kept without one |
Omniauth\CacheThrottle (alias Omniauth\Contract\Throttle) |
CacheThrottle |
on throttle.pool, with throttle.limits; absent when throttle is off |
omniauth.oauth.providers |
Omniauth\OAuth\Provider\Providers |
made by Auth\OAuthProviders::create() from oauth.providers; public |
omniauth.oauth.factory.google |
Omniauth\Google\GoogleFactory |
when omniauth/google is installed; tagged omniauth.oauth_provider_factory |
omniauth.totp.box, omniauth.totp.sealed_secrets |
SodiumSecretBox (lazy), SealedTotpSecrets |
when totp.secret_key is set: the sealed store decorates stores.totp_secrets |
Omnibridge\Symfony\Auth\Contexts |
Contexts |
the request as omniauth's Context |
Omnibridge\Symfony\Auth\OmniauthAuthenticator |
the authenticator a firewall names | |
Omnibridge\Symfony\Auth\PendingVoter |
tagged security.voter |
|
Omnibridge\Symfony\Auth\PendingTrustResolver |
decorates security.authentication.trust_resolver |
|
Omnibridge\Symfony\Auth\SecondFactorGate |
tagged kernel.event_subscriber |
|
Omnibridge\Symfony\Auth\CeremonyController |
public, controller.service_arguments |
|
Omnibridge\Symfony\Auth\AuthRouteLoader |
tagged routing.loader, type omniauth |
The stores are the application's: each contract's service by its own name, or the id stores
names (omnibase binds its Base\Auth\ stores). The clock is the container's clock service when
it has one. omniauth's events go to Symfony's event_dispatcher: listen to
Omniauth\Event\SignedIn (and the others, Events) as to any event.
A class of the application that implements Omniauth\OAuth\Provider\ProviderFactory is
autoconfigured with the tag omniauth.oauth_provider_factory and makes the entries of its kind.
The bridge registers no console command and no Twig function.
The authenticator
OmniauthAuthenticator knows a sign-in by its route (the router runs before the firewall):
| Request | Factor | Proof given to verify() |
|---|---|---|
POST to one of password.routes |
password |
identifier, password, remember from the form's fields; the form's CSRF token checked first |
GET of link.route with a non-empty link.parameter |
link |
token |
POST to passkey.route |
passkey |
the body as json; remember from the JSON, else passkey.remember |
POST to second_factor.check_route |
the factor chosen on the page (else the first offered that is not a backup code); a backup code when the code is not the chosen factor's digits and backup codes are offered | code, trust; the CSRF token checked first |
a provider's check_route |
oauth |
provider, and the query's code, state, error |
What it answers:
- Authenticated: a self-validating passport for the account (it must be a Symfony
UserInterface), Security'sRememberMeBadgeenabled whenResult::$remember, thetrusted_devicecookie when omniauth gave one (path/, HTTP only,SameSite=Lax, secure when the request is, fortrusted_device.lifetime); a redirect to the posted_target_pathwhen it is an address of this site, else the firewall's saved target path, elsedefault_target_path; a passkey gets{"status": "ok", "errorMessage": ""}. - SecondFactorRequired: a
PendingToken(below) and a redirect tosecond_factor.form_route; a passkey gets the JSON withredirect. - Failed and IdentityUnknown: a
SignInRefused(aCustomUserMessageAuthenticationExceptioncarrying the factor, omniauth's failure and reason, and the identity). The password goes back to its own route, the others tologin_route, the error in_security.last_error; a passkey gets{"status": "failed", "errorMessage": ...}with HTTP 401; a refused link empties the route's parameter and lets the page behind answer as for no link; a refused second factor goes back to its page with the error, or tologin_routewhen the attempt is gone (expired,no-attempt); an unknown identity is kept in the session (omnibridge.auth.pending_identity) and goes tooauth.sign_up_routewhen one is set.
Every passport carries an OmniauthBadge (the Result): a listener of Security's own events
(CheckPassportEvent, LoginSuccessEvent, LoginFailureEvent) that does what an omniauth
listener does skips a passport that carries it.
The session keys it uses: _security.last_username (the identifier typed),
_security.last_error, omnibridge.auth.factor (the second factor chosen),
omnibridge.auth.second_factor_error, omnibridge.auth.pending_identity (an array: provider,
subject, email, emailVerified, name), and omniauth's own omniauth.attempt and
omniauth.enrolment.<factor>.
Messages
A failure's message is looked up as <failure>:<reason>, then <failure>, in messages over
these defaults (Security's own message keys, which its translations already have):
| Failure | Default message |
|---|---|
bad-credentials, invalid-link, refused, not-enrolled |
Invalid credentials. |
bad-code, not-offered |
code_invalid |
expired, no-attempt |
Authentication request could not be processed due to a system problem. |
throttled |
Too many failed login attempts, please try again in %minutes% minute. (%minutes% is always 1) |
account-refused |
Account is disabled. |
csrf (the bridge's own) |
Invalid CSRF token. |
omniauth:
messages:
'account-refused:banned': 'security.banned'
'refused': 'security.refused.%reason%'
The routes
omniauth: { resource: ., type: omniauth } imports, under the names and at the addresses
configured:
| Name (default) | Path (default) | Methods | Controller |
|---|---|---|---|
2fa_login |
/login/2fa |
GET | CeremonyController::secondFactor |
2fa_login_check |
/login/2fa/check |
any | answered by the authenticator; a stray GET goes back to the page |
passkey_login_options |
/login/passkey/options |
POST | CeremonyController::passkeyOptions (with passkey) |
passkey_login |
/login/passkey |
POST | answered by the authenticator (with passkey) |
oauth_<name> |
/connect/<name> |
GET | CeremonyController::oauthStart: a redirect to the provider; 404 for a provider not served |
oauth_<name>_check |
/connect/<name>/check |
GET | answered by the authenticator |
The sign-in page itself, the link's page and the sign-out are the application's routes
(login_route, link.route, the firewall's logout).
The second factor's page renders second_factor.template (TwigBundle required) with the variables
scheb/2fa's FormController gave it, so a template written for scheb/2fa renders unchanged:
twoFactorProvider (totp, email for omniauth's email-code, backup-code),
availableTwoFactorProviders, authenticationError, authenticationErrorData,
displayTrustedOption (true when trusted devices are on), authCodeParameterName,
trustedParameterName, isCsrfProtectionEnabled, csrfParameterName, csrfTokenId,
checkPathRoute, checkPathUrl, logoutPath; ?preferProvider=email picks the factor shown. The
code by e-mail is sent when its page is first shown, and again once it has expired. The page is
answered with Cache-Control: no-store.
Between two factors
PendingToken: the token of a visitor whose first factor passed. It carries no role, is never "fully" signed in (PendingTrustResolverrefusesIS_AUTHENTICATED_FULLYto it) and lists the factors offered (offered()).PendingVotergrantsIS_AUTHENTICATED_2FA_IN_PROGRESSto that token alone: neither a visitor signed out nor one signed in opens the second factor's page.SecondFactorGatesends that visitor to the second factor's page from any other page, after the firewall (priority 7 onkernel.request) and when Security refused a page (kernel.exception, priority 2) - but the page and its check, a pageaccess_controlopens to everybody (PUBLIC_ACCESS) or to that token, and the sign-out.
Propose a change docs/symfony.md
Laravel
omnibridge/laravel (Omnibridge\Laravel\Auth\, step AU8) wires omniauth under Laravel as
omnibridge/symfony wires it under Symfony, from config/omniauth.php - the same keys as
omniauth.yaml, so a site and its Laravel twin say the same thing - plus what Laravel needs of its
own: a guard and a user provider, the gate's answers to Security's attributes, the sign-in pages,
the sign-out and a signed remember-me cookie. This page describes omnibridge/laravel's Auth\
namespace at its 1.x branch.
Turning it on
composer require omnisense/omniauth omnibridge/laravel
php artisan vendor:publish --tag=omniauth-config # config/omniauth.php, to edit
// config/omnibridge.php
'auth' => true,
// config/auth.php
'guards' => ['web' => ['driver' => 'omniauth', 'provider' => 'omnibase']],
'providers' => ['omnibase' => ['driver' => 'omniauth']],
OmnibridgeServiceProvider registers Auth\AuthServiceProvider when the class Omniauth\Omniauth
exists; the provider wires nothing at boot unless omnibridge.auth is on (true; false or
null is off, the default). The controllers read the default guard, which must be the
omniauth one (LogicException otherwise).
config/omniauth.php
Each method's key turns it on; null (or false) turns it off. The defaults are those the
provider fills in (Auth\Settings), the same as omniauth.yaml's where both have the key.
| Key | Default | |
|---|---|---|
secret |
null: app.key (a base64: key decoded) |
keys the e-mailed codes' hashes and signs the trusted devices' tokens: the Symfony site's kernel.secret, for its trusted browsers and codes to be good here |
login_route |
security_login |
where a refused or abandoned attempt goes back to |
stores |
'omnibase' |
'omnibase': omnibase's Base\Auth\ stores on the entity manager, each where the container does not bind its contract already; or a map of bindings by key (accounts, passwords, link_tokens, email_codes, backup_codes, trusted_version, totp_secrets, identities, passkeys, sender), each contract's own name for a key left out |
password |
on | null or false: off |
password.path |
/login |
the sign-in page: GET shows it, POST signs in |
password.form |
null |
the form's name, its fields read under it; null: at the top of the request |
password.identifier, .password, .remember |
_username, _password, _remember_me |
the form's fields |
password.target_path_parameter |
_target_path |
a posted (or query) address of this site to go to once signed in |
password.default_target_path |
/ |
where to go otherwise (after Laravel's intended URL) |
password.csrf |
['field' => '_csrf_token', 'intent' => 'authenticate'] |
checked through Omnibridge\Contract\Tokens; ['enabled' => false] turns it off |
password.template |
security/login.html.twig |
the page's template, rendered by Base\Screen\Negotiation |
password.allow_login_token |
true |
the form's allow_login_token variable: the page offers the sign-in link |
password.hasher |
null: NativePasswordHasher (bcrypt, cost 13) |
otherwise security.yaml's password_hashers keys (algorithm, cost, time_cost, memory_cost, migrate_from...), read by Symfony's PasswordHasherFactory |
link |
null (off) |
needs a Sender |
link.route, link.path |
security_loginWithToken, /login/token/{token?} |
GET with a token signs in; GET without one shows the page; POST sends a link |
link.parameter |
token |
|
link.lifetime |
900 |
seconds |
link.template |
security/login_token.html.twig |
|
link.form, link.email |
login_token, email |
the address form's name and field |
link.csrf |
['field' => '_csrf_token', 'intent' => 'login_token'] |
|
email_code |
null (off) |
['digits' => 6, 'lifetime' => 600]; needs a Sender |
backup_codes |
null (off) |
['count' => 8] |
totp |
null (off) |
['leeway' => 0, 'period' => 30, 'digits' => 6, 'issuer' => null, 'server_name' => null, 'secret_key' => null]; omniauth/otp |
totp.secret_key |
null |
env('OMNIAUTH_TOTP_KEY'), base64 of 32 bytes, the Symfony site's very key: the TotpSecrets binding wrapped in SealedTotpSecrets over a SodiumSecretBox |
trusted_device |
null (off) |
['lifetime' => 2592000, 'cookie' => 'trusted_device', 'firewall' => 'main'] |
passkey |
null (off) |
['rp_id' => null, 'rp_name' => null, 'origins' => [], 'timeout' => null, 'remember' => true, 'options_path' => '/login/passkey/options', 'options_route' => 'passkey_login_options', 'path' => '/login/passkey', 'route' => 'passkey_login']; omniauth/webauthn |
oauth |
null (off) |
['link_by_email' => null, 'sign_up_route' => null, 'providers' => [...]] |
oauth.providers.<name> |
client_id ('': not served), client_secret, redirect_uri (null: the path of check_path), scopes, hosted_domain, type, service (a binding that is the provider), start_route / start_path (oauth_<name>, /connect/<name>), check_route / check_path (oauth_<name>_check, /connect/<name>/check) |
|
oauth.link_by_email |
null: NoSecondFactorLinking |
true, false, or a binding (Omniauth\OAuth\Contract\EmailLinking) |
oauth.sign_up_route |
null |
where an unknown identity goes (its PendingIdentity in the session) |
second_factor.after |
null: [password, link, oauth] |
the first factors a second one follows |
second_factor.form_path, .form_route |
/login/2fa, 2fa_login |
|
second_factor.check_path, .check_route |
/login/2fa/check, 2fa_login_check |
|
second_factor.template |
security/login_2fa.html.twig |
|
second_factor.code, .trusted |
_auth_code, _trusted |
|
second_factor.csrf |
['field' => '_csrf_token', 'intent' => 'two_factor'] |
|
second_factor.open_routes |
[] |
routes a visitor between two factors may still open, besides the page, its check and the sign-out |
logout.path, .route |
/logout-request, security_logoutRequest |
the sign-out itself (GET or POST), the session's token required: 403 without it |
logout.page_path, .page_route |
/logout, security_logout |
the page: with the token, straight to the sign-out; without, asks to confirm |
logout.template |
security/logout.html.twig |
|
logout.csrf |
['field' => '_csrf_token', 'intent' => 'logout'] |
|
logout.target |
/ |
a path, or a route name |
remember_me |
['cookie' => 'REMEMBERME', 'lifetime' => 604800] |
null: no remember-me |
role_hierarchy |
[] |
security.yaml's role_hierarchy: a role and the roles it includes |
throttle |
['store' => null, 'pool' => null, 'limits' => []] |
false: no throttle. store: a Laravel cache store (null: the default one); pool: a PSR-6 pool's binding instead (the Symfony site's, so both hosts count the same tries); limits: ['password' => [5, 60]] per scope. The TOTP steps used are kept there too |
captcha |
['pool' => null, 'firewalls' => ['main']] |
with omnibase 4.x's stores: the captcha after failed sign-ins (below) |
mail |
['mailer' => null, 'from' => null, 'loopback' => false, 'test_recipients' => [], 'project_dir' => null] |
with omnibase 4.x: the sign-in e-mails (below) |
messages |
[] |
by failure or failure:reason, %reason% replaced: the same lookup and defaults as under Symfony |
routes |
true |
false: declare the routes yourself with Omnibridge\Laravel\Auth\Routes::register($router, $settings) |
Bindings
| Binding | What |
|---|---|
Omniauth\Omniauth |
the conductor (singleton) over the methods turned on |
Omniauth\Method\Methods, omniauth.second_factors |
the methods; the second factors alone |
Omniauth\Contract\SecondFactorPolicy |
EnrolledSecondFactors; omnibase's Base\Auth\Policy around it when stores is 'omnibase' and Base\Service\SecurityPolicy is bound |
Omniauth\Contract\State (Auth\SessionState) |
the request's session store (session.store); nothing kept without one |
Omniauth\Contract\Throttle |
CacheThrottle on throttle.store or throttle.pool |
Symfony\Component\PasswordHasher\PasswordHasherInterface |
the hasher of password.hasher |
Omniauth\OAuth\Provider\Providers |
from oauth.providers, through omniauth/google's GoogleFactory (when installed) and every binding tagged omniauth.oauth_provider_factory |
Auth\Settings, Auth\Contexts, Auth\RememberMe, Auth\Roles, Auth\Pages, Auth\OmniauthUserProvider |
the bridge's own |
omniauth.totp.sealed_secrets |
the sealed TOTP store, when totp.secret_key is set and TotpSecrets is an alias of another binding |
At boot the provider also: registers the guard driver and the user provider driver omniauth;
answers Security's attributes from the gate (Gate::before, Auth\Roles); leaves the
trusted_device and remember-me cookies out of EncryptCookies (the Symfony site writes and
reads them plain); appends Middleware\SecondFactorPending to the web group, before the auth
middleware; registers the routes; and, with omnibase's stores, its sign-in listeners.
Routes
In the web group, without Laravel's CSRF middleware (the forms' tokens are checked by the bridge
through the Tokens contract, with Security's field names):
| Name (default) | Path (default) | Methods | Action |
|---|---|---|---|
security_login (login_route) |
/login |
GET, POST | SignIn::login: the page, or the password |
security_loginWithToken |
/login/token/{token?} |
GET | SignIn::link: with a token, signs in |
| (unnamed) | /login/token/{token?} |
POST | SignIn::requestLink: sends a link; the same answer whether the address has an account |
2fa_login |
/login/2fa |
GET | Ceremony::secondFactorPage |
2fa_login_check |
/login/2fa/check |
GET, POST | SignIn::secondFactorCheck |
passkey_login_options |
/login/passkey/options |
POST | Ceremony::passkeyOptions |
passkey_login |
/login/passkey |
POST | SignIn::passkey |
oauth_<name> |
/connect/<name> |
GET | Ceremony::oauthStart (404 for a provider not served) |
oauth_<name>_check |
/connect/<name>/check |
GET | SignIn::oauthCheck |
security_logout |
/logout |
GET | SignIn::logoutPage |
security_logoutRequest |
/logout-request |
GET, POST | SignIn::logout |
The link routes exist with link on, the passkey routes with passkey, the password route with
password. Controllers\SignIn answers the Result as omnibridge/symfony's authenticator does
(the same redirects, the same JSON for a passkey, the same session-kept identity for the sign-up),
with a Refusal (message key and data, factor, failure, reason) kept in the session under
omnibridge.auth.last_error for the page that follows. The second factor's page receives the same
variables as under Symfony, so the Symfony site's template renders
unchanged. Pages are rendered through the bound Base\Screen\Negotiation and answered with
Cache-Control: no-store, private.
The guard
Auth\OmniauthGuard (a StatefulGuard) keeps the account's handle in the session
(omnibridge.auth.handle) and reads the account back through omniauth's Accounts at each
request, wrapped as Auth\AccountUser (the handle as its identifier, the account's
getRoles() as its roles; a method the wrapper has not is the account's). Omnibridge\Contract\Access::getUser(),
the screens' viewer and Twig's app.user unwrap it: the application sees its own object.
| Method | |
|---|---|
enter(Result $result) |
signs in an authenticated result: a new session id (data kept), the handle stored, the remember-me cookie when Result::$remember |
attempt(array $credentials, bool $remember) |
asks omniauth the password (identifier, email or username, and password); false when a second factor is required (the attempt waits in the session) |
pending() |
the attempt between two factors, or null |
account() |
the application's own object of the account signed in |
login(), loginUsingId(), once(), onceUsingId(), validate(), viaRemember(), logout() |
Laravel's StatefulGuard; logout() forgets the handle, regenerates the CSRF token, abandons omniauth's attempt and forgets the remember-me cookie |
Auth\OmniauthUserProvider (driver omniauth) finds accounts by handle or by identifier and checks
a password with the same hasher as PasswordMethod (a password longer than 72 bytes verifies as
under Symfony); it keeps no remember token.
The remember-me cookie (Auth\RememberMe) is base64url(handle):expiry:signature, the signature
an HMAC-SHA256 under secret of the account's class, handle, expiry, password hash and address
(Security's signature_properties): a new password or a new address forgets every browser. No
token is stored.
Auth\Roles answers the gate: PUBLIC_ACCESS and IS_AUTHENTICATED_ANONYMOUSLY to everybody,
IS_AUTHENTICATED and IS_AUTHENTICATED_REMEMBERED to an account signed in,
IS_AUTHENTICATED_FULLY to one not signed in by its remember-me cookie,
IS_AUTHENTICATED_2FA_IN_PROGRESS to the visitor between two factors, IS_IMPERSONATOR and
IS_REMEMBERED to nobody, ROLE_* to an account that holds it or a role above it in
role_hierarchy; any other ability is left to the gate's own definitions.
Middleware\SecondFactorPending sends a visitor between two factors to the second factor's page
from any route but the page, its check, the sign-out's two routes and second_factor.open_routes.
With omnibase
With stores: 'omnibase' and omnisense/omnibase 4.x, Auth\OmnibaseEffects registers what a
sign-in does around omniauth under Symfony, with the same omnibase classes: the browsers an account
signs in from (Base\Security\ConnectionTracker), the new device warned of, the captcha after
failed sign-ins from an address (Base\Security\SignInCaptcha, its gateway config/omnishield.php's,
counted in captcha.pool), "welcome back" to a disabled account, the sign-in e-mails written by
Base\Notifier\Mail\SignInMails and sent by Laravel's mailer (mail), omnibase's
NotifierSender as omniauth's Sender, and Middleware\EnrolmentPrompt (the second factor's
enrolment an account is sent to). Without omnibase 4.x but with its stores, the listeners
Base\Auth\Listener\UpdateAccount and Auth\Listener\WelcomeFlash (the word of welcome) listen to
Omniauth\Event\SignedIn.
Propose a change docs/laravel.md
Concepts
A sign-in is an attempt: one visitor, the factors passed so far, the second factors still
offered, what a ceremony keeps between its two steps. The conductor runs each step against the
method of a factor and answers with a result; the host keeps the attempt between two
requests (Contract\State) and reads the result to answer the visitor. The interfaces the
application and the host fill in are the contracts.
The conductor
use Omniauth\Omniauth;
$omniauth = new Omniauth(
accounts: $accounts, // Contract\Accounts
methods: $methods, // Method\Methods: the methods mounted, one per factor
state: $state, // Contract\State: the session, or ArrayState
policy: null, // Contract\SecondFactorPolicy; null: new EnrolledSecondFactors($methods)
throttle: null, // Contract\Throttle; null: no limit
events: null, // PSR-14 EventDispatcherInterface; null: no event
clock: null, // PSR-20 ClockInterface; null: SystemClock
lifetime: Omniauth::LIFETIME, // seconds an attempt lasts: 600
);
| Method | Returns | |
|---|---|---|
begin(Factor|string $factor, Context $context, Proof|array $proof = []) |
Method\Challenge |
the first step of a Challenging method: send a link or a code, give options, send to a provider |
verify(Factor|string $factor, Context $context, Proof|array $proof = []) |
Result |
checks a proof: a first factor starts a new attempt, a second one goes on with the attempt in progress |
attempt() |
?Attempt |
the attempt in progress (the second factor's page reads offered()), null when none |
abandon() |
void |
forgets the attempt: cancelled, or signed out |
enrol(Factor|string $factor, object $account) |
Enrolment |
sets a factor up from the account's settings: ->begin($context, $proof), then ->confirm($context, $proof) |
revoke(Factor|string $factor, object $account) |
Enrolled |
turns a factor off; the trusted devices fall back with it |
methods() |
Method\Methods |
the methods mounted |
| Constant | Value | |
|---|---|---|
Omniauth::ATTEMPT |
omniauth.attempt |
the State key of the sign-in attempt |
Omniauth::ENROLMENT |
omniauth.enrolment. |
the prefix of an enrolment's key, followed by the factor (omniauth.enrolment.totp) |
Omniauth::LIFETIME |
600 |
seconds after which an attempt is gone (expired) |
A factor is given as the enum or as its value ('email-code'); a value that is no factor throws
PHP's \ValueError, a factor no method was mounted for Exception\UnknownMethod
(Errors).
What verify() does
The same sequence whatever the method - that single sequence is what makes the behaviour the same under every host:
- A first factor starts a new attempt (keeping what a ceremony kept: a challenge begun before
it). A second factor goes on with the attempt in progress: none, or one older than the
lifetime, fails with
no-attemptorexpired; a factor the attempt did not offer fails withnot-offered. - The throttle: scope the factor's value (
password,email-code...), key what was typed and the visitor's address for a first factor (ringo@example.org|203.0.113.7, lower case), the account for a second one (account:<handle>, whatever the address). Refused:throttled, and nothing is checked. Event\Verifying: a listener may refuse (refused, with its reason).- The method checks the proof and gives a
Method\Verdict. A provider's identity that no account is linked to ends the attempt withIdentityUnknown; a refusal keeps the attempt and fails with the method's failure (bad-credentialswhen it gave none) and reason. Accounts::check(): an account that may not sign in now ends the attempt withaccount-refusedand its reason (Event\AccountRefusedis dispatched).- The throttle is reset for that scope and key; the factor is marked passed; "remember me" is
kept when a first factor's proof asked it (
remember) or the method asked it (the sign-in link). - The second-factor rule says what remains (The second-factor rule). When
something does, each ambient method (the trusted device) is tried on the way; if one passes, the
rule is asked again. Still due:
SecondFactorRequiredwith the factors offered, the attempt kept,Event\SecondFactorRequired. - Done:
Authenticated, the attempt cleared,Event\SignedIn; after a second factor whose proof askedtrust, the first ambient method's new cookie value inResult::$trustDevice.
Every failure dispatches Event\SignInFailed (the factor, the failure, the context, the
identifier typed, the account when known, the reason - never the secret).
begin() follows the first steps of the same sequence: a first factor restarts the attempt, a
second one needs the attempt in progress and a factor it offered; the throttle's scope is
<factor>.begin (link.begin, email-code.begin: what sends something), keyed by the address
typed alone for a first factor (identifier:ringo@example.org, lower case: what is sent goes to
that address, whichever network asks), as a verification is otherwise; Event\Beginning may
refuse; the method's challenge is returned and the attempt saved. A method that is not
Challenging has nothing to begin: LogicException.
Enrolment
$enrolment = $omniauth->enrol('email-code', $account); // LogicException for a method that is not Enrolling
$challenge = $enrolment->begin($context); // a code sent, a secret and its address, options to sign, or nothing
$enrolled = $enrolment->confirm($context, ['code' => '481516']);
$enrolled->isDone(); // true, or $enrolled->failure and ->reason
$enrolled->secrets; // what to show once (backup codes)
An enrolment has an attempt of its own (omniauth.enrolment.<factor>), the account identified,
apart from any sign-in. begin() starts it over (a code sent again has its own ten minutes) and is
throttled as <factor>.begin; confirm() is throttled as <factor>, keyed by the account. Once
done: the enrolment is forgotten, the throttle reset, Event\CredentialAdded dispatched - and a new
password makes every trusted browser fall back to the second factor.
revoke() dispatches Event\Revoking (a listener may refuse: Enrolled::failed(Failure::Refused, $reason)), turns the factor off, makes the trusted devices fall back unless the factor revoked is
the trusted device itself (which forgets every browser), and dispatches Event\CredentialRemoved.
A method that is not Revocable (the password, the link, a passkey, an identity provider) throws a
LogicException.
The attempt
Omniauth\Attempt is what State keeps between two requests, as an array of scalars:
[
'started' => 1792497600, // when it began (the clock's timestamp)
'handle' => '42', // the account found by the first factor (Accounts::handle()), or null
'passed' => ['password'], // the factors passed, in their order
'offered' => ['email-code', 'backup-code'],
'kept' => ['email-code' => 1792498200], // what ceremonies keep (a deadline, a challenge, the OAuth state)
'remember' => false,
]
It holds the account's handle, never the account: the conductor loads the account again
(Accounts::byHandle()) each time it reads the attempt back, and an account gone since ends the
attempt. Anything malformed in the state reads as no attempt.
| Method | |
|---|---|
startedAt(): int, account(): ?object, handle(): ?string |
|
passed(): list<Factor>, hasPassed(Factor): bool, offered(): list<Factor>, remembers(): bool |
|
keep(Factor|string $key, scalar|array|null $value) |
a ceremony keeps a value until its verification |
kept(Factor|string $key) |
reads it |
take(Factor|string $key) |
reads it and forgets it: a challenge serves once |
restart(int $now): Attempt |
a new attempt that keeps only what ceremonies kept |
toArray(): array, Attempt::fromArray(mixed): ?Attempt |
what State keeps |
identify(), pass(), offer() and remember() are the conductor's (@internal).
The result
Omniauth\Result, read-only:
| Property | Type | |
|---|---|---|
status |
Status |
where the attempt stands |
account |
?object |
the account: when authenticated, when a second factor is required, and when it was refused (account-refused) |
passed |
list<Factor> |
the factors passed in this attempt, in their order |
offered |
list<Factor> |
SecondFactorRequired: what the second factor's page offers (any one suffices), the backup code last |
failure |
?Failure |
Failed: why |
reason |
?string |
a stable word under the failure: the account's (banned, locked), a listener's, a method's (state-mismatch, replayed...) |
remember |
bool |
the host sets its own remember-me |
trustDevice |
?string |
the trusted_device cookie's new value, to set as it is |
identity |
?PendingIdentity |
IdentityUnknown: what a sign-up form fills in |
isAuthenticated() and needsSecondFactor() read the status; Result::failed($failure, $reason, $account) builds a failure.
Status (Omniauth\Status, a string enum):
| Case | Value | The host |
|---|---|---|
Authenticated |
authenticated |
renews its session id, keeps the account's handle; sets its remember-me when remember, the cookie when trustDevice |
SecondFactorRequired |
second-factor-required |
shows the second factor's page with offered |
IdentityUnknown |
identity-unknown |
keeps identity in its session and shows its sign-up (Identity providers) |
Failed |
failed |
translates failure (and reason) on the page the visitor came from |
Failure (Omniauth\Failure): stable keys a page translates, never a message.
| Case | Value | When |
|---|---|---|
BadCredentials |
bad-credentials |
no such account, or not that secret: one answer for both; an identity provider's refusals, with their reason |
BadCode |
bad-code |
a code (e-mailed, backup, from an application) that is not the right one |
InvalidLink |
invalid-link |
a sign-in link unknown, already used or past its time |
Expired |
expired |
what the ceremony began with is gone: a code past its time, an attempt older than its lifetime, a challenge already used |
Throttled |
throttled |
too many tries: refused before anything was checked |
AccountRefused |
account-refused |
the proof is good but the account may not sign in now (reason: banned, locked...) |
Refused |
refused |
a listener of Verifying, Beginning or Revoking refused (reason: its word); a passkey's relying party that does not serve the host |
NoAttempt |
no-attempt |
a second factor with no first one passed before it |
NotOffered |
not-offered |
a second factor the attempt did not offer |
NotEnrolled |
not-enrolled |
a factor this account has not set up; an identity the rule will not link by its address (link-from-settings) |
Factors
Omniauth\Factor names what a person proves, once for every method, policy and host: the key of
routes, settings and translations.
| Case | Value | role() |
Method |
|---|---|---|---|
Password |
password |
first | PasswordMethod |
Link |
link |
first | LinkMethod |
Passkey |
passkey |
first | omniauth/webauthn's PasskeyMethod |
OAuth |
oauth |
first | OAuthMethod |
Totp |
totp |
second | omniauth/otp's TotpMethod |
EmailCode |
email-code |
second | EmailCodeMethod |
BackupCode |
backup-code |
second | BackupCodeMethod |
TrustedDevice |
trusted-device |
second | TrustedDevices |
Factor::of(Factor|string) gives the case (PHP's \ValueError for an unknown value);
role() gives Role::First (it finds the account) or Role::Second (the account is known).
The request and the proof
Omniauth\Context, read-only - what the host knows of the request, with no object of its framework:
| Property | Type | Default | |
|---|---|---|---|
origin |
string |
'' |
https://example.org:8443: WebAuthn compares it; a provider's return path is completed with it |
host |
string |
'' |
the relying party's id depends on it |
ip |
?string |
null |
the throttle's key for a first factor |
userAgent |
?string |
null |
|
trustedDevice |
?string |
null |
the trusted_device cookie as the host read it |
firewall |
string |
main |
the trusted devices' fwl claim (scheb/2fa's firewall name) |
Omniauth\Proof wraps what the visitor sent, an array: ['identifier' => ..., 'password' => ...], ['code' => ...], ['token' => ...], a WebAuthn credential's JSON under json, an OAuth
return's code and state. Two keys mean the same to every method: remember (at the first
factor: the host sets its remember-me) and trust (at the second: a trusted-device cookie).
| Method | |
|---|---|
Proof::of(Proof|array) |
|
string($key): string |
the value as a string; '' when missing or not a scalar |
bool($key): bool |
filter_var(..., FILTER_VALIDATE_BOOL): '1', 'on', 'true' are true |
has($key): bool |
set and not null |
json(): string |
the json key, or every value encoded |
all(): array |
What a ceremony gives back
Method\Challenge, the answer of begin() and of an enrolment's begin():
kind |
Built by | payload |
|
|---|---|---|---|
none (Challenge::NONE) |
Challenge::none($data) |
null | nothing to give: the next step follows (data: a TOTP secret and its address) |
json (JSON) |
Challenge::json($json) |
the JSON | options for a script (WebAuthn's) |
redirect (REDIRECT) |
Challenge::redirect($url) |
the address | the provider's authorization address |
sent (SENT) |
Challenge::sent($data) |
null | a link or a code went out - or the address has no account: the same answer (data: the code's until) |
failed (FAILED) |
Challenge::failed($failure, $reason) |
null | failure and reason say why; hasFailed() is true |
Enrolled (failure, secrets, reason; isDone(), Enrolled::done($secrets),
Enrolled::failed($failure, $reason)) is what setting a factor up, or turning it off, ended with.
Message (factor, secret, until) is what a method asks the host's Sender to send: the
link's token or the code as secret - never logged, never stored as it is.
PendingIdentity (provider, subject, email, emailVerified, name) is someone a provider
vouched for whom no account is linked to.
Method interfaces
A method checks a proof within an attempt and touches neither request nor response. Its
interfaces, in Omniauth\Method\ - what the conductor asks of it:
| Interface | Methods | Meaning |
|---|---|---|
Method |
factor(): Factor, verify(Attempt, Proof, Context): Verdict |
every method |
Challenging |
begin(Attempt, Proof, Context): Challenge |
gives something first; keeps what it must remember in the attempt, never in the challenge |
SecondFactor |
enrolled(object $account): bool |
a second factor an account sets up: the rule asks whether this one has |
Ambient |
remember(object $account, Context): string |
a second factor read from the context (the cookie), tried by the conductor itself; gives the cookie's value |
Enrolling |
enrolBegin(Attempt, Proof, Context): Challenge, enrolConfirm(Attempt, Proof, Context): Enrolled |
set up from the settings |
Revocable |
revoke(object $account): void |
turned off |
Method\Verdict is what a method concluded: Verdict::passed($account, $remember),
Verdict::refused($failure, $reason), Verdict::expired(), Verdict::unknown(PendingIdentity);
hasPassed().
Method\Methods holds the methods mounted, one per factor (a second one for the same factor
replaces the first): add(), has(), get() (throws UnknownMethod), secondFactors() (the
SecondFactor methods that are not ambient), ambient(); iterable and countable. Writing a method
of your own: Writing a method or a provider.
Propose a change docs/concepts.md
The contracts
omniauth decides who signs in; the application keeps its accounts and their proofs, the host its
request, response, session and pages. Between them, small interfaces in Omniauth\Contract\.
The accounts
Accounts - an account is any object of the application, which implements nothing of omniauth:
| Method | What it answers |
|---|---|
byIdentifier(string $identifier): ?object |
the account of what the person types (an address, a user name), or null |
byHandle(string $handle): ?object / handle(object $account): string |
the stable handle (the id; also the passkeys' userHandle): what the attempt keeps between requests |
identifier(object $account): string |
what the host knows the account by (Symfony's getUserIdentifier()); the trusted-device tokens carry it, and the TOTP address shows it |
displayName(object $account): string, email(object $account): ?string |
what a message and an authenticator show; email() is also what an identity provider's verified address is compared with |
check(object $account): void |
may it sign in now? throws Exception\AccountRefused with a stable reason (banned, locked...) |
check() is called after the proof passed, never before: an unknown address and a banned account
are told apart only once the password is right.
The stores of each proof
One store per proof, so a site takes only what it serves:
| Contract | Methods | Kept | omnibase (Base\Auth\, step AU4) |
|---|---|---|---|
Passwords |
hash($account): ?string, update($account, string $hash) |
the hash, as the hasher wrote it; update() after a rehash or a new password |
user.password |
LinkTokens |
issue($account, string $hash, \DateTimeImmutable $until), consume(string $hash, \DateTimeImmutable $now): ?object |
each link's SHA-256 and deadline; consume() gives the account while the link is valid at $now and uses it up on the way: it serves once |
userToken "login-token" |
PlainLinkTokens |
consumePlain(string $value, \DateTimeImmutable $now): ?object |
for the switch only: links kept in clear before omniauth | idem, until the last expires |
EmailCodes |
enabled($account): bool, enable(), disable(), remember($account, string $hash), remembered($account): ?string, forget($account) |
on or off, the code's keyed hash | user.emailAuthEnabled, user.emailAuthCode |
BackupCodes |
hashes($account): list<string>, replace($account, list<string> $hashes) |
the codes' password_hash() hashes |
user.backupCodes |
TrustedVersion |
version($account): int, revokeAll($account) |
the trusted devices' version; revokeAll() raises it |
user.trustedTokenVersion |
TotpSecrets |
secret($account): ?string, enable($account, string $secret), disable($account) |
the base32 secret (read by omniauth/otp; sealed at rest by its SealedTotpSecrets) |
user.secret |
Identities |
owner(string $provider, string $subject): ?object, link($account, $provider, $subject, ?string $email, bool $emailVerified), unlink($account, $provider), used($provider, $subject, \DateTimeImmutable $at), providers($account): list<string> |
provider + the person's identifier there (read by Omniauth\OAuth\OAuthMethod); one subject of a provider has one account, an account one identity per provider |
userIdentity (AU5) |
Omniauth\WebAuthn\Contract\Passkeys (omniauth/webauthn) |
of($account), byCredentialId(string $base64), add($account, PasskeyRecord, ?string $label), used(PasskeyRecord) |
the passkeys, as PasskeyRecord values |
userPasskey |
No table, no ORM here: the structure stays the application's, described once, read by every host.
Omniauth\Testing\InMemoryStore implements all of them but the passkeys over TestAccount
objects (Testing).
The host's small duties
| Duty | Contract | Plain PHP and tests | Symfony / Laravel (omnibridge) |
|---|---|---|---|
| Keep the attempt between two requests | State (get, set, remove; arrays of scalars) |
ArrayState (over $_SESSION: ArrayState::session()) |
the session (SessionState in each bridge) |
| Limit the tries | Throttle (consume(string $scope, string $key): bool, reset(string $scope, string $key)) |
CacheThrottle on any PSR-6 pool or PSR-16 cache |
CacheThrottle on a cache pool (Symfony) or a cache store (Laravel) |
| Send a link or a code | Sender (send(object $account, Message $message)) |
Testing\RecordingSender |
omnibase's NotifierSender |
| CSRF on every POST | Omnibridge\Contract\Tokens (omnisense/omnibridge) |
your form's own | each bridge's Tokens |
| Events | PSR-14, optional | any dispatcher | the host's dispatcher |
| Clock | PSR-20, optional | SystemClock |
the container's clock |
| The second-factor rule | SecondFactorPolicy (required(object $account, array $passed, Context $context): list<Factor>) |
EnrolledSecondFactors |
the same, decorated by omnibase's rule |
What stays the host's alone: its firewall or guard, reading the request and writing the response, the session cookie and its renewal at each sign-in (against session fixation), signing out, the remember-me, switching user, API tokens, and the pages with their forms.
ArrayState keeps a PHP array by reference (new ArrayState($array), or none for its own);
ArrayState::session() binds it to $_SESSION and throws a LogicException when no session is
started; all() gives what it holds.
The
Throttlecontract's docblock names Symfony'sRateLimiterFactoryand Laravel'sRateLimiteras hosts' throttles; both bridges mount omniauth'sCacheThrottleinstead (on a cache pool, or a cache store). Either may implement the contract: one with an atomic store is exact under concurrent tries,CacheThrottleis not (Configuration).
Propose a change docs/contracts.md
Password
new PasswordMethod($accounts, $passwords, $hasher = null); // null: NativePasswordHasher, bcrypt cost 13 - Symfony's "auto"
$omniauth->verify('password', $context, ['identifier' => 'ringo@example.org', 'password' => '...', 'remember' => true]);
Checked by symfony/password-hasher under every host, never by password_verify() alone:
Symfony prehashes a password longer than 72 bytes (or holding a NUL byte) in SHA-512 and base64
before bcrypt, so password_verify() - and Laravel's Hash - would refuse those accounts. The
same hasher says when to rehash: a hash of another cost is rewritten at sign-in
(Passwords::update()), and a hash of the hasher's own cost is left as it is (Laravel's default
of 12 would rehash every Symfony account on each sign-in).
Under Symfony, pass the hasher security.yaml builds (PasswordHasherFactory, "auto": a
MigratingPasswordHasher); the hashes it wrote yesterday verify the same.
An unknown address and a wrong password get the same answer (bad-credentials) in the same time:
an unknown account is checked against a hash of nothing. A banned account is refused
(account-refused, with its reason) only once its password is right. Five tries a minute for an
identifier from an address (CacheThrottle::LIMITS['password']).
A new password, from the settings: $omniauth->enrol('password', $account)->confirm($context, ['password' => 'new', 'current' => 'old']) - current is checked when it is given (leave it out
for a reset by e-mail) - and every trusted browser falls back to the second factor.
What is checked
| Case | Answer |
|---|---|
no identifier, no account for it, an account without a hash, an empty password |
bad-credentials, after a verification against a hash of the word omniauth (drawn once per method) |
| a wrong password | bad-credentials |
| the right password | passed; when the hasher says needsRehash(), the new hash is kept (Passwords::update()) first |
remember true in the proof |
Result::$remember |
The account is found by Accounts::byIdentifier(): an address, a user name - what the store
accepts. Accounts::check() runs after the password passed (the conductor's step), so a banned
account answers account-refused only to whoever knows its password.
| Set-up step | Proof | Answer |
|---|---|---|
enrol('password', $account)->begin($context) |
- | Challenge::none() |
->confirm($context, $proof) |
password (the new one), current (optional) |
an empty password, or a current given that is not the account's: Enrolled::failed(bad-credentials); otherwise the new hash kept, Event\CredentialAdded, and the trusted devices fall back (TrustedVersion::revokeAll()) |
A password cannot be turned off through omniauth: revoke('password', $account) throws a
LogicException (PasswordMethod is not Revocable).
Propose a change docs/password.md
Sign-in link
new LinkMethod($accounts, $linkTokens, $sender, $lifetime = 900, $clock = null);
$omniauth->begin('link', $context, ['identifier' => 'ringo@example.org']); // Challenge "sent", whatever the address
$omniauth->verify('link', $context, ['token' => $tokenFromTheLink]); // the link's page, a GET from a mailbox
By Marco's decision QA4 (2026-10-20):
- 15 minutes (
LinkMethod::LIFETIME), once (LinkTokens::consume()uses it up); - hashed: the token is 32 random bytes (base64url, 43 characters) sent to the account; the store
keeps its SHA-256 (
LinkMethod::hash()), so whoever reads the table cannot sign in with it; - a new link at each request (the old "a link is still valid, nothing sent" is gone), 3 an hour
(
link.begininCacheThrottle::LIMITS), counted per address typed (lower case), whichever network the requests come from; - the same answer whether the address has an account or not, and nothing sent for one that has none.
The message (Message: the factor, the token as secret, until) goes to the host's Sender,
which builds the address of its link page. A link signs in with the host's remember-me, as
omnibase's did; it is a first factor like the password, so the second factor follows when the
account has one.
The links issued before the switch were kept in clear: a store that still holds some also
implements PlainLinkTokens, and they serve once, as they are, until their end.
The link's steps
| Step | Proof | Answer |
|---|---|---|
begin('link', ...) |
identifier: what the person typed |
Challenge::sent() always; for an account found, a token drawn, LinkTokens::issue($account, sha256, $until) and Sender::send($account, Message) |
verify('link', ...) |
token: the link's token |
LinkTokens::consume(sha256, $now), then PlainLinkTokens::consumePlain($token, $now) when the store implements it; an account: passed, with the remember-me asked; none (empty, unknown, used, past its time): invalid-link |
The whole way, with the in-memory store (run in the harness on 2026-10-25):
<?php
require 'vendor/autoload.php';
use Omniauth\ArrayState;
use Omniauth\CacheThrottle;
use Omniauth\Context;
use Omniauth\Link\LinkMethod;
use Omniauth\Method\Methods;
use Omniauth\Omniauth;
use Omniauth\Testing\ArrayCache;
use Omniauth\Testing\InMemoryStore;
use Omniauth\Testing\RecordingSender;
use Omniauth\Testing\TestAccount;
$store = new InMemoryStore(new TestAccount('42', 'ringo', 'ringo@example.org'));
$sender = new RecordingSender();
$omniauth = new Omniauth($store, new Methods([new LinkMethod($store, $store, $sender)]), new ArrayState(), throttle: new CacheThrottle(new ArrayCache()));
$context = new Context(origin: 'https://example.org', host: 'example.org', ip: '203.0.113.7');
echo $omniauth->begin('link', $context, ['identifier' => 'nobody@example.org'])->kind, ', ', $sender->count(), " sent\n";
echo $omniauth->begin('link', $context, ['identifier' => 'ringo@example.org'])->kind, ', ', $sender->count(), " sent\n";
$token = $sender->last()->secret; // the link: https://example.org/login/token/<token>
echo strlen($token), ' characters, good for ', $sender->last()->until->getTimestamp() - time(), " seconds\n";
$result = $omniauth->verify('link', $context, ['token' => $token]);
echo $result->status->value, ' as ', $result->account->identifier, ', remember: ', var_export($result->remember, true), "\n";
echo $omniauth->verify('link', $context, ['token' => $token])->failure->value, " the second time\n";
foreach (range(1, 2) as $i) {
$omniauth->begin('link', $context, ['identifier' => 'ringo@example.org']);
}
echo $omniauth->begin('link', $context, ['identifier' => 'ringo@example.org'])->failure?->value, " at the fourth link of the hour for one address\n";
echo $omniauth->begin('link', new Context(ip: '198.51.100.9'), ['identifier' => 'ringo@example.org'])->failure?->value, " from another network too\n";
sent, 0 sent
sent, 1 sent
43 characters, good for 900 seconds
authenticated as ringo, remember: true
invalid-link the second time
throttled at the fourth link of the hour for one address
throttled from another network too
Under Symfony the link's page is the application's route link.route (a GET with the token signs
in through the authenticator); under Laravel the bridge's security_loginWithToken
(/login/token/{token?}), whose POST sends the link (Symfony, Laravel).
Code by e-mail
new EmailCodeMethod($accounts, $emailCodes, $sender, $secret, $digits = 6, $lifetime = 600, $clock = null);
$omniauth->begin('email-code', $context); // the second-factor page, or "send again"
$omniauth->verify('email-code', $context, ['code' => '481516', 'trust' => true]);
A second factor: offered once a first factor passed for an account that turned it on
(EmailCodes::enabled()). Six digits drawn as scheb/2fa draws them, good for 10 minutes and once.
By Marco's decision QA5 (2026-10-20) the code is kept hashed: an HMAC-SHA256 keyed by the
host's secret and bound to the account (EmailCodeMethod::hash()), compared in constant time; the
deadline is kept in the attempt. Five tries per account in 15 minutes, whatever the address
(email-code), 3 codes sent in 15 minutes (email-code.begin).
Turning it on, from the settings: enrol('email-code', $account)->begin($context) sends a code,
->confirm($context, ['code' => ...]) checks it and turns the factor on. Off: revoke('email-code', $account) - the trusted browsers fall back with it.
The code's steps
| Step | Answer |
|---|---|
begin('email-code', ...) (the second factor's page, "send again") |
a code drawn (random_int() between 10^(digits-1) and 10^digits - 1: never a leading zero), its hash kept (EmailCodes::remember()), its deadline kept in the attempt, Sender::send($account, Message); Challenge::sent(['until' => <timestamp>]) |
verify('email-code', ...) with code |
spaces removed; no code kept or no deadline in the attempt: expired; past the deadline: expired, the hash forgotten; not the code: bad-code (the code stays good until its deadline); the code: passed, the hash forgotten (once) |
enrol('email-code', $account)->begin() / ->confirm(['code' => ...]) |
the same code sent and checked in the enrolment's own attempt; once checked, EmailCodes::enable() |
revoke('email-code', $account) |
EmailCodes::disable() and forget(); the trusted devices fall back |
enrolled() is EmailCodes::enabled(). The constructor refuses an empty secret and digits outside
4 to 9 (InvalidArgumentException). hash($account, $code) is
hash_hmac('sha256', handle."\0".code, $secret): a host that sent a code under another secret
(the Symfony site's kernel.secret and the twin's) must share it for the code to be good on both.
The code expires after $lifetime seconds, but the attempt that holds its deadline expires after
the conductor's lifetime (600 seconds by default) whichever comes first.
Propose a change docs/email-code.md
Backup codes
new BackupCodeMethod($backupCodes, $count = 8);
$codes = $omniauth->enrol('backup-code', $account)->confirm($context)->secrets; // show them once
$omniauth->verify('backup-code', $context, ['code' => 'A1B2C3-D4E5F6']);
Eight codes, A1B2C3-D4E5F6 as omnibase draws them, kept as password_hash() hashes - the very
hashes omnibase's user.backupCodes holds, read as they are. Each serves once: the one that
matches is removed. They stand in for another second factor and are offered after it; alone, they
ask for none (as in scheb/2fa). A new set replaces the former; revoke('backup-code', $account)
clears them.
The backup codes' steps
| Step | Answer |
|---|---|
verify('backup-code', ...) with code |
trimmed and upper-cased; empty: bad-code; compared with password_verify() against each hash in turn; the one that matches is removed (BackupCodes::replace() with the others) and passes; none: bad-code |
enrol('backup-code', $account)->begin() |
Challenge::none() |
->confirm($context) |
a new set: $count codes XXXXXX-XXXXXX (6 upper-case hexadecimal digits, a hyphen, 6 more: BackupCodeMethod::draw()), hashed with password_hash(PASSWORD_DEFAULT) in place of the former set; Enrolled::$secrets holds them, to show once |
revoke('backup-code', $account) |
BackupCodes::replace($account, []); the trusted devices fall back |
enrolled() is true while the account has at least one hash. Backup codes alone ask no second
factor: the rule offers them after another second factor the account set up, and last
(The second-factor rule). Each code costs one password_verify() (bcrypt) per
hash tried: up to $count of them for a wrong code.
Propose a change docs/backup-codes.md
Trusted devices
new TrustedDevices($accounts, $trustedVersion, $secret, $lifetime = 2592000, $clock = null);
$result = $omniauth->verify('email-code', $context, ['code' => '...', 'trust' => true]);
setcookie(TrustedDevices::COOKIE, $result->trustDevice, ...); // "trusted_device", 30 days
// next time: new Context(..., trustedDevice: $_COOKIE['trusted_device'] ?? null)
A second factor already passed on this browser. The conductor tries it by itself when a second
factor is required (Method\Ambient): a valid token passes as Factor::TrustedDevice and no code
is asked. After a second factor passed with trust, Result::$trustDevice is the cookie's new
value.
scheb/2fa's format, byte for byte, so the browsers trusted before the switch stay trusted:
JWTs signed HS256 with the host's secret (Symfony's kernel.secret), claims iat, exp (with
their microseconds when they have some, as lcobucci/jwt writes them), usr (the account's
Accounts::identifier()), fwl (Context::$firewall), vsn (the account's TrustedVersion),
several tokens joined by ; - the other accounts' and firewalls' kept, this one's replaced, the
invalid and expired dropped. Written with PHP's HMAC, with no JWT library; read as scheb reads
them (signature, alg, iat <= now < exp). The key needs 32 bytes, as lcobucci/jwt requires.
A token counts while its version is the account's: turning a second factor off, setting a new
password or revoke('trusted-device', $account) raises it, and every browser falls back.
The proof: Tests/TrustedDevicesTest compares omniauth's tokens with scheb's JwtTokenEncoder's
for the same input (recorded, and live in the harness, where scheb/2fa is a development
dependency), and each reads the other's.
The tokens' checks
verify() (the conductor's, on its own) |
passes when Context::$trustedDevice holds a valid token whose usr is the account's identifier, fwl the context's firewall and vsn the account's version; otherwise not-enrolled |
remember($account, $context) |
the cookie's new value: the valid tokens of other accounts or firewalls kept, this account's and firewall's replaced by one issued now, until now + $lifetime |
revoke($account) |
TrustedVersion::revokeAll(): every browser falls back |
trusts($account, $cookie, $firewall = 'main') |
the same check as verify(), for a host's own use |
encode($username, $firewall, $version, $issuedAt, $until), decode($token) |
one token, written and read as scheb/2fa's JwtTokenEncoder does; decode() gives the claims, or null for a token not signed with the key, not HS256 (or encrypted), not yet valid (iat, nbf) or expired (exp) |
TrustedDevices::COOKIE is trusted_device, TrustedDevices::LIFETIME 2592000 seconds (30
days). The constructor refuses a secret shorter than 32 bytes (InvalidArgumentException). The
cookie itself - its name, path, flags and expiry - is the host's: the bridges set it on /, HTTP
only, SameSite=Lax, secure on HTTPS, for the configured lifetime. Trusted devices are offered on
the second factor's page as "trust this browser" when the method is mounted
(displayTrustedOption); the proof's trust asks it.
Propose a change docs/trusted-devices.md
Identity providers (OAuth 2, OpenID Connect)
composer require omnisense/omniauth omniauth/google
The mechanics every provider shares are omniauth's own, under Omniauth\OAuth\ (they were the
package omniauth/oauth until 2026-10-23: same namespace, same class names); omniauth requires no
OAuth library. Each provider is a package of its own - omniauth/google
first, which brings league/oauth2-client and league/oauth2-google. No bundle, no framework: the
links between your accounts and their providers stay in your table, behind omniauth's
Contract\Identities (a provider, the person's identifier there - sub -, the account).
The whole thing by hand - the in-memory stores of Omniauth\Testing stand in for yours, and
Omniauth\OAuth\Testing\FakeProvider for Google and the person consenting there:
<?php
require 'vendor/autoload.php';
use Omniauth\ArrayState;
use Omniauth\Context;
use Omniauth\Method\Methods;
use Omniauth\OAuth\NoSecondFactorLinking;
use Omniauth\OAuth\OAuthMethod;
use Omniauth\OAuth\Provider\Providers;
use Omniauth\OAuth\Testing\FakeProvider;
use Omniauth\Omniauth;
use Omniauth\Testing\InMemoryStore;
use Omniauth\Testing\TestAccount;
$store = new InMemoryStore($ringo = new TestAccount('42', 'ringo', 'ringo@example.org'));
// Yours: omniauth/google's new GoogleProvider($clientId, $clientSecret, '/login/google/callback')
$google = new FakeProvider('google', redirectUri: 'https://example.org/login/google/callback');
$session = []; // $_SESSION, in a real page
$methods = new Methods();
$methods->add(new OAuthMethod($store, $store, new Providers($google), new NoSecondFactorLinking($methods)));
$omniauth = new Omniauth(accounts: $store, methods: $methods, state: new ArrayState($session));
$context = new Context(origin: 'https://example.org', host: 'example.org', ip: '203.0.113.7');
// /connect/google: the visitor is sent to the provider.
$challenge = $omniauth->begin('oauth', $context, ['provider' => 'google']);
// header('Location: '.$challenge->payload); the person consents; the browser comes back with ?code=…&state=…
$return = $google->consent($challenge->payload, ['sub' => '108204', 'email' => 'ringo@example.org', 'email_verified' => true]);
// /login/google/callback: the return's query, as it is.
$result = $omniauth->verify('oauth', $context, $return);
echo $result->status->value, ' as ', $result->account->identifier, ', linked to google ', $ringo->identities['google'], "\n";
// authenticated as ringo, linked to google 108204
// Someone no account knows: a result for the sign-up page, no account made.
$return = $google->consent($omniauth->begin('oauth', $context)->payload, ['sub' => '9001', 'email' => 'john@example.org', 'email_verified' => true]);
$result = $omniauth->verify('oauth', $context, $return);
echo $result->status->value, ': ', $result->identity->email, "\n";
// identity-unknown: john@example.org
What your pages do around it is the host's part: the start route redirects to
$challenge->payload; the return route hands its query to verify() and, on Authenticated,
renews its session id and keeps the account's handle; on SecondFactorRequired it shows the
second-factor page (an account that has one passes it after Google too: Marco's decision QA2); on
IdentityUnknown it keeps $result->identity in its session and shows its sign-up, then calls
OAuthMethod::adopt($account, $identity) once the account exists; on a failure, the sign-in page
with $result->reason translated.
Next: the method and its linking rule, then the providers, below.
The method
OAuthMethod is a first factor (Factor::OAuth), Challenging and Enrolling:
new OAuthMethod(
accounts: $accounts, // Omniauth\Contract\Accounts
identities: $identities, // Omniauth\Contract\Identities: provider + sub -> account
providers: new Providers(new GoogleProvider($id, $secret, '/login/google/callback')), // omniauth/google
byEmail: new NoSecondFactorLinking($methods), // true (any account, the default), false (never), or a rule
clock: null, // PSR-20: Identities::used()'s date; null: SystemClock
);
providers() gives the Providers it serves (what the sign-in page lists: names()).
The trip
begin() (['provider' => 'google'], or nothing when one provider is served) |
draws a state, a PKCE verifier and a nonce (32 random bytes each, base64url), keeps them in the attempt with the provider's name, answers Challenge::redirect() with the provider's authorization address: the state, the S256 challenge, the return address and scopes, and the nonce for an OpenID Connect provider (omniauth/google's, FakeProvider; a plain LeagueProvider does not send it); an unknown provider, or none named when several are served, is bad-credentials / unknown-provider; it keeps the origin of the request too (Context::$origin), on which a return path (/login/google/callback) is completed - here and again at the exchange. The verifier never leaves the server. |
verify() (the return's query: code, state, or error) |
takes what the attempt kept - once: a return replayed finds nothing, expired -, refuses a return whose provider (the route's) is not the trip's (state-mismatch), compares the state in constant time before anything else (a return this visitor did not begin is not read further: no exchange, no request), then reads error (access_denied: provider-denied; any other: provider-error) and code (none: missing-code), then has the provider exchange the code with the verifier and read the person; a ProviderError becomes bad-credentials with its reason. A provider that vouches for an identity of another provider's name is a programming error (LogicException). |
The state stops a return the visitor did not begin (login CSRF: an attacker's own code landed in the victim's browser). PKCE stops a code used in a trip it was not issued for (a code stolen from a log or a referrer, or injected with the victim's state): the provider refuses to exchange it with another trip's verifier. The nonce ties the ID token to the trip. What the attempt keeps lives as long as the attempt (ten minutes).
Who it is: the linking rule (QA3)
- The account linked to the provider's subject (
Identities::owner($provider, $sub)), whatever the address says now: Google'ssubis the person's stable identifier there; an address can change hands. - Else, only when the provider says the address is verified (Google's
email_verified), the account of that address (Accounts::byIdentifier(), thenAccounts::email()compared, case aside: a user name that looks like the address is not it) - linked there and then (Identities::link()), unless:byEmailrefuses it:falsenever links by an address;NoSecondFactorLinkingrefuses an account that set up a second factor (Marco's QA3: never for an account with a second factor or an administration role - omnibase's own rule adds the roles, step AU4);- the account is linked to another subject of that provider already: an address does not move
a link.
Then:
not-enrolled, reasonlink-from-settings- the person signs in otherwise and links the provider from the settings.
- Else nobody:
Status::IdentityUnknown,$result->identity(aPendingIdentity: provider, subject, address, whether it is verified, name). No account is created here - not even a transient one (lesringostars' hwi/oauth-bundle made one, and an address without an account ended in a 500 "User identifier is NULL"). The host shows its sign-up, prefilled; once the account exists,adopt($account, $identity)links it. Keep the identity in the session, never in a form field.
An address the provider does not vouch for is never looked up: an unverified address finds no
account, and the sign-up knows it is not proved (emailVerified: false).
After it
The conductor does what it does after any first factor: Accounts::check() (banned, locked...),
then the second-factor rule - an account that has a second factor passes it after Google
(Marco's QA2: EnrolledSecondFactors::AFTER holds Factor::OAuth; lesringostars today signs in
by Google without it).
From the settings
$enrolment = $omniauth->enrol(Factor::OAuth, $account);
$challenge = $enrolment->begin($context, ['provider' => 'google']); // redirect
$enrolled = $enrolment->confirm($context, $return); // the return's query
The same trip, state and PKCE included, in the enrolment's own attempt (a sign-in in progress is
not touched). The address does not matter there (the person is signed in); the subject is refused
when another account holds it (linked-elsewhere), and an account keeps one identity per provider
(other-identity). With one return address per provider, the host tells the two apart by whether
the visitor is signed in: signed in, enrol()->confirm(); not, verify().
Refusals
bad-credentials with a stable reason: unknown-provider (at the beginning), state-mismatch,
provider-denied (the person said no: error=access_denied), provider-error, missing-code,
exchange-failed (the provider refused the code: used, expired, or not this trip's verifier),
invalid-id-token, no-subject, hosted-domain, and from the settings linked-elsewhere,
other-identity; not-enrolled / link-from-settings (above); expired when the attempt kept
nothing. The provider's own message is for the server's log (ProviderError::getMessage()), not
the page.
A link keeps what the provider said of the address at that moment (Identities::link()'s $email
and $emailVerified), and every sign-in by an identity marks it used (Identities::used(), the
method's clock). Turning a link off is the store's Identities::unlink($account, $provider), which
the host's settings page calls; omnibase's store is Base\Auth\UserIdentities on its
userIdentity table.
The providers
A provider (Provider\Provider) gives the authorization address for an Authorization (state,
verifier, nonce, the origin the trip began at) and, for the code that came back, the Identity it
vouches for - or throws a ProviderError with a stable reason. omniauth has none that speaks
to a real server: each provider is a package of its own (Marco's decision, 2026-10-21, the shape
of omnishield's captchas) - omniauth/google
first, over league/oauth2-google.
The return address
A provider's return address is its configuration's, never a request's: an absolute address, or a
path (/login/google/callback). A path is completed by the origin of the request that began
the trip (Authorization::returnAddress(), the origin kept in the attempt with the state): a site
served on two hosts, or a long-running worker (FrankenPHP) that serves several, sends each visitor
back to the host they left from - never to the first host the worker served -, and the exchange
repeats that trip's address, as an authorization server compares them. Each address must be one of
the client's registered return addresses at the provider.
By configuration
Providers::configured() reads the tree a host's omniauth.yaml (or config/omniauth.php) holds,
each entry made by the first factory (Provider\ProviderFactory) that knows its kind:
Providers::configured([
'google' => ['client_id' => $id, 'client_secret' => $secret, 'redirect_uri' => '/login/google/callback'],
], new Omniauth\Google\GoogleFactory());
An entry without a client_id is not served (lesringostars declares providers with no credentials:
they stay out); one without a redirect_uri, or that no factory makes, is a mistake
(InvalidArgumentException, naming the package to install). Providers given as they are (a
service) sit beside them: new Providers($given, ...Providers::configured($config, ...$factories)).
Providers::fromConfig() (deprecated, kept one release) makes its Google entries through
omniauth/google's factory when that package is installed; Omniauth\OAuth\Provider\GoogleProvider
is an alias of omniauth/google's class for the same release.
Over league/oauth2-client
Provider\LeagueProvider makes any league/oauth2-client provider an omniauth Provider: the
authorization address with omniauth's state and S256 challenge, the code exchanged with its
verifier, the person read from the resource owner's details ($fields names the subject, the
address, whether it is verified, the name). omniauth/google's GoogleProvider is built on it; a
provider without a package of its own can be served through it by hand
(league/oauth2-facebook, a GenericProvider on a provider's three addresses). league is not
required by omniauth - the provider packages require it -, so the class loads without it but is
usable only with it. PKCE is omniauth's: leave the league provider's pkceMethod unset.
(Omniauth\Google\LeagueProvider is an alias of it for one release.)
use League\OAuth2\Client\Provider\GenericProvider;
use Omniauth\OAuth\Provider\LeagueProvider;
$forge = new LeagueProvider(
name: 'forge', // the provider's key: the routes' {provider}, Identities' $provider
league: new GenericProvider([
'clientId' => $id, 'clientSecret' => $secret,
'urlAuthorize' => 'https://forge.example/oauth/authorize',
'urlAccessToken' => 'https://forge.example/oauth/token',
'urlResourceOwnerDetails' => 'https://forge.example/api/user',
]),
scopes: ['read:user'], // [] : the league provider's defaults
fields: ['subject' => 'id', 'email' => 'email', 'verified' => 'email_verified', 'name' => 'name'],
redirectUri: '/login/forge/callback', // null: the league provider's own redirectUri
);
For a trip begun at https://example.org, its authorization address (run in the
harness on 2026-10-25, no network: building the address calls nothing) is
forge.example/oauth/authorize with state, code_challenge, code_challenge_method, scope
(read:user), redirect_uri (https://example.org/login/forge/callback) and league's own
response_type, approval_prompt and client_id.
| Argument | Type | Default | |
|---|---|---|---|
name |
string |
- | the provider's key |
league |
League\OAuth2\Client\Provider\AbstractProvider |
- | the league provider; league() gives it back |
scopes |
list<string> |
[] |
sent as scope; empty: the league provider's own |
fields |
array<string, ?string> |
LeagueProvider::FIELDS: subject null (the resource owner's getId()), email email, verified null, name name |
the details' keys; verified null means the provider says nothing, so no address is ever said verified and no account is linked by it |
redirectUri |
?string |
null |
each trip's return address, absolute or a path, sent in the address and again at the exchange |
What it does: the address is league's getAuthorizationUrl() with state, code_challenge,
code_challenge_method=S256, scope and redirect_uri (a league provider that drew a PKCE code
of its own is refused with a LogicException); the exchange is getAccessToken('authorization_code', code, code_verifier, redirect_uri), any exception there a ProviderError exchange-failed; the
person is getResourceOwner()'s toArray() (a failure exchange-failed, league/oauth2-google's
HostedDomainException hosted-domain), a subject missing or empty no-subject. The Identity
keeps every detail in claims. A subclass overrides authorizationOptions(), identity(),
details() or fromClaims() (omniauth/google adds the nonce and reads the ID token).
In tests
Testing\FakeProvider is an authorization server in memory: consent($authorizationUrl, $person)
gives the return's code and state, deny() an access_denied; identify() exchanges a code
once, only with the verifier whose challenge the address carried and for the return address it was
given to, and checks the nonce. Its return address may be a path too. Under it a host's tests sign
in with no network, and a forged or injected return fails as it would at Google.
new FakeProvider(
name: 'google',
redirectUri: 'https://example.org/login/google/callback', // or a path
clientId: 'omniauth-test-client',
scopes: ['openid', 'email', 'profile'],
);
Its address is FakeProvider::AUTHORIZE (https://provider.test/authorize) with the client, the
return address, response_type=code, the scopes, the state, the S256 challenge and the nonce.
consent() takes an Identity or an array (sub, email, email_verified, name) and refuses
an address not of its client and return address, or without its challenge
(InvalidArgumentException); returnUrl($return) gives the address the browser lands on;
$exchanges lists every exchange asked (code and verifier). A code unknown or used, a verifier
that is not the challenge's, a return address that is not the authorization's are ProviderError
exchange-failed; a nonce that is not the trip's invalid-id-token.
Conformance suite
Omniauth\Testing\ProviderContractTests is the Provider contract as PHPUnit tests: every provider
package runs it in a Tests/<Name>ContractTest.php of its own (FakeProvider in the core's
Tests/FakeProviderContractTest.php, omniauth/google in its Tests/GoogleContractTest.php). The
test case gives the provider for a return address, and makes its server answer - recorded
answers, no network: consented() (the server will vouch for the person on the code it returns,
the authorization's nonce in its ID token), refused() (an invalid_grant), and unanswered()
(an HTTP 503 or no connection; null, the default, for a provider in memory). With factory() and
entry() it checks the factory too, with secret() that no message shows the client's secret.
| The authorization address | https, the trip's state, the S256 challenge (never the verifier), response_type=code, the client, the nonce (sendsNonce()), the configured return address; each trip its own |
| A return path | completed by the trip's origin; no origin known: a LogicException |
| The exchange | a code the server vouches for: an Identity of this provider, its subject, an address said verified only when there is one |
| Refusals | a refused code, a server that does not answer: a ProviderError with the reason exchange-failed, never another exception, never the secret in its message |
| The factory | named after the entry's key; null for an entry of another kind; through Providers::configured(), an entry without a client_id left out, one without a redirect_uri an InvalidArgumentException |
use Omniauth\Testing\ProviderContractTests;
final class GoogleContractTest extends TestCase
{
use ProviderContractTests;
protected function provider(string $redirectUri): Provider { /* a GoogleProvider on a Guzzle MockHandler */ }
protected function consented(Provider $provider, Authorization $authorization, string $authorizationUrl): string { /* the token answer queued */ }
protected function refused(Provider $provider): string { /* an invalid_grant queued */ }
protected function unanswered(Provider $provider): string { /* a 503 queued */ }
}
The methods (Method\Method: passwords, codes, TOTP, passkeys) are another contract, each with
its own tests: this suite is the identity providers'.
Propose a change docs/oauth.md
The second-factor rule
interface SecondFactorPolicy
{
/** @return list<Factor> the second factors still due (any one suffices); none: done */
public function required(object $account, array $passed, Context $context): array;
}
The default, EnrolledSecondFactors: an account that set up a second factor (an authenticator
application, the code by e-mail: each Method\SecondFactor says whether it is enrolled()) must
pass one of them - or a backup code, offered last - after a first factor of the list
EnrolledSecondFactors::AFTER; a second factor passed (a trusted device among them) is enough.
AFTER is [Password, Link, OAuth], Marco's decisions of 2026-10-20:
- QA1 - no second factor after a passkey: it already proves the device and the gesture or the
code that unlocks it.
Passkeyis not in the list. - QA2 - a second factor after an identity provider: an account with its TOTP no longer signs in
by Google without it.
OAuthis in the list; it is the one behaviour that differs from the bundles by default.
A host changes the list (new EnrolledSecondFactors($methods, [...])) or writes its own policy:
omnibase decorates this one with its SecurityPolicy (a role that requires a second factor and has
none is sent to enrol; the demo account never is). Refusing to turn a factor off ("the
administrator wins") is a listener of Event\Revoking.
How the default rule decides
EnrolledSecondFactors::required($account, $passed, $context), with $after the list above:
- none of the factors passed is in
$after(a passkey alone, say): nothing is due; - a second factor was passed already (a trusted device, read by the conductor on the way): nothing is due;
- the second factors mounted that the account set up (
Method\SecondFactor::enrolled(); the ambient trusted device is not one) are listed in the order the methods were mounted; without any but backup codes, nothing is due; - otherwise those factors are due, any one of them, and backup codes last when the account has some.
use Omniauth\EnrolledSecondFactors;
use Omniauth\Factor;
// A second factor after a passkey too: QA1 undone for this host.
$policy = new EnrolledSecondFactors($methods, [Factor::Password, Factor::Link, Factor::OAuth, Factor::Passkey]);
$omniauth = new Omniauth($accounts, $methods, $state, policy: $policy);
The conductor builds new EnrolledSecondFactors($methods) when no policy is given. A policy of the
host's own receives the account, the factors passed and the Context (the host can ask more of a
visitor from an unknown network, say) and returns the factors still due; an empty list ends the
sign-in.
Propose a change docs/second-factor.md
Events
PSR-14, optional: pass any EventDispatcherInterface as events:.
| Event | When | A listener may |
|---|---|---|
Beginning |
before a ceremony begins (a link or a code about to go out) | refuse($reason) |
Verifying |
before a method checks a proof, after the throttle | refuse($reason): omnishield (not a human), omnibase's SignInGuard (a captcha is due) |
SecondFactorRequired |
a first factor passed, a second is due | |
SignedIn |
signed in (the account, the factors passed, the context) | track the device, send "a new device", bring the account up to date |
SignInFailed |
any failure (the factor, the failure, the identifier, the address - never the secret) | count, log |
AccountRefused |
the proof was good, the account may not sign in | send "welcome back" to a disabled account |
Revoking |
before a factor is turned off | refuse($reason): a role that requires it keeps it |
CredentialAdded, CredentialRemoved |
a factor set up (a password changed) or turned off | notify |
A refusal ends the attempt with Failure::Refused and the listener's reason in Result::$reason.
What each event carries
| Event | Properties (read-only) | Dispatched by |
|---|---|---|
Beginning |
factor, context, attempt, identifier (first factors) |
begin(), after the throttle |
Verifying |
factor, context, attempt, identifier (first factors) |
verify(), after the throttle |
SecondFactorRequired |
account, passed, offered, context |
verify() |
SignedIn |
account, passed, context |
verify() |
SignInFailed |
factor, failure, context, identifier, account (when known), reason |
every failure of verify(); begin() only when throttled |
AccountRefused |
account, reason, factor, context |
verify(), before its SignInFailed |
Revoking |
account, factor |
revoke() |
CredentialAdded |
account, factor |
an enrolment confirmed (a new password included) |
CredentialRemoved |
account, factor |
revoke() done |
Beginning, Verifying and Revoking have refuse(string $reason) and refusal(): ?string;
the last listener to refuse sets the reason. A begin() refused by a listener answers
Challenge::failed(refused, $reason) and dispatches no SignInFailed; an IdentityUnknown result
dispatches neither SignedIn nor SignInFailed. Without a dispatcher (events: null) nothing is
dispatched and nothing is refused.
A listener refusing a sign-in from one address, with a dispatcher of a few lines (run in the harness on 2026-10-25):
<?php
require 'vendor/autoload.php';
use Omniauth\ArrayState;
use Omniauth\Context;
use Omniauth\Event\SignedIn;
use Omniauth\Event\SignInFailed;
use Omniauth\Event\Verifying;
use Omniauth\Method\Methods;
use Omniauth\Omniauth;
use Omniauth\Password\PasswordMethod;
use Omniauth\Testing\InMemoryStore;
use Omniauth\Testing\TestAccount;
use Psr\EventDispatcher\EventDispatcherInterface;
// Any PSR-14 dispatcher; a tiny one here.
$events = new class implements EventDispatcherInterface {
/** @var array<class-string, list<callable>> */
public array $listeners = [];
public function dispatch(object $event): object
{
foreach ($this->listeners[$event::class] ?? [] as $listener) {
$listener($event);
}
return $event;
}
};
$events->listeners[Verifying::class][] = static function (Verifying $event): void {
if ('203.0.113.66' === $event->context->ip) {
$event->refuse('not-a-human'); // omnishield's answer, say
}
};
$events->listeners[SignInFailed::class][] = static fn (SignInFailed $e) => print("failed: {$e->factor->value} {$e->failure->value} {$e->reason} {$e->identifier}\n");
$events->listeners[SignedIn::class][] = static fn (SignedIn $e) => print('signed in: '.$e->account->identifier.' from '.$e->context->ip."\n");
$store = new InMemoryStore(new TestAccount('42', 'ringo', 'ringo@example.org', password_hash('yellow-submarine', PASSWORD_BCRYPT)));
$omniauth = new Omniauth($store, new Methods([new PasswordMethod($store, $store)]), new ArrayState(), events: $events);
$proof = ['identifier' => 'ringo', 'password' => 'yellow-submarine'];
$result = $omniauth->verify('password', new Context(ip: '203.0.113.66'), $proof);
echo $result->failure->value, ': ', $result->reason, "\n";
$omniauth->verify('password', new Context(ip: '203.0.113.7'), $proof);
failed: password refused not-a-human ringo
refused: not-a-human
signed in: ringo from 203.0.113.7
Propose a change docs/events.md
Configuration reference
omniauth has no configuration file of its own: each class takes its options in its constructor.
The hosts' files - config/packages/omniauth.yaml (Symfony)
and config/omniauth.php (Laravel) - are the bridges' and build
these classes from their keys.
The conductor's options
new Omniauth(...):
| Argument | Type | Default | |
|---|---|---|---|
accounts |
Contract\Accounts |
required | |
methods |
Method\Methods |
required | one method per factor |
state |
Contract\State |
required | where the attempt lives between two requests |
policy |
?Contract\SecondFactorPolicy |
null: new EnrolledSecondFactors($methods) |
|
throttle |
?Contract\Throttle |
null: no limit |
|
events |
?Psr\EventDispatcher\EventDispatcherInterface |
null: no event, nothing refused |
|
clock |
?Psr\Clock\ClockInterface |
null: SystemClock |
an attempt's age |
lifetime |
int |
600 (Omniauth::LIFETIME) |
seconds an attempt (and an enrolment) lasts |
The methods of the core
| Class | Argument | Type | Default |
|---|---|---|---|
Password\PasswordMethod |
accounts, passwords |
Accounts, Passwords |
required |
hasher |
?Symfony\Component\PasswordHasher\PasswordHasherInterface |
null: NativePasswordHasher (bcrypt, cost 13: Symfony's "auto") |
|
Link\LinkMethod |
accounts, tokens, sender |
Accounts, LinkTokens, Sender |
required |
lifetime |
int, seconds |
900 (LinkMethod::LIFETIME) |
|
clock |
?ClockInterface |
null: SystemClock |
|
EmailCode\EmailCodeMethod |
accounts, codes, sender |
Accounts, EmailCodes, Sender |
required |
secret |
string, not empty |
required: keys the codes' hashes (the host's secret) | |
digits |
int, 4 to 9 |
6 |
|
lifetime |
int, seconds |
600 (EmailCodeMethod::LIFETIME) |
|
clock |
?ClockInterface |
null |
|
BackupCode\BackupCodeMethod |
codes |
BackupCodes |
required |
count |
int |
8: codes drawn at each set-up |
|
TrustedDevice\TrustedDevices |
accounts, versions |
Accounts, TrustedVersion |
required |
secret |
string, 32 bytes or more |
required: signs the tokens (Symfony's kernel.secret) |
|
lifetime |
int, seconds |
2592000 (TrustedDevices::LIFETIME, 30 days) |
|
clock |
?ClockInterface |
null |
|
OAuth\OAuthMethod |
accounts, identities, providers |
Accounts, Identities, Provider\Providers |
required |
byEmail |
Contract\EmailLinking|bool |
true: any account linked by an address the provider vouches for; false: never; NoSecondFactorLinking: never one with a second factor |
|
clock |
?ClockInterface |
null |
The packages' methods: omniauth/otp's TotpMethod (leeway 0, period 30, digits 6,
algorithm sha1, issuer, server, clock) and omniauth/webauthn's PasskeyMethod
(rp: a RelyingParty with id, name, origins, insecure; timeout) - their manuals list
every option.
The second-factor rule
new EnrolledSecondFactors(Methods $methods, array $after = EnrolledSecondFactors::AFTER):
$after lists the first factors a second one follows, [Factor::Password, Factor::Link, Factor::OAuth] by default (The second-factor rule).
new NoSecondFactorLinking(Methods $methods) reads the methods when it is asked, so it can be built
before the OAuth method is added to them.
The throttle's limits
new CacheThrottle($cache, array $limits = [], ?ClockInterface $clock = null, string $prefix = 'omniauth.throttle.') counts the tries in any PSR-6 pool (CacheItemPoolInterface) or PSR-16
cache (CacheInterface): a fixed window per scope and key, [tries, seconds]. $limits replaces
the defaults scope by scope:
| Scope | Tries | Window | What it limits |
|---|---|---|---|
password |
5 | 60 s | password checks, per identifier and visitor's address |
link.begin |
3 | 3600 s | sign-in links sent, per address typed (whatever the visitor's address) |
email-code |
5 | 900 s | e-mailed codes checked, per account |
email-code.begin |
3 | 900 s | e-mailed codes sent, per account |
backup-code |
5 | 900 s | backup codes checked, per account |
totp |
5 | 900 s | authenticator codes checked, per account |
* |
5 | 60 s | any other scope: link, oauth, oauth.begin, passkey, passkey.begin, trusted-device... |
The scopes are the factor's value for a verification (and an enrolment's confirmation), the
factor's value and .begin for a beginning. The keys are the conductor's: <identifier in lower case>|<ip> for a first factor (- without an address), identifier:<identifier in lower case>
for a first factor's beginning with an address typed, account:<handle> once the account is
known. A try is counted before the check; a success resets its scope and key. The window starts at
the first try and lasts its seconds; the cache entry expires with it. Keys are hashed (SHA-256,
under $prefix), so any identifier is a valid cache key.
use Omniauth\CacheThrottle;
use Symfony\Component\Cache\Adapter\RedisAdapter;
$throttle = new CacheThrottle(new RedisAdapter($redis), ['password' => [10, 300]]); // 10 tries in 5 minutes
Three tries in five minutes, then the same account from another address (run in the
harness on 2026-10-25, on Testing\ArrayCache and a FixedClock):
<?php
require 'vendor/autoload.php';
use Omniauth\ArrayState;
use Omniauth\CacheThrottle;
use Omniauth\Context;
use Omniauth\Method\Methods;
use Omniauth\Omniauth;
use Omniauth\Password\PasswordMethod;
use Omniauth\Testing\ArrayCache;
use Omniauth\Testing\FixedClock;
use Omniauth\Testing\InMemoryStore;
use Omniauth\Testing\TestAccount;
$clock = new FixedClock();
$store = new InMemoryStore(new TestAccount('42', 'ringo', 'ringo@example.org', password_hash('yellow-submarine', PASSWORD_BCRYPT)));
$omniauth = new Omniauth(
accounts: $store,
methods: new Methods([new PasswordMethod($store, $store)]),
state: new ArrayState(),
throttle: new CacheThrottle(new ArrayCache($clock), ['password' => [3, 300]], $clock), // 3 tries in 5 minutes
clock: $clock,
);
$context = new Context(ip: '203.0.113.7');
foreach (['a', 'b', 'c', 'yellow-submarine'] as $password) {
echo $omniauth->verify('password', $context, ['identifier' => 'ringo', 'password' => $password])->failure?->value ?? 'authenticated', "\n";
}
$clock->advance(300);
echo $omniauth->verify('password', $context, ['identifier' => 'ringo', 'password' => 'yellow-submarine'])->status->value, " after 5 minutes\n";
echo $omniauth->verify('password', new Context(ip: '198.51.100.9'), ['identifier' => 'ringo', 'password' => 'a'])->failure->value, " from another address\n";
bad-credentials
bad-credentials
bad-credentials
throttled
authenticated after 5 minutes
bad-credentials from another address
CacheThrottlereads, then writes: two requests at the same instant may both pass the last try. A host that needs the limit exact under concurrency implementsContract\Throttleon a store that counts atomically (a rate limiter with a lock).
The identity providers
Providers::configured(array $config, ProviderFactory ...$factories) reads, per entry (its key the
provider's name):
| Key | ||
|---|---|---|
client_id |
string | empty or missing: the entry is left out (a provider without credentials is not served) |
redirect_uri |
string | required for an entry with a client_id (InvalidArgumentException): an absolute address, or a path the trip's origin completes |
type |
string | the kind; missing: the entry's name. The first factory that makes the entry wins; none: InvalidArgumentException naming the package to install |
client_secret, scopes, ... |
the kind's own keys, read by its factory (omniauth/google: client_secret, scopes, hosted_domain) |
new Providers(Provider ...$providers) holds providers given as they are; the two combine:
new Providers($given, ...Providers::configured($config, $factory)). A second provider of the same
name replaces the first. Providers::fromConfig() is deprecated (one release): it makes the Google
entries through omniauth/google's factory.
LeagueProvider and FakeProvider take their options in their constructors
(Identity providers).
The state
new ArrayState(array &$values = []) keeps the attempt in a PHP array held by reference;
ArrayState::session() over $_SESSION (after session_start()). The keys it writes are
omniauth.attempt and omniauth.enrolment.<factor>.
Propose a change docs/configuration.md
Errors
A visitor's mistake is never an exception: a wrong password, an expired code, a forged return
from a provider, too many tries all come back as a Result (or a Challenge, an Enrolled) with
a Failure and, often, a reason (Concepts). Exceptions are the host's
and the application's mistakes, and the one refusal a store throws on purpose.
The exceptions
Every exception class of omniauth implements Omniauth\Exception\OmniauthException (a
\Throwable):
| Exception | Extends | Thrown |
|---|---|---|
Exception\AccountRefused |
\RuntimeException |
by the application's Accounts::check(): the account may not sign in now. $reason (banned, locked...) is the stable word; the conductor catches it and answers account-refused with that reason. Message: The account may not sign in: banned. |
Exception\UnknownMethod |
\InvalidArgumentException |
Methods::get() (so begin(), verify(), enrol(), revoke()) for a factor no method was mounted for. Message: No method is mounted for "totp"; mounted: password. |
OAuth\Exception\ProviderError |
\RuntimeException |
by a provider (Provider::identify()): it vouched for nobody. $reason: exchange-failed (ProviderError::EXCHANGE), invalid-id-token (ID_TOKEN), no-subject (SUBJECT), hosted-domain (HOSTED_DOMAIN). OAuthMethod catches it and answers bad-credentials with that reason; the message (and the previous exception, league's or Guzzle's) is for the server's log, never the page |
Omniauth\Otp\Exception\UnreadableSecret (omniauth/otp) |
\RuntimeException |
a sealed TOTP secret that does not open (another key, an altered row): not caught by the method - a sign-in with that factor fails loudly rather than letting the account in without it |
Programming errors
Not OmniauthExceptions - a host or an application calling omniauth wrongly:
| Exception | When |
|---|---|
\ValueError |
a factor given as a string that is no Factor value (verify('sms', ...)) |
\LogicException |
begin() of a method that has nothing to begin (The "password" method has nothing to begin: verify() it directly.); enrol() of one that is not set up by an account; revoke() of one that cannot be turned off (The "password" method cannot be turned off.); ArrayState::session() before session_start(); a provider's return path with no origin known (Context::$origin empty at the beginning); a league provider that draws its own PKCE verifier; a provider vouching for an identity of another provider's name; Providers::fromConfig() without omniauth/google |
\InvalidArgumentException |
EmailCodeMethod without a secret or with digits outside 4 to 9; TrustedDevices with a secret under 32 bytes; Providers::configured() given an entry without redirect_uri, or one no factory makes; an Identity without a subject; the test doubles given the wrong objects |
These come out of Providers::configured() and the conductor as they are (run in the
harness on 2026-10-25):
Omniauth\Exception\UnknownMethod: No method is mounted for "totp"; mounted: password.
ValueError: "sms" is not a valid backing value for enum Omniauth\Factor
LogicException: The "password" method has nothing to begin: verify() it directly.
LogicException: The "password" method cannot be turned off.
InvalidArgumentException: The "google" provider has no redirect_uri: its return address (or path) is its configuration's.
InvalidArgumentException: No provider factory makes "github" (type "github"): install its package (omniauth/github) and pass its factory.
and an account the application refuses, through the conductor:
$store = new InMemoryStore(new TestAccount('7', 'pete', 'pete@example.org', password_hash('x', PASSWORD_BCRYPT), refused: 'banned'));
$omniauth = new Omniauth($store, new Methods([new PasswordMethod($store, $store)]), new ArrayState());
$result = $omniauth->verify('password', new Context(ip: '203.0.113.7'), ['identifier' => 'pete', 'password' => 'x']);
echo $result->status->value, ' ', $result->failure->value, ' ', $result->reason, ' ', $result->account?->identifier, "\n";
// failed account-refused banned pete
The failures and their reasons
What a page translates - Result::$failure, and Result::$reason under it:
| Failure | Reasons | Given by |
|---|---|---|
bad-credentials |
- | the password; a stub |
unknown-provider, state-mismatch, provider-denied, provider-error, missing-code, exchange-failed, invalid-id-token, no-subject, hosted-domain, linked-elsewhere, other-identity |
an identity provider (Identity providers) | |
unreadable-credential, unknown-passkey, invalid-assertion, invalid-attestation, known-passkey |
omniauth/webauthn | |
bad-code |
- | an e-mailed code, a backup code, a TOTP code that is not the right one |
replayed, unreadable-secret |
omniauth/otp: a code already used (or older than the last used); a secret that is not base32 | |
invalid-link |
- | a sign-in link unknown, used or past its time |
expired |
- | a code past its time; an attempt older than its lifetime; a ceremony's challenge already used or never begun |
throttled |
- | the throttle, before anything was checked |
account-refused |
the account's (banned, locked...) |
Accounts::check() |
refused |
a listener's | Verifying, Beginning, Revoking |
relying-party |
omniauth/webauthn: the host is not under the relying party's id | |
csrf |
the bridges: a form's token refused (not omniauth's own) | |
no-attempt |
- | a second factor with no first one passed |
not-offered |
- | a second factor the attempt did not offer |
not-enrolled |
- | a factor the account has not set up (TOTP without a secret, an untrusted browser) |
link-from-settings |
an identity provider: an account the rule will not link by its address |
What to catch where
| Where | Catch |
|---|---|
| the sign-in pages | nothing: read the Result; translate failure and reason, never show a provider's message |
a store's check() |
throw AccountRefused with a stable reason the page translates |
| a provider of your own | throw ProviderError with one of its reasons for anything the server answered or failed to answer; let programming errors through |
| booting the container, a deploy | \InvalidArgumentException, \LogicException: a configuration to fix, not to retry |
| a sign-in with omniauth/otp's sealed secrets | UnreadableSecret: a key missing or wrong on this host - an alert, not a visitor's error |
Propose a change docs/errors.md
Testing
Omniauth\Testing\ holds what a host's tests (and a first try) need, with no library:
| Class | What it is |
|---|---|
TestAccount |
an account with every proof on it (hash, links, codes, version, identities) |
InMemoryStore |
Accounts, Passwords, LinkTokens, PlainLinkTokens, BackupCodes, TrustedVersion, Identities over TestAccounts; emailCodes() and totpSecrets() for the two whose methods share names |
RecordingSender |
sends nothing, keeps every Message: last()->secret is the link's token or the code |
FixedClock |
PSR-20, still until advance($seconds) or set() |
ArrayCache |
PSR-16 in an array, on that clock: CacheThrottle with no cache library |
StubMethod |
a first factor (a passkey, a provider) passing for ['handle' => ...], without its library |
StubSecondFactor |
a second factor (an authenticator application) enrolled for the handles given |
A host's test drives the conductor with them, here a passkey and an authenticator application stood in for (run in the harness on 2026-10-25):
<?php
require 'vendor/autoload.php';
use Omniauth\ArrayState;
use Omniauth\Context;
use Omniauth\Factor;
use Omniauth\Method\Methods;
use Omniauth\Omniauth;
use Omniauth\Password\PasswordMethod;
use Omniauth\Testing\FixedClock;
use Omniauth\Testing\InMemoryStore;
use Omniauth\Testing\StubMethod;
use Omniauth\Testing\StubSecondFactor;
use Omniauth\Testing\TestAccount;
$store = new InMemoryStore(new TestAccount('1', 'ringo', 'ringo@example.org', password_hash('x', PASSWORD_BCRYPT)));
$omniauth = new Omniauth($store, new Methods([
new PasswordMethod($store, $store),
new StubMethod(Factor::Passkey, $store), // a passkey, without webauthn-lib
new StubSecondFactor(Factor::Totp, $store, ['1']), // an authenticator application set up by account 1
]), new ArrayState(), clock: new FixedClock());
echo $omniauth->verify('passkey', new Context(), ['handle' => '1'])->status->value, " by passkey (QA1: no second factor)\n";
echo $omniauth->verify('password', new Context(), ['identifier' => 'ringo', 'password' => 'x'])->status->value, " by password\n";
echo $omniauth->verify('totp', new Context(), ['code' => '123456'])->status->value, " by totp\n";
authenticated by passkey (QA1: no second factor)
second-factor-required by password
authenticated by totp
The test doubles in detail
TestAccount - public properties, every proof on the account:
| Property | Type | Default | Read by |
|---|---|---|---|
handle, identifier |
string |
- | Accounts::handle(), identifier() |
email |
?string |
null |
email(); byIdentifier() matches it too, case aside |
password |
?string |
null |
a password_hash() or Symfony hash |
name |
?string |
null |
displayName() (else the identifier) |
refused |
?string |
null |
a reason: check() throws AccountRefused with it |
totpSecret |
?string |
null |
InMemoryTotpSecrets |
emailCodeEnabled, emailCode |
bool, ?string |
false, null |
InMemoryEmailCodes |
trustedVersion |
int |
0 |
TrustedVersion |
links, plainLinks |
array<string, int> |
[] |
a link's hash (or clear value) => until when it serves |
backupCodes |
list<string> |
[] |
the hashes |
identities, identityDetails |
array |
[] |
provider => subject; provider => email, emailVerified, usedAt |
InMemoryStore takes TestAccount objects (add() adds one later) and refuses any other object
(InvalidArgumentException); emailCodes() and totpSecrets() give the stores of those two
contracts over the same accounts. RecordingSender keeps $sent (account and message pairs),
last() and count(). FixedClock starts at 2026-10-20 12:00:00 UTC unless given another time;
ArrayCache reads its lifetimes on the clock it is given (the system's otherwise). StubMethod
refuses a second factor and StubSecondFactor a first one (InvalidArgumentException);
StubSecondFactor's code is 123456 unless given another.
Identity providers in tests
Omniauth\OAuth\Testing\FakeProvider stands in for Google and the person consenting there, with no
network (Identity providers); a provider package checks itself against the
contract with Omniauth\Testing\ProviderContractTests (Conformance suite).
The method packages ship their own doubles: omniauth/otp's Testing\AuthenticatorApp (the person's
phone), omniauth/webauthn's Testing\SoftwareAuthenticator (a P-256 authenticator answering as a
browser) and Testing\InMemoryPasskeys.
In a framework's tests
Under Symfony and Laravel the stores are services: bind the in-memory ones (or the application's
on a test database) in the test environment, and replace the Sender with a RecordingSender to
read the link or the code a test signs in with. omnibridge's own suites do so (tests/AuthTest.php
in omnibridge/symfony and omnibridge/laravel: every method signed in through a kernel or an
application, a simulated Google included).
Propose a change docs/testing.md
The Docker harness
docker/ runs this package with every omniauth/* method package - from GitHub (branch 1.x), or
from the checkouts beside this one when OMNIAUTH_PLUGINS=../.. is set in docker/.env - on PHP
8.4, with scheb/2fa installed beside them as a development dependency (the trusted-device tokens
are compared with its encoder's, omniauth/otp's codes with its TOTP authenticator's), and what each
method package's tests need beside it (docker/harness/plugins.php: doctrine/dbal and
web-auth/webauthn-symfony-bundle for omniauth/webauthn's comparison with the bundle's records).
Nothing runs on the host.
cd docker && cp .env.dist .env # the checkouts of the workspace
docker compose run --rm omniauth test # every package's tests
docker compose run --rm omniauth bare # plain PHP: a whole sign-in by hand, and what PHP loaded
docker compose down -v
bare builds everything by hand on the in-memory stores: a password alone; a password, a code by
e-mail and "trust this browser", then the same account on that browser with no code; a backup code;
a link, then the same link again; a password of 102 bytes hashed by Symfony; six wrong passwords.
Tests/BareTest runs it, and loads every class of the package, in PHP processes of their own, and
checks that nothing is loaded but omniauth, the PSR interfaces and symfony/password-hasher - not a
class of a framework, of scheb/2fa or of an application, though the harness has them installed.
Running a script of your own
The entrypoint also runs php, composer and sh as they are; the harness's autoloader is
/harness/vendor/autoload.php. The examples of this manual were run so, a scratch directory
mounted read-only:
docker compose run --rm -v "$PWD/scratch:/scratch:ro" omniauth php /scratch/example.php
On 2026-10-25 (PHP 8.4.26, PHPUnit 11.5.57, the four packages from their checkouts), test ran
235 tests, 2490 assertions, green, one skipped; bare printed each attempt's result as JSON
(password: authenticated as ringo; two-steps: second-factor-required with email-code and
backup-code offered, then authenticated by password + email-code with a trusted device,
then by password + trusted-device).
The harness's Composer project lives in the volume omniauth-harness_harness: docker compose down -v removes it, and the next run installs again (over the network: the method packages that
have no checkout under OMNIAUTH_PLUGINS come from GitHub).
Propose a change docs/harness.md
Writing a method or a provider
omniauth has two extension points: a method (a way to prove a factor, Method\Method) and an
identity provider (a server the OAuth method sends the visitor to, OAuth\Provider\Provider).
The packages omniauth/otp and omniauth/webauthn are methods; omniauth/google is a provider.
A method
A method checks a proof within an attempt and touches neither request nor response. It implements
Method\Method and, as it needs, the other interfaces of Methods:
use Omniauth\Attempt;
use Omniauth\Context;
use Omniauth\Factor;
use Omniauth\Failure;
use Omniauth\Method\SecondFactor;
use Omniauth\Method\Verdict;
use Omniauth\Proof;
/** A code read from a hardware token, checked by the application's own service. */
final class HardwareTokenMethod implements SecondFactor
{
public function __construct(private readonly TokenChecker $checker) {}
public function factor(): Factor
{
return Factor::Totp; // the factors are omniauth's enum: a method stands for one of them
}
public function enrolled(object $account): bool
{
return $this->checker->hasToken($account);
}
public function verify(Attempt $attempt, Proof $proof, Context $context): Verdict
{
$account = $attempt->account(); // a second factor: the account the first factor found
if (null === $account) {
return Verdict::refused(Failure::NoAttempt);
}
return $this->checker->check($account, $proof->string('code'))
? Verdict::passed($account)
: Verdict::refused(Failure::BadCode);
}
}
The rules every method of the core keeps:
- The factor is one of
Factor's cases.Methodsholds one method per factor: a method of your own replaces the core's for that factor.Factor::role()decides whether it is a first factor (it finds the account:Verdict::passed($account)with the account it found) or a second one (the account is$attempt->account()). - What a ceremony remembers goes into the attempt, never into the answer:
$attempt->keep($factor, $value)atbegin(),$attempt->take($factor)atverify()(a challenge serves once), values of scalars and arrays (the attempt is serialised into the session). Nothing kept at verification time isVerdict::expired(). - One answer for "no such account" and "wrong secret":
bad-credentialsfor a first factor, in the same time when the method can (the password checks a hash of nothing). - Stable failures, stable reasons: a
Failurecase, and a short reason a page translates (replayed,invalid-assertion); a library's message is for the log. - No request, no response: what the request says comes in the
Context, what the visitor sent in theProof; what the host must do comes out in theChallengeor theVerdict. - The conductor does the rest - throttle, events,
Accounts::check(), the second-factor rule,SignedIn: a method does not dispatch, throttle or check the account.
A method to set up from the settings implements Enrolling (enrolBegin(), enrolConfirm(), the
enrolment being an Attempt with its account identified); one to turn off, Revocable. A second
factor the rule must know about implements SecondFactor::enrolled(). A second factor read from the
request rather than typed implements Ambient (the conductor tries it by itself and asks it for the
cookie's value after trust).
Test a method through the conductor, on the in-memory stores and a FixedClock
(Testing).
An identity provider
A provider gives the authorization address of a trip and, for the code that came back, the
Identity it vouches for:
use Omniauth\OAuth\Authorization;
use Omniauth\OAuth\Exception\ProviderError;
use Omniauth\OAuth\Identity;
use Omniauth\OAuth\Provider\Provider;
interface Provider
{
public function name(): string; // its key: the routes' {provider}, Identities' $provider
public function authorizationUrl(Authorization $authorization): string; // carries the state, the S256 challenge, the nonce
/** @throws ProviderError */
public function identify(string $code, Authorization $authorization): Identity;
}
What omniauth gives the provider, OAuth\Authorization:
| Member | |
|---|---|
state, verifier, nonce |
32 random bytes each, base64url (43 characters); the verifier never leaves the server |
origin |
the scheme and host of the request that began the trip (https://example.org:8443), or null |
challenge() |
the verifier's S256 challenge, BASE64URL(SHA-256(verifier)) (RFC 7636 §4.2) |
returnAddress(string $configured) |
an absolute address as it is; a path on the trip's origin (LogicException without one) |
matches(string $state) |
constant-time comparison (the method's, before anything else) |
Authorization::fresh(?string $origin), toArray(), fromArray(), Authorization::base64url() |
What the provider gives back, OAuth\Identity: provider (its own name - the method refuses an
identity of another), subject (not empty: InvalidArgumentException), email, emailVerified
(true only when the provider says so: Identity::verified($claim) reads a JSON true, "true", 1
or "1"), name, and every claim as the provider said it (claims).
The rules:
- The return address is the configuration's, never the request's: absolute, or a path completed
by
Authorization::returnAddress(); the same address is sent again at the exchange. - PKCE and the state are omniauth's: the address carries
stateandcode_challengewithcode_challenge_method=S256; the exchange sendscode_verifier. An OpenID Connect provider sends thenonceand checks it in the ID token. - Everything the server refuses or fails to answer is a
ProviderErrorwith one of its four reasons - never another exception, never the client's secret in its message. - Over league/oauth2-client, extend
OAuth\Provider\LeagueProviderand override what differs (Over league/oauth2-client): omniauth/google adds the nonce inauthorizationOptions()and reads the ID token inidentity().
A provider made from a host's configuration comes with a factory:
use Omniauth\OAuth\Provider\Provider;
use Omniauth\OAuth\Provider\ProviderFactory;
final class ForgeFactory implements ProviderFactory
{
public function create(string $name, array $entry): ?Provider
{
if ('forge' !== ($entry['type'] ?? $name)) {
return null; // not this factory's kind: the next factory is asked
}
return new ForgeProvider($name, (string) $entry['client_id'], (string) ($entry['client_secret'] ?? ''), (string) $entry['redirect_uri']);
}
}
Providers::configured() asks each factory in turn and has already left out the entries without a
client_id and refused those without a redirect_uri. Under Symfony a factory class of the
application is autoconfigured (tag omniauth.oauth_provider_factory); under Laravel, tag its binding
omniauth.oauth_provider_factory.
Its conformance suite
A provider package runs Omniauth\Testing\ProviderContractTests on recorded answers, in a
Tests/<Name>ContractTest.php: Conformance suite lists what each
test checks and the hooks to fill (provider(), consented(), refused(), unanswered(),
factory(), entry(), secret(), sendsNonce()). The trait requires phpunit/phpunit (omniauth
suggests it). Methods have no shared suite: each package's tests drive its method through the
conductor.
Propose a change docs/providers.md
Verified, and not
What the tests prove
Run on 2026-10-25 in the harness (docker compose run --rm omniauth test, PHP
8.4.26, PHPUnit 11.5.57), the four packages from their checkouts: 235 tests, 2490 assertions,
green - the core 126, omniauth/google 28, omniauth/otp 47, omniauth/webauthn 34. One skipped:
ProvidersTest::testTheFormerFromConfigNeedsGooglesPackage, which needs omniauth/google absent
(omniauth/google's own tests cover the deprecated path).
The core's tests (every file also linted on PHP 8.2):
- each method's way through and its refusals on the in-memory stores - wrong secret, unknown address answered as a wrong password, expired, used twice, throttled, a banned account, the second factor offered, passed, not offered, its attempt expired;
- QA1 and QA2 through the default rule;
- the trusted-device tokens equal to those scheb/2fa 8.6.1's
JwtTokenEncoderwrites for the same input, whole seconds and microseconds, each read by the other (recorded, and live with scheb/2fa installed in the harness); - hashes written by symfony/password-hasher 8.1 ("auto": bcrypt, cost 13), one of a 102-byte
password that
password_verify()alone refuses; docs/installation.mdanddocs/oauth.mdrun as written (Tests/DocsTest.php);- in plain PHP (
Tests/BareTest.php), a whole sign-in by hand loading nothing but omniauth, the PSR interfaces and symfony/password-hasher; a sign-in through a fake identity provider loading no OAuth library; every class of the package loading with them alone,LeagueProviderincluded; - the identity providers' trip (state, PKCE, nonce, forged and injected returns, the linking rule,
a return path on the host of each trip) on the fake provider, and
LeagueProviderover a generic league provider with recorded answers; - the providers' conformance suite (
Omniauth\Testing\ProviderContractTests) green forFakeProviderand omniauth/google, on recorded answers.
The examples of this manual were run in the harness on 2026-10-25, and their output pasted as
printed: the installation, the identity providers, the sign-in link, events, testing, the
throttle's limits, errors, LeagueProvider's address.
The bridges are tested in omnibridge: tests/AuthTest.php of omnibridge/symfony (a kernel signing
in through omniauth alone: each method, the second factor, its page and nobody else's, refusals,
CSRF, throttle, remember-me, trusted device, a simulated Google, the return address on the host of
each request under one container) and of omnibridge/laravel; they were not run for this manual.
What is not verified
- An application's own stores: omnibase fills them (
Base\Auth\, step AU4) and tests them in its own suite. - A cookie written by a running scheb/2fa on an application: the encoder is compared, not a browser's cookie.
- Concurrent tries against
CacheThrottle(read then written, not atomic: a host that needs it exact uses a store that counts atomically). - A real identity provider: Google's answers are recorded from its documentation (omniauth/google's manual).
- The Symfony and Laravel pages were written from omnibridge's code at
its 1.x branch; the
omniauth.yamldefaults were resolved with Symfony's ConfigProcessorin omnibridge/symfony's image, the Laravel defaults read fromAuth\Settings.
Responsibility
omnisense/omniauth and its omniauth/* packages are independent code: omniauth/google is an
unofficial client of Google's sign-in, not affiliated with or endorsed by Google, and whoever runs
it complies with Google's terms for its APIs. The MIT licence covers this code only.