Aller au contenu

omnisense/omniauth

Omniauth: who signs in, and by which factors, under any PHP host - the contracts (accounts, the stores of each proof, the second-factor rule, the host's small duties), the conductor (begin, then verify; the attempt, the result), the methods that need no library (password through symfony/password-hasher, sign-in link, code by e-mail, backup codes, trusted devices in scheb/2fa's cookie format), the sign-in through identity providers (Omniauth\OAuth\: state, PKCE and nonce, the linking by the provider's subject, the sign-up of an unknown identity; each provider a package of its own, omniauth/google first), PSR-14 events and Testing helpers. WebAuthn (passkeys) and one-time passwords (TOTP) are packages of their own; the Symfony and Laravel bridges live in omnibridge.

Étage
Famille
Version
1.x
Suite de conformité
branchée, CI verte
Vérifié en réel
Vérifié en partie
Licence
MIT
composer require omnisense/omniauth:1.x-dev

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::AFTER is [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

Proposer une modification 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/omniauth requires psr/cache (^2.0 or ^3.0), psr/simple-cache (^2.0 or ^3.0), psr/clock (^1.0), psr/event-dispatcher (^1.0) and symfony/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 for CacheThrottle in plain PHP), phpunit/phpunit (the providers' conformance suite) and omnisense/omnibridge (the hosts' contracts, among them Omnibridge\Contract\Tokens for a form's CSRF token).
  • omniauth/otp adds ext-sodium, spomky-labs/otphp and paragonie/constant_time_encoding; omniauth/webauthn adds ext-openssl, web-auth/webauthn-lib, symfony/serializer and symfony/uid; omniauth/google adds 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 (PasswordMethod over Passwords, EmailCodeMethod over EmailCodes and a Sender...), mounted in a Method\Methods;
  • the conductor, Omniauth, over the accounts, the methods, the visitor's State (here an array; the session under a framework) and optionally a Throttle, a PSR-14 dispatcher, a PSR-20 clock and a second-factor rule (by default EnrolledSecondFactors);
  • a Context per request (origin, host, address, browser, the trusted_device cookie, 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 OmnibridgeBundle registers omniauth's extension where omniauth is installed; config/packages/omniauth.yaml turns each method on, and one authenticator, Omnibridge\Symfony\Auth\OmniauthAuthenticator, signs in by all of them: Symfony.
  • Laravel: omnibridge/laravel's Auth\AuthServiceProvider, on with config/omnibridge.php's 'auth' => true; config/omniauth.php takes the same keys as omniauth.yaml, and the guard driver omniauth signs in: Laravel.
  • Any other host: build the conductor once per request (or once per worker, with a State that reads the current request's session), as the script above does, and answer its Result as described above.

Proposer une modification 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 over Omniauth::verify(), the "second factor in progress" token answering IS_AUTHENTICATED_2FA_IN_PROGRESS (scheb/2fa's name, so access_control does 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 as State, omniauth's CacheThrottle on a cache pool as Throttle, the hasher security.yaml sets for the user class: Symfony.
  • omnibridge/laravel (Omnibridge\Laravel\Auth\, step AU8): a guard and a user provider (driver omniauth) over the same conductor, the same addresses and route names, the session as State, CacheThrottle on 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.

Proposer une modification 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's RememberMeBadge enabled when Result::$remember, the trusted_device cookie when omniauth gave one (path /, HTTP only, SameSite=Lax, secure when the request is, for trusted_device.lifetime); a redirect to the posted _target_path when it is an address of this site, else the firewall's saved target path, else default_target_path; a passkey gets {"status": "ok", "errorMessage": ""}.
  • SecondFactorRequired: a PendingToken (below) and a redirect to second_factor.form_route; a passkey gets the JSON with redirect.
  • Failed and IdentityUnknown: a SignInRefused (a CustomUserMessageAuthenticationException carrying the factor, omniauth's failure and reason, and the identity). The password goes back to its own route, the others to login_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 to login_route when the attempt is gone (expired, no-attempt); an unknown identity is kept in the session (omnibridge.auth.pending_identity) and goes to oauth.sign_up_route when 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 (PendingTrustResolver refuses IS_AUTHENTICATED_FULLY to it) and lists the factors offered (offered()).
  • PendingVoter grants IS_AUTHENTICATED_2FA_IN_PROGRESS to that token alone: neither a visitor signed out nor one signed in opens the second factor's page.
  • SecondFactorGate sends that visitor to the second factor's page from any other page, after the firewall (priority 7 on kernel.request) and when Security refused a page (kernel.exception, priority 2) - but the page and its check, a page access_control opens to everybody (PUBLIC_ACCESS) or to that token, and the sign-out.

Proposer une modification 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.

Proposer une modification 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:

  1. 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-attempt or expired; a factor the attempt did not offer fails with not-offered.
  2. 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.
  3. Event\Verifying: a listener may refuse (refused, with its reason).
  4. The method checks the proof and gives a Method\Verdict. A provider's identity that no account is linked to ends the attempt with IdentityUnknown; a refusal keeps the attempt and fails with the method's failure (bad-credentials when it gave none) and reason.
  5. Accounts::check(): an account that may not sign in now ends the attempt with account-refused and its reason (Event\AccountRefused is dispatched).
  6. 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).
  7. 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: SecondFactorRequired with the factors offered, the attempt kept, Event\SecondFactorRequired.
  8. Done: Authenticated, the attempt cleared, Event\SignedIn; after a second factor whose proof asked trust, the first ambient method's new cookie value in Result::$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.

Proposer une modification 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 Throttle contract's docblock names Symfony's RateLimiterFactory and Laravel's RateLimiter as hosts' throttles; both bridges mount omniauth's CacheThrottle instead (on a cache pool, or a cache store). Either may implement the contract: one with an atomic store is exact under concurrent tries, CacheThrottle is not (Configuration).

Proposer une modification 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).

Proposer une modification docs/password.md

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.begin in CacheThrottle::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.

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).

Proposer une modification docs/link.md

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.

Proposer une modification 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.

Proposer une modification 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.

Proposer une modification 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)

  1. The account linked to the provider's subject (Identities::owner($provider, $sub)), whatever the address says now: Google's sub is the person's stable identifier there; an address can change hands.
  2. Else, only when the provider says the address is verified (Google's email_verified), the account of that address (Accounts::byIdentifier(), then Accounts::email() compared, case aside: a user name that looks like the address is not it) - linked there and then (Identities::link()), unless:
    • byEmail refuses it: false never links by an address; NoSecondFactorLinking refuses 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, reason link-from-settings - the person signs in otherwise and links the provider from the settings.
  3. Else nobody: Status::IdentityUnknown, $result->identity (a PendingIdentity: 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'.

Proposer une modification 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. Passkey is 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. OAuth is 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:

  1. none of the factors passed is in $after (a passkey alone, say): nothing is due;
  2. a second factor was passed already (a trusted device, read by the conductor on the way): nothing is due;
  3. 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;
  4. 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.

Proposer une modification 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

Proposer une modification 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

CacheThrottle reads, then writes: two requests at the same instant may both pass the last try. A host that needs the limit exact under concurrency implements Contract\Throttle on 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>.

Proposer une modification 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

Proposer une modification 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).

Proposer une modification 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).

Proposer une modification 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. Methods holds 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) at begin(), $attempt->take($factor) at verify() (a challenge serves once), values of scalars and arrays (the attempt is serialised into the session). Nothing kept at verification time is Verdict::expired().
  • One answer for "no such account" and "wrong secret": bad-credentials for a first factor, in the same time when the method can (the password checks a hash of nothing).
  • Stable failures, stable reasons: a Failure case, 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 the Proof; what the host must do comes out in the Challenge or the Verdict.
  • 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 state and code_challenge with code_challenge_method=S256; the exchange sends code_verifier. An OpenID Connect provider sends the nonce and checks it in the ID token.
  • Everything the server refuses or fails to answer is a ProviderError with one of its four reasons - never another exception, never the client's secret in its message.
  • Over league/oauth2-client, extend OAuth\Provider\LeagueProvider and override what differs (Over league/oauth2-client): omniauth/google adds the nonce in authorizationOptions() and reads the ID token in identity().

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.

Proposer une modification 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 JwtTokenEncoder writes 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.md and docs/oauth.md run 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, LeagueProvider included;
  • 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 LeagueProvider over a generic league provider with recorded answers;
  • the providers' conformance suite (Omniauth\Testing\ProviderContractTests) green for FakeProvider and 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.yaml defaults were resolved with Symfony's Config Processor in omnibridge/symfony's image, the Laravel defaults read from Auth\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.

Proposer une modification docs/verified.md