Files
preauth/tests/Support/TotpTestHelper.php
T
lyra 108e9623e6 Add passkey configuration and availability policy (inert)
Groundwork for passkey authentication, with the feature switched off by
default and no behaviour change when it is off.

Decision D1: passkeys require central authentication. A passkey is scoped to
a relying party spanning the base domain, which only exists when
SUBDOMAIN_REDIRECT is on and AUTH_SUBDOMAIN resolves to a base domain. The
RP ID is therefore always that base domain, never the request host.

Decision D4: HTTPS is required and is not exemptible. The allowed origin is
built as https://{authSubdomain} from configuration and never from the
request, so an http:// origin cannot be accepted, and isAvailableFor()
additionally refuses to offer the UI on a non-secure connection. The
deprecated setSecuredRelyingPartyId() escape hatch is not used and there is
deliberately no override that could reintroduce one.

Enabling PASSKEY_ENABLED without a usable configuration is a hard error via
a non-optional cache warmer, because entrypoint.sh runs cache:warmup on every
production boot: a misconfigured deployment fails to start instead of
offering a button that cannot work.

Also drops 12 obsolete phpstan-baseline entries for TotpTestHelper: adding
#[\Override] to its anonymous clock removed the rule violation at its source
rather than suppressing it.

Suite: 333 tests / 770 assertions (was 313 / 738), 100% coverage on new
files. phpstan level 6 clean, php-cs-fixer clean, conformance 35/35.
2026-09-27 02:37:07 +00:00

128 lines
3.8 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Support;
use App\ConfigBag;
use App\Utilities;
use DateTimeImmutable;
use OTPHP\TOTP;
use Override;
use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Clock\ClockInterface as PsrClockInterface;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
/**
* Provides a deterministic TOTP fixture plus a frozen clock and ready-made
* ConfigBag / cache-pool helpers for tests that exercise TOTP-dependent code.
*/
trait TotpTestHelper
{
/** well-known Base32 test secret (JBSWY3DPEHPK3PXP) */
private const string TOTP_SECRET = 'JBSWY3DPEHPK3PXP';
/** Frozen timestamp used for deterministic TOTP codes. */
protected const string FROZEN_TIME = '2025-06-15 12:00:00';
/** Frozen clock that always returns the same instant. */
private function frozenClock(): PsrClockInterface
{
$time = self::FROZEN_TIME;
return new class($time) implements PsrClockInterface {
public function __construct(private string $time)
{
}
#[Override]
public function now(): DateTimeImmutable
{
return new DateTimeImmutable($this->time);
}
};
}
/** Provisioning URI built from the well-known secret + frozen clock. */
private function totpUri(): string
{
$totp = TOTP::createFromSecret(self::TOTP_SECRET, $this->frozenClock());
$totp->setLabel('Test-TOTP');
return $totp->getProvisioningUri();
}
/** The TOTP code that is valid at the frozen timestamp. */
private function validTotpCode(): string
{
return TOTP::createFromSecret(self::TOTP_SECRET, $this->frozenClock())->now();
}
/** A fresh in-memory cache pool suitable for wrapping in MonitorCacheKeys. */
private function emptyPool(): CacheItemPoolInterface
{
return new ArrayAdapter();
}
/**
* Build a ConfigBag wired with the deterministic TOTP and frozen clock.
* Extra params override the sensible defaults.
*/
private function makeConfig(
?int $cookieTtl = 3600,
?int $ipTtl = 0,
bool $teapot = true,
string $errorMessage = 'Error',
string $teapotTitle = 'Teapot',
string $tooManyTitle = 'Too Many',
string $remoteUserMode = 'session',
string $remoteUserStatic = 'authenticated',
string $remoteUserMap = '',
string $title = 'Pre-Authentication System',
bool $passkeyEnabled = false,
string $passkeyRpName = '',
string $passkeyUserVerification = 'required',
int $passkeyTimeout = 60000,
): ConfigBag {
$clock = $this->frozenClock();
$utilities = $this->createUtilities($clock);
return new ConfigBag(
$utilities,
$clock,
$cookieTtl,
$this->totpUri(),
$ipTtl,
$teapot,
$errorMessage,
$teapotTitle,
$tooManyTitle,
$remoteUserMode,
$remoteUserStatic,
$remoteUserMap,
$title,
$passkeyEnabled,
$passkeyRpName,
$passkeyUserVerification,
$passkeyTimeout,
);
}
/**
* Minimal Utilities stub that never triggers TOTP generation when
* a non-empty totpUri is supplied to ConfigBag.
*/
private function createUtilities(?PsrClockInterface $clock = null): Utilities
{
$clock ??= $this->frozenClock();
$cache = $this->createStub(CacheItemPoolInterface::class);
$cache->method('hasItem')->willReturn(false);
$item = $this->createStub(CacheItemInterface::class);
$item->method('isHit')->willReturn(false);
$cache->method('getItem')->willReturn($item);
return new Utilities($clock, $cache);
}
}