omniauth / otp
Omniauth's one-time passwords over spomky-labs/otphp, under any PHP host - authenticator applications (TOTP; HOTP possible): the second factor, its enrolment (a secret and its otpauth:// address) and its revocation, with scheb/2fa's parameters (SHA1, 30 s, 6 digits, the same leeway and provisioning
Requires
- php: >=8.2
- ext-sodium: *
- omniscient/omniauth: ^1.0@dev
- paragonie/constant_time_encoding: ^2.6|^3.0
- psr/cache: ^2.0|^3.0
- psr/clock: ^1.0
- psr/simple-cache: ^2.0|^3.0
- spomky-labs/otphp: ^11.3
Requires (Dev)
- phpunit/phpunit: ^11.0
- scheb/2fa: ^8.0
Suggests
None
Provides
None
Conflicts
None
Replaces
- omniauth/totp: 1.x-dev
This package is auto-updated.
Last update: 2026-10-23 04:02:28 UTC
README
One-time passwords - authenticator applications (TOTP, RFC 6238) - for omniscient/omniauth, under any PHP host, over spomky-labs/otphp - the library scheb/2fa verifies with, on the same parameters, so the secrets an application keeps today go on working and a code scheb accepts is accepted here. Once: the same code is refused the second time.
use Omniauth\{ArrayState, Context, Factor, Omniauth}; use Omniauth\Method\Methods; use Omniauth\Password\PasswordMethod; use Omniauth\Otp\{CacheUsedSteps, SealedTotpSecrets, SodiumSecretBox, TotpMethod}; $omniauth = new Omniauth( accounts: $accounts, // your accounts, behind Omniauth\Contract\Accounts methods: new Methods([ new PasswordMethod($accounts, $passwords), new TotpMethod($accounts, new SealedTotpSecrets($secrets, SodiumSecretBox::fromBase64($key)), new CacheUsedSteps($pool)), // $secrets: Contract\TotpSecrets (user.secret); $key: from your vault, never a file of the repository; $pool: PSR-6 or PSR-16 ]), state: new ArrayState($_SESSION), ); $context = new Context(origin: 'https://example.org', host: 'example.org', ip: $_SERVER['REMOTE_ADDR']); $result = $omniauth->verify('password', $context, ['identifier' => 'ringo@example.org', 'password' => '...']); // SecondFactorRequired, $result->offered === [Factor::Totp] $result = $omniauth->verify('totp', $context, ['code' => '481 516']); // Authenticated, $result->passed === [Factor::Password, Factor::Totp]; the same code again: bad-code, "replayed" $enrolment = $omniauth->enrol(Factor::Totp, $account); // from the settings $challenge = $enrolment->begin($context); // $challenge->data: 'secret', 'uri' (otpauth://, for the QR code) $enrolment->confirm($context, ['code' => '123456']); // a first code: the secret is kept
It requires omniscient/omniauth, spomky-labs/otphp ^11.3 (and paragonie's constant-time encoding, which otphp requires), PHP's sodium extension (bundled with PHP) and the PSR cache and clock interfaces. No framework.
The same as scheb/2fa
Every site sets scheb/2fa's TOTP the same way, and omnibase's User gives it the same
configuration; TotpMethod's defaults are those:
| scheb/2fa today | TotpMethod |
|
|---|---|---|
| algorithm, period, digits | User::getTotpAuthenticationConfiguration(): SHA1, 30 s, 6 |
ALGORITHM, PERIOD, DIGITS |
| leeway | scheb_two_factor.totp.leeway: 0 on all 25 sites |
LEEWAY = 0 (seconds, as scheb's) |
| verification | TOTP::verify($code, null, $leeway), spaces stripped |
the same call |
| secret | TotpAuthenticator::generateSecret(): 32 bytes, base32 upper, unpadded |
TotpMethod::secret(): the same |
| QR code's address | getQRContent(): label = the identifier (@ server name when set), issuer when set |
uri(): the same address |
| the same code twice | accepted | refused (bad-code, reason replayed) |
A code once
Contract\UsedSteps remembers, per account, the last time step whose code was accepted: a code of
that step or an earlier one is refused, inside the leeway too (RFC 6238 ยง5.2). CacheUsedSteps
keeps it in any PSR-6 pool or PSR-16 cache - the host's cache pool - for as long as the code could
still verify. Read then written, not atomic: two requests with the same code at the same instant may
both pass (the conductor's throttle, five codes per 15 minutes per account, bounds the rest).
The secrets at rest (QA5)
Marco's decision QA5: the TOTP secrets are encrypted at rest with libsodium. SodiumSecretBox
seals a secret with sodium_crypto_secretbox (XSalsa20-Poly1305, encrypted and authenticated)
under a 32-byte key, as otp:v1: followed by the base64url of the nonce and the box (130
characters for a 52-character secret: omnibase's user.secret, VARCHAR 255, holds it).
SealedTotpSecrets wraps the application's TotpSecrets store with the box: the store keeps the
box's text, TotpMethod reads the base32 secret. A secret is opened only to verify a code or to
draw the QR code; whether an account has one is read without opening it. A secret kept in clear
before the box is read as it is (it has no prefix) until the rows are rewritten; a secret that does
not open (another key, an altered row) throws Exception\UnreadableSecret - never "no secret",
which would let the account in without its second factor.
The key comes from the host's secrets vault or environment, in base64
(SodiumSecretBox::fromBase64()), never from a file of the repository; the two hosts of one
database (a Symfony site and its Laravel twin) use the same key: omnibridge/symfony reads
omniauth.totp.secret_key, omnibridge/laravel omniauth.totp.secret_key of config/omniauth.php.
Rotating it, and rewriting existing rows: the method's page.
The shape
TotpMethod |
a second factor (SecondFactor, Enrolling, Revocable): verify(), enrolBegin()/enrolConfirm(), revoke(), uri(), code(), secret() |
Contract\UsedSteps, CacheUsedSteps |
a code once |
Contract\SecretBox, SodiumSecretBox, SealedTotpSecrets |
the secrets sealed at rest (Exception\UnreadableSecret when one does not open) |
Testing\AuthenticatorApp |
the person's phone: scans the enrolment's uri, shows the code of the moment |
The store of the secrets is omniauth's Omniauth\Contract\TotpSecrets (omnibase: user.secret,
step AU4); Omniauth\Testing\InMemoryTotpSecrets in tests.
Formerly omniauth/totp
This package was omniauth/totp until 2026-10-23 (renamed: one-time passwords, TOTP today and
HOTP possible), with its history. It replaces omniauth/totp, so the two are never installed
together, and the former namespace Omniauth\Totp\ stays an alias of Omniauth\Otp\ for one
release (Legacy/: each former name loads the new class, with a deprecation notice). The class
names (TotpMethod...), the method's name ('totp', Factor::Totp) and the codes do not change.
composer remove omniauth/totp && composer require omniauth/otp
Documentation
Verified, and not
- Verified (omniauth's harness,
docker compose run --rm omniauth testin the core'sdocker/, PHP 8.4, scheb/2fa 8.6.1 installed beside; every file linted on PHP 8.2):- RFC 6238's SHA1 vectors; a password then a code, through the conductor; an account without a secret asked nothing; a code once - the same code in its period refused, the next period's accepted, the same code good for another account, an earlier code inside the leeway refused after a later one; the leeway in scheb's seconds; wrong and empty codes; five wrong codes and the account throttled whatever the address; the enrolment (secret, address, a wrong then a good first code, the secret kept, its code used up), an enrolment never begun, the revocation (the trusted devices fall back); the address with a server name and an issuer; a sealed store;
- against scheb/2fa itself: a secret scheb drew and a code scheb accepts (it accepts it twice)
sign in here once, and the second time are refused; second by second across two period
boundaries, every code of the periods around and a wrong one, for leeways 0, 5 and 29, scheb's
checkCode()and a first use here answer the same (more than 300 comparisons each); the QR code's address equal togetQRContent()with and without a server name and an issuer; codes and an address scheb wrote, recorded, equal here without scheb; docs/installation.mdrun as written; in plain PHP (BareTest), an enrolment and a sign-in by hand loading nothing but omniauth, otphp, paragonie's encoding, the PSR interfaces and symfony/password-hasher - no class of a framework, of scheb/2fa or of an application.- sealed secrets: the format (prefix, base64url, 130 characters), a new nonce each time, the
same key on another instance, a clear secret read as it is, a sealed one not sealed twice;
another key, an altered, a truncated or a non-base64 value refused with
UnreadableSecret(never taken for "no secret": the second factor is still asked); the key in base64 (standard and URL-safe), refused at a wrong length; an enrolment and a sign-in on a sealed store - the store keeps the box's text, the rule asks whether the account has a secret without opening it, the code is verified with the secret opened, the settings' QR address is the secret's; - the former
Omniauth\Totp\names: each the new class, each with its deprecation notice.
- Not verified: a real phone's application (at AU7, in Chrome, against the site); concurrent tries
of the same code against
CacheUsedSteps.
Licence
MIT.