Aller au contenu

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 address) so the secrets an application keeps today go on working, a code accepted once only, and the secrets sealed at rest with libsodium (SodiumSecretBox, the key in the host's vault; the ones kept in clear read until rewritten). Formerly omniauth/totp, which it replaces (the Omniauth\Totp\ names stay aliases for one release).

Étage
Fournisseur · Omniauth
Version
1.x
Suite de conformité
non branchée
Vérifié en réel
Vérifié en partie
Licence
MIT
composer require omniauth/otp:1.x-dev

One-time passwords - authenticator applications (TOTP, RFC 6238) - as a second factor of omnisense/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/2fa accepts is accepted here - once: the same code is refused the second time. The secrets can be sealed at rest with libsodium (Marco's decision QA5). It calls no service: everything happens between the server and the person's phone.

new TotpMethod($accounts, new SealedTotpSecrets($secrets, SodiumSecretBox::fromBase64($key)), new CacheUsedSteps($pool));

This package was omniauth/totp until 2026-10-23 (renamed: TOTP today, HOTP possible). It replaces omniauth/totp, and the former namespace Omniauth\Totp\ stays an alias of Omniauth\Otp\ for one release (Legacy/: each former class name loads the new class, with a deprecation notice). The factor keeps its name, totp (Factor::Totp).

Installation

composer require omnisense/omniauth omniauth/otp

Requires PHP 8.2 with ext-sodium (bundled with PHP), omnisense/omniauth (1.x), spomky-labs/otphp (^11.3), paragonie/constant_time_encoding (^2.6 or ^3.0), psr/cache, psr/simple-cache and psr/clock. No framework. Under omnibridge/symfony and omnibridge/laravel, the totp key turns it on (Hosts).

Options

new TotpMethod(...):

Argument Type Default
accounts Omniauth\Contract\Accounts required the handle (the steps used are per account) and the identifier (the QR address's label)
secrets Omniauth\Contract\TotpSecrets required the base32 secrets (omnibase: user.secret); a SealedTotpSecrets to seal them
used Contract\UsedSteps required the steps already used: CacheUsedSteps on a cache
leeway int, 0 to period - 1 0 (TotpMethod::LEEWAY) seconds a code is accepted before and after its period (scheb/2fa's leeway)
period int, at least 1 30 (PERIOD)
digits int, 6 to 10 6 (DIGITS)
algorithm string sha1 (ALGORITHM) otphp's digest name
issuer ?string null the QR address's issuer (scheb's issuer)
server ?string null appended to the label after @ (scheb's server_name)
clock ?Psr\Clock\ClockInterface null: SystemClock

Values outside those ranges throw an InvalidArgumentException from the constructor.

Class Constructor
CacheUsedSteps (CacheItemPoolInterface|CacheInterface $cache, string $prefix = 'omniauth.totp.') the last step each account used, in any PSR-6 pool or PSR-16 cache, keyed by the handle's SHA-256
SodiumSecretBox (string $key): 32 raw bytes; SodiumSecretBox::fromBase64($key): base64, standard or URL-safe, padded or not generateKey() draws a key (base64), isSealed($stored), seal(), open(); PREFIX is otp:v1:
SealedTotpSecrets (TotpSecrets $store, Contract\SecretBox $box) the store with its secrets sealed: secret() opens, has() does not, enable() seals

Capabilities

a second factor (Method\SecondFactor): enrolled() yes: the account has a secret (SealedTotpSecrets::has() without opening it)
begin() (Method\Challenging) no: the code is on the phone, nothing to send
set up from the settings (Method\Enrolling) yes: a secret and its otpauth:// address, then a first code
turned off (Method\Revocable) yes: TotpSecrets::disable()
a code once (RFC 6238 §5.2) yes, and none older than the last one used
scheb/2fa's parameters, secrets and QR address yes (The same as scheb/2fa)
HOTP (counter-based codes) no
secrets sealed at rest yes, with SealedTotpSecrets and SodiumSecretBox

TotpMethod also gives uri($account, ?string $secret = null) (the otpauth:// address of a secret, or of the account's own: the settings' QR code; null when it has none), code($secret, ?int $timestamp = null) (the code of a moment) and TotpMethod::secret() (a new secret: 32 random bytes, base32, upper case, unpadded - 52 characters, as scheb/2fa draws them).

The same as scheb/2fa

scheb/2fa TotpMethod
algorithm, period, digits omnibase's User::getTotpAuthenticationConfiguration(): SHA1, 30 s, 6 ALGORITHM, PERIOD, DIGITS
leeway scheb_two_factor.totp.leeway: 0 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()
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)

Hosts

omnibridge/symfony (omniauth.yaml) omnibridge/laravel (config/omniauth.php)
on totp: ~ 'totp' => [...]
leeway, period, digits, issuer, server_name totp.*, the defaults above totp.*, the same
the steps used CacheUsedSteps on throttle.pool CacheUsedSteps on the throttle's store or pool
secret_key totp.secret_key: a lazy SodiumSecretBox, SealedTotpSecrets decorating the totp_secrets store totp.secret_key: the TotpSecrets binding wrapped the same way

The two hosts of one database must hold the same key (OMNIAUTH_TOTP_KEY on omnibase sites). The second factor's page names this factor totp, as scheb/2fa did.

Errors

The failures a sign-in or an enrolment answers are listed in The method: bad-code (reasons replayed, unreadable-secret), not-enrolled, expired. The exceptions:

Exception When
Exception\UnreadableSecret (an OmniauthException, \RuntimeException) a sealed secret that does not open: another key, an altered, truncated or non-base64 value. Its message names neither the key nor the secret
\InvalidArgumentException TotpMethod with a period, leeway or digits out of range; a box key that is not 32 bytes or not base64; AuthenticatorApp::scan() of an address that is not TOTP

Verified, and not

Run in omniauth's harness on 2026-10-25 (PHP 8.4, scheb/2fa 8.6 installed beside): omniauth/otp's 47 tests green. They cover RFC 6238's SHA1 vectors; a password then a code through the conductor; 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; five wrong codes and the account throttled whatever the address; the enrolment and the revocation; the address with a server name and an issuer; against scheb/2fa itself, second by second across two period boundaries for leeways 0, 5 and 29, scheb's checkCode() and a first use here answer the same, and the QR address equals getQRContent(); the sealed secrets (format, nonce, another key, altered and truncated values refused with UnreadableSecret, a sign-in and an enrolment on a sealed store); docs/installation.md run as written; in plain PHP (BareTest), nothing loaded but omniauth, otphp, paragonie's encoding, the PSR interfaces and symfony/password-hasher; the former Omniauth\Totp\ names.

Not verified: a real phone's application against a site; concurrent tries of the same code against CacheUsedSteps (read then written, not atomic: two requests with the same code at the same instant may both pass; the throttle, five codes per 15 minutes per account, bounds the rest).

Proposer une modification docs/index.md

Installation

composer require omnisense/omniauth omniauth/otp

It requires omnisense/omniauth, spomky-labs/otphp (the library scheb/2fa verifies with) and the PSR cache and clock interfaces. No framework: the secrets stay in your table, behind omniauth's Contract\TotpSecrets; the steps already used, in any PSR-6 pool or PSR-16 cache.

The whole thing by hand - the in-memory stores of Omniauth\Testing stand in for yours, and Omniauth\Otp\Testing\AuthenticatorApp for the person's phone:

<?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\ArrayCache;
use Omniauth\Testing\FixedClock;
use Omniauth\Testing\InMemoryStore;
use Omniauth\Testing\TestAccount;
use Omniauth\Otp\CacheUsedSteps;
use Omniauth\Otp\Testing\AuthenticatorApp;
use Omniauth\Otp\TotpMethod;
use Symfony\Component\PasswordHasher\Hasher\NativePasswordHasher;

$clock = new FixedClock();                                 // yours: none (the system's); fixed here, the block runs in one go
$hasher = new NativePasswordHasher();
$store = new InMemoryStore($ringo = new TestAccount('42', 'ringo', 'ringo@example.org', $hasher->hash('yellow-submarine')));

$session = [];                                             // $_SESSION, in a real page
$omniauth = new Omniauth(
    accounts: $store,
    methods: new Methods([
        new PasswordMethod($store, $store, $hasher),
        new TotpMethod($store, $store->totpSecrets(), new CacheUsedSteps(new ArrayCache($clock)), clock: $clock),   // your cache pool
    ]),
    state: new ArrayState($session),
    clock: $clock,
);
$context = new Context(origin: 'https://example.org', host: 'example.org', ip: '203.0.113.7');

// From the account's settings: a secret and its address, drawn as a QR code by your page.
$enrolment = $omniauth->enrol(Factor::Totp, $ringo);
$challenge = $enrolment->begin($context);
echo $challenge->data['uri'] === 'otpauth://totp/ringo?secret='.$challenge->data['secret'] ? "otpauth://totp/ringo?secret=...\n" : '';
// otpauth://totp/ringo?secret=...

$phone = AuthenticatorApp::scan($challenge->data['uri'], $clock);
echo $enrolment->confirm($context, ['code' => $phone->code()])->isDone() ? "set up\n" : "not set up\n";
// set up

// Half a minute later: a password, then the code of the moment (the enrolment's is used up).
$clock->advance(30);
$result = $omniauth->verify('password', $context, ['identifier' => 'ringo', 'password' => 'yellow-submarine']);
echo $result->status->value, ': ', $result->offered[0]->value, "\n";
// second-factor-required: totp

$result = $omniauth->verify('totp', $context, ['code' => $phone->code()]);
echo $result->status->value, ' by ', implode(' + ', array_map(fn ($f) => $f->value, $result->passed)), "\n";
// authenticated by password + totp

A code is good once: the same code again, even within its 30 seconds, is refused (bad-code, reason replayed) - scheb/2fa accepted it again.

The parameters are scheb/2fa's, as every site sets them today: SHA1, 30 seconds, 6 digits, leeway: 0 (TotpMethod::ALGORITHM, PERIOD, DIGITS, LEEWAY), no server name, no issuer. The secrets those sites keep (user.secret, base32) are read as they are.

Run in omniauth's harness on 2026-10-25 (the block as it is, Tests/DocsTest.php), it prints:

otpauth://totp/ringo?secret=...
set up
second-factor-required: totp
authenticated by password + totp

Next: the method.

Proposer une modification docs/installation.md

The method

new TotpMethod(
    accounts: $accounts,                 // Omniauth\Contract\Accounts: the handle, the identifier (the QR code's label)
    secrets: $secrets,                   // Omniauth\Contract\TotpSecrets (omnibase: user.secret)
    used: new CacheUsedSteps($pool),     // Contract\UsedSteps: a code once
    leeway: 0,                           // seconds before and after a period, as scheb's `leeway`
    period: 30, digits: 6, algorithm: 'sha1',   // scheb/2fa's, as omnibase's User gives them
    issuer: null, server: null,          // scheb's `issuer` and `server_name`: the QR code's label
    clock: null,                         // PSR-20; the system's by default
);

A second factor: EnrolledSecondFactors asks it after a password, a link or an identity provider of an account that has a secret (enrolled()), and offers the backup codes beside it.

Signing in

$omniauth->verify('totp', $context, ['code' => '123 456']), once a first factor passed. Spaces are ignored. otphp verifies the code at this moment within the leeway, exactly as scheb/2fa calls it; then the code's time step is claimed for the account (UsedSteps::claim()): a step already claimed, or an earlier one, is refused.

Failure Reason When
bad-code - not the code of the moment (or empty)
replayed the right code, already used - or older than the last one used
unreadable-secret the stored secret is not base32
not-enrolled - the account has no secret

A sealed store whose value does not open under the key (another key, an altered row) throws Exception\UnreadableSecret out of verify(): the sign-in fails loudly, never as "no secret". The code's step is claimed only once otphp has verified it; a code that matches at the leeway's edge claims that step (intdiv($at, $period)), and the claim is remembered until the code could no longer verify ((step + 1) * period + leeway - now + 1 seconds).

Setting it up, from the settings

Step Call Gives
begin $omniauth->enrol(Factor::Totp, $account)->begin($context) Challenge::none(['secret' => ..., 'uri' => 'otpauth://totp/ringo?secret=...']); the secret waits in the enrolment (the session), not in the store
confirm ->confirm($context, ['code' => ...]) Enrolled::done() and TotpSecrets::enable(); a wrong code keeps the secret waiting

A confirmation with no secret waiting (no begin(), or the enrolment's ten minutes gone) is Enrolled::failed(expired); a first code is claimed like any other, so the same code does not sign in again right after.

The page draws the QR code of uri (omnibase keeps endroid/qr-code for that) and may show secret for typing in. $method->uri($account) gives the address of the account's own secret (today's qr_code_totp route).

Turning it off

$omniauth->revoke(Factor::Totp, $account): TotpSecrets::disable(), and the conductor makes the trusted devices fall back.

Sealed secrets (QA5)

$secrets = new SealedTotpSecrets($store, SodiumSecretBox::fromBase64($key));   // $key: base64 of 32 bytes, from the vault
new TotpMethod($accounts, $secrets, new CacheUsedSteps($pool));

What the store keeps: otp:v1: then the base64url (unpadded) of the 24-byte nonce followed by sodium_crypto_secretbox($secret, $nonce, $key) - 130 characters for a 52-character secret. A value without the prefix is a secret kept in clear before the box: open() returns it as it is, so a site switches on the box first and rewrites its rows after; seal() leaves a sealed value as it is, so the rewrite can run twice. SealedTotpSecrets::has() answers whether an account has a secret without opening it (TotpMethod::enrolled() asks it at every sign-in); secret() opens it, for verify(), enrolConfirm()'s store and uri() (the settings' QR code). A value that does not open throws Exception\UnreadableSecret, whose message names neither the key nor the secret.

The key. SodiumSecretBox::generateKey() draws one (base64). It lives in the host's secrets vault or environment - for omnibase sites OMNIAUTH_TOTP_KEY, read by omnibridge/symfony from omniauth.yaml's totp.secret_key and by omnibridge/laravel from config/omniauth.php's totp.secret_key -, never in a file of the repository: development keeps its own in .env.local (Symfony) or the twin's .env (both untracked), the test environment a fixed test key of its own. Both hosts of one database must hold the same key.

Rewriting the rows. Once the box is on, the secrets kept in clear are rewritten by the application (omnibase sites: a Doctrine migration, up seals each user.secret that lacks the prefix, down opens each that has it), then SELECT COUNT(*) FROM user WHERE secret IS NOT NULL AND secret <> '' AND secret NOT LIKE 'otp:v1:%' is 0.

Rotating the key. No code is needed for a rotation, only an order: (1) draw a new key; (2) with the old key and the new one at hand, rewrite every row - open each sealed value with a box on the old key, seal it with a box on the new key (a value that already opens with the new key is left; a secretbox under the wrong key fails to open, it never yields a wrong secret, so the two are told apart safely); (3) put the new key in the vault of every host of that database at once, and drop the old one. Between (2) and (3) the hosts must not write new secrets (enrolments) - a short maintenance window, or a box that tries the new key then the old one, which SodiumSecretBox could gain the day a rotation needs it without a window. A compromised key means rewriting with a new key and, since a secret read under the old key is known, asking the accounts concerned to enrol again. The prefix's version (v1) names the format, not the key: a new format would be v2, read beside v1.

Proposer une modification docs/otp.md