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.
128 lines
3.8 KiB
PHP
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);
|
|
}
|
|
}
|