From 108e9623e6cc56a937771e2b94ba8d9d1966e59d Mon Sep 17 00:00:00 2001 From: Lyra Bot Date: Sun, 27 Sep 2026 02:37:07 +0000 Subject: [PATCH] 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. --- .env.test | 6 + composer.json | 3 +- composer.lock | 1167 ++++++++++++++++- config/packages/property_info.yaml | 3 + config/packages/twig.yaml | 6 + config/services.yaml | 25 + docs/examples/.env.example | 20 + docs/examples/Caddyfile | 33 + docs/passkey-auth-subdomain-plan.md | 819 ++++++++++++ phpstan-baseline.neon | 66 - phpunit.dist.xml | 2 + .../PasskeyConfigurationWarmer.php | 52 + src/ConfigBag.php | 54 + src/Enum/UserVerification.php | 34 + .../PasskeyConfigurationException.php | 21 + src/Service/PasskeyPolicy.php | 120 ++ src/Service/PasskeyPolicyInterface.php | 85 ++ symfony.lock | 21 + tests/Support/TotpTestHelper.php | 12 + .../PasskeyConfigurationWarmerTest.php | 45 + tests/Unit/Service/PasskeyPolicyTest.php | 234 ++++ 21 files changed, 2760 insertions(+), 68 deletions(-) create mode 100644 config/packages/property_info.yaml create mode 100644 docs/passkey-auth-subdomain-plan.md create mode 100644 src/CacheWarmer/PasskeyConfigurationWarmer.php create mode 100644 src/Enum/UserVerification.php create mode 100644 src/Exception/PasskeyConfigurationException.php create mode 100644 src/Service/PasskeyPolicy.php create mode 100644 src/Service/PasskeyPolicyInterface.php create mode 100644 tests/Unit/CacheWarmer/PasskeyConfigurationWarmerTest.php create mode 100644 tests/Unit/Service/PasskeyPolicyTest.php diff --git a/.env.test b/.env.test index 5d7f163..7f9c469 100644 --- a/.env.test +++ b/.env.test @@ -12,6 +12,12 @@ BURST_COUNT=10 BURST_TIME=30 UPPER_COUNT=100 UPPER_TIME=3600 +PASSKEY_ENABLED=0 +PASSKEY_RP_NAME='' +PASSKEY_USER_VERIFICATION='required' +PASSKEY_TIMEOUT=60000 +PASSKEY_BUTTON_NAME='Sign in with a passkey' +PASSKEY_REGISTER_NAME='Register this device as a passkey' PUBLIC_PATHS='' PUBLIC_BURST_COUNT=100 PUBLIC_BURST_TIME=60 diff --git a/composer.json b/composer.json index b658372..0854731 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,8 @@ "symfony/runtime": "8.1.*", "symfony/twig-bundle": "8.1.*", "symfony/uid": "8.1.*", - "symfony/yaml": "8.1.*" + "symfony/yaml": "8.1.*", + "web-auth/webauthn-lib": "^5.3" }, "config": { "allow-plugins": { diff --git a/composer.lock b/composer.lock index f70b185..77fa57d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e057ec8918177382ec6fe101350d6b86", + "content-hash": "419c4270fd950043a3cd93a9a50754f6", "packages": [ { "name": "bacon/bacon-qr-code", @@ -61,6 +61,66 @@ }, "time": "2026-04-05T21:06:35+00:00" }, + { + "name": "brick/math", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/brick/math.git", + "reference": "2effe05d2177c451b86c6a073196a4034c02f211" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/brick/math/zipball/2effe05d2177c451b86c6a073196a4034c02f211", + "reference": "2effe05d2177c451b86c6a073196a4034c02f211", + "shasum": "" + }, + "require": { + "php": "^8.2" + }, + "require-dev": { + "phpstan/phpstan": "2.2.13", + "phpstan/phpstan-phpunit": "2.0.18", + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Brick\\Math\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Arbitrary-precision arithmetic library", + "keywords": [ + "Arbitrary-precision", + "BigInteger", + "BigRational", + "arithmetic", + "bigdecimal", + "bignum", + "bignumber", + "brick", + "decimal", + "integer", + "math", + "mathematics", + "rational" + ], + "support": { + "issues": "https://github.com/brick/math/issues", + "source": "https://github.com/brick/math/tree/1.0.0" + }, + "funding": [ + { + "url": "https://github.com/BenMorel", + "type": "github" + } + ], + "time": "2026-09-12T10:28:18+00:00" + }, { "name": "dasprid/enum", "version": "1.0.7", @@ -111,6 +171,54 @@ }, "time": "2025-09-16T12:23:56+00:00" }, + { + "name": "doctrine/deprecations", + "version": "1.1.6", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=14" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", + "psr/log": "^1 || ^2 || ^3" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" + }, + "time": "2026-02-07T07:09:04+00:00" + }, { "name": "paragonie/constant_time_encoding", "version": "v3.1.3", @@ -180,6 +288,229 @@ }, "time": "2025-09-24T15:06:41+00:00" }, + { + "name": "phpdocumentor/reflection-common", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "Common reflection classes used by phpdocumentor to reflect the code structure", + "homepage": "http://www.phpdoc.org", + "keywords": [ + "FQSEN", + "phpDocumentor", + "phpdoc", + "reflection", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", + "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" + }, + "time": "2020-06-27T09:03:43+00:00" + }, + { + "name": "phpdocumentor/reflection-docblock", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/7bae67520aa9f5ecc506d646810bd40d9da54582", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.1", + "ext-filter": "*", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.2", + "phpdocumentor/type-resolver": "^2.0", + "phpstan/phpdoc-parser": "^2.0", + "webmozart/assert": "^1.9.1 || ^2" + }, + "require-dev": { + "mockery/mockery": "~1.3.5 || ~1.6.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-mockery": "^1.1", + "phpstan/phpstan-webmozart-assert": "^1.2", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^5.26", + "shipmonk/dead-code-detector": "^0.5.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + }, + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.3" + }, + "time": "2026-03-18T20:49:53+00:00" + }, + { + "name": "phpdocumentor/type-resolver", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/TypeResolver.git", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.0", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.0", + "phpstan/phpdoc-parser": "^2.0" + }, + "require-dev": { + "ext-tokenizer": "*", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev", + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + } + ], + "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", + "support": { + "issues": "https://github.com/phpDocumentor/TypeResolver/issues", + "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0" + }, + "time": "2026-01-06T21:53:42+00:00" + }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.3.5", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/148cefffaf0233e4c08cc13db8a195a56dd6dfe9", + "reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.5" + }, + "time": "2026-08-31T16:05:28+00:00" + }, { "name": "psr/cache", "version": "3.0.0", @@ -430,6 +761,78 @@ }, "time": "2024-09-11T13:17:53+00:00" }, + { + "name": "spomky-labs/cbor-php", + "version": "3.4.2", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/cbor-php.git", + "reference": "8f5ea00a07ad529d20886505cdbeb2b9ac7bb2d6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/cbor-php/zipball/8f5ea00a07ad529d20886505cdbeb2b9ac7bb2d6", + "reference": "8f5ea00a07ad529d20886505cdbeb2b9ac7bb2d6", + "shasum": "" + }, + "require": { + "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18|^0.19|^0.20|^1.0", + "ext-mbstring": "*", + "php": ">=8.0", + "symfony/polyfill-php81": "^1.32" + }, + "require-dev": { + "ext-json": "*", + "roave/security-advisories": "dev-latest", + "symfony/error-handler": "^6.4|^7.1|^8.0", + "symfony/var-dumper": "^6.4|^7.1|^8.0" + }, + "suggest": { + "ext-bcmath": "Improves the library performance when ext-gmp is missing, and is required to handle the Big Float and Decimal Fraction Tags (4 and 5)", + "ext-gmp": "Strongly recommended when decoding untrusted input: without it, converting the byte string of a Big Number Tag (2 and 3) is quadratic in its length. Also improves the library performance overall" + }, + "type": "library", + "autoload": { + "psr-4": { + "CBOR\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/Spomky-Labs/cbor-php/contributors" + } + ], + "description": "CBOR Encoder/Decoder for PHP", + "keywords": [ + "Concise Binary Object Representation", + "RFC7049", + "cbor" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/cbor-php/issues", + "source": "https://github.com/Spomky-Labs/cbor-php/tree/3.4.2" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-09-15T06:55:29+00:00" + }, { "name": "spomky-labs/otphp", "version": "11.5.0", @@ -500,6 +903,116 @@ ], "time": "2026-06-06T23:41:24+00:00" }, + { + "name": "spomky-labs/pki-framework", + "version": "1.6.3", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/pki-framework.git", + "reference": "792e909d4e387adffe3c4f404451c7d57a3d2022" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/792e909d4e387adffe3c4f404451c7d57a3d2022", + "reference": "792e909d4e387adffe3c4f404451c7d57a3d2022", + "shasum": "" + }, + "require": { + "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18|^0.19|^0.20|^1.0", + "ext-mbstring": "*", + "php": ">=8.1" + }, + "require-dev": { + "ekino/phpstan-banned-code": "^1.0|^2.0|^3.0", + "ext-gmp": "*", + "ext-openssl": "*", + "infection/infection": "^0.28|^0.29|^0.31", + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpstan/extension-installer": "^1.3|^2.0", + "phpstan/phpstan": "^1.8|^2.0", + "phpstan/phpstan-deprecation-rules": "^1.0|^2.0", + "phpstan/phpstan-phpunit": "^1.1|^2.0", + "phpstan/phpstan-strict-rules": "^1.3|^2.0", + "phpunit/phpunit": "^10.1|^11.0|^12.0", + "rector/rector": "^1.0|^2.0", + "roave/security-advisories": "dev-latest", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symplify/easy-coding-standard": "^12.0 || ^13.0" + }, + "suggest": { + "ext-bcmath": "For better performance (or GMP)", + "ext-gmp": "For better performance (or BCMath)", + "ext-openssl": "For OpenSSL based cyphering", + "ext-sodium": "To verify Ed25519 signatures where the OpenSSL extension has no EdDSA" + }, + "type": "library", + "autoload": { + "psr-4": { + "SpomkyLabs\\Pki\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joni Eskelinen", + "email": "jonieske@gmail.com", + "role": "Original developer" + }, + { + "name": "Florent Morselli", + "email": "florent.morselli@spomky-labs.com", + "role": "Spomky-Labs PKI Framework developer" + } + ], + "description": "A PHP framework for managing Public Key Infrastructures. It comprises X.509 public key certificates, attribute certificates, certification requests and certification path validation.", + "homepage": "https://github.com/spomky-labs/pki-framework", + "keywords": [ + "DER", + "Private Key", + "ac", + "algorithm identifier", + "asn.1", + "asn1", + "attribute certificate", + "certificate", + "certification request", + "cryptography", + "csr", + "decrypt", + "ec", + "encrypt", + "pem", + "pkcs", + "public key", + "rsa", + "sign", + "signature", + "verify", + "x.509", + "x.690", + "x509", + "x690" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/pki-framework/issues", + "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.6.3" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-09-12T19:02:49+00:00" + }, { "name": "symfony/cache", "version": "v8.1.6", @@ -679,6 +1192,83 @@ ], "time": "2026-06-05T06:23:12+00:00" }, + { + "name": "symfony/clock", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/701ef4de9705d6c32292ebee5e8044094a09fbf6", + "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/clock": "^1.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, { "name": "symfony/config", "version": "v8.1.5", @@ -2549,6 +3139,173 @@ ], "time": "2026-04-10T16:19:22+00:00" }, + { + "name": "symfony/property-access", + "version": "v8.1.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/property-access.git", + "reference": "1a41232c678972b93ce499a504e19ea09dfcd0b2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/property-access/zipball/1a41232c678972b93ce499a504e19ea09dfcd0b2", + "reference": "1a41232c678972b93ce499a504e19ea09dfcd0b2", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/property-info": "^7.4.4|^8.0.4" + }, + "require-dev": { + "symfony/cache": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PropertyAccess\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides functions to read and write from/to an object or array using a simple string notation", + "homepage": "https://symfony.com", + "keywords": [ + "access", + "array", + "extraction", + "index", + "injection", + "object", + "property", + "property-path", + "reflection" + ], + "support": { + "source": "https://github.com/symfony/property-access/tree/v8.1.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-30T12:40:56+00:00" + }, + { + "name": "symfony/property-info", + "version": "v8.1.7", + "source": { + "type": "git", + "url": "https://github.com/symfony/property-info.git", + "reference": "b42ee98197831d33788c33492cfc35ba62d1f701" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/property-info/zipball/b42ee98197831d33788c33492cfc35ba62d1f701", + "reference": "b42ee98197831d33788c33492cfc35ba62d1f701", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/string": "^7.4|^8.0", + "symfony/type-info": "^7.4.7|^8.0.7" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1" + }, + "require-dev": { + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "symfony/cache": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PropertyInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KΓ©vin Dunglas", + "email": "dunglas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts information about PHP class' properties using metadata of popular sources", + "homepage": "https://symfony.com", + "keywords": [ + "doctrine", + "phpdoc", + "property", + "symfony", + "type", + "validator" + ], + "support": { + "source": "https://github.com/symfony/property-info/tree/v8.1.7" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-04T10:14:04+00:00" + }, { "name": "symfony/rate-limiter", "version": "v8.1.6", @@ -2787,6 +3544,105 @@ ], "time": "2026-05-29T05:06:50+00:00" }, + { + "name": "symfony/serializer", + "version": "v8.1.7", + "source": { + "type": "git", + "url": "https://github.com/symfony/serializer.git", + "reference": "9a88015b4bb1a2bc5a3bbdf6ca3025444ba67321" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/serializer/zipball/9a88015b4bb1a2bc5a3bbdf6ca3025444ba67321", + "reference": "9a88015b4bb1a2bc5a3bbdf6ca3025444ba67321", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/property-access": "<8.1", + "symfony/property-info": "<7.4.15", + "symfony/type-info": "<7.4" + }, + "require-dev": { + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "seld/jsonlint": "^1.10", + "symfony/cache": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/filesystem": "^7.4|^8.0", + "symfony/form": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/property-access": "^8.1", + "symfony/property-info": "^7.4.15|~8.0.15|^8.1.2", + "symfony/translation-contracts": "^2.5|^3", + "symfony/type-info": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Serializer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/serializer/tree/v8.1.7" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-08T13:39:13+00:00" + }, { "name": "symfony/service-contracts", "version": "v3.7.3", @@ -3238,6 +4094,88 @@ ], "time": "2026-06-05T06:23:12+00:00" }, + { + "name": "symfony/type-info", + "version": "v8.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/type-info.git", + "reference": "ceb48db5b38d6a48640c414be0c69d53980ae5c5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/type-info/zipball/ceb48db5b38d6a48640c414be0c69d53980ae5c5", + "reference": "ceb48db5b38d6a48640c414be0c69d53980ae5c5", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/container": "^1.1|^2.0" + }, + "conflict": { + "phpstan/phpdoc-parser": "<1.30" + }, + "require-dev": { + "phpstan/phpdoc-parser": "^1.30|^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\TypeInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mathias Arlaud", + "email": "mathias.arlaud@gmail.com" + }, + { + "name": "Baptiste LEDUC", + "email": "baptiste.leduc@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts PHP types information.", + "homepage": "https://symfony.com", + "keywords": [ + "PHPStan", + "phpdoc", + "symfony", + "type" + ], + "support": { + "source": "https://github.com/symfony/type-info/tree/v8.1.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T17:47:34+00:00" + }, { "name": "symfony/uid", "version": "v8.1.5", @@ -3641,6 +4579,233 @@ } ], "time": "2026-07-03T20:44:34+00:00" + }, + { + "name": "web-auth/cose-lib", + "version": "4.8.2", + "source": { + "type": "git", + "url": "https://github.com/web-auth/cose-lib.git", + "reference": "8849e8bf043a2d42d0bec5bda5db2469ad376148" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-auth/cose-lib/zipball/8849e8bf043a2d42d0bec5bda5db2469ad376148", + "reference": "8849e8bf043a2d42d0bec5bda5db2469ad376148", + "shasum": "" + }, + "require": { + "brick/math": "^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0", + "ext-json": "*", + "ext-openssl": "*", + "php": ">=8.1", + "spomky-labs/pki-framework": "^1.0" + }, + "conflict": { + "spomky-labs/cbor-php": "<3.4.0" + }, + "require-dev": { + "spomky-labs/cbor-php": "^3.4" + }, + "suggest": { + "ext-bcmath": "Recommended: without GMP or BCMath, signing with RSASSA-PSS (PS256/PS384/PS512) blinds its private exponentiation in pure PHP", + "ext-gmp": "Recommended: without GMP or BCMath, signing with RSASSA-PSS (PS256/PS384/PS512) blinds its private exponentiation in pure PHP", + "ext-sodium": "Required by the EdDSA/Ed25519 signature algorithms (-8, -19, -260, -261) and to recompute an OKP public key from its private key", + "spomky-labs/cbor-php": "Required by the RFC 9052 header reader and cryptographic structures. 3.4.0 or later: it ships the six COSE message classes (CBOR\\Tag\\CoseSign1Tag and its siblings) that replace the deprecated Cose\\...Tag classes, and its decoder is what enforces the RFC 9052 label uniqueness and nesting bounds this library relies on" + }, + "type": "library", + "autoload": { + "psr-4": { + "Cose\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-auth/cose/contributors" + } + ], + "description": "CBOR Object Signing and Encryption (COSE) For PHP", + "homepage": "https://github.com/web-auth", + "keywords": [ + "COSE", + "RFC8152" + ], + "support": { + "issues": "https://github.com/web-auth/cose-lib/issues", + "source": "https://github.com/web-auth/cose-lib/tree/4.8.2" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-09-15T17:30:33+00:00" + }, + { + "name": "web-auth/webauthn-lib", + "version": "5.3.9", + "source": { + "type": "git", + "url": "https://github.com/web-auth/webauthn-lib.git", + "reference": "727e378fb7a36c26be5c911e4a0120c146741ce7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/727e378fb7a36c26be5c911e4a0120c146741ce7", + "reference": "727e378fb7a36c26be5c911e4a0120c146741ce7", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-openssl": "*", + "paragonie/constant_time_encoding": "^2.6|^3.0", + "php": ">=8.2", + "phpdocumentor/reflection-docblock": "^5.3|^6.0", + "psr/clock": "^1.0", + "psr/event-dispatcher": "^1.0", + "psr/log": "^1.0|^2.0|^3.0", + "spomky-labs/cbor-php": "^3.4", + "spomky-labs/pki-framework": "^1.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^3.2", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "web-auth/cose-lib": "^4.8" + }, + "suggest": { + "psr/log-implementation": "Recommended to receive logs from the library", + "symfony/event-dispatcher": "Recommended to use dispatched events", + "web-token/jwt-library": "Mandatory for fetching Metadata Statement from distant sources" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/web-auth/webauthn-framework", + "name": "web-auth/webauthn-framework" + } + }, + "autoload": { + "psr-4": { + "Webauthn\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-auth/webauthn-library/contributors" + } + ], + "description": "FIDO2/Webauthn Support For PHP", + "homepage": "https://github.com/web-auth", + "keywords": [ + "FIDO2", + "fido", + "webauthn" + ], + "support": { + "source": "https://github.com/web-auth/webauthn-lib/tree/5.3.9" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-09-10T21:10:45+00:00" + }, + { + "name": "webmozart/assert", + "version": "2.4.1", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", + "php": "^8.2" + }, + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" + }, + "type": "library", + "extra": { + "psalm": { + "pluginClass": "Webmozart\\Assert\\PsalmPlugin" + }, + "branch-alias": { + "dev-master": "2.0-dev", + "dev-feature/2-0": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "support": { + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/2.4.1" + }, + "time": "2026-06-15T15:31:57+00:00" } ], "packages-dev": [ diff --git a/config/packages/property_info.yaml b/config/packages/property_info.yaml new file mode 100644 index 0000000..dd31b9d --- /dev/null +++ b/config/packages/property_info.yaml @@ -0,0 +1,3 @@ +framework: + property_info: + with_constructor_extractor: true diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml index 40a0c7c..8e12342 100644 --- a/config/packages/twig.yaml +++ b/config/packages/twig.yaml @@ -15,4 +15,10 @@ twig: teapot_message: '%env(TEAPOT_MESSAGE)%' too_many_title: '%env(TOO_MANY_TITLE)%' too_many_message: '%env(TOO_MANY_MESSAGE)%' + passkey_button_name: '%env(PASSKEY_BUTTON_NAME)%' + passkey_register_name: '%env(PASSKEY_REGISTER_NAME)%' debug: '%env(SHELL_VERBOSITY)%' + + # `passkeys` is computed per request by the controller-facing templates via + # PasskeyPolicyInterface, never from an env var, so that availability and the + # D1/D4 prerequisites cannot drift apart. diff --git a/config/services.yaml b/config/services.yaml index bb828a9..7c1c83d 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -55,6 +55,18 @@ parameters: env(PUBLIC_UPPER_COUNT): 500 # max requests per sustained window per IP env(PUBLIC_UPPER_TIME): 3600 # sustained window in seconds (1 hour) + # --- passkey authentication --- + # Requires central auth (SUBDOMAIN_REDIRECT=1 + AUTH_SUBDOMAIN) and HTTPS. + # Enabling this without central auth makes the container fail at cache warmup + # rather than offering a feature that cannot work. + env(PASSKEY_ENABLED): '0' # boolean, 1 to offer passkeys on the auth subdomain + env(PASSKEY_RP_NAME): '' # blank to use TITLE + env(PASSKEY_USER_VERIFICATION): 'required' # required|preferred|discouraged + env(PASSKEY_TIMEOUT): '60000' # milliseconds + # Extra options, custom labels + env(PASSKEY_BUTTON_NAME): 'Sign in with a passkey' + env(PASSKEY_REGISTER_NAME): 'Register this device as a passkey' + # --- styling options --- env(TITLE): 'Pre-Authentication System' env(BG_COLOR): '#029386' # teal @@ -96,6 +108,15 @@ parameters: app.error_message: '%env(ERROR_MESSAGE)%' app.teapot_title: '%env(TEAPOT_TITLE)%' app.too_many_title: '%env(TOO_MANY_TITLE)%' + app.title: '%env(TITLE)%' + + app.passkey_enabled: '%env(bool:PASSKEY_ENABLED)%' + app.passkey_rp_name: '%env(PASSKEY_RP_NAME)%' + app.passkey_user_verification: '%env(PASSKEY_USER_VERIFICATION)%' + app.passkey_timeout: '%env(int:PASSKEY_TIMEOUT)%' + + app.passkey_button_name: '%env(PASSKEY_BUTTON_NAME)%' + app.passkey_register_name: '%env(PASSKEY_REGISTER_NAME)%' services: # default configuration for services in *this* file @@ -110,3 +131,7 @@ services: # add more service definitions when explicit configuration is needed # please note that last definitions always *replace* previous ones + + # the boot-time passkey configuration check runs during `cache:warmup`, so a + # misconfigured deployment fails to start instead of failing in a browser + App\Service\PasskeyPolicyInterface: '@App\Service\PasskeyPolicy' diff --git a/docs/examples/.env.example b/docs/examples/.env.example index 34d7459..614b00b 100644 --- a/docs/examples/.env.example +++ b/docs/examples/.env.example @@ -15,6 +15,26 @@ #SUBDOMAIN_REDIRECT=false # default disabled, boolean #AUTH_SUBDOMAIN='' # blank, hostname we send user to, to see login page +# --- passkey authentication --- +# Passkeys (Touch ID / Windows Hello / security keys) as an alternative to TOTP. +# +# REQUIRES central authentication (SUBDOMAIN_REDIRECT=true plus AUTH_SUBDOMAIN) +# and HTTPS. Passkeys are bound to a relying party that spans the base domain, +# which only exists when central auth is configured; and browsers refuse to run +# a ceremony over plain HTTP. +# +# Enabling this without central auth is a hard error: the container fails at +# start-up (cache:warmup) rather than offering a passkey button that cannot work. +# +# There is deliberately no option to allow an http:// origin, and none to relax +# the requirement for local development. See the README for the local TLS setup. +#PASSKEY_ENABLED=false # default disabled, boolean +#PASSKEY_RP_NAME='' # blank to use TITLE +#PASSKEY_USER_VERIFICATION='required' # required | preferred | discouraged +#PASSKEY_TIMEOUT=60000 # ceremony timeout in milliseconds +#PASSKEY_BUTTON_NAME='Sign in with a passkey' +#PASSKEY_REGISTER_NAME='Register this device as a passkey' + # --- extra options --- # how long do we allow *ALL* traffic from an ip address after successful login diff --git a/docs/examples/Caddyfile b/docs/examples/Caddyfile index 2fe0a7a..a3d3e87 100644 --- a/docs/examples/Caddyfile +++ b/docs/examples/Caddyfile @@ -49,10 +49,43 @@ protected.example.com { # optionally, if you want to use a subdomain for central preauth # set SUBDOMAIN_REDIRECT to true # and AUTH_SUBDOMAIN to match the subdomain you use here +# +# Passkeys (PASSKEY_ENABLED) require this block AND HTTPS: the ceremony runs +# here and the credential is scoped to the base domain. Caddy provisions a +# certificate automatically for a real hostname, so nothing extra is needed in +# production. This block is also deliberately NOT behind forward_auth β€” the +# browser talks to it directly during a ceremony. auth.example.com { reverse_proxy preauth } +# --- local development with passkeys --- +# Browsers only allow a WebAuthn ceremony over HTTPS, and preauth does not offer +# an exemption for http://localhost (that would be a way to run passkeys +# insecurely in production). So to exercise passkeys locally, give yourself a +# real hostname and a locally-trusted certificate: +# +# 1. Point the names at your machine: +# # /etc/hosts +# 127.0.0.1 auth.preauthtest.local app.preauthtest.local +# 2. Trust a certificate for them (mkcert installs a local CA): +# mkcert auth.preauthtest.local app.preauthtest.local +# +# 3. In preauth's .env: +# SUBDOMAIN_REDIRECT=true +# AUTH_SUBDOMAIN=auth.preauthtest.local +# PASSKEY_ENABLED=true +# +# 4. Terminate TLS here and proxy to the container: +# +# auth.preauthtest.local, "*.preauthtest.local" { +# tls /path/to/auth.preauthtest.local+1.pem /path/to/auth.preauthtest.local+1-key.pem +# reverse_proxy preauth +# } +# +# Note "localhost" itself cannot be used: it has no base domain, so central +# auth cannot be configured and passkeys stay disabled. + # --- public rate-limited access (v1.1) --- # Configure PUBLIC_PATHS env var to specify which paths are public. # Example: PUBLIC_PATHS=/public/** diff --git a/docs/passkey-auth-subdomain-plan.md b/docs/passkey-auth-subdomain-plan.md new file mode 100644 index 0000000..81dcead --- /dev/null +++ b/docs/passkey-auth-subdomain-plan.md @@ -0,0 +1,819 @@ +# Plan β€” Passkey Authentication for the Dedicated Auth Subdomain + +**Status:** πŸ“‹ Draft for review β€” no application code written yet +**Target:** next minor release (version to confirm β€” see Q1.1) +**Prepared:** 2026-09-26 against `main` @ `0458d9b` +**Revised:** 2026-09-27 β€” review round 2 (D4/D5, Β§2.3) +**Verified against:** `web-auth/webauthn-lib` 5.3.9 on PHP 8.5.11 / Symfony 8.1 + +--- + +## 0. Decisions locked in (from review feedback) + +Five clarifications from the project owner reshape this plan. They are +**decisions**, not options, and everything below follows from them. + +| # | Decision | Consequence | +|---|---|---| +| **D1** | **Central auth (dedicated auth subdomain) is a hard prerequisite** for passkeys. Without it, a passkey would collide with / confuse the passkey for the protected service itself. | Passkeys are simply **not offered** unless `SUBDOMAIN_REDIRECT=true` *and* `AUTH_SUBDOMAIN` is set. The RP ID is *always* `authBase()`. There is no single-host passkey mode, no per-service RP ID, and no ambiguity to document away. | +| **D2** | **Registration happens in the browser**, initiated by a simple "register passkey" checkbox on the login form β€” not a CLI command. | Registration reuses the existing login form, nonce/CSRF machinery and TOTP verification. This also **answers the identity question**: the identity is the `Session ID` field the user already types, exactly as with TOTP. | +| **D3** | **Rate limiting covers all forms of login.** If an IP is rate-limited, that includes passkeys. | Passkey ceremonies run **behind** the existing `RejectListener` gate and consume the **same** login limiter budget on failure. No way to sidestep a lockout by switching methods. | +| **D4** | **HTTPS is required β€” in development too.** No "secured relying party" exemption is supported, deprecated or otherwise. | The derived allowed-origin is *always* `https://…`, built from config and never from the request. The `PASSKEY_ALLOWED_ORIGINS` escape hatch from the first draft is **deleted**. Local development uses real TLS (Β§4.2). HTTPS becomes part of the boot-time assertion alongside D1 (Q3.1). | +| **D5** | **Attestation is `none`, deliberately.** The "set a real value instead" instinct was tested and is wrong *here* β€” every alternative is either broken or bypassable (Β§2.3). | Records are anonymous: zero AAGUID, `EmptyTrustPath`. No metadata service, no `web-token/jwt-library` dependency, no download of the FIDO BLOB. `SECURITY.md` states the reasoning and the conditions that would change it. | + +Consequences worth stating plainly: + +- The separate `passkey_limiter`, `PASSKEY_ENABLED=false` default, and the whole + "should we support single-host passkeys?" question from the first draft are + **gone**. D1 removes the configuration matrix; D3 removes the second limiter. +- The first draft's Β§7 (CLI registration, enrolment tokens, `--identity`) is + **deleted**. D2 replaces it with a checkbox. +- D4 keeps D1 exactly as strict β€” HTTPS is an **additional** requirement, never a + relaxation. D5 is the one place where "use the stricter-sounding option" loses, + and Β§2.3 shows the measurements behind that. + +--- + +## 1. Goal + +Let a user authenticate with a passkey (Touch ID, Windows Hello, Android +biometrics, hardware security key) instead of typing a 6-digit TOTP code β€” +served from the dedicated auth subdomain, where one passkey unlocks every +service on the base domain. + +TOTP and backup codes remain and are never removed (Q2.1). + +--- + +## 2. What the spike proved + +The first draft contained claims that had not been executed. They now have been: +the library was installed, and a script performed a **complete registration and +assertion ceremony with real ES256 cryptography**, plus the negative cases. + +**Environment:** PHP 8.5.11, Composer 2.10.3, `web-auth/webauthn-lib` **5.3.9**. +Baseline suite green before and after install: **313 tests / 738 assertions**. +`composer audit`: *"No security vulnerability advisories found."* +`.ci/conformance.sh --profile=auth-gateway`: **all 35 checks pass**. + +### 2.1 Confirmed correct + +| Claim | Result | +|---|---| +| Installs on PHP 8.5 + Symfony 8.1 with no conflicts | βœ… resolves to 5.3.9; `lint:container`, `lint:yaml`, `lint:twig` all pass | +| Only needs `ext-json` + `ext-openssl` | βœ… (`ext-openssl` is present in every official PHP image, so `Dockerfile` needs **no** extension work) | +| No Symfony Security bundle, no Doctrine, no bundler | βœ… `CeremonyStepManagerFactory` + `Authenticator*ResponseValidator::create()` are pure; the `webauthn-symfony-bundle` is unnecessary | +| rpId `example.com` admits an origin on `auth.example.com` | βœ… assertion ACCEPTED | +| …and also on `app.example.com` with the *same* credential | βœ… ACCEPTED β€” one passkey across all subdomains, as designed | +| Credential is cryptographically bound to the rpId | βœ… a forged `rpIdHash` is rejected: *"rpId hash mismatch"* | +| An origin outside the allow-list is rejected | βœ… *"Invalid origin. Not in the list of allowed origins."* | +| Wrong origin / wrong challenge rejected | βœ… `AuthenticatorResponseVerificationException` in both cases | +| CSP `publickey-credentials-*` do **not** inherit `default-src` | βœ… confirmed in the CSP3 spec (Β§6.8.3 fallback list omits WebAuthn directives) β€” the CSP change in Β§5.5 is required | +| base64url credential IDs survive `makeCacheKey()` without collision | βœ… 200 000 random 32-byte IDs, zero collisions | +| `attestation: 'none'` yields an anonymous record | βœ… `attestationType="none"`, zero AAGUID, `EmptyTrustPath` (Β§2.3, config A) | +| Origin scheme can never be inferred from the request | βœ… the scheme comes from the single allow-list string; an `https://` entry rejects an `http://` origin (Β§4.2) | +| `localhost` cannot accidentally enable passkeys | βœ… `baseDomain('localhost') === null` β‡’ `authBase() === null` β‡’ D1 unsatisfied (Β§4.2) | +| `auth.localhost` *does* satisfy D1 | βœ… `authBase() === 'auth.localhost'` (Β§4.2) | +| No MDS β‡’ no `web-token/jwt-library` needed | βœ… `FidoAllianceCompliantMetadataService` throws unless the JWT library is present; not installed, and MDS is not used (D5) | + +### 2.2 Corrections to the first draft (things that would have bitten us) + +| # | First draft said | Reality | Impact | +|---|---|---|---| +| **C1** | "`CredentialRecord` is JSON-serializable, so it fits the no-database constraint." | It is a **plain class**, not `JsonSerializable`. Persistence goes through `WebauthnSerializerFactory` (a Symfony Serializer with ~25 custom normalizers). | The store must use that factory. `symfony/serializer`, `property-info`, `property-access` arrive as transitive deps β€” no extra work, but the store can't just `json_encode()`. | +| **C2** | (unstated) treat option objects as plain JSON | `json_encode($creationOptions)` **throws** `JsonException: Malformed UTF-8` β€” the challenge is raw binary. Options **must** be serialized by the same factory, which base64url-encodes binary fields. | Both the `begin` payload and the stored record go through one `SerializerInterface`. Caught immediately by the spike; would otherwise have been a runtime 500 on first test. | +| **C3** | "the package carries 3 published advisories" | `composer audit` against 5.3.9 reports **none**. | No remediation work; record the clean audit in the CHANGELOG. | +| **C4** | counter handling not mentioned | Counter replay raises `CounterException`, which can **mask** the real reason a verification failed. | Test helper must use an incrementing counter per ceremony, or negative tests give false passes (this actually happened during the spike and had to be fixed). | +| **C5** | separate `passkey_limiter` + `publicRateLimitCache`-style pool | Decision D3 makes it redundant for the *login* budget. | Drop it. One small limiter remains, for a different purpose (Β§5.4). | + +### 2.3 Attestation: why `none`, measured rather than assumed + +The review asked the right question β€” *"is there any downside to `null`, and if it +needs a note in `SECURITY.md`, shouldn't we set a real value?"* β€” so it was +tested instead of argued. Seven configurations were run against 5.3.9 +(`spike_attestation.php`, `spike_att2.php`). Results are summarised, not +predicted: + +| # | Configuration | Outcome | What the server actually learns | +|---|---|---|---| +| **A** | `attestation=none`, `fmt=none` | βœ… accepted | `attestationType="none"`, aaguid all-zero, `EmptyTrustPath`. **Nothing.** | +| **B** | `attestation=direct`, `fmt=packed` **self**, no MDS | βœ… accepted | A real AAGUID string β€” but no metadata to interpret it against, so it is untrusted and uninterpretable. | +| **C** | `attestation=direct`, `fmt=packed` **basic** (`x5c` cert), no MDS | ❌ **rejected** | *"The Metadata Statement Repository is mandatory when requesting attestation objects."* | +| **C2** | …same, MDS enabled, metadata **empty** | ❌ **rejected** | *"The Metadata Statement for the AAGUID … is missing."* This is the real cost of MDS: **every** authenticator must be known in advance. | +| **C3** | MDS enabled, but the client sends a **zero** AAGUID | βœ… **accepted** | *"Null AAGUID detected. Skipping metadata verification."* β€” **MDS is bypassable by design.** | +| **C4** | MDS enabled, `fmt=packed` **self** attestation, AAGUID **unknown** to MDS | βœ… **accepted** | `processSelfAttestation()` returns early when the AAGUID has no metadata entry, so **self attestation is never refused by MDS** β€” even a *known-unknown* device passes. | +| **D** | `attestation=direct` requested, client sends `fmt=none` | βœ… **accepted** | Asking for `direct` does **not** compel compliance β€” conveyance is a *preference*, so the RP cannot force it. | + +Three conclusions follow, and they are the reason D5 is `none`: + +1. **Attestation cannot be *enforced*, only *requested*.** Configuration D shows a + client answering a `direct` request with `none` and being accepted regardless. + Any policy that depends on the client cooperating is not a security control. +2. **MDS is bypassable two different ways.** C3 is the decisive row: a zero AAGUID + short-circuits metadata verification *before* the repository is ever consulted. + Since passkeys from Apple/Google/Windows deliberately send zero AAGUIDs, an + attacker can present the same shape and skip MDS entirely β€” while legitimate + users are unaffected. C4 closes the remaining door on the same conclusion: with + `fmt=packed` **self** attestation (the format a software/platform authenticator + can produce without any vendor certificate), `processSelfAttestation()` returns + early when the AAGUID has no metadata entry, so even a device that is *unknown* + to MDS is accepted. Taken together: an MDS deployment refuses honest + certificate-bearing authenticators that postdate its cached BLOB (C2), while + still admitting the bypassable and self-attested cases. That is the worst + combination β€” friction for legitimate users, no assurance gained. +3. **`none` is not a weaker version of the same check β€” it is the honest + description of reality.** The property that actually protects users is that the + credential is cryptographically bound to the RP ID and origin (Β§2.1), which + holds identically in every row above. Attestation answers *"which device model + is this?"* β€” a question this project does not need to answer, because it does + not run a device-allow-list policy. + +**What a real value would actually cost**, for the record: `direct` requires the +metadata repository (C) β€” verified as a hard failure, not a warning β€” which means +`web-token/jwt-library`, `symfony/http-client`, a periodic download of the FIDO +Alliance BLOB, certificate-chain validation on every registration, and a new +failure mode where a legitimate new phone is **rejected at enrolment** because its +AAGUID postdates the cached BLOB. All of that to gain a bypassable signal. + +> **Where to revisit this.** D5 is the right call *for a self-hosted +gateway that does not distinguish devices*. It stops being the right call if the +project ever wants to (a) refuse specific authenticator models, or (b) prove +enrolment happened on hardware rather than a synced passkey. Both would require +MDS **plus** a decision to reject zero AAGUIDs β€” which is why the reasoning is +recorded in `SECURITY.md` rather than left implicit in a constant. + +--- + +## 3. The flow, end to end + +### 3.1 First-time setup (D2 β€” in the browser) + +``` +Browser β†’ https://app.example.com/dashboard + forward_auth β†’ preauth (host=app.example.com) β†’ InterceptListener + matchesAuth() && host !== auth subdomain + β‡’ 303 https://auth.example.com/?return=https%3A%2F%2Fapp.example.com%2Fdashboard + +Browser β†’ https://auth.example.com/?return=… +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Pre-Authentication System β”‚ +β”‚ β”‚ +β”‚ Session ID: [ lyra ] β”‚ +β”‚ Authentication Token:[ 123456 ] β”‚ +β”‚ [x] Register this device as a passkey ← new β”‚ +β”‚ [ Submit ] β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +The checkbox only appears when passkeys are available (D1 satisfied) β€” see Β§4. + +**Submission with the box ticked** becomes a three-step ceremony: + +``` +1. POST / (auth host), form fields username+totp+nonce+register=passkey + LoginListener β†’ LoginManager verifies TOTP/backup code + nonce [unchanged] + ↳ instead of issuing a session, it starts a REGISTRATION ceremony: + stores passkey_reg_ β†’ { challenge, identity, userHandle } (TTL 300s) + ⇐ 200 JSON { register: { publicKey: , ceremonyId: } } + +2. Browser: navigator.credentials.create({ publicKey: options }) + β†’ user approves with Touch ID / Windows Hello / security key + +3. POST / (auth host) X-Preauth-Passkey: register-finish + body: { ceremonyId, credential: } + PasskeyListener β†’ PasskeyManager verifies attestation against the stored + challenge; stores the credential under the identity from the record + ⇐ 303 Location: + Set-Cookie: __Http-Domain-Preauth=… +``` + +The TOTP check in step 1 is what authorises registration. There is no separate +enrolment token, no CLI, and **no way to create a credential without already +holding a valid TOTP code** β€” which is exactly the security property the CLI +design was reaching for. + +> **Implementation note β€” where the hand-off actually goes.** +> `LoginListener::onKernelRequest()` is a straight chain: it builds a `Payload`, +> calls `$this->loginManager->checkToken(...)`, and on a non-null response it +> does `$event->setResponse($response); return;` β€” on `null` it immediately +> scores a failure and consumes a rate-limit token. There is no "authenticated +> but do not issue a session" branch to hook. +> +> So the clean split is: `LoginListener` detects `register=passkey` in the POST +> body and marks the **`Payload`** with the intent; `LoginManager::checkToken()` +> verifies TOTP/backup-code **and the nonce** exactly as it does today, and only +> then, if the intent is set, delegates to the registration ceremony instead of +> issuing a session. That keeps the nonce/CSRF guarantee in the one place that +> already enforces it β€” the alternative (starting a ceremony from the listener +> before `checkToken` runs) would move nonce validation and would need care to +> avoid double-spending it. + +### 3.2 Everyday login (assertion) + +``` +Browser β†’ https://auth.example.com/?return=… + [ πŸ”‘ Sign in with a passkey ] ← button, one tap + ─────────── or use a code ─────────── + Session ID: [ … ] Token: [ … ] [ Submit ] + +Passkey button: +1. POST / X-Preauth-Passkey: login-begin + ⇐ 200 JSON { publicKey: { challenge, rpId, allowCredentials[], … }, + ceremonyId } +2. navigator.credentials.get({ publicKey }) +3. POST / X-Preauth-Passkey: login-finish body: { ceremonyId, credential } + PasskeyManager verifies the assertion against the stored record + ⇐ 303 + cookie, or 401 JSON { message, nonce } +``` + +No username is typed: the credential carries its own identity (stored at +registration). `allowCredentials` lists all registered credentials, so the OS +picker decides which device to use. + +### 3.3 Listener priority (D3) + +``` +Priority Listener Action +──────── ───────────────────── ───────────────────────────────────────── +99 AcceptListener Valid cookie β†’ 200 OK +88 AllowListener Valid IP session β†’ 200 OK +84 PublicAccessListener Public path + rate limit β†’ 200/429 +77 RejectListener LOGIN RATE-LIMIT GATE β†’ 418/429 +70 PasskeyListener (new) WebAuthn ceremony β†’ JSON +66 LoginListener TOTP / backup-code login +55 InterceptListener Fallback β†’ redirect or login page +``` + +**Why 70 β€” after `RejectListener` and before `LoginListener`:** + +- **After 77 (D3):** a rate-limited IP is refused *before* any ceremony can + start. Passkeys cannot be used to sidestep a lockout. This is the whole point + of the reviewer's third clarification, and it reverses the first draft. +- **Before 66:** essential. `LoginListener` treats *any* POST to the auth + subdomain as a login attempt (`$domainManager->getAuthSubdomain() === $host`). + A ceremony `finish` POST has no `username`/`totp`, so `Payload::load()` returns + `null` and the request would be scored as a **failed login and burn a rate-limit + token**. `PasskeyListener` must claim the request first. + +`PasskeyListener` sets a response for *every* request carrying its header β€” +including malformed ones β€” so control never falls through to +`InterceptListener`, which would render HTML to a `fetch()` caller. (Q3.2) + +--- + +## 4. Availability rule (D1 + D4) + +Passkeys are offered **only** when all of these hold: + +```php +$passkeysAvailable = + $config->passkeyEnabled() // PASSKEY_ENABLED=1 (default 0) + && null !== $domainManager->authBase() // SUBDOMAIN_REDIRECT=1 && AUTH_SUBDOMAIN set + && $domainManager->getAuthSubdomain() === $request->getHost(); // we are ON the auth host +``` + +…and, separately, the **deployment** must satisfy HTTPS (D4). That is checked +once at boot rather than per request, because "is this request HTTPS" is not the +right question behind a TLS-terminating proxy β€” see Β§4.2. + +Consequences: + +- **RP ID is always `authBase()`** β€” never the request host, never configurable + per-service. `example.com` for `auth.example.com`. +- **Allowed origins is exactly one entry**: `https://{AUTH_SUBDOMAIN}`, built + from config. Because `InterceptListener` funnels every unauthenticated user to + the auth host, no other origin ever needs to run a ceremony. This is the + tightest configuration that still delivers "one passkey, every service" (Β§2.1). +- On a protected host, `InterceptListener` already redirects before rendering a + login page, so the checkbox is naturally absent there. +- If someone sets `PASSKEY_ENABLED=1` without central auth, the app must + **fail loudly at boot**, not silently ignore it (Q3.1). A silent ignore is how + you get "I enrolled a passkey and now I can't log in" support tickets. + +`rpName` for the OS prompt defaults to `TITLE`. + +### 4.1 Identity and userHandle + +The identity is the `Session ID` the user typed β€” the same value TOTP uses, so +`Remote-User` modes (`session`/`static`/`mapped`) keep working unchanged. + +- `userHandle` = `hash('sha256', $identity, true)` (32 raw bytes). Fixed length, + stable per identity, and does not leak the label into the authenticator. +- On assertion, the identity is read from the **stored credential record**, not + from the client-returned `userHandle`. The client's copy is never trusted. +- Because registration is gated behind a successful TOTP login, one identity + cannot be registered by someone who does not already hold the TOTP secret. + +### 4.2 HTTPS (D4) β€” enforced, not exempted + +D4 removes the exemption system entirely: **there is no code path that accepts an +`http://` origin for passkeys**, and no configuration that re-enables one. The +library's `setSecuredRelyingPartyId()` (deprecated since 5.2, confirmed in +`CeremonyStepManagerFactory`) is **never called**. + +Measured behaviour of the origin check (`spike_origin.php`), all with rpId +`example.com`: + +| Allowed origins | Client origin | Result | +|---|---|---| +| `https://auth.example.com` | `https://auth.example.com` | βœ… accepted | +| `http://localhost:8000` | `http://localhost:8000` | βœ… accepted β€” **only** because `http://` was explicitly allow-listed | +| `localhost:8000` (host-only) | `http://localhost:8000` | ❌ rejected | +| `https://auth.example.com` | `http://auth.example.com` | ❌ rejected | +| `https://example.com` +subdomains | `https://app.example.com` | βœ… accepted | +| `https://example.com` +subdomains | `http://app.example.com` | ❌ rejected | +| `https://example.com` (no subdomains) | `https://app.example.com` | ❌ rejected β€” *"Subdomains are not allowed."* | + +The scheme is therefore never inferred from the request; it comes from the single +`https://{AUTH_SUBDOMAIN}` string. Note the second row β€” the library *will* accept +plain HTTP **if the operator writes it into the allow-list**, which is precisely +the hole D4 closes by deleting `PASSKEY_ALLOWED_ORIGINS`. + +**Two gotchas this creates for local development**, both verified against +`DomainManager` (`spike_devhost.php`): + +1. `baseDomain('localhost')` returns **`null`** by design, so `authBase()` is also + `null` and **`localhost` can never satisfy D1** β€” passkeys stay off there no + matter what. `auth.localhost`, by contrast, resolves to `authBase()` of + `auth.localhost` and *does* satisfy D1. +2. Because the origin must be `https://`, dev cannot simply point a browser at + `http://auth.localhost`. The supported dev workflow is therefore **a local TLS + certificate**, not an exemption: + +``` +# Development with real TLS β€” the only supported way to exercise passkeys +AUTH_SUBDOMAIN=auth.preauthtest.local +SUBDOMAIN_REDIRECT=true +PASSKEY_ENABLED=1 +# /etc/hosts β†’ 127.0.0.1 auth.preauthtest.local app.preauthtest.local +# mkcert auth.preauthtest.local app.preauthtest.local +# Caddy terminates TLS with the mkcert cert and reverse_proxies to :80 +``` + +This is a **documentation and CI** change, not an application-code change: the app +already sits behind a TLS-terminating proxy in production (`docker/Caddyfile` +serves plain HTTP on `:80`, `trusted_headers` includes `x-forwarded-proto`), so +D4 adds no runtime branching. `docs/examples/Caddyfile` gains a TLS-enabled +development block, and the functional tests (Β§7.2) drive the HTTPS origin directly +because they build `clientDataJSON` by hand β€” no real TLS needed in the suite. + +> **Not `localhost`.** Because D4 forbids `http://`, the classic +> `http://localhost` dev story simply does not apply to passkeys. `localhost` is +> treated as *"passkeys unavailable"*, which keeps D1 intact instead of carving +> out an exception that would then need its own tests. + +--- + +## 5. Design detail + +### 5.1 `PasskeyManager` (new service) + +Owns both ceremonies. Library types stay inside this class so a future v6 rename +touches one file. + +```php +final readonly class PasskeyManager implements PasskeyInterface +{ + public function beginLogin(Request $request): array; // β†’ options + ceremonyId + public function finishLogin(array $body, Request $request): ?Response; + public function beginRegistration(string $identity, Request $request): array; + public function finishRegistration(array $body, Request $request): ?Response; +} +``` + +Built on the verified recipe: + +```php +$attestationManager = AttestationStatementSupportManager::create(); +$attestationManager->add(NoneAttestationStatementSupport::create()); // D5 (Β§2.3) + +$csm = new CeremonyStepManagerFactory(); +$csm->setAllowedOrigins(["https://{$domainManager->getAuthSubdomain()}"]); +$csm->setAlgorithmManager(AlgorithmManager::create()->add(ES256::create())); +$csm->setAttestationStatementSupportManager($attestationManager); + +$attestationValidator = AuthenticatorAttestationResponseValidator::create($csm->creationCeremony()); +$assertionValidator = AuthenticatorAssertionResponseValidator::create($csm->requestCeremony()); +$serializer = (new WebauthnSerializerFactory($attestationManager))->create(); +``` + +- `setSecuredRelyingPartyId()` is **deprecated in 5.2** (confirmed in the source, + `@deprecated since 5.2.0 … Use setAllowedOrigins instead`) β€” **never called + (D4)**. Development uses real TLS, not an exemption (Β§4.2). +- `attestation: 'none'` for registration **(D5, Β§2.3)**; no metadata service, so + neither `web-token/jwt-library` nor `symfony/http-client` is needed β€” the + latter confirmed absent from the current install, so reaching for MDS would add + a second new dependency, not just code. +- Counter: keep the library default; document that clone detection is not relied + upon (Q3.5). Test helpers must increment (C4). + +### 5.2 Ceremony state + +Stored in the **`nonceCache`** pool (already APCu, already excluded from +`kernel.reset` in `TestKernel`, already short-lived, and β€” correctly β€” *not* +persisted to disk, so ceremonies do not survive a restart): + +``` +passkey_cer_ β†’ { type: 'login'|'register', + challenge: , + identity?: string, // register only + userHandle?: string, // register only + returnUrl?: string, + createdAt: } TTL 300s +``` + +- `ceremonyId` is a fresh 15-byte base64url string, issued to the client. The + client's copy of the challenge is **never** trusted; the server-side record is + authoritative. +- **Single-use**: deleted on read at `finish`, before verification, so a failed + or replayed assertion cannot be retried against the same challenge. +- TTL 300 s (5 min) rather than the nonce's 120 s, because a user has to + interact with a biometric prompt. + +### 5.3 Credential store (new service) + +```php +final readonly class PasskeyCredentialStore implements PasskeyCredentialStoreInterface +{ + public function all(): array; // for allowCredentials + public function find(string $credentialId): ?array; // record + metadata + public function save(CredentialRecord $record, string $identity, string $label): void; + public function updateCounter(CredentialRecord $record): void; + public function remove(string $credentialId): bool; + public function count(): int; +} +``` + +Cache layout in **`sessionCache`** (the persisted pool): + +``` +passkey_cred_ β†’ { record: , + identity: string, + label: string, + createdAt: iso8601, + lastUsedAt: iso8601|null } +passkey_index β†’ { : {identity, label, createdAt}, … } +``` + +> ⚠️ **Verified gotcha.** `PersistCache::persist()` only flushes keys recorded by +> a `MonitorCacheKeys` instance, and it watches `sessionCache`. `LoginManager` +> and `BackupCodeManager` therefore each wrap their injected pool: +> `$this->sessionCache = new MonitorCacheKeys($sessionCache);`. +> `PasskeyCredentialStore` **must do the same**, or credentials live only in APCu +> and vanish on the next container restart β€” a bug that would surface only after +> a redeploy. Add an explicit test asserting the write is visible in the +> underlying persistent pool. + +`passkey_index` avoids scanning the whole key space for the login page's +`allowCredentials` list. + +### 5.4 Rate limiting (D3) + +**No new limiter for the login budget.** Instead: + +| Event | Limiter behaviour | +|---|---| +| Any request to the auth host, incl. `*-begin` | `RejectListener` (77) gates first β€” a blocked IP never reaches `PasskeyListener` | +| `login-finish` **failure** | consumes `login_limiter` (1 token) β€” identical to a wrong TOTP code | +| `register-finish` **failure** | consumes `login_limiter` | +| successful ceremony | consumes nothing | +| `*-begin` | not consumed (a legitimate login must not burn failure budget) | + +This satisfies "if the login attempt has been rate limited, that would include +all forms of login": after 10 failures the IP is blocked for *every* method, and +failures from any method count toward the same 10. + +To stop `begin`-spam from filling the cache with ceremony records, add **one** +small limiter that bounds *starts* only β€” it is a resource guard, not the auth +budget: + +```yaml +passkey_begin_burst: + policy: 'sliding_window' + limit: '%env(int:PASSKEY_BEGIN_BURST_COUNT)%' # default 30 + interval: '%env(int:PASSKEY_BEGIN_BURST_TIME)% seconds' # default 60 + cache_pool: 'passkeyRateLimitCache' +``` + +plus a `passkeyRateLimitCache` pool (APCu in prod, array in test) and an entry in +`tests/TestKernel`'s reset-exclusion list. On over-limit, `begin` answers +`429` with `Retry-After`, matching `PublicAccessListener`. (Q3.6 asks whether this +guard is wanted at all.) + +### 5.5 Response caching and CSP + +**Caching.** `SecurityHeadersListener` sets `no-store` only on *non-2xx* +responses, on the assumption that 2xx is consumed by `forward_auth`. That is +false here: `begin` returns a **`200` JSON body straight to the browser**, and +the auth subdomain is `reverse_proxy`-ed with no `forward_auth` in front of it at +all. Ceremony responses must therefore be no-store too. Proposed: `PasskeyListener` +marks them with an internal `X-Preauth-Ceremony` header, and +`SecurityHeadersListener` turns that into the full no-store set and strips the +marker β€” keeping the caching policy in the one place that owns it. (Q3.7) + +**CSP.** `publickey-credentials-get` / `publickey-credentials-create` do **not** +fall back to `default-src` (confirmed, Β§2.1), and the current policy is +`default-src 'none'`. When passkeys are available the policy becomes: + +``` +default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; +connect-src 'self'; publickey-credentials-get 'self'; publickey-credentials-create 'self'; +``` + +`connect-src 'self'` must be added **in both modes** β€” today it is added only for +the inline (non-auth-subdomain) case, but the passkey flow always uses `fetch()`. +When passkeys are unavailable the header is byte-identical to today. + +### 5.6 Templates and script + +| File | Purpose | +|---|---| +| `templates/_passkey.html.twig` | "Sign in with a passkey" button + `navigator.credentials.get()` handler | +| `templates/login.html.twig` | gains the checkbox (register) and includes the button, both only when available | +| `templates/_passkey_register.html.twig` | `navigator.credentials.create()` handler, driven by the JSON returned in step 1 of Β§3.1 | + +`_script.html.twig` keeps its existing submit handler; ticking the checkbox +switches the submit into the registration branch. Kept as separate templates so +the "passkeys unavailable β‡’ byte-identical login page" property stays testable. + +Base64url helpers must mirror the library's encoding exactly (no padding, +`-`/`_` alphabet); the spike's working script is the reference. + +### 5.7 Configuration + +| Variable | Default | Notes | +|---|---|---| +| `PASSKEY_ENABLED` | `0` | master switch; requires **D1 and D4** or the app fails at boot (Q3.1) | +| `PASSKEY_RP_NAME` | `%env(TITLE)%` | shown by the OS prompt | +| `PASSKEY_USER_VERIFICATION` | `required` | `required`/`preferred`/`discouraged` | +| `PASSKEY_TIMEOUT` | `60000` | ms, passed to the browser | +| `PASSKEY_BEGIN_BURST_COUNT` / `_TIME` | `30` / `60` | Β§5.4 resource guard | +| `PASSKEY_BUTTON_NAME` | `Sign in with a passkey` | styling-option family | +| `PASSKEY_REGISTER_NAME` | `Register this device as a passkey` | checkbox label | + +> **Deleted by D4:** `PASSKEY_ALLOWED_ORIGINS`. The allowed origin is always +> derived as `https://{AUTH_SUBDOMAIN}` and there is no override β€” see Β§4.2. + +Defaults preserve today's behaviour exactly. + +--- + +## 6. Files + +**New** + +| File | Purpose | +|---|---| +| `src/Service/PasskeyManager.php` + `PasskeyInterface.php` | both ceremonies, library types contained | +| `src/Service/PasskeyCredentialStore.php` + interface | cache-backed records + index | +| `src/Listener/PasskeyListener.php` | priority 70, header dispatch, JSON | +| `templates/_passkey.html.twig` | login button + assertion script | +| `templates/_passkey_register.html.twig` | registration script | +| `tests/Support/PasskeyTestHelper.php` | ES256 generator, ceremony builder, incrementing counter (C4) | +| `tests/Unit/Service/PasskeyManagerTest.php` | ceremony control flow | +| `tests/Unit/Service/PasskeyCredentialStoreTest.php` | storage, index, persistence, key collisions | +| `tests/Unit/Listener/PasskeyListenerTest.php` | every branch | +| `tests/Functional/PasskeyFlowTest.php` | real crypto end-to-end (Β§7.2) | + +**Changed** + +| File | Change | +|---|---| +| `composer.json` / `composer.lock` / `symfony.lock` | `web-auth/webauthn-lib: ^5.3` (done on the spike branch) | +| `phpunit.dist.xml` | recipe-added `doctrine/deprecations` triggers (spike artefact β€” keep) | +| `config/packages/property_info.yaml` | recipe-added (spike artefact β€” keep) | +| `config/services.yaml` | `app.passkey_*` parameters | +| `config/packages/rate_limiter.yaml` | `passkey_begin_burst` | +| `config/packages/cache.yaml` + `test/cache.yaml` | `passkeyRateLimitCache` | +| `config/packages/twig.yaml` | passkey globals | +| `src/ConfigBag.php` | `passkeyEnabled()`, `rpName()`, `userVerification()`, `timeout()`, labels | +| `src/Kernel.php` or a compiler pass | boot-time check that **D1 and D4** hold when enabled (Q3.1, Β§4.2) | +| `src/Listener/SecurityHeadersListener.php` | CSP additions; ceremony no-store marker | +| `src/Listener/LoginListener.php` | detect `register=passkey` on the POST and mark the `Payload` with the intent (see Β§3.1 note) | +| `src/Service/LoginManager.php` | on success-with-intent, delegate to the registration ceremony instead of issuing a session; extract the session-issuing tail (Q3.8) | +| `templates/login.html.twig` | checkbox + button | +| `tests/TestKernel.php` | `passkeyRateLimitCache` in the reset-exclusion list | +| `tests/Support/ListenerTestHelper.php` | passkey limiter factory | +| `.env.test`, `docs/examples/.env.example`, `docs/examples/Caddyfile`, `docs/examples/compose.yaml` | config + docs; **TLS dev block** (Β§4.2) | +| `readme.md`, `CHANGELOG.md`, `ROADMAP.md`, `SECURITY.md`, `DESIGN_CONSIDERATIONS.md` | Β§9 | + +--- + +## 7. Testing + +### 7.1 Reusing the spike + +`tests/Support/PasskeyTestHelper.php` is the spike's working code, refactored: +ES256 keypair β†’ COSE key β†’ `authenticatorData` β†’ sign β†’ JSON. Two rules learned +the hard way: + +- **Increment the counter every ceremony** (C4), or a negative test can pass for + the wrong reason (`CounterException` masking the real failure). +- **Serialise options through `WebauthnSerializerFactory`**, never `json_encode` (C2). + +### 7.2 Cases + +| Case | Expected | +|---|---| +| Register on `auth.example.com` (rpId `example.com`), then assert from the same host | βœ… 303, `__Http-Domain-Preauth`, `Domain=example.com`, `Remote-User` | +| Assert the same credential from `app.example.com` | βœ… success by design β€” asserted explicitly so the scope is documented in code | +| Register while `PASSKEY_ENABLED=0` / without central auth | ❌ checkbox absent; `begin` inert; no cache writes | +| Registration submitted with a **bad TOTP** | ❌ 401, no ceremony started, login limiter consumed | +| Registration with a **spent nonce** | ❌ 401, no ceremony | +| `begin` from a rate-limited IP | ❌ 418/429 from `RejectListener`, never reaches passkey code | +| Failed assertion | ❌ 401, **login limiter consumed** (D3) | +| Failed assertion Γ— N, then a correct TOTP | ❌ still blocked β€” shared budget | +| Assertion replayed with the same `ceremonyId` | ❌ 401 (record deleted on read) | +| Unknown `credentialId` | ❌ 401, same generic message as a bad TOTP (no enumeration) | +| Origin not in the allow-list | ❌ 401 (`Invalid origin…`) | +| **`http://` origin with the derived `https://` allow-list** | ❌ 401 β€” D4; asserted explicitly so the exemption cannot creep back | +| **`PASSKEY_ALLOWED_ORIGINS` is not consulted** | ❌ setting it has no effect (D4) | +| **`PASSKEY_ENABLED=1` with `AUTH_SUBDOMAIN=localhost`** | ❌ boot failure β€” D1 unsatisfiable (Β§4.2) | +| **`PASSKEY_ENABLED=1` on plain HTTP deployment** | ❌ boot failure β€” D4 (Q3.1) | +| Forged `rpIdHash` | ❌ 401 (`rpId hash mismatch`) | +| Zero AAGUID / self attestation payload | βœ… accepted exactly as a `none` record would be β€” documents D5's reasoning in code | +| Ceremony responses | βœ… full no-store header set | +| Login page when passkeys unavailable | βœ… byte-identical to today | +| Persistence | βœ… a saved credential is present in the **persistent** pool, not just APCu | +| `begin` spam | βœ… bounded by `passkey_begin_burst` | + +### 7.3 Gates + +Baseline to preserve: **313 tests / 738 assertions**, 100 % line/method/class +coverage, `phpstan` level 6 clean, `php-cs-fixer` clean, `composer audit` clean, +conformance 35/35. Note `phpunit.dist.xml` runs with `failOnDeprecation=true`, so +deprecations from the new dependency must be watched (the recipe already added +the `doctrine/deprecations` triggers). + +--- + +## 8. Implementation order + +Each step is independently committable and leaves the suite green. + +1. **Dependency** *(done on the spike branch)* β€” `composer require + web-auth/webauthn-lib`; suite + lints + audit + conformance verified. +2. **Availability + config** β€” `ConfigBag` accessors, `services.yaml`, boot-time + **D1 + D4** assertion, Twig globals, test env. Feature fully inert; assert the + login page is unchanged. Includes the TLS development setup in + `docs/examples/` (Β§4.2), so contributors can exercise the feature locally. +3. **Credential store** β€” with `MonitorCacheKeys` wrapping and the persistence + test. No WebAuthn types needed yet (`CredentialRecord` can be stubbed). +4. **`PasskeyManager`** β€” both ceremonies, ceremony state, single-use deletion, + limiter consumption on failure. Unit-tested with a stubbed validator. +5. **`PasskeyListener`** β€” priority 70, header dispatch, always terminate, + no-store marker. Unit-test every branch incl. "post-shaped request must not + reach `LoginListener`". +6. **Extract session issuing** from `LoginManager` so both paths share it β€” + prove equality against the existing `LoginManagerTest`/`AuthenticationFlowTest` + before touching anything else (Q3.8). +7. **Registration UI** β€” checkbox in `login.html.twig`, the `Payload`-intent + hand-off described in Β§3.1, `_passkey_register.html.twig`. +8. **Login UI + CSP** β€” `_passkey.html.twig`, `SecurityHeadersListener`, extend + `CacheControlFlowTest` and `SecurityHeadersListenerTest`. +9. **Functional tests** with real crypto (Β§7.2). +10. **Docs** (Β§9) and **final gates**, then PR to `main`. + +--- + +## 9. Documentation + +| File | Update | +|---|---| +| `readme.md` | "Passkey Authentication" section: **the central-auth prerequisite**, **the HTTPS requirement (development included)**, enabling, the checkbox, the passkey button, RP ID, fallbacks | +| `CHANGELOG.md` | `[Unreleased]` `Added`/`Security`; record library 5.3.9 and the clean audit | +| `ROADMAP.md` | Phase 2c done, noting the deviations from the original sketch (browser registration, no bundle, D1/D3/D4/D5) | +| `SECURITY.md` | ceremony model, challenge TTL/one-shot, RP ID scope, **the D5 attestation rationale and the conditions that would reverse it (Β§2.3)**, HTTPS-only origins, counter caveat, shared rate-limit budget | +| `DESIGN_CONSIDERATIONS.md` | the 2xx-caching gap; `CredentialRecord` serialization; the shared-limiter decision; **why attestation was deliberately declined** | +| `docs/examples/.env.example` | new variables; **note that `PASSKEY_ALLOWED_ORIGINS` does not exist by design** | +| `docs/examples/Caddyfile` | auth-subdomain block already `reverse_proxy`-ed; **add a TLS-enabled development block (Β§4.2)** and note why the plain-HTTP shortcut is not offered | + +--- + +## 10. Risks + +| # | Risk | Mitigation | +|---|---|---| +| R1 | RP ID / origin misconfiguration | D1 removes the matrix: RP ID is always `authBase()`, origins is always the auth host. Asserted by tests. | +| R2 | Ceremony responses cached (first browser-facing 2xx) | Β§5.5 marker + `CacheControlFlowTest` cases | +| R3 | CSP blocks the ceremony | Β§5.5 directives; verify in a real browser during staging (Q3.9) | +| R4 | Library churn (v5 renamed types; `setSecuredRelyingPartyId` deprecated) | pin `^5.3`; library types contained in `PasskeyManager`; avoid deprecated calls | +| R5 | Credential loss on restart | `MonitorCacheKeys` wrap + explicit persistence test (Β§5.3) | +| R6 | Non-technical users lose their passkey device | TOTP/backup codes unchanged and always available; the checkbox is opt-in | +| R7 | `begin` cache-fill | Β§5.4 resource guard | +| R8 | New transitive deps (`symfony/serializer`, `property-info`) | already installed as part of the spike; container lint passes | +| R9 | **A deployment enables passkeys without TLS, and the feature silently half-works** | D4 + the extended boot assertion (Β§4.2, Q3.1): `PASSKEY_ENABLED=1` in a non-HTTPS configuration **fails at `cache:warmup`** instead of failing later in the browser | +| R10 | **"We should verify the device" creeps back in as a requirement** | Β§2.3 records the measurements and the two conditions that would justify revisiting; a functional test asserts a zero-AAGUID payload is handled deliberately, so any change is a visible, reviewed diff | + +--- + +## 11. Remaining open questions + +D1–D5 removed most of the first draft's 22 questions. These are what is left; +each has a proposal, so "yes" is a valid answer. + +**Q1.1 β€” Version target.** `CHANGELOG.md`'s `[Unreleased]` heading still says +v1.1 while git tags reach `v1.3.0`. Target the next minor and repair the heading +in a separate labelled commit? *Proposal: yes.* + +**Q1.2 β€” Where the checkbox appears.** *Proposal: always visible when passkeys +are available (same as the login button), since a user who has just landed on +the auth page is exactly the person most likely to be enrolling a new device.* + +**Q1.3 β€” What if the same device registers twice** (same identity, second +passkey)? *Proposal: allow it β€” the OS may legitimately create a second +credential, and `excludeCredentials` will let the authenticator dedupe. `Q2.6` +of the first draft (a cap) becomes: cap at a configurable N (default 20).* + +**Q2.1 β€” Re-confirm: TOTP stays?** *Proposal: yes, unchanged, and never +disabled by enabling passkeys.* + +**Q3.1 β€” How to enforce the D1 prerequisite.** Boot-time hard failure when +`PASSKEY_ENABLED=1` without central auth, or log a warning and disable? +*Proposal: **hard failure** at container start (`cache:warmup`) β€” a silent +disable is how you get "my passkey stopped working" tickets.* **Extended by D4:** +the same boot check also asserts HTTPS, so "enabled but unusable" cannot ship. +The check is on **configuration**, not on the request, because behind a TLS +terminating proxy `isSecure()` is not authoritative (Β§4.2). + +**Q3.2 β€” Always terminate a ceremony with JSON?** *Proposal: yes β€” any request +carrying `X-Preauth-Passkey` gets a JSON response, never the HTML login page.* + +**Q3.3 β€” Attestation policy.** **RESOLVED β€” D5: `none`.** Measured, not assumed: +`direct` cannot be enforced (config D), MDS is bypassable by the zero AAGUIDs that +real passkeys send (config C3), and requiring MDS would reject legitimate new +authenticators (config C2) while adding two dependencies. Full evidence and the +conditions that would reverse it are in **Β§2.3**. *Set a real value instead* +was considered and rejected on the evidence. + +**Q3.4 β€” Local development over HTTP.** **RESOLVED β€” D4: not supported.** No +`securedRelyingPartyId` exemption, deprecated or otherwise; local development uses +real TLS with a local certificate (Β§4.2). `PASSKEY_ALLOWED_ORIGINS` is deleted. +Note `localhost` deliberately cannot satisfy D1, so there is no half-configured +state to document away. + +**Q3.5 β€” Counter checking.** *Proposal: keep the library default; document that +counter-based clone detection is not relied upon (many passkeys always report 0).* + +**Q3.6 β€” Keep the `begin` resource guard?** It is not part of the login budget +(D3 governs that) β€” it only bounds cache-fill. *Proposal: keep it; it is ~15 +lines and mirrors the existing `public_limiter` pattern.* + +**Q3.7 β€” Caching-policy mechanism.** `X-Preauth-Ceremony` marker header consumed +by `SecurityHeadersListener` (keeps cache policy in one place), or set headers +directly in `PasskeyListener`? *Proposal: the marker.* + +**Q3.8 β€” Extract the session-issuing tail from `LoginManager`?** *Proposal: yes, +as its own commit β€” duplicating cookie/redirect/`Remote-User` logic is how the +two paths drift.* + +**Q3.9 β€” Browser matrix.** Which browsers must be verified by hand on staging +(iOS Safari, Chrome, Firefox, and a hardware key) before release? *Proposal: all +four; note the CSP directive is the most likely divergence.* + +--- + +### Resolved in this round + +| Question | Resolution | +|---|---| +| Q3.3 β€” attestation value | **D5: `none`**, with measurements in Β§2.3 | +| Q3.4 β€” dev over HTTP | **D4: real TLS only**; `PASSKEY_ALLOWED_ORIGINS` deleted (Β§4.2) | +| Q3.1 β€” boot check scope | extended to assert **D1 *and* D4** | + +--- + +## 12. Rollback + +- `PASSKEY_ENABLED=0` (the default) makes the feature inert; reverting is + redeploying the previous image tag. No migrations. +- If passkeys were enabled and are rolled back, credential records remain in + `sessionCache`/filesystem under `passkey_*` keys, unread by the old code. + Sessions continue to work; nothing is invalidated. +- The dependency addition reverts with `composer.lock`. + +--- + +## 13. Notes for the reviewer + +- **Round 2 added D4 (HTTPS required, no exemptions) and D5 (attestation stays + `none`, on measured evidence).** D4 is covered in Β§4.2, D5 in Β§2.3; the two + questions that drove them are marked resolved in Β§11. +- Three claims in this revision were **measured, not reasoned**: the attestation + matrix (Β§2.3), the origin/HTTPS behaviour (Β§4.2), and the `localhost` Γ— D1 + interaction (Β§4.2). Scripts: `spike_attestation.php`, `spike_att2.php`, + `spike_origin.php`, `spike_devhost.php`. +- Incidentally confirmed while testing: `symfony/http-client` is **not** in the + current install, so MDS would have been a second new dependency, not a drop-in. +- The spike branch (`spike/passkey-deps`) currently carries `composer.json`, + `composer.lock`, `symfony.lock`, `phpunit.dist.xml` and + `config/packages/property_info.yaml` changes. Decide whether step 2 continues + on that branch or starts fresh from `main`. +- The spike scripts themselves were **removed** from the working tree (kept in + `/tmp/spike-backup/` for reference) so they never reach a PR; the reusable + parts are folded into `tests/Support/PasskeyTestHelper.php` in step 9. +- The environment details (PHP 8.5.11 via Sury, Composer, `pcov`) are local to + this container and are not a project change. + +--- + +*End of plan.* diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index ae3b561..76b5cb3 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -486,42 +486,18 @@ parameters: count: 1 path: tests/Unit/Command/GenerateBackupCodesCommandTest.php - - - message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#' - identifier: method.missingOverride - count: 1 - path: tests/Unit/ConfigBagRemoteUserTest.php - - message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertNull\(\) with null will always evaluate to true\.$#' identifier: staticMethod.alreadyNarrowedType count: 2 path: tests/Unit/Enum/ScopeTest.php - - - message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#' - identifier: method.missingOverride - count: 1 - path: tests/Unit/Listener/AcceptListenerTest.php - - - - message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#' - identifier: method.missingOverride - count: 1 - path: tests/Unit/Listener/AllowListenerTest.php - - message: '#^Class Symfony\\Component\\RateLimiter\\Exception\\ReserveNotSupportedException constructor invoked with 0 parameters, 1\-3 required\.$#' identifier: arguments.count count: 2 path: tests/Unit/Listener/InterceptListenerTest.php - - - message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#' - identifier: method.missingOverride - count: 1 - path: tests/Unit/Listener/InterceptListenerTest.php - - message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#' identifier: method.missingOverride @@ -582,12 +558,6 @@ parameters: count: 1 path: tests/Unit/Listener/LoginListenerTest.php - - - message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#' - identifier: method.missingOverride - count: 1 - path: tests/Unit/Listener/LoginListenerTest.php - - message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#' identifier: method.missingOverride @@ -642,12 +612,6 @@ parameters: count: 2 path: tests/Unit/Listener/PublicAccessListenerTest.php - - - message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#' - identifier: method.missingOverride - count: 1 - path: tests/Unit/Listener/PublicAccessListenerTest.php - - message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#' identifier: method.missingOverride @@ -702,12 +666,6 @@ parameters: count: 2 path: tests/Unit/Listener/RejectListenerTest.php - - - message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#' - identifier: method.missingOverride - count: 1 - path: tests/Unit/Listener/RejectListenerTest.php - - message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#' identifier: method.missingOverride @@ -768,12 +726,6 @@ parameters: count: 1 path: tests/Unit/Service/BackupCodeManagerTest.php - - - message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#' - identifier: method.missingOverride - count: 1 - path: tests/Unit/Service/BackupCodeManagerTest.php - - message: '#^Call to an undefined method App\\Service\\BackupCodeInterface\:\:method\(\)\.$#' identifier: method.notFound @@ -798,24 +750,12 @@ parameters: count: 1 path: tests/Unit/Service/LoginManagerTest.php - - - message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#' - identifier: method.missingOverride - count: 1 - path: tests/Unit/Service/LoginManagerTest.php - - message: '#^Class class@anonymous/tests/Unit/Trait/GetTotpTraitTest\.php\:22 has an uninitialized readonly property \$config\. Assign it in the constructor\.$#' identifier: property.uninitializedReadonly count: 1 path: tests/Unit/Trait/GetTotpTraitTest.php - - - message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#' - identifier: method.missingOverride - count: 1 - path: tests/Unit/Trait/GetTotpTraitTest.php - - message: '#^Readonly property class@anonymous/tests/Unit/Trait/GetTotpTraitTest\.php\:22\:\:\$config is assigned outside of the constructor\.$#' identifier: property.readOnlyAssignNotInConstructor @@ -954,12 +894,6 @@ parameters: count: 1 path: tests/Unit/Trait/MakeNonceTraitTest.php - - - message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#' - identifier: method.missingOverride - count: 1 - path: tests/Unit/Trait/StringTraitTest.php - - message: '#^Call to function method_exists\(\) with ''Symfony\\\\Component\\\\Dotenv\\\\Dotenv'' and ''bootEnv'' will always evaluate to false\.$#' identifier: function.impossibleType diff --git a/phpunit.dist.xml b/phpunit.dist.xml index 061c0a7..d2f51fe 100644 --- a/phpunit.dist.xml +++ b/phpunit.dist.xml @@ -46,6 +46,8 @@ + Doctrine\Deprecations\Deprecation::trigger + Doctrine\Deprecations\Deprecation::delegateTriggerToBackend trigger_deprecation diff --git a/src/CacheWarmer/PasskeyConfigurationWarmer.php b/src/CacheWarmer/PasskeyConfigurationWarmer.php new file mode 100644 index 0000000..4d93eda --- /dev/null +++ b/src/CacheWarmer/PasskeyConfigurationWarmer.php @@ -0,0 +1,52 @@ +passkeyPolicy->assertConfigurationIsUsable(); + + return []; + } + + /** + * Never optional: skipping this warmer would defeat its entire purpose. + */ + #[Override] + public function isOptional(): bool + { + return false; + } +} diff --git a/src/ConfigBag.php b/src/ConfigBag.php index e3229d4..8cebf89 100644 --- a/src/ConfigBag.php +++ b/src/ConfigBag.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App; use App\Enum\RemoteUserMode; +use App\Enum\UserVerification; use Psr\Cache\InvalidArgumentException; use Psr\Clock\ClockInterface; use Symfony\Component\DependencyInjection\Attribute\Autowire; @@ -23,6 +24,14 @@ final readonly class ConfigBag private string $remoteUserStatic; /** @var array */ private array $remoteUserMap; + private string $title; + private bool $passkeyEnabled; + private string $passkeyRpName; + private UserVerification $passkeyUserVerification; + private int $passkeyTimeout; + + /** Passkey ceremony timeout in milliseconds (WebAuthn default). */ + private const int DEFAULT_PASSKEY_TIMEOUT = 60000; /** @throws InvalidArgumentException */ public function __construct( @@ -38,6 +47,11 @@ final readonly class ConfigBag #[Autowire('%app.remote_user%')] string $remoteUserMode, #[Autowire('%app.remote_user_static%')] string $remoteUserStatic, #[Autowire('%app.remote_user_map%')] string $remoteUserMap, + #[Autowire('%app.title%')] string $title = 'Pre-Authentication System', + #[Autowire('%app.passkey_enabled%')] bool $passkeyEnabled = false, + #[Autowire('%app.passkey_rp_name%')] string $passkeyRpName = '', + #[Autowire('%app.passkey_user_verification%')] string $passkeyUserVerification = 'required', + #[Autowire('%app.passkey_timeout%')] int $passkeyTimeout = self::DEFAULT_PASSKEY_TIMEOUT, ) { $this->clock = $clock; $this->cookieTtl = $cookieTtl; @@ -51,6 +65,11 @@ final readonly class ConfigBag $this->remoteUserMode = RemoteUserMode::tryFrom($remoteUserMode) ?? RemoteUserMode::Session; $this->remoteUserStatic = $remoteUserStatic; $this->remoteUserMap = $this->parseUserMap($remoteUserMap); + $this->title = $title; + $this->passkeyEnabled = $passkeyEnabled; + $this->passkeyRpName = $passkeyRpName; + $this->passkeyUserVerification = UserVerification::fromConfig($passkeyUserVerification); + $this->passkeyTimeout = $passkeyTimeout > 0 ? $passkeyTimeout : self::DEFAULT_PASSKEY_TIMEOUT; } /** @@ -132,4 +151,39 @@ final readonly class ConfigBag { return $this->remoteUserMap; } + + public function title(): string + { + return $this->title; + } + + /** + * Whether the passkey feature is switched on by configuration. + * + * This says nothing about whether the configuration is *usable* β€” that is + * {@see Service\PasskeyPolicyInterface::isEnabled()}, which also + * requires the central-auth prerequisite (D1). + */ + public function passkeyEnabled(): bool + { + return $this->passkeyEnabled; + } + + /** Relying-party name shown in the authenticator prompt; blank falls back to the title. */ + public function passkeyRpName(): string + { + return $this->passkeyRpName; + } + + /** User-verification requirement; an unrecognised value falls back to `required`. */ + public function passkeyUserVerification(): string + { + return $this->passkeyUserVerification->value; + } + + /** Ceremony timeout in milliseconds. */ + public function passkeyTimeout(): int + { + return $this->passkeyTimeout; + } } diff --git a/src/Enum/UserVerification.php b/src/Enum/UserVerification.php new file mode 100644 index 0000000..0d284c7 --- /dev/null +++ b/src/Enum/UserVerification.php @@ -0,0 +1,34 @@ +config->passkeyEnabled() && null !== $this->domainManager->authBase(); + } + + #[Override] + public function isAvailableFor(Request $request): bool + { + return $this->isEnabled() + && $request->isSecure() + && $this->domainManager->getAuthSubdomain() === $request->getHost(); + } + + #[Override] + public function rpId(): string + { + return $this->domainManager->authBase() ?? throw $this->notConfigured(); + } + + #[Override] + public function allowedOrigins(): array + { + return [self::SCHEME.'://'.$this->authSubdomain()]; + } + + #[Override] + public function authSubdomain(): string + { + $subdomain = $this->domainManager->getAuthSubdomain(); + + return $subdomain ?? throw $this->notConfigured(); + } + + #[Override] + public function rpName(): string + { + return $this->config->passkeyRpName() ?: $this->config->title(); + } + + #[Override] + public function userVerification(): string + { + return $this->config->passkeyUserVerification(); + } + + #[Override] + public function timeout(): int + { + return $this->config->passkeyTimeout(); + } + + /** + * @throws PasskeyConfigurationException + */ + #[Override] + public function assertConfigurationIsUsable(): void + { + if (!$this->config->passkeyEnabled()) { + return; + } + + /* D1: without a base domain there is no RP ID and no shared credential */ + if (null === $this->domainManager->authBase()) { + throw new PasskeyConfigurationException('PASSKEY_ENABLED is on, but central authentication is not configured. Passkeys require SUBDOMAIN_REDIRECT=1 together with a valid AUTH_SUBDOMAIN whose base domain can be determined (a domain such as "auth.example.com" β€” not "localhost" and not an IP address). Either configure central authentication or set PASSKEY_ENABLED=0.'); + } + + /* D4: the subdomain must be a real domain able to present a TLS certificate. + * authBase() returning null already excludes localhost and bare IPs, so + * this guards against an auth subdomain that is a single label. */ + if (!str_contains($this->authSubdomain(), '.')) { + throw new PasskeyConfigurationException('AUTH_SUBDOMAIN must be a fully qualified domain name (for example "auth.example.com") because passkeys require HTTPS and a certificate cannot be issued for a single-label host.'); + } + } + + private function notConfigured(): PasskeyConfigurationException + { + return new PasskeyConfigurationException( + 'Passkeys are enabled but central authentication is not configured, ' + .'so no relying party identity is available.', + ); + } +} diff --git a/src/Service/PasskeyPolicyInterface.php b/src/Service/PasskeyPolicyInterface.php new file mode 100644 index 0000000..9ae5364 --- /dev/null +++ b/src/Service/PasskeyPolicyInterface.php @@ -0,0 +1,85 @@ +time); @@ -77,6 +79,11 @@ trait TotpTestHelper 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); @@ -94,6 +101,11 @@ trait TotpTestHelper $remoteUserMode, $remoteUserStatic, $remoteUserMap, + $title, + $passkeyEnabled, + $passkeyRpName, + $passkeyUserVerification, + $passkeyTimeout, ); } diff --git a/tests/Unit/CacheWarmer/PasskeyConfigurationWarmerTest.php b/tests/Unit/CacheWarmer/PasskeyConfigurationWarmerTest.php new file mode 100644 index 0000000..4245d7f --- /dev/null +++ b/tests/Unit/CacheWarmer/PasskeyConfigurationWarmerTest.php @@ -0,0 +1,45 @@ +createMock(PasskeyPolicyInterface::class); + $policy->expects(self::once())->method('assertConfigurationIsUsable'); + + $warmer = new PasskeyConfigurationWarmer($policy); + + self::assertSame([], $warmer->warmUp('/tmp/cache')); + } + + public function test_it_is_not_optional(): void + { + /* an optional warmer can be skipped, which would defeat the check */ + $warmer = new PasskeyConfigurationWarmer($this->createStub(PasskeyPolicyInterface::class)); + + self::assertFalse($warmer->isOptional()); + } + + public function test_it_propagates_a_configuration_failure(): void + { + $policy = $this->createStub(PasskeyPolicyInterface::class); + $policy->method('assertConfigurationIsUsable') + ->willThrowException(new PasskeyConfigurationException('nope')); + + $this->expectException(PasskeyConfigurationException::class); + (new PasskeyConfigurationWarmer($policy))->warmUp('/tmp/cache'); + } +} diff --git a/tests/Unit/Service/PasskeyPolicyTest.php b/tests/Unit/Service/PasskeyPolicyTest.php new file mode 100644 index 0000000..dc08c66 --- /dev/null +++ b/tests/Unit/Service/PasskeyPolicyTest.php @@ -0,0 +1,234 @@ + "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()); + } +}