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.
235 lines
8.7 KiB
PHP
235 lines
8.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Unit\Service;
|
|
|
|
use App\Enum\UserVerification;
|
|
use App\Exception\PasskeyConfigurationException;
|
|
use App\Service\DomainInterface;
|
|
use App\Service\PasskeyPolicy;
|
|
use App\Tests\Support\TotpTestHelper;
|
|
use Override;
|
|
use PHPUnit\Framework\TestCase;
|
|
use Symfony\Component\HttpFoundation\Request;
|
|
|
|
/**
|
|
* Covers the availability rule (D1) and the HTTPS requirement (D4).
|
|
*
|
|
* The two decisions are enforced in one place precisely so that they can be
|
|
* tested exhaustively here rather than re-derived at each call site.
|
|
*/
|
|
final class PasskeyPolicyTest extends TestCase
|
|
{
|
|
use TotpTestHelper;
|
|
|
|
/**
|
|
* A stand-in for the real DomainManager that mirrors its base-domain rule:
|
|
* `localhost` and bare IPs yield null; otherwise the last two labels are
|
|
* kept, or three when the final two form a known multi-part TLD.
|
|
*
|
|
* Verified against DomainManager: "auth.example.com" => "example.com",
|
|
* "auth.example.co.uk" => "example.co.uk", "auth" => "auth",
|
|
* "localhost" => null.
|
|
*/
|
|
private function makeDomain(bool $subdomainRedirect, string $authSubdomain): DomainInterface
|
|
{
|
|
$authBase = null;
|
|
if ('' !== $authSubdomain
|
|
&& 'localhost' !== $authSubdomain
|
|
&& !filter_var($authSubdomain, \FILTER_VALIDATE_IP)
|
|
) {
|
|
$parts = explode('.', strtolower($authSubdomain));
|
|
$keep = 2;
|
|
$count = \count($parts);
|
|
if ($count > 2 && 'uk' === $parts[$count - 1] && \in_array($parts[$count - 2], ['co', 'org', 'ac', 'gov'], true)) {
|
|
$keep = 3;
|
|
}
|
|
$authBase = implode('.', \array_slice($parts, -min($keep, $count)));
|
|
}
|
|
|
|
return new class($subdomainRedirect, $authSubdomain, $subdomainRedirect ? $authBase : null) implements DomainInterface {
|
|
public function __construct(
|
|
private bool $redirect,
|
|
private string $authSubdomain,
|
|
private ?string $authBase,
|
|
) {
|
|
}
|
|
|
|
#[Override]
|
|
public function getAuthSubdomain(): ?string
|
|
{
|
|
return $this->redirect ? $this->authSubdomain : null;
|
|
}
|
|
|
|
#[Override]
|
|
public function validReturn(string $url): bool
|
|
{
|
|
return $this->redirect;
|
|
}
|
|
|
|
#[Override]
|
|
public function matchesAuth(string $host): bool
|
|
{
|
|
return $this->redirect;
|
|
}
|
|
|
|
#[Override]
|
|
public function authBase(): ?string
|
|
{
|
|
return $this->authBase;
|
|
}
|
|
};
|
|
}
|
|
|
|
private function makePolicy(
|
|
bool $passkeyEnabled = true,
|
|
bool $subdomainRedirect = true,
|
|
string $authSubdomain = 'auth.example.com',
|
|
string $userVerification = 'required',
|
|
int $timeout = 60000,
|
|
string $rpName = '',
|
|
string $title = 'Pre-Authentication System',
|
|
): PasskeyPolicy {
|
|
$config = $this->makeConfig(
|
|
passkeyEnabled: $passkeyEnabled,
|
|
passkeyUserVerification: $userVerification,
|
|
passkeyTimeout: $timeout,
|
|
passkeyRpName: $rpName,
|
|
title: $title,
|
|
);
|
|
|
|
return new PasskeyPolicy($config, $this->makeDomain($subdomainRedirect, $authSubdomain));
|
|
}
|
|
|
|
/* ── D1: enabled + prerequisite ─────────────────────────────────────── */
|
|
|
|
public function test_enabled_requires_both_the_switch_and_central_auth(): void
|
|
{
|
|
self::assertTrue($this->makePolicy()->isEnabled());
|
|
self::assertFalse($this->makePolicy(passkeyEnabled: false)->isEnabled());
|
|
self::assertFalse($this->makePolicy(subdomainRedirect: false)->isEnabled());
|
|
}
|
|
|
|
public function test_rp_id_is_always_the_auth_base_domain(): void
|
|
{
|
|
self::assertSame('example.com', $this->makePolicy()->rpId());
|
|
self::assertSame('example.co.uk', $this->makePolicy(authSubdomain: 'auth.example.co.uk')->rpId());
|
|
}
|
|
|
|
public function test_rp_id_throws_when_not_configured(): void
|
|
{
|
|
$this->expectException(PasskeyConfigurationException::class);
|
|
$this->makePolicy(subdomainRedirect: false)->rpId();
|
|
}
|
|
|
|
/* ── D4: HTTPS is the only accepted origin ─────────────────────────── */
|
|
|
|
public function test_allowed_origin_is_always_https(): void
|
|
{
|
|
self::assertSame(['https://auth.example.com'], $this->makePolicy()->allowedOrigins());
|
|
}
|
|
|
|
public function test_allowed_origin_never_reflects_the_request_scheme(): void
|
|
{
|
|
$policy = $this->makePolicy();
|
|
$request = Request::create('http://auth.example.com/', 'GET');
|
|
|
|
self::assertSame(['https://auth.example.com'], $policy->allowedOrigins());
|
|
self::assertFalse($policy->isAvailableFor($request));
|
|
}
|
|
|
|
public function test_available_only_on_the_auth_host_over_https(): void
|
|
{
|
|
$policy = $this->makePolicy();
|
|
|
|
$secure = Request::create('https://auth.example.com/', 'GET');
|
|
$insecure = Request::create('http://auth.example.com/', 'GET');
|
|
$otherHost = Request::create('https://app.example.com/', 'GET');
|
|
|
|
self::assertTrue($policy->isAvailableFor($secure));
|
|
self::assertFalse($policy->isAvailableFor($insecure));
|
|
self::assertFalse($policy->isAvailableFor($otherHost));
|
|
}
|
|
|
|
public function test_available_respects_the_switch(): void
|
|
{
|
|
$request = Request::create('https://auth.example.com/', 'GET');
|
|
|
|
self::assertFalse($this->makePolicy(passkeyEnabled: false)->isAvailableFor($request));
|
|
}
|
|
|
|
/* ── boot-time assertion (D1 + D4) ─────────────────────────────────── */
|
|
|
|
public function test_assertion_is_silent_when_disabled(): void
|
|
{
|
|
$this->makePolicy(passkeyEnabled: false, subdomainRedirect: false)->assertConfigurationIsUsable();
|
|
$this->addToAssertionCount(1);
|
|
}
|
|
|
|
public function test_assertion_passes_for_a_valid_configuration(): void
|
|
{
|
|
$this->makePolicy()->assertConfigurationIsUsable();
|
|
$this->addToAssertionCount(1);
|
|
}
|
|
|
|
public function test_assertion_fails_without_central_auth(): void
|
|
{
|
|
$this->expectException(PasskeyConfigurationException::class);
|
|
$this->expectExceptionMessageMatches('/central authentication is not configured/');
|
|
$this->makePolicy(subdomainRedirect: false, authSubdomain: '')->assertConfigurationIsUsable();
|
|
}
|
|
|
|
public function test_assertion_fails_for_localhost(): void
|
|
{
|
|
/* localhost has no base domain, so it can never satisfy D1 */
|
|
$this->expectException(PasskeyConfigurationException::class);
|
|
$this->makePolicy(authSubdomain: 'localhost')->assertConfigurationIsUsable();
|
|
}
|
|
|
|
public function test_assertion_fails_for_a_single_label_subdomain(): void
|
|
{
|
|
/* D4: no certificate can be issued for a single-label host */
|
|
$this->expectException(PasskeyConfigurationException::class);
|
|
$this->expectExceptionMessageMatches('/fully qualified domain name/');
|
|
$this->makePolicy(authSubdomain: 'auth')->assertConfigurationIsUsable();
|
|
}
|
|
|
|
/* ── configuration accessors ───────────────────────────────────────── */
|
|
|
|
public function test_rp_name_falls_back_to_the_title(): void
|
|
{
|
|
self::assertSame('Pre-Authentication System', $this->makePolicy(rpName: '')->rpName());
|
|
self::assertSame('My Gateway', $this->makePolicy(rpName: 'My Gateway')->rpName());
|
|
}
|
|
|
|
public function test_user_verification_and_timeout_are_passed_through(): void
|
|
{
|
|
$policy = $this->makePolicy(userVerification: 'preferred', timeout: 30000);
|
|
|
|
self::assertSame('preferred', $policy->userVerification());
|
|
self::assertSame(30000, $policy->timeout());
|
|
}
|
|
|
|
public function test_unknown_user_verification_falls_back_to_required(): void
|
|
{
|
|
/* an unrecognised value must never silently weaken the requirement */
|
|
$policy = $this->makePolicy(
|
|
userVerification: 'nonsense',
|
|
);
|
|
|
|
self::assertSame(UserVerification::Required->value, $policy->userVerification());
|
|
}
|
|
|
|
public function test_non_positive_timeout_falls_back_to_the_default(): void
|
|
{
|
|
self::assertSame(60000, $this->makePolicy(timeout: 0)->timeout());
|
|
self::assertSame(60000, $this->makePolicy(timeout: -100)->timeout());
|
|
}
|
|
|
|
public function test_auth_subdomain_is_exposed_for_ceremony_urls(): void
|
|
{
|
|
self::assertSame('auth.example.com', $this->makePolicy()->authSubdomain());
|
|
}
|
|
}
|