diff --git a/.ci-tools/phpstan-baseline.neon b/.ci-tools/phpstan-baseline.neon index f65c4d489..95df433ba 100644 --- a/.ci-tools/phpstan-baseline.neon +++ b/.ci-tools/phpstan-baseline.neon @@ -303,6 +303,30 @@ parameters: count: 1 path: ../src/symfony/src/DependencyInjection/Compiler/LoggerSetterCompilerPass.php + - + rawMessage: 'Method Webauthn\Bundle\DependencyInjection\Compiler\PasskeyEndpointsCompilerPass::createControllerDefinition() has a parameter $container with a type declaration of Symfony\Component\DependencyInjection\ContainerBuilder, but containers should not be injected.' + identifier: ergebnis.noParameterWithContainerTypeDeclaration + count: 1 + path: ../src/symfony/src/DependencyInjection/Compiler/PasskeyEndpointsCompilerPass.php + + - + rawMessage: 'Method Webauthn\Bundle\DependencyInjection\Compiler\PasskeyEndpointsCompilerPass::createPasskeyEndpointsResponse() has a parameter $container with a type declaration of Symfony\Component\DependencyInjection\ContainerBuilder, but containers should not be injected.' + identifier: ergebnis.noParameterWithContainerTypeDeclaration + count: 1 + path: ../src/symfony/src/DependencyInjection/Compiler/PasskeyEndpointsCompilerPass.php + + - + rawMessage: 'Method Webauthn\Bundle\DependencyInjection\Compiler\PasskeyEndpointsCompilerPass::process() has a parameter $container with a type declaration of Symfony\Component\DependencyInjection\ContainerBuilder, but containers should not be injected.' + identifier: ergebnis.noParameterWithContainerTypeDeclaration + count: 1 + path: ../src/symfony/src/DependencyInjection/Compiler/PasskeyEndpointsCompilerPass.php + + - + rawMessage: 'Parameter #1 $value of method Webauthn\Bundle\DependencyInjection\Compiler\PasskeyEndpointsCompilerPass::createUrlDefinition() expects array|string, array|bool|float|int|string given.' + identifier: argument.type + count: 3 + path: ../src/symfony/src/DependencyInjection/Compiler/PasskeyEndpointsCompilerPass.php + - rawMessage: Anonymous function should return array but returns mixed. identifier: return.type @@ -831,6 +855,12 @@ parameters: count: 1 path: ../src/symfony/src/DependencyInjection/WebauthnExtension.php + - + rawMessage: 'Method Webauthn\Bundle\DependencyInjection\WebauthnExtension::loadPasskeyEndpointsConfig() has a parameter $container with a type declaration of Symfony\Component\DependencyInjection\ContainerBuilder, but containers should not be injected.' + identifier: ergebnis.noParameterWithContainerTypeDeclaration + count: 1 + path: ../src/symfony/src/DependencyInjection/WebauthnExtension.php + - rawMessage: 'Method Webauthn\Bundle\DependencyInjection\WebauthnExtension::loadRequestControllersSupport() has a parameter $container with a type declaration of Symfony\Component\DependencyInjection\ContainerBuilder, but containers should not be injected.' identifier: ergebnis.noParameterWithContainerTypeDeclaration @@ -867,6 +897,12 @@ parameters: count: 1 path: ../src/symfony/src/DependencyInjection/WebauthnExtension.php + - + rawMessage: 'Parameter #2 $config of method Webauthn\Bundle\DependencyInjection\WebauthnExtension::loadPasskeyEndpointsConfig() expects array, mixed given.' + identifier: argument.type + count: 1 + path: ../src/symfony/src/DependencyInjection/WebauthnExtension.php + - rawMessage: 'Parameter #2 $config of method Webauthn\Bundle\DependencyInjection\WebauthnExtension::loadRequestControllersSupport() expects array, mixed given.' identifier: argument.type @@ -882,7 +918,7 @@ parameters: - rawMessage: 'Parameter #2 $value of method Symfony\Component\DependencyInjection\Container::setParameter() expects array|bool|float|int|string|UnitEnum|null, mixed given.' identifier: argument.type - count: 5 + count: 9 path: ../src/symfony/src/DependencyInjection/WebauthnExtension.php - @@ -5049,6 +5085,54 @@ parameters: count: 1 path: ../src/webauthn/src/Denormalizer/TrustPathDenormalizer.php + - + rawMessage: Constructor in Webauthn\Denormalizer\UrlNormalizer has parameter $urlGenerator with default value. + identifier: ergebnis.noConstructorParameterWithDefaultValue + count: 1 + path: ../src/webauthn/src/Denormalizer/UrlNormalizer.php + + - + rawMessage: 'Method Webauthn\Denormalizer\UrlNormalizer::__construct() has parameter $urlGenerator with a nullable type declaration.' + identifier: ergebnis.noParameterWithNullableTypeDeclaration + count: 1 + path: ../src/webauthn/src/Denormalizer/UrlNormalizer.php + + - + rawMessage: 'Method Webauthn\Denormalizer\UrlNormalizer::__construct() has parameter $urlGenerator with null as default value.' + identifier: ergebnis.noParameterWithNullDefaultValue + count: 1 + path: ../src/webauthn/src/Denormalizer/UrlNormalizer.php + + - + rawMessage: 'Method Webauthn\Denormalizer\UrlNormalizer::getSupportedTypes() has parameter $format with a nullable type declaration.' + identifier: ergebnis.noParameterWithNullableTypeDeclaration + count: 1 + path: ../src/webauthn/src/Denormalizer/UrlNormalizer.php + + - + rawMessage: 'Method Webauthn\Denormalizer\UrlNormalizer::normalize() has parameter $format with a nullable type declaration.' + identifier: ergebnis.noParameterWithNullableTypeDeclaration + count: 1 + path: ../src/webauthn/src/Denormalizer/UrlNormalizer.php + + - + rawMessage: 'Method Webauthn\Denormalizer\UrlNormalizer::normalize() has parameter $format with null as default value.' + identifier: ergebnis.noParameterWithNullDefaultValue + count: 1 + path: ../src/webauthn/src/Denormalizer/UrlNormalizer.php + + - + rawMessage: 'Method Webauthn\Denormalizer\UrlNormalizer::supportsNormalization() has parameter $format with a nullable type declaration.' + identifier: ergebnis.noParameterWithNullableTypeDeclaration + count: 1 + path: ../src/webauthn/src/Denormalizer/UrlNormalizer.php + + - + rawMessage: 'Method Webauthn\Denormalizer\UrlNormalizer::supportsNormalization() has parameter $format with null as default value.' + identifier: ergebnis.noParameterWithNullDefaultValue + count: 1 + path: ../src/webauthn/src/Denormalizer/UrlNormalizer.php + - rawMessage: 'Method Webauthn\Denormalizer\VerificationMethodANDCombinationsDenormalizer::getSupportedTypes() has parameter $format with a nullable type declaration.' identifier: ergebnis.noParameterWithNullableTypeDeclaration @@ -7269,6 +7353,96 @@ parameters: count: 1 path: ../src/webauthn/src/MetadataService/Statement/Version.php + - + rawMessage: Constructor in Webauthn\PasskeyEndpointsResponse has parameter $enroll with default value. + identifier: ergebnis.noConstructorParameterWithDefaultValue + count: 1 + path: ../src/webauthn/src/PasskeyEndpointsResponse.php + + - + rawMessage: Constructor in Webauthn\PasskeyEndpointsResponse has parameter $manage with default value. + identifier: ergebnis.noConstructorParameterWithDefaultValue + count: 1 + path: ../src/webauthn/src/PasskeyEndpointsResponse.php + + - + rawMessage: Constructor in Webauthn\PasskeyEndpointsResponse has parameter $prfUsageDetails with default value. + identifier: ergebnis.noConstructorParameterWithDefaultValue + count: 1 + path: ../src/webauthn/src/PasskeyEndpointsResponse.php + + - + rawMessage: 'Method Webauthn\PasskeyEndpointsResponse::__construct() has parameter $enroll with a nullable type declaration.' + identifier: ergebnis.noParameterWithNullableTypeDeclaration + count: 1 + path: ../src/webauthn/src/PasskeyEndpointsResponse.php + + - + rawMessage: 'Method Webauthn\PasskeyEndpointsResponse::__construct() has parameter $enroll with null as default value.' + identifier: ergebnis.noParameterWithNullDefaultValue + count: 1 + path: ../src/webauthn/src/PasskeyEndpointsResponse.php + + - + rawMessage: 'Method Webauthn\PasskeyEndpointsResponse::__construct() has parameter $manage with a nullable type declaration.' + identifier: ergebnis.noParameterWithNullableTypeDeclaration + count: 1 + path: ../src/webauthn/src/PasskeyEndpointsResponse.php + + - + rawMessage: 'Method Webauthn\PasskeyEndpointsResponse::__construct() has parameter $manage with null as default value.' + identifier: ergebnis.noParameterWithNullDefaultValue + count: 1 + path: ../src/webauthn/src/PasskeyEndpointsResponse.php + + - + rawMessage: 'Method Webauthn\PasskeyEndpointsResponse::__construct() has parameter $prfUsageDetails with a nullable type declaration.' + identifier: ergebnis.noParameterWithNullableTypeDeclaration + count: 1 + path: ../src/webauthn/src/PasskeyEndpointsResponse.php + + - + rawMessage: 'Method Webauthn\PasskeyEndpointsResponse::__construct() has parameter $prfUsageDetails with null as default value.' + identifier: ergebnis.noParameterWithNullDefaultValue + count: 1 + path: ../src/webauthn/src/PasskeyEndpointsResponse.php + + - + rawMessage: 'Method Webauthn\PasskeyEndpointsResponse::create() has parameter $enroll with a nullable type declaration.' + identifier: ergebnis.noParameterWithNullableTypeDeclaration + count: 1 + path: ../src/webauthn/src/PasskeyEndpointsResponse.php + + - + rawMessage: 'Method Webauthn\PasskeyEndpointsResponse::create() has parameter $enroll with null as default value.' + identifier: ergebnis.noParameterWithNullDefaultValue + count: 1 + path: ../src/webauthn/src/PasskeyEndpointsResponse.php + + - + rawMessage: 'Method Webauthn\PasskeyEndpointsResponse::create() has parameter $manage with a nullable type declaration.' + identifier: ergebnis.noParameterWithNullableTypeDeclaration + count: 1 + path: ../src/webauthn/src/PasskeyEndpointsResponse.php + + - + rawMessage: 'Method Webauthn\PasskeyEndpointsResponse::create() has parameter $manage with null as default value.' + identifier: ergebnis.noParameterWithNullDefaultValue + count: 1 + path: ../src/webauthn/src/PasskeyEndpointsResponse.php + + - + rawMessage: 'Method Webauthn\PasskeyEndpointsResponse::create() has parameter $prfUsageDetails with a nullable type declaration.' + identifier: ergebnis.noParameterWithNullableTypeDeclaration + count: 1 + path: ../src/webauthn/src/PasskeyEndpointsResponse.php + + - + rawMessage: 'Method Webauthn\PasskeyEndpointsResponse::create() has parameter $prfUsageDetails with null as default value.' + identifier: ergebnis.noParameterWithNullDefaultValue + count: 1 + path: ../src/webauthn/src/PasskeyEndpointsResponse.php + - rawMessage: Class "Webauthn\PublicKeyCredential" is not allowed to extend "Webauthn\Credential". identifier: ergebnis.noExtends @@ -7968,6 +8142,12 @@ parameters: count: 1 path: ../src/webauthn/src/SimpleFakeCredentialGenerator.php + - + rawMessage: Constructor in Webauthn\Url has parameter $params with default value. + identifier: ergebnis.noConstructorParameterWithDefaultValue + count: 1 + path: ../src/webauthn/src/Url.php + - rawMessage: 'Method Webauthn\Util\Base64::decode() is not final, but since the containing class is abstract, it should be.' identifier: ergebnis.finalInAbstractClass diff --git a/src/symfony/src/Controller/PasskeyEndpointsController.php b/src/symfony/src/Controller/PasskeyEndpointsController.php new file mode 100644 index 000000000..68c77f238 --- /dev/null +++ b/src/symfony/src/Controller/PasskeyEndpointsController.php @@ -0,0 +1,33 @@ +serializer->serialize($this->passkeyEndpoints, 'json', [ + AbstractObjectNormalizer::PRESERVE_EMPTY_OBJECTS => true, + ]); + + return new JsonResponse($endpoints, 200, [], true); + } +} diff --git a/src/symfony/src/DependencyInjection/Compiler/PasskeyEndpointsCompilerPass.php b/src/symfony/src/DependencyInjection/Compiler/PasskeyEndpointsCompilerPass.php new file mode 100644 index 000000000..9b45872c6 --- /dev/null +++ b/src/symfony/src/DependencyInjection/Compiler/PasskeyEndpointsCompilerPass.php @@ -0,0 +1,95 @@ +hasParameter('webauthn.passkey_endpoints.enabled')) { + return; + } + + $enabled = $container->getParameter('webauthn.passkey_endpoints.enabled'); + if ($enabled !== true) { + return; + } + + $this->createPasskeyEndpointsResponse($container); + $this->createControllerDefinition($container); + } + + private function createPasskeyEndpointsResponse(ContainerBuilder $container): void + { + $enroll = $container->getParameter('webauthn.passkey_endpoints.enroll'); + $manage = $container->getParameter('webauthn.passkey_endpoints.manage'); + $prfUsageDetails = $container->getParameter('webauthn.passkey_endpoints.prf_usage_details'); + + // Create Url definitions from string configuration + $enrollUrl = $enroll !== null ? $this->createUrlDefinition($enroll) : null; + $manageUrl = $manage !== null ? $this->createUrlDefinition($manage) : null; + $prfUrl = $prfUsageDetails !== null ? $this->createUrlDefinition($prfUsageDetails) : null; + + $responseDefinition = new Definition(PasskeyEndpointsResponse::class, [$enrollUrl, $manageUrl, $prfUrl]); + + $container->setDefinition(PasskeyEndpointsResponse::class, $responseDefinition); + } + + /** + * Creates a Url definition from configuration value (string or array) using the serializer to denormalize it. + * + * @param string|array $value + */ + private function createUrlDefinition(string|array $value): Definition + { + $urlDefinition = new Definition(Url::class); + $urlDefinition->setFactory([new Reference(SerializerInterface::class), 'denormalize']); + $urlDefinition->setArguments([$value, Url::class, 'json']); + + return $urlDefinition; + } + + private function createControllerDefinition(ContainerBuilder $container): void + { + if (! $container->hasDefinition(Loader::class)) { + return; + } + + if (! $container->hasDefinition(PasskeyEndpointsResponse::class)) { + return; + } + + $controllerDefinition = new Definition( + PasskeyEndpointsController::class, + [$container->getDefinition(PasskeyEndpointsResponse::class), new Reference(SerializerInterface::class)] + ); + $controllerDefinition->setPublic(true); + + $container->setDefinition(PasskeyEndpointsController::class, $controllerDefinition); + + $loaderDefinition = $container->getDefinition(Loader::class); + $loaderDefinition->addMethodCall('add', [ + '/.well-known/passkey-endpoints', + null, + PasskeyEndpointsController::class, + 'GET', + ]); + } +} diff --git a/src/symfony/src/DependencyInjection/Configuration.php b/src/symfony/src/DependencyInjection/Configuration.php index 976ee486f..ebdb52b4e 100644 --- a/src/symfony/src/DependencyInjection/Configuration.php +++ b/src/symfony/src/DependencyInjection/Configuration.php @@ -21,6 +21,7 @@ use Webauthn\MetadataService\CertificateChain\PhpCertificateChainValidator; use Webauthn\PublicKeyCredentialCreationOptions; use Webauthn\SimpleFakeCredentialGenerator; +use function assert; final readonly class Configuration implements ConfigurationInterface { @@ -132,6 +133,7 @@ public function getConfigTreeBuilder(): TreeBuilder $this->addRequestProfilesConfig($rootNode); $this->addMetadataConfig($rootNode); $this->addControllersConfig($rootNode); + $this->addPasskeyEndpointsConfig($rootNode); return $treeBuilder; } @@ -514,4 +516,59 @@ private function addMetadataConfig(ArrayNodeDefinition $rootNode): void ->end() ->end(); } + + private function addPasskeyEndpointsConfig(ArrayNodeDefinition $rootNode): void + { + $rootNode->children() + ->arrayNode('passkey_endpoints') + ->canBeEnabled() + ->info( + 'Enable the .well-known/passkey-endpoints discovery endpoint as defined in the W3C Passkey Endpoints specification.' + ) + ->children() + ->append($this->getUrlNode('enroll', 'URL to the passkey enrollment/creation interface.')) + ->append($this->getUrlNode('manage', 'URL to the passkey management interface.')) + ->append( + $this->getUrlNode( + 'prf_usage_details', + 'URL to informational page about PRF (Pseudo-Random Function) extension usage.' + ) + ) + ->end() + ->end() + ->end(); + } + + private function getUrlNode(string $name, string $info): ArrayNodeDefinition + { + $treeBuilder = new TreeBuilder($name); + $node = $treeBuilder->getRootNode(); + assert($node instanceof ArrayNodeDefinition); + $node + ->info($info) + ->beforeNormalization() + ->ifString() + ->then(static fn (string $v): array => [ + 'path' => $v, + ]) + ->end() + ->children() + ->scalarNode('path') + ->isRequired() + ->info('The absolute HTTPS URL or Symfony route name.') + ->example(['https://example.com/enroll', 'app_passkey_enroll']) + ->end() + ->arrayNode('params') + ->treatFalseLike([]) + ->treatTrueLike([]) + ->treatNullLike([]) + ->prototype('variable') + ->end() + ->info('Route parameters (only used when path is a Symfony route name).') + ->end() + ->end() + ->end(); + + return $node; + } } diff --git a/src/symfony/src/DependencyInjection/WebauthnExtension.php b/src/symfony/src/DependencyInjection/WebauthnExtension.php index cf9bbb2e3..159ec0027 100644 --- a/src/symfony/src/DependencyInjection/WebauthnExtension.php +++ b/src/symfony/src/DependencyInjection/WebauthnExtension.php @@ -115,6 +115,8 @@ public function load(array $configs, ContainerBuilder $container): void $container->setParameter('webauthn.creation_profiles', $config['creation_profiles']); $container->setParameter('webauthn.request_profiles', $config['request_profiles']); + $this->loadPasskeyEndpointsConfig($container, $config['passkey_endpoints']); + $loader->load('services.php'); $loader->load('cose.php'); $loader->load('security.php'); @@ -366,4 +368,15 @@ private function loadMetadataServices(ContainerBuilder $container, FileLoader $l $container->setAlias(CertificateChainValidator::class, $config['certificate_chain_checker']); $loader->load('metadata_statement_supports.php'); } + + /** + * @param mixed[] $config + */ + private function loadPasskeyEndpointsConfig(ContainerBuilder $container, array $config): void + { + $container->setParameter('webauthn.passkey_endpoints.enabled', $config['enabled'] ?? false); + $container->setParameter('webauthn.passkey_endpoints.enroll', $config['enroll'] ?? null); + $container->setParameter('webauthn.passkey_endpoints.manage', $config['manage'] ?? null); + $container->setParameter('webauthn.passkey_endpoints.prf_usage_details', $config['prf_usage_details'] ?? null); + } } diff --git a/src/symfony/src/Resources/config/services.php b/src/symfony/src/Resources/config/services.php index 53f6187db..780e29ab0 100644 --- a/src/symfony/src/Resources/config/services.php +++ b/src/symfony/src/Resources/config/services.php @@ -45,6 +45,7 @@ use Webauthn\Denormalizer\SignalAllAcceptedCredentialsDenormalizer; use Webauthn\Denormalizer\SignalCurrentUserDetailsDenormalizer; use Webauthn\Denormalizer\SignalUnknownCredentialDenormalizer; +use Webauthn\Denormalizer\UrlNormalizer; use Webauthn\Denormalizer\VerificationMethodANDCombinationsDenormalizer; use Webauthn\Denormalizer\WebauthnSerializerFactory; use Webauthn\SimpleFakeCredentialGenerator; @@ -180,6 +181,12 @@ ->tag('serializer.normalizer', [ 'priority' => 1024, ]); + $service + ->set(UrlNormalizer::class) + ->args([service('router')]) + ->tag('serializer.normalizer', [ + 'priority' => 1024, + ]); $service ->set(AuthenticationExtensionsDenormalizer::class) ->tag('serializer.normalizer', [ diff --git a/src/symfony/src/WebauthnBundle.php b/src/symfony/src/WebauthnBundle.php index dac61e081..8c92d4cff 100644 --- a/src/symfony/src/WebauthnBundle.php +++ b/src/symfony/src/WebauthnBundle.php @@ -19,6 +19,7 @@ use Webauthn\Bundle\DependencyInjection\Compiler\EventDispatcherSetterCompilerPass; use Webauthn\Bundle\DependencyInjection\Compiler\ExtensionOutputCheckerCompilerPass; use Webauthn\Bundle\DependencyInjection\Compiler\LoggerSetterCompilerPass; +use Webauthn\Bundle\DependencyInjection\Compiler\PasskeyEndpointsCompilerPass; use Webauthn\Bundle\DependencyInjection\Factory\Security\WebauthnFactory; use Webauthn\Bundle\DependencyInjection\Factory\Security\WebauthnServicesFactory; use Webauthn\Bundle\DependencyInjection\WebauthnExtension; @@ -62,6 +63,7 @@ public function build(ContainerBuilder $container): void PassConfig::TYPE_BEFORE_OPTIMIZATION, 0 ); + $container->addCompilerPass(new PasskeyEndpointsCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 0); $this->registerMappings($container); diff --git a/src/webauthn/src/Denormalizer/UrlNormalizer.php b/src/webauthn/src/Denormalizer/UrlNormalizer.php new file mode 100644 index 000000000..da073f9ad --- /dev/null +++ b/src/webauthn/src/Denormalizer/UrlNormalizer.php @@ -0,0 +1,63 @@ +urlGenerator === null) { + return $data->path; + } + + // If the path is already a valid absolute URL (https://...), return it directly + if (filter_var($data->path, FILTER_VALIDATE_URL) !== false) { + return $data->path; + } + + // Otherwise, try to generate an absolute URL from a Symfony route name + try { + return $this->urlGenerator->generate($data->path, $data->params, UrlGeneratorInterface::ABSOLUTE_URL); + } catch (Throwable) { + // If the URL cannot be generated, return the path as is + return $data->path; + } + } + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return $data instanceof Url; + } + + /** + * @return array + */ + public function getSupportedTypes(?string $format): array + { + return [ + Url::class => true, + ]; + } +} diff --git a/src/webauthn/src/Denormalizer/WebauthnSerializerFactory.php b/src/webauthn/src/Denormalizer/WebauthnSerializerFactory.php index 21daf33ed..e737e2172 100644 --- a/src/webauthn/src/Denormalizer/WebauthnSerializerFactory.php +++ b/src/webauthn/src/Denormalizer/WebauthnSerializerFactory.php @@ -49,6 +49,7 @@ public function create(): SerializerInterface new AuthenticationExtensionNormalizer(), new PublicKeyCredentialDescriptorNormalizer(), new AttestedCredentialDataNormalizer(), + new UrlNormalizer(), new AttestationObjectDenormalizer(), new AttestationStatementDenormalizer($this->attestationStatementSupportManager), new AuthenticationExtensionsDenormalizer(), diff --git a/src/webauthn/src/PasskeyEndpointsResponse.php b/src/webauthn/src/PasskeyEndpointsResponse.php new file mode 100644 index 000000000..a4a67a2ef --- /dev/null +++ b/src/webauthn/src/PasskeyEndpointsResponse.php @@ -0,0 +1,46 @@ + $params Route parameters (only used when path is a route name) + */ + public function __construct( + public string $path, + public array $params = [], + ) { + } + + /** + * @param array $params Route parameters + */ + public static function create(string $path, array $params = []): self + { + return new self($path, $params); + } +} diff --git a/tests/library/Unit/PasskeyEndpointsResponseTest.php b/tests/library/Unit/PasskeyEndpointsResponseTest.php new file mode 100644 index 000000000..8686281ca --- /dev/null +++ b/tests/library/Unit/PasskeyEndpointsResponseTest.php @@ -0,0 +1,101 @@ +getSerializer() + ->serialize($response, 'json', [ + AbstractObjectNormalizer::SKIP_NULL_VALUES => true, + ]); + + // Then - empty array is normalized to [] + static::assertSame('[]', $json); + } + + #[Test] + public function createResponseWithAllFieldsReturnsCompleteJsonObject(): void + { + // Given + $response = PasskeyEndpointsResponse::create( + enroll: Url::create('https://example.com/enroll'), + manage: Url::create('https://example.com/manage'), + prfUsageDetails: Url::create('https://example.com/prf-info') + ); + + // When + $json = $this->getSerializer() + ->serialize($response, 'json', [ + AbstractObjectNormalizer::SKIP_NULL_VALUES => true, + ]); + + // Then + static::assertJsonStringEqualsJsonString( + '{"enroll":"https://example.com/enroll","manage":"https://example.com/manage","prfUsageDetails":"https://example.com/prf-info"}', + $json + ); + } + + #[Test] + public function createResponseWithSomeFieldsOmitsNullValues(): void + { + // Given + $response = PasskeyEndpointsResponse::create( + enroll: Url::create('https://example.com/enroll'), + manage: null, + prfUsageDetails: Url::create('https://example.com/prf-info') + ); + + // When + $json = $this->getSerializer() + ->serialize($response, 'json', [ + AbstractObjectNormalizer::SKIP_NULL_VALUES => true, + ]); + + // Then + static::assertJsonStringEqualsJsonString( + '{"enroll":"https://example.com/enroll","prfUsageDetails":"https://example.com/prf-info"}', + $json + ); + } + + #[Test] + public function serializeCorrectlyHandlesNullValues(): void + { + // Given + $response = new PasskeyEndpointsResponse( + enroll: Url::create('https://example.com/enroll'), + manage: Url::create('https://example.com/manage') + ); + + // When + $json = $this->getSerializer() + ->serialize($response, 'json', [ + AbstractObjectNormalizer::SKIP_NULL_VALUES => true, + ]); + + // Then + static::assertJsonStringEqualsJsonString( + '{"enroll":"https://example.com/enroll","manage":"https://example.com/manage"}', + $json + ); + } +} diff --git a/tests/symfony/config/config.yml b/tests/symfony/config/config.yml index df3aefa38..8e7e8d127 100644 --- a/tests/symfony/config/config.yml +++ b/tests/symfony/config/config.yml @@ -61,6 +61,9 @@ services: Webauthn\Tests\Bundle\Functional\AdminController: autowire: true tags: [ 'controller.service_arguments' ] + Webauthn\Tests\Bundle\Functional\ManageController: + autowire: true + tags: [ 'controller.service_arguments' ] Psr\Clock\ClockInterface: class: Webauthn\Tests\Bundle\Functional\MockClock @@ -161,6 +164,14 @@ webauthn: enabled: true mds_repository: 'Webauthn\Tests\Bundle\Functional\MetadataStatementRepository' status_report_repository: 'Webauthn\Tests\Bundle\Functional\MetadataStatementRepository' + passkey_endpoints: + enabled: true + enroll: 'https://localhost:8443/passkey/enroll' + manage: + path: 'app_manage' + params: + foo: bar1 + baz: bar2 security: providers: diff --git a/tests/symfony/config/routing.php b/tests/symfony/config/routing.php index a4b8983ce..531f5781f 100644 --- a/tests/symfony/config/routing.php +++ b/tests/symfony/config/routing.php @@ -5,6 +5,7 @@ use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator; use Webauthn\Tests\Bundle\Functional\AdminController; use Webauthn\Tests\Bundle\Functional\HomeController; +use Webauthn\Tests\Bundle\Functional\ManageController; use Webauthn\Tests\Bundle\Functional\SecurityController; return static function (RoutingConfigurator $routes): void { @@ -29,4 +30,9 @@ $routes->add('app_admin', '/admin') ->controller([AdminController::class, 'admin']) ->methods(['GET']); + + // Admin + $routes->add('app_manage', '/manage/{foo}') + ->controller([ManageController::class, 'manage']) + ->methods(['GET']); }; diff --git a/tests/symfony/functional/ManageController.php b/tests/symfony/functional/ManageController.php new file mode 100644 index 000000000..baf311bf6 --- /dev/null +++ b/tests/symfony/functional/ManageController.php @@ -0,0 +1,18 @@ + 'endpoint', + ]); + } +} diff --git a/tests/symfony/functional/PasskeyEndpoints/PasskeyEndpointsTest.php b/tests/symfony/functional/PasskeyEndpoints/PasskeyEndpointsTest.php new file mode 100644 index 000000000..ed8e94772 --- /dev/null +++ b/tests/symfony/functional/PasskeyEndpoints/PasskeyEndpointsTest.php @@ -0,0 +1,82 @@ + 'on', + ]); + $client->request('GET', '/.well-known/passkey-endpoints'); + + self::assertResponseIsSuccessful(); + self::assertResponseHeaderSame('Content-Type', 'application/json'); + + $content = $client->getResponse() + ->getContent(); + static::assertIsString($content); + + $data = json_decode($content, true); + static::assertIsArray($data); + static::assertArrayHasKey('enroll', $data); + static::assertArrayHasKey('manage', $data); + static::assertSame('https://localhost:8443/passkey/enroll', $data['enroll']); + static::assertSame('https://localhost/manage/bar1?baz=bar2', $data['manage']); + } + + #[Test] + public function passkeyEndpointsReturnsEmptyObjectWhenNotConfigured(): void + { + // This test would require a different kernel config without passkey_endpoints + // For now, we'll just verify the response structure is valid + $client = static::createClient(server: [ + 'HTTPS' => 'on', + ]); + $client->request('GET', '/.well-known/passkey-endpoints'); + + self::assertResponseIsSuccessful(); + + $content = $client->getResponse() + ->getContent(); + static::assertIsString($content); + + $data = json_decode($content, true); + static::assertIsArray($data); + } + + #[Test] + public function passkeyEndpointsReturnsCorrectContentType(): void + { + $client = static::createClient(server: [ + 'HTTPS' => 'on', + ]); + $client->request('GET', '/.well-known/passkey-endpoints'); + + self::assertResponseIsSuccessful(); + self::assertResponseHeaderSame('Content-Type', 'application/json'); + } + + #[Test] + public function passkeyEndpointsOnlyAcceptsGetMethod(): void + { + $client = static::createClient(server: [ + 'HTTPS' => 'on', + ]); + $client->request('POST', '/.well-known/passkey-endpoints'); + + // POST should return 405 Method Not Allowed + self::assertResponseStatusCodeSame(Response::HTTP_METHOD_NOT_ALLOWED); + } +}