omniauth / totp
Omniauth's authenticator applications (TOTP) over spomky-labs/otphp, under any PHP host: 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 address) so the secrets an applicati
Requires
- php: >=8.2
- 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
None
This package is auto-updated.
Last update: 2026-10-20 22:54:51 UTC
README
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\Totp\{CacheUsedSteps, TotpMethod}; $omniauth = new Omniauth( accounts: $accounts, // your accounts, behind Omniauth\Contract\Accounts methods: new Methods([ new PasswordMethod($accounts, $passwords), new TotpMethod($accounts, $secrets, new CacheUsedSteps($pool)), // $secrets: Contract\TotpSecrets (user.secret); $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) 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: the seam (QA5)
Marco's decision QA5 encrypts the TOTP secrets (sodium, the key in the secrets vault) in a step of
its own, after lesringostars switches. The seam is here: Contract\SecretBox (seal(), open())
and SealedTotpSecrets, which wraps the application's TotpSecrets store with a box - the method
never knows. No box ships yet; until then secrets go through the store as they are (base32, in
clear, as today).
The shape
TotpMethod |
a second factor (SecondFactor, Enrolling, Revocable): verify(), enrolBegin()/enrolConfirm(), revoke(), uri(), code(), secret() |
Contract\UsedSteps, CacheUsedSteps |
a code once |
Contract\SecretBox, SealedTotpSecrets |
the seam for sealed secrets |
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.
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.
- Not verified: a real phone's application (at AU7, in Chrome, against the site); the host's store
(
Base\Auth\UserTotpSecrets, AU4); concurrent tries of the same code againstCacheUsedSteps; sealed secrets (no box yet: QA5's own step).
Licence
MIT.