diff --git a/composer.json b/composer.json index 20ade09dbb979..3368c7d767858 100644 --- a/composer.json +++ b/composer.json @@ -150,9 +150,12 @@ "symfony/phpunit-bridge": "^5.4|^6.0", "symfony/runtime": "self.version", "symfony/security-acl": "~2.8|~3.0", + "symfony/string": "^5.4|^6.0", "twig/cssinliner-extra": "^2.12|^3", "twig/inky-extra": "^2.12|^3", - "twig/markdown-extra": "^2.12|^3" + "twig/markdown-extra": "^2.12|^3", + "web-token/jwt-checker": "^3.1", + "web-token/jwt-signature-algorithm-ecdsa": "^3.1" }, "conflict": { "ext-psr": "<1.1|>=2", diff --git a/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md b/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md index f71cea472f7de..b41e3e6469d9b 100644 --- a/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md @@ -11,6 +11,7 @@ CHANGELOG * Make `Security::login()` return the authenticator response * Deprecate the `security.firewalls.logout.csrf_token_generator` config option, use `security.firewalls.logout.csrf_token_manager` instead * Make firewalls event dispatcher traceable on debug mode + * Add `TokenHandlerFactoryInterface`, `OidcUserInfoTokenHandlerFactory`, `OidcTokenHandlerFactory` and `ServiceTokenHandlerFactory` for `AccessTokenFactory` 6.2 --- diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/AccessToken/OidcTokenHandlerFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/AccessToken/OidcTokenHandlerFactory.php new file mode 100644 index 0000000000000..6f19f3845cb15 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/AccessToken/OidcTokenHandlerFactory.php @@ -0,0 +1,89 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken; + +use Jose\Component\Core\Algorithm; +use Jose\Component\Core\JWK; +use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\SignatureAlgorithmFactory; +use Symfony\Component\Config\Definition\Builder\NodeBuilder; +use Symfony\Component\DependencyInjection\ChildDefinition; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Reference; + +/** + * Configures a token handler for decoding and validating an OIDC token. + * + * @experimental + */ +class OidcTokenHandlerFactory implements TokenHandlerFactoryInterface +{ + public function create(ContainerBuilder $container, string $id, array|string $config): void + { + $tokenHandlerDefinition = $container->setDefinition($id, new ChildDefinition('security.access_token_handler.oidc')); + $tokenHandlerDefinition->replaceArgument(3, $config['claim']); + $tokenHandlerDefinition->replaceArgument(4, $config['audience']); + + // Create the signature algorithm and the JWK + if (!ContainerBuilder::willBeAvailable('web-token/jwt-core', Algorithm::class, ['symfony/security-bundle'])) { + $container->register('security.access_token_handler.oidc.signature', 'stdClass') + ->addError('You cannot use the "oidc" token handler since "web-token/jwt-core" is not installed. Try running "web-token/jwt-core".'); + $container->register('security.access_token_handler.oidc.jwk', 'stdClass') + ->addError('You cannot use the "oidc" token handler since "web-token/jwt-core" is not installed. Try running "web-token/jwt-core".'); + } else { + $container->register('security.access_token_handler.oidc.signature', Algorithm::class) + ->setFactory([SignatureAlgorithmFactory::class, 'create']) + ->setArguments([$config['signature']['algorithm']]); + $container->register('security.access_token_handler.oidc.jwk', JWK::class) + ->setFactory([JWK::class, 'createFromJson']) + ->setArguments([$config['signature']['key']]); + } + $tokenHandlerDefinition->replaceArgument(0, new Reference('security.access_token_handler.oidc.signature')); + $tokenHandlerDefinition->replaceArgument(1, new Reference('security.access_token_handler.oidc.jwk')); + } + + public function getKey(): string + { + return 'oidc'; + } + + public function addConfiguration(NodeBuilder $node): void + { + $node + ->arrayNode($this->getKey()) + ->fixXmlConfig($this->getKey()) + ->children() + ->scalarNode('claim') + ->info('Claim which contains the user identifier (e.g.: sub, email..).') + ->defaultValue('sub') + ->end() + ->scalarNode('audience') + ->info('Audience set in the token, for validation purpose.') + ->defaultNull() + ->end() + ->arrayNode('signature') + ->isRequired() + ->children() + ->scalarNode('algorithm') + ->info('Algorithm used to sign the token.') + ->isRequired() + ->end() + ->scalarNode('key') + ->info('JSON-encoded JWK used to sign the token (must contain a "kty" key).') + ->isRequired() + ->end() + ->end() + ->end() + ->end() + ->end() + ; + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/AccessToken/OidcUserInfoTokenHandlerFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/AccessToken/OidcUserInfoTokenHandlerFactory.php new file mode 100644 index 0000000000000..08b1019f2c210 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/AccessToken/OidcUserInfoTokenHandlerFactory.php @@ -0,0 +1,77 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken; + +use Symfony\Component\Config\Definition\Builder\NodeBuilder; +use Symfony\Component\DependencyInjection\ChildDefinition; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\HttpClient\HttpClient; + +/** + * Configures a token handler for an OIDC server. + * + * @experimental + */ +class OidcUserInfoTokenHandlerFactory implements TokenHandlerFactoryInterface +{ + public function create(ContainerBuilder $container, string $id, array|string $config): void + { + $tokenHandlerDefinition = $container->setDefinition($id, new ChildDefinition('security.access_token_handler.oidc_user_info')); + $tokenHandlerDefinition->replaceArgument(2, $config['claim']); + + // Create the client service + if (!isset($config['client']['id'])) { + $clientDefinitionId = 'http_client.security.access_token_handler.oidc_user_info'; + if (!ContainerBuilder::willBeAvailable('symfony/http-client', HttpClient::class, ['symfony/security-bundle'])) { + $container->register($clientDefinitionId, 'stdClass') + ->addError('You cannot use the "oidc_user_info" token handler since the HttpClient component is not installed. Try running "composer require symfony/http-client".'); + } else { + $container->register($clientDefinitionId, HttpClient::class) + ->setFactory([HttpClient::class, 'create']) + ->setArguments([$config['client']]) + ->addTag('http_client.client'); + } + } + + $tokenHandlerDefinition->replaceArgument(0, new Reference($config['client']['id'] ?? $clientDefinitionId)); + } + + public function getKey(): string + { + return 'oidc_user_info'; + } + + public function addConfiguration(NodeBuilder $node): void + { + $node + ->arrayNode($this->getKey()) + ->fixXmlConfig($this->getKey()) + ->children() + ->scalarNode('claim') + ->info('Claim which contains the user identifier (e.g.: sub, email..).') + ->defaultValue('sub') + ->end() + ->arrayNode('client') + ->info('HttpClient to call the OIDC server.') + ->isRequired() + ->beforeNormalization() + ->ifString() + ->then(static function ($v): array { return ['id' => $v]; }) + ->end() + ->prototype('scalar')->end() + ->end() + ->end() + ->end() + ; + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/AccessToken/ServiceTokenHandlerFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/AccessToken/ServiceTokenHandlerFactory.php new file mode 100644 index 0000000000000..f38a70db99417 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/AccessToken/ServiceTokenHandlerFactory.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken; + +use Symfony\Component\Config\Definition\Builder\NodeBuilder; +use Symfony\Component\DependencyInjection\ChildDefinition; +use Symfony\Component\DependencyInjection\ContainerBuilder; + +/** + * Configures a token handler from a service id. + * + * @see \Symfony\Bundle\SecurityBundle\Tests\DependencyInjection\Security\Factory\AccessTokenFactoryTest + * + * @experimental + */ +class ServiceTokenHandlerFactory implements TokenHandlerFactoryInterface +{ + public function create(ContainerBuilder $container, string $id, array|string $config): void + { + $container->setDefinition($id, new ChildDefinition($config)); + } + + public function getKey(): string + { + return 'id'; + } + + public function addConfiguration(NodeBuilder $node): void + { + $node->scalarNode($this->getKey())->end(); + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/AccessToken/TokenHandlerFactoryInterface.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/AccessToken/TokenHandlerFactoryInterface.php new file mode 100644 index 0000000000000..bfa9535e7544e --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/AccessToken/TokenHandlerFactoryInterface.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken; + +use Symfony\Component\Config\Definition\Builder\NodeBuilder; +use Symfony\Component\DependencyInjection\ContainerBuilder; + +/** + * Allows creating configurable token handlers. + * + * @experimental + */ +interface TokenHandlerFactoryInterface +{ + /** + * Creates a generic token handler service. + */ + public function create(ContainerBuilder $container, string $id, array|string $config): void; + + /** + * Gets a generic token handler configuration key. + */ + public function getKey(): string; + + /** + * Adds a generic token handler configuration. + */ + public function addConfiguration(NodeBuilder $node): void; +} diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/AccessTokenFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/AccessTokenFactory.php index a59a9a6f3ede0..28d0beda0997e 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/AccessTokenFactory.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/AccessTokenFactory.php @@ -11,7 +11,9 @@ namespace Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory; +use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken\TokenHandlerFactoryInterface; use Symfony\Component\Config\Definition\Builder\NodeDefinition; +use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; use Symfony\Component\DependencyInjection\ChildDefinition; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Reference; @@ -27,7 +29,10 @@ final class AccessTokenFactory extends AbstractFactory implements StatelessAuthe { private const PRIORITY = -40; - public function __construct() + /** + * @param array $tokenHandlerFactories + */ + public function __construct(private readonly array $tokenHandlerFactories) { $this->options = []; $this->defaultFailureHandlerOptions = []; @@ -40,7 +45,6 @@ public function addConfiguration(NodeDefinition $node): void $builder = $node->children(); $builder - ->scalarNode('token_handler')->isRequired()->end() ->scalarNode('realm')->defaultNull()->end() ->arrayNode('token_extractors') ->fixXmlConfig('token_extractors') @@ -55,6 +59,38 @@ public function addConfiguration(NodeDefinition $node): void ->scalarPrototype()->end() ->end() ; + + $tokenHandlerNodeBuilder = $builder + ->arrayNode('token_handler') + ->example([ + 'id' => 'App\Security\CustomTokenHandler', + ]) + + ->beforeNormalization() + ->ifString() + ->then(static function (string $v): array { return ['id' => $v]; }) + ->end() + + ->beforeNormalization() + ->ifTrue(static function ($v) { return \is_array($v) && 1 < \count($v); }) + ->then(static function () { throw new InvalidConfigurationException('You cannot configure multiple token handlers.'); }) + ->end() + + // "isRequired" must be set otherwise the following custom validation is not called + ->isRequired() + ->beforeNormalization() + ->ifTrue(static function ($v) { return \is_array($v) && !$v; }) + ->then(static function () { throw new InvalidConfigurationException('You must set a token handler.'); }) + ->end() + + ->children() + ; + + foreach ($this->tokenHandlerFactories as $factory) { + $factory->addConfiguration($tokenHandlerNodeBuilder); + } + + $tokenHandlerNodeBuilder->end(); } public function getPriority(): int @@ -73,10 +109,11 @@ public function createAuthenticator(ContainerBuilder $container, string $firewal $failureHandler = isset($config['failure_handler']) ? new Reference($this->createAuthenticationFailureHandler($container, $firewallName, $config)) : null; $authenticatorId = sprintf('security.authenticator.access_token.%s', $firewallName); $extractorId = $this->createExtractor($container, $firewallName, $config['token_extractors']); + $tokenHandlerId = $this->createTokenHandler($container, $firewallName, $config['token_handler'], $userProviderId); $container ->setDefinition($authenticatorId, new ChildDefinition('security.authenticator.access_token')) - ->replaceArgument(0, new Reference($config['token_handler'])) + ->replaceArgument(0, new Reference($tokenHandlerId)) ->replaceArgument(1, new Reference($extractorId)) ->replaceArgument(2, $userProviderId ? new Reference($userProviderId) : null) ->replaceArgument(3, $successHandler) @@ -110,4 +147,20 @@ private function createExtractor(ContainerBuilder $container, string $firewallNa return $extractorId; } + + private function createTokenHandler(ContainerBuilder $container, string $firewallName, array $config, ?string $userProviderId): string + { + $key = array_keys($config)[0]; + $id = sprintf('security.access_token_handler.%s', $firewallName); + + foreach ($this->tokenHandlerFactories as $factory) { + if ($key !== $factory->getKey()) { + continue; + } + + $factory->create($container, $id, $config[$key], $userProviderId); + } + + return $id; + } } diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/SignatureAlgorithmFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/SignatureAlgorithmFactory.php new file mode 100644 index 0000000000000..f9f876deff2bf --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/SignatureAlgorithmFactory.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory; + +use Jose\Component\Core\Algorithm as SignatureAlgorithm; +use Jose\Component\Signature\Algorithm; +use Symfony\Component\Security\Core\Exception\InvalidArgumentException; +use Symfony\Component\Security\Http\AccessToken\Oidc\OidcTokenHandler; + +/** + * Creates a signature algorithm for {@see OidcTokenHandler}. + * + * @experimental + */ +final class SignatureAlgorithmFactory +{ + public static function create(string $algorithm): SignatureAlgorithm + { + switch ($algorithm) { + case 'ES256': + if (!class_exists(Algorithm\ES256::class)) { + throw new \LogicException('You cannot use the "ES256" signature algorithm since "web-token/jwt-signature-algorithm-ecdsa" is not installed. Try running "composer require web-token/jwt-signature-algorithm-ecdsa".'); + } + + return new Algorithm\ES256(); + case 'ES384': + if (!class_exists(Algorithm\ES384::class)) { + throw new \LogicException('You cannot use the "ES384" signature algorithm since "web-token/jwt-signature-algorithm-ecdsa" is not installed. Try running "composer require web-token/jwt-signature-algorithm-ecdsa".'); + } + + return new Algorithm\ES384(); + case 'ES512': + if (!class_exists(Algorithm\ES512::class)) { + throw new \LogicException('You cannot use the "ES512" signature algorithm since "web-token/jwt-signature-algorithm-ecdsa" is not installed. Try running "composer require web-token/jwt-signature-algorithm-ecdsa".'); + } + + return new Algorithm\ES512(); + default: + throw new InvalidArgumentException(sprintf('Unsupported signature algorithm "%s". Only ES* algorithms are supported. If you want to use another algorithm, create your TokenHandler as a service.', $algorithm)); + } + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator_access_token.php b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator_access_token.php index f1aea7cb2c3d1..fafe477d5bd23 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator_access_token.php +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator_access_token.php @@ -14,6 +14,8 @@ use Symfony\Component\Security\Http\AccessToken\ChainAccessTokenExtractor; use Symfony\Component\Security\Http\AccessToken\FormEncodedBodyExtractor; use Symfony\Component\Security\Http\AccessToken\HeaderAccessTokenExtractor; +use Symfony\Component\Security\Http\AccessToken\Oidc\OidcTokenHandler; +use Symfony\Component\Security\Http\AccessToken\Oidc\OidcUserInfoTokenHandler; use Symfony\Component\Security\Http\AccessToken\QueryAccessTokenExtractor; use Symfony\Component\Security\Http\Authenticator\AccessTokenAuthenticator; @@ -40,5 +42,24 @@ ->args([ abstract_arg('access token extractors'), ]) + + // OIDC + ->set('security.access_token_handler.oidc_user_info', OidcUserInfoTokenHandler::class) + ->abstract() + ->args([ + abstract_arg('http client'), + service('logger')->nullOnInvalid(), + 'sub', + ]) + + ->set('security.access_token_handler.oidc', OidcTokenHandler::class) + ->abstract() + ->args([ + abstract_arg('signature algorithm'), + abstract_arg('jwk'), + service('logger')->nullOnInvalid(), + 'sub', + null, + ]) ; }; diff --git a/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php b/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php index bf30dafbee612..2cbca705f93c1 100644 --- a/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php +++ b/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php @@ -23,6 +23,9 @@ use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\RegisterTokenUsageTrackingPass; use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\ReplaceDecoratedRememberMeHandlerPass; use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\SortFirewallListenersPass; +use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken\OidcTokenHandlerFactory; +use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken\OidcUserInfoTokenHandlerFactory; +use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken\ServiceTokenHandlerFactory; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\AccessTokenFactory; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\CustomAuthenticatorFactory; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\FormLoginFactory; @@ -74,7 +77,11 @@ public function build(ContainerBuilder $container) $extension->addAuthenticatorFactory(new CustomAuthenticatorFactory()); $extension->addAuthenticatorFactory(new LoginThrottlingFactory()); $extension->addAuthenticatorFactory(new LoginLinkFactory()); - $extension->addAuthenticatorFactory(new AccessTokenFactory()); + $extension->addAuthenticatorFactory(new AccessTokenFactory([ + new ServiceTokenHandlerFactory(), + new OidcUserInfoTokenHandlerFactory(), + new OidcTokenHandlerFactory(), + ])); $extension->addUserProviderFactory(new InMemoryFactory()); $extension->addUserProviderFactory(new LdapFactory()); diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Security/Factory/AccessTokenFactoryTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Security/Factory/AccessTokenFactoryTest.php index f31798113b75c..a9da80fbb40ba 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Security/Factory/AccessTokenFactoryTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Security/Factory/AccessTokenFactoryTest.php @@ -12,6 +12,9 @@ namespace Symfony\Bundle\SecurityBundle\Tests\DependencyInjection\Security\Factory; use PHPUnit\Framework\TestCase; +use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken\OidcTokenHandlerFactory; +use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken\OidcUserInfoTokenHandlerFactory; +use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken\ServiceTokenHandlerFactory; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\AccessTokenFactory; use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; @@ -29,7 +32,7 @@ public function testBasicServiceConfiguration() 'token_extractors' => ['BAR', 'FOO'], ]; - $factory = new AccessTokenFactory(); + $factory = new AccessTokenFactory($this->createTokenHandlerFactories()); $finalizedConfig = $this->processConfig($config, $factory); $factory->createAuthenticator($container, 'firewall1', $finalizedConfig, 'userprovider'); @@ -37,19 +40,99 @@ public function testBasicServiceConfiguration() $this->assertTrue($container->hasDefinition('security.authenticator.access_token.firewall1')); } - public function testDefaultServiceConfiguration() + public function testDefaultTokenHandlerConfiguration() { $container = new ContainerBuilder(); $config = [ 'token_handler' => 'in_memory_token_handler_service_id', ]; - $factory = new AccessTokenFactory(); + $factory = new AccessTokenFactory($this->createTokenHandlerFactories()); $finalizedConfig = $this->processConfig($config, $factory); $factory->createAuthenticator($container, 'firewall1', $finalizedConfig, 'userprovider'); $this->assertTrue($container->hasDefinition('security.authenticator.access_token.firewall1')); + $this->assertTrue($container->hasDefinition('security.access_token_handler.firewall1')); + } + + public function testIdTokenHandlerConfiguration() + { + $container = new ContainerBuilder(); + $config = [ + 'token_handler' => ['id' => 'in_memory_token_handler_service_id'], + ]; + + $factory = new AccessTokenFactory($this->createTokenHandlerFactories()); + $finalizedConfig = $this->processConfig($config, $factory); + + $factory->createAuthenticator($container, 'firewall1', $finalizedConfig, 'userprovider'); + + $this->assertTrue($container->hasDefinition('security.authenticator.access_token.firewall1')); + $this->assertTrue($container->hasDefinition('security.access_token_handler.firewall1')); + } + + public function testOidcUserInfoTokenHandlerConfigurationWithExistingClient() + { + $container = new ContainerBuilder(); + $config = [ + 'token_handler' => ['oidc_user_info' => ['client' => 'oidc.client']], + ]; + + $factory = new AccessTokenFactory($this->createTokenHandlerFactories()); + $finalizedConfig = $this->processConfig($config, $factory); + + $factory->createAuthenticator($container, 'firewall1', $finalizedConfig, 'userprovider'); + + $this->assertTrue($container->hasDefinition('security.authenticator.access_token.firewall1')); + $this->assertTrue($container->hasDefinition('security.access_token_handler.firewall1')); + $this->assertFalse($container->hasDefinition('http_client.security.access_token_handler.oidc_user_info')); + } + + public function testOidcUserInfoTokenHandlerConfigurationWithClientCreation() + { + $container = new ContainerBuilder(); + $config = [ + 'token_handler' => ['oidc_user_info' => ['client' => ['base_uri' => 'https://www.example.com/realms/demo/protocol/openid-connect/userinfo']]], + ]; + + $factory = new AccessTokenFactory($this->createTokenHandlerFactories()); + $finalizedConfig = $this->processConfig($config, $factory); + + $factory->createAuthenticator($container, 'firewall1', $finalizedConfig, 'userprovider'); + + $this->assertTrue($container->hasDefinition('security.authenticator.access_token.firewall1')); + $this->assertTrue($container->hasDefinition('security.access_token_handler.firewall1')); + $this->assertTrue($container->hasDefinition('http_client.security.access_token_handler.oidc_user_info')); + } + + public function testMultipleTokenHandlersSet() + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('You cannot configure multiple token handlers.'); + + $config = [ + 'token_handler' => [ + 'id' => 'in_memory_token_handler_service_id', + 'oidc_user_info' => ['client' => 'oidc.client'], + ], + ]; + + $factory = new AccessTokenFactory($this->createTokenHandlerFactories()); + $this->processConfig($config, $factory); + } + + public function testNoTokenHandlerSet() + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('You must set a token handler.'); + + $config = [ + 'token_handler' => [], + ]; + + $factory = new AccessTokenFactory($this->createTokenHandlerFactories()); + $this->processConfig($config, $factory); } public function testNoExtractorsDefined() @@ -63,7 +146,7 @@ public function testNoExtractorsDefined() 'token_extractors' => [], ]; - $factory = new AccessTokenFactory(); + $factory = new AccessTokenFactory($this->createTokenHandlerFactories()); $this->processConfig($config, $factory); } @@ -76,7 +159,7 @@ public function testNoHandlerDefined() 'failure_handler' => 'failure_handler_service_id', ]; - $factory = new AccessTokenFactory(); + $factory = new AccessTokenFactory($this->createTokenHandlerFactories()); $this->processConfig($config, $factory); } @@ -90,4 +173,13 @@ private function processConfig(array $config, AccessTokenFactory $factory) return $node->finalize($normalizedConfig); } + + private function createTokenHandlerFactories(): array + { + return [ + new ServiceTokenHandlerFactory(), + new OidcUserInfoTokenHandlerFactory(), + new OidcTokenHandlerFactory(), + ]; + } } diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/AccessTokenTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/AccessTokenTest.php index 6ee313b9ffbbb..49d31cb664f5e 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/AccessTokenTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/AccessTokenTest.php @@ -11,6 +11,11 @@ namespace Symfony\Bundle\SecurityBundle\Tests\Functional; +use Jose\Component\Core\AlgorithmManager; +use Jose\Component\Core\JWK; +use Jose\Component\Signature\Algorithm\ES256; +use Jose\Component\Signature\JWSBuilder; +use Jose\Component\Signature\Serializer\CompactSerializer; use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; use Symfony\Component\HttpFoundation\Response; @@ -327,4 +332,40 @@ public function testSelfContainedTokens() $this->assertSame(200, $response->getStatusCode()); $this->assertSame(['message' => 'Welcome @dunglas!'], json_decode($response->getContent(), true)); } + + public function testOidcSuccess() + { + $time = time(); + $claims = [ + 'iat' => $time, + 'nbf' => $time, + 'exp' => $time + 3600, + 'iss' => 'https://www.example.com/', + 'aud' => 'Symfony OIDC', + 'sub' => 'e21bf182-1538-406e-8ccb-e25a17aba39f', + 'username' => 'dunglas', + ]; + $token = (new CompactSerializer())->serialize((new JWSBuilder(new AlgorithmManager([ + new ES256(), + ])))->create() + ->withPayload(json_encode($claims)) + // tip: use https://mkjwk.org/ to generate a JWK + ->addSignature(new JWK([ + 'kty' => 'EC', + 'crv' => 'P-256', + 'x' => '0QEAsI1wGI-dmYatdUZoWSRWggLEpyzopuhwk-YUnA4', + 'y' => 'KYl-qyZ26HobuYwlQh-r0iHX61thfP82qqEku7i0woo', + 'd' => 'iA_TV2zvftni_9aFAQwFO_9aypfJFCSpcCyevDvz220', + ]), ['alg' => 'ES256']) + ->build() + ); + + $client = $this->createClient(['test_case' => 'AccessToken', 'root_config' => 'config_oidc.yml']); + $client->request('GET', '/foo', [], [], ['HTTP_AUTHORIZATION' => sprintf('Bearer %s', $token)]); + $response = $client->getResponse(); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(['message' => 'Welcome @dunglas!'], json_decode($response->getContent(), true)); + } } diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_oidc.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_oidc.yml new file mode 100644 index 0000000000000..45802961a1a61 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_oidc.yml @@ -0,0 +1,34 @@ +imports: + - { resource: ./../config/framework.yml } + +framework: + http_method_override: false + serializer: ~ + +security: + password_hashers: + Symfony\Component\Security\Core\User\InMemoryUser: plaintext + + providers: + in_memory: + memory: + users: + dunglas: { password: foo, roles: [ROLE_USER] } + + firewalls: + main: + pattern: ^/ + access_token: + token_handler: + oidc: + claim: 'username' + audience: 'Symfony OIDC' + signature: + algorithm: 'ES256' + # tip: use https://mkjwk.org/ to generate a JWK + key: '{"kty":"EC","d":"iA_TV2zvftni_9aFAQwFO_9aypfJFCSpcCyevDvz220","crv":"P-256","x":"0QEAsI1wGI-dmYatdUZoWSRWggLEpyzopuhwk-YUnA4","y":"KYl-qyZ26HobuYwlQh-r0iHX61thfP82qqEku7i0woo"}' + token_extractors: 'header' + realm: 'My API' + + access_control: + - { path: ^/foo, roles: ROLE_USER } diff --git a/src/Symfony/Bundle/SecurityBundle/composer.json b/src/Symfony/Bundle/SecurityBundle/composer.json index c1b5ba239e269..8fb916cd27114 100644 --- a/src/Symfony/Bundle/SecurityBundle/composer.json +++ b/src/Symfony/Bundle/SecurityBundle/composer.json @@ -48,7 +48,13 @@ "symfony/twig-bridge": "^5.4|^6.0", "symfony/validator": "^5.4|^6.0", "symfony/yaml": "^5.4|^6.0", - "twig/twig": "^2.13|^3.0.4" + "twig/twig": "^2.13|^3.0.4", + "web-token/jwt-checker": "^3.1", + "web-token/jwt-signature-algorithm-hmac": "^3.1", + "web-token/jwt-signature-algorithm-ecdsa": "^3.1", + "web-token/jwt-signature-algorithm-rsa": "^3.1", + "web-token/jwt-signature-algorithm-eddsa": "^3.1", + "web-token/jwt-signature-algorithm-none": "^3.1" }, "conflict": { "symfony/browser-kit": "<5.4", diff --git a/src/Symfony/Component/Security/Core/CHANGELOG.md b/src/Symfony/Component/Security/Core/CHANGELOG.md index 7ba95c0568113..b489556c919bb 100644 --- a/src/Symfony/Component/Security/Core/CHANGELOG.md +++ b/src/Symfony/Component/Security/Core/CHANGELOG.md @@ -1,6 +1,12 @@ CHANGELOG ========= +6.3 +--- + + * Add `AttributesBasedUserProviderInterface` to allow `$attributes` optional argument on `loadUserByIdentifier` + * Add `OidcUser` with OIDC support for `OidcUserInfoTokenHandler` + 6.2 --- diff --git a/src/Symfony/Component/Security/Core/Tests/User/OidcUserTest.php b/src/Symfony/Component/Security/Core/Tests/User/OidcUserTest.php new file mode 100644 index 0000000000000..7925628386c55 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Tests/User/OidcUserTest.php @@ -0,0 +1,176 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Core\Tests\User; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Security\Core\User\OidcUser; + +class OidcUserTest extends TestCase +{ + public function testCannotCreateUserWithoutSubProperty() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The "sub" claim cannot be empty.'); + + new OidcUser(); + } + + public function testCreateFullUserWithAdditionalClaimsUsingPositionalParameters() + { + $this->assertEquals(new OidcUser( + userIdentifier: 'john.doe', + roles: ['ROLE_USER', 'ROLE_ADMIN'], + sub: 'e21bf182-1538-406e-8ccb-e25a17aba39f', + name: 'John DOE', + givenName: 'John', + familyName: 'DOE', + middleName: 'Fitzgerald', + nickname: 'Johnny', + preferredUsername: 'john.doe', + profile: 'https://www.example.com/john-doe', + picture: 'https://www.example.com/pics/john-doe.jpg', + website: 'https://www.example.com', + email: 'john.doe@example.com', + emailVerified: true, + gender: 'male', + birthdate: '1980-05-15', + zoneinfo: 'Europe/Paris', + locale: 'fr-FR', + phoneNumber: '+33 (0) 6 12 34 56 78', + phoneNumberVerified: false, + address: [ + 'formatted' => '1 Rue des Moulins 75000 Paris - France', + 'street_address' => '1 Rue des Moulins', + 'locality' => 'Paris', + 'region' => 'Île-de-France', + 'postal_code' => '75000', + 'country' => 'France', + ], + updatedAt: (new \DateTimeImmutable())->setTimestamp(1669628917), + additionalClaims: [ + 'impersonator' => [ + 'username' => 'jane.doe@example.com', + ], + 'customId' => 12345, + ], + ), new OidcUser(...[ + 'userIdentifier' => 'john.doe', + 'roles' => ['ROLE_USER', 'ROLE_ADMIN'], + 'sub' => 'e21bf182-1538-406e-8ccb-e25a17aba39f', + 'name' => 'John DOE', + 'givenName' => 'John', + 'familyName' => 'DOE', + 'middleName' => 'Fitzgerald', + 'nickname' => 'Johnny', + 'preferredUsername' => 'john.doe', + 'profile' => 'https://www.example.com/john-doe', + 'picture' => 'https://www.example.com/pics/john-doe.jpg', + 'website' => 'https://www.example.com', + 'email' => 'john.doe@example.com', + 'emailVerified' => true, + 'gender' => 'male', + 'birthdate' => '1980-05-15', + 'zoneinfo' => 'Europe/Paris', + 'locale' => 'fr-FR', + 'phoneNumber' => '+33 (0) 6 12 34 56 78', + 'phoneNumberVerified' => false, + 'address' => [ + 'formatted' => '1 Rue des Moulins 75000 Paris - France', + 'street_address' => '1 Rue des Moulins', + 'locality' => 'Paris', + 'region' => 'Île-de-France', + 'postal_code' => '75000', + 'country' => 'France', + ], + 'updatedAt' => (new \DateTimeImmutable())->setTimestamp(1669628917), + 'impersonator' => [ + 'username' => 'jane.doe@example.com', + ], + 'customId' => 12345, + ])); + } + + public function testCreateFullUserWithAdditionalClaims() + { + $this->assertEquals(new OidcUser( + userIdentifier: 'john.doe', + roles: ['ROLE_USER', 'ROLE_ADMIN'], + sub: 'e21bf182-1538-406e-8ccb-e25a17aba39f', + name: 'John DOE', + givenName: 'John', + familyName: 'DOE', + middleName: 'Fitzgerald', + nickname: 'Johnny', + preferredUsername: 'john.doe', + profile: 'https://www.example.com/john-doe', + picture: 'https://www.example.com/pics/john-doe.jpg', + website: 'https://www.example.com', + email: 'john.doe@example.com', + emailVerified: true, + gender: 'male', + birthdate: '1980-05-15', + zoneinfo: 'Europe/Paris', + locale: 'fr-FR', + phoneNumber: '+33 (0) 6 12 34 56 78', + phoneNumberVerified: false, + address: [ + 'formatted' => '1 Rue des Moulins 75000 Paris - France', + 'street_address' => '1 Rue des Moulins', + 'locality' => 'Paris', + 'region' => 'Île-de-France', + 'postal_code' => '75000', + 'country' => 'France', + ], + updatedAt: (new \DateTimeImmutable())->setTimestamp(1669628917), + additionalClaims: [ + [ + 'username' => 'jane.doe@example.com', + ], + 12345, + ], + ), new OidcUser( + 'john.doe', + ['ROLE_USER', 'ROLE_ADMIN'], + 'e21bf182-1538-406e-8ccb-e25a17aba39f', + 'John DOE', + 'John', + 'DOE', + 'Fitzgerald', + 'Johnny', + 'john.doe', + 'https://www.example.com/john-doe', + 'https://www.example.com/pics/john-doe.jpg', + 'https://www.example.com', + 'john.doe@example.com', + true, + 'male', + '1980-05-15', + 'Europe/Paris', + 'fr-FR', + '+33 (0) 6 12 34 56 78', + false, + [ + 'formatted' => '1 Rue des Moulins 75000 Paris - France', + 'street_address' => '1 Rue des Moulins', + 'locality' => 'Paris', + 'region' => 'Île-de-France', + 'postal_code' => '75000', + 'country' => 'France', + ], + (new \DateTimeImmutable())->setTimestamp(1669628917), + [ + 'username' => 'jane.doe@example.com', + ], + 12345 + )); + } +} diff --git a/src/Symfony/Component/Security/Core/User/AttributesBasedUserProviderInterface.php b/src/Symfony/Component/Security/Core/User/AttributesBasedUserProviderInterface.php new file mode 100644 index 0000000000000..10cbb434e342e --- /dev/null +++ b/src/Symfony/Component/Security/Core/User/AttributesBasedUserProviderInterface.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Core\User; + +use Symfony\Component\Security\Core\Exception\UserNotFoundException; + +/** + * Overrides UserProviderInterface to add an "attributes" argument on loadUserByIdentifier. + * This is particularly useful with self-contained access tokens. + * + * @experimental + */ +interface AttributesBasedUserProviderInterface extends UserProviderInterface +{ + /** + * Loads the user for the given user identifier (e.g. username or email) and attributes. + * + * This method must throw UserNotFoundException if the user is not found. + * + * @throws UserNotFoundException + */ + public function loadUserByIdentifier(string $identifier, array $attributes = []): UserInterface; +} diff --git a/src/Symfony/Component/Security/Core/User/OidcUser.php b/src/Symfony/Component/Security/Core/User/OidcUser.php new file mode 100644 index 0000000000000..eea433b53c26e --- /dev/null +++ b/src/Symfony/Component/Security/Core/User/OidcUser.php @@ -0,0 +1,184 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Core\User; + +/** + * UserInterface implementation used by the access-token security workflow with an OIDC server. + * + * @experimental + */ +class OidcUser implements UserInterface +{ + private array $additionalClaims = []; + + public function __construct( + private ?string $userIdentifier = null, + private array $roles = ['ROLE_USER'], + + // Standard Claims (https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims) + private ?string $sub = null, + private ?string $name = null, + private ?string $givenName = null, + private ?string $familyName = null, + private ?string $middleName = null, + private ?string $nickname = null, + private ?string $preferredUsername = null, + private ?string $profile = null, + private ?string $picture = null, + private ?string $website = null, + private ?string $email = null, + private ?bool $emailVerified = null, + private ?string $gender = null, + private ?string $birthdate = null, + private ?string $zoneinfo = null, + private ?string $locale = null, + private ?string $phoneNumber = null, + private ?bool $phoneNumberVerified = null, + private ?array $address = null, + private ?\DateTimeInterface $updatedAt = null, + + // Additional Claims (https://openid.net/specs/openid-connect-core-1_0.html#AdditionalClaims) + ...$additionalClaims + ) { + if (null === $sub || '' === $sub) { + throw new \InvalidArgumentException('The "sub" claim cannot be empty.'); + } + + $this->additionalClaims = $additionalClaims['additionalClaims'] ?? $additionalClaims; + } + + /** + * OIDC or OAuth specs don't have any "role" notion. + * + * If you want to implement "roles" from your OIDC server, + * send a "roles" constructor argument to this object + * (e.g.: using a custom UserProvider). + */ + public function getRoles(): array + { + return $this->roles; + } + + public function getUserIdentifier(): string + { + return (string) ($this->userIdentifier ?? $this->getSub()); + } + + public function eraseCredentials() + { + } + + public function getSub(): ?string + { + return $this->sub; + } + + public function getName(): ?string + { + return $this->name; + } + + public function getGivenName(): ?string + { + return $this->givenName; + } + + public function getFamilyName(): ?string + { + return $this->familyName; + } + + public function getMiddleName(): ?string + { + return $this->middleName; + } + + public function getNickname(): ?string + { + return $this->nickname; + } + + public function getPreferredUsername(): ?string + { + return $this->preferredUsername; + } + + public function getProfile(): ?string + { + return $this->profile; + } + + public function getPicture(): ?string + { + return $this->picture; + } + + public function getWebsite(): ?string + { + return $this->website; + } + + public function getEmail(): ?string + { + return $this->email; + } + + public function getEmailVerified(): ?bool + { + return $this->emailVerified; + } + + public function getGender(): ?string + { + return $this->gender; + } + + public function getBirthdate(): ?string + { + return $this->birthdate; + } + + public function getZoneinfo(): ?string + { + return $this->zoneinfo; + } + + public function getLocale(): ?string + { + return $this->locale; + } + + public function getPhoneNumber(): ?string + { + return $this->phoneNumber; + } + + public function getphoneNumberVerified(): ?bool + { + return $this->phoneNumberVerified; + } + + public function getAddress(): ?array + { + return $this->address; + } + + public function getUpdatedAt(): ?\DateTimeInterface + { + return $this->updatedAt; + } + + public function getAdditionalClaims(): array + { + return $this->additionalClaims; + } +} diff --git a/src/Symfony/Component/Security/Core/composer.json b/src/Symfony/Component/Security/Core/composer.json index 5f933534b29ce..8613e703564c0 100644 --- a/src/Symfony/Component/Security/Core/composer.json +++ b/src/Symfony/Component/Security/Core/composer.json @@ -29,6 +29,7 @@ "symfony/expression-language": "^5.4|^6.0", "symfony/http-foundation": "^5.4|^6.0", "symfony/ldap": "^5.4|^6.0", + "symfony/string": "^5.4|^6.0", "symfony/translation": "^5.4|^6.0", "symfony/validator": "^5.4|^6.0", "psr/log": "^1|^2|^3" diff --git a/src/Symfony/Component/Security/Http/AccessToken/Oidc/Exception/InvalidSignatureException.php b/src/Symfony/Component/Security/Http/AccessToken/Oidc/Exception/InvalidSignatureException.php new file mode 100644 index 0000000000000..4eaf52ca7ba43 --- /dev/null +++ b/src/Symfony/Component/Security/Http/AccessToken/Oidc/Exception/InvalidSignatureException.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\AccessToken\Oidc\Exception; + +use Symfony\Component\Security\Core\Exception\AuthenticationException; + +/** + * This exception is thrown when the token signature is invalid. + * + * @experimental + */ +class InvalidSignatureException extends AuthenticationException +{ + public function getMessageKey(): string + { + return 'Invalid token signature.'; + } +} diff --git a/src/Symfony/Component/Security/Http/AccessToken/Oidc/Exception/MissingClaimException.php b/src/Symfony/Component/Security/Http/AccessToken/Oidc/Exception/MissingClaimException.php new file mode 100644 index 0000000000000..eed0b9d1c2896 --- /dev/null +++ b/src/Symfony/Component/Security/Http/AccessToken/Oidc/Exception/MissingClaimException.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\AccessToken\Oidc\Exception; + +use Symfony\Component\Security\Core\Exception\AuthenticationException; + +/** + * This exception is thrown when the user is invalid on the OIDC server (e.g.: "email" property is not in the scope). + * + * @experimental + */ +class MissingClaimException extends AuthenticationException +{ + public function getMessageKey(): string + { + return 'Missing claim.'; + } +} diff --git a/src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcTokenHandler.php b/src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcTokenHandler.php new file mode 100644 index 0000000000000..047cc3318e017 --- /dev/null +++ b/src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcTokenHandler.php @@ -0,0 +1,105 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\AccessToken\Oidc; + +use Jose\Component\Checker; +use Jose\Component\Checker\ClaimCheckerManager; +use Jose\Component\Core\Algorithm; +use Jose\Component\Core\AlgorithmManager; +use Jose\Component\Core\JWK; +use Jose\Component\Signature\JWSTokenSupport; +use Jose\Component\Signature\JWSVerifier; +use Jose\Component\Signature\Serializer\CompactSerializer; +use Jose\Component\Signature\Serializer\JWSSerializerManager; +use Psr\Log\LoggerInterface; +use Symfony\Component\Clock\NativeClock; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface; +use Symfony\Component\Security\Http\AccessToken\Oidc\Exception\InvalidSignatureException; +use Symfony\Component\Security\Http\AccessToken\Oidc\Exception\MissingClaimException; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; + +/** + * The token handler decodes and validates the token, and retrieves the user identifier from it. + * + * @experimental + */ +final class OidcTokenHandler implements AccessTokenHandlerInterface +{ + use OidcTrait; + + public function __construct( + private Algorithm $signatureAlgorithm, + private JWK $jwk, + private ?LoggerInterface $logger = null, + private string $claim = 'sub', + private ?string $audience = null + ) { + } + + public function getUserBadgeFrom(string $accessToken): UserBadge + { + if (!class_exists(JWSVerifier::class) || !class_exists(Checker\HeaderCheckerManager::class)) { + throw new \LogicException('You cannot use the "oidc" token handler since "web-token/jwt-signature" and "web-token/jwt-checker" are not installed. Try running "composer require web-token/jwt-signature web-token/jwt-checker".'); + } + + try { + // Decode the token + $jwsVerifier = new JWSVerifier(new AlgorithmManager([$this->signatureAlgorithm])); + $serializerManager = new JWSSerializerManager([new CompactSerializer()]); + $jws = $serializerManager->unserialize($accessToken); + $claims = json_decode($jws->getPayload(), true); + + // Verify the signature + if (!$jwsVerifier->verifyWithKey($jws, $this->jwk, 0)) { + throw new InvalidSignatureException(); + } + + // Verify the headers + $headerCheckerManager = new Checker\HeaderCheckerManager([ + new Checker\AlgorithmChecker([$this->signatureAlgorithm->name()]), + ], [ + new JWSTokenSupport(), + ]); + // if this check fails, an InvalidHeaderException is thrown + $headerCheckerManager->check($jws, 0); + + // Verify the claims + $clock = class_exists(NativeClock::class) ? new NativeClock() : null; + $checkers = [ + new Checker\IssuedAtChecker(0, false, $clock), + new Checker\NotBeforeChecker(0, false, $clock), + new Checker\ExpirationTimeChecker(0, false, $clock), + ]; + if ($this->audience) { + $checkers[] = new Checker\AudienceChecker($this->audience); + } + $claimCheckerManager = new ClaimCheckerManager($checkers); + // if this check fails, an InvalidClaimException is thrown + $claimCheckerManager->check($claims); + + if (empty($claims[$this->claim])) { + throw new MissingClaimException(sprintf('"%s" claim not found.', $this->claim)); + } + + // UserLoader argument can be overridden by a UserProvider on AccessTokenAuthenticator::authenticate + return new UserBadge($claims[$this->claim], fn () => $this->createUser($claims), $claims); + } catch (\Throwable $e) { + $this->logger?->error('An error while decoding and validating the token.', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + throw new BadCredentialsException('Invalid credentials.', $e->getCode(), $e); + } + } +} diff --git a/src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcTrait.php b/src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcTrait.php new file mode 100644 index 0000000000000..89d03409d9d6d --- /dev/null +++ b/src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcTrait.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\AccessToken\Oidc; + +use Symfony\Component\Security\Core\User\OidcUser; + +use function Symfony\Component\String\u; + +/** + * Creates {@see OidcUser} from claims. + * + * @internal + */ +trait OidcTrait +{ + private function createUser(array $claims): OidcUser + { + if (!\function_exists(\Symfony\Component\String\u::class)) { + throw new \LogicException('You cannot use the "OidcUserInfoTokenHandler" since the String component is not installed. Try running "composer require symfony/string".'); + } + + foreach ($claims as $claim => $value) { + unset($claims[$claim]); + if ('' === $value || null === $value) { + continue; + } + $claims[u($claim)->camel()->toString()] = $value; + } + + if (isset($claims['updatedAt']) && '' !== $claims['updatedAt']) { + $claims['updatedAt'] = (new \DateTimeImmutable())->setTimestamp($claims['updatedAt']); + } + + if (\array_key_exists('emailVerified', $claims) && null !== $claims['emailVerified'] && '' !== $claims['emailVerified']) { + $claims['emailVerified'] = (bool) $claims['emailVerified']; + } + + if (\array_key_exists('phoneNumberVerified', $claims) && null !== $claims['phoneNumberVerified'] && '' !== $claims['phoneNumberVerified']) { + $claims['phoneNumberVerified'] = (bool) $claims['phoneNumberVerified']; + } + + return new OidcUser(...$claims); + } +} diff --git a/src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcUserInfoTokenHandler.php b/src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcUserInfoTokenHandler.php new file mode 100644 index 0000000000000..d69775f192e20 --- /dev/null +++ b/src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcUserInfoTokenHandler.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\AccessToken\Oidc; + +use Psr\Log\LoggerInterface; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface; +use Symfony\Component\Security\Http\AccessToken\Oidc\Exception\MissingClaimException; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * The token handler validates the token on the OIDC server and retrieves the user identifier. + * + * @experimental + */ +final class OidcUserInfoTokenHandler implements AccessTokenHandlerInterface +{ + use OidcTrait; + + public function __construct( + private HttpClientInterface $client, + private ?LoggerInterface $logger = null, + private string $claim = 'sub' + ) { + } + + public function getUserBadgeFrom(string $accessToken): UserBadge + { + try { + // Call the OIDC server to retrieve the user info + // If the token is invalid or expired, the OIDC server will return an error + $claims = $this->client->request('GET', '', [ + 'auth_bearer' => $accessToken, + ])->toArray(); + + if (empty($claims[$this->claim])) { + throw new MissingClaimException(sprintf('"%s" claim not found on OIDC server response.', $this->claim)); + } + + // UserLoader argument can be overridden by a UserProvider on AccessTokenAuthenticator::authenticate + return new UserBadge($claims[$this->claim], fn () => $this->createUser($claims), $claims); + } catch (\Throwable $e) { + $this->logger?->error('An error occurred on OIDC server.', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + throw new BadCredentialsException('Invalid credentials.', $e->getCode(), $e); + } + } +} diff --git a/src/Symfony/Component/Security/Http/Authenticator/AccessTokenAuthenticator.php b/src/Symfony/Component/Security/Http/Authenticator/AccessTokenAuthenticator.php index e88a04056e42b..c925e00050bed 100644 --- a/src/Symfony/Component/Security/Http/Authenticator/AccessTokenAuthenticator.php +++ b/src/Symfony/Component/Security/Http/Authenticator/AccessTokenAuthenticator.php @@ -59,7 +59,7 @@ public function authenticate(Request $request): Passport } $userBadge = $this->accessTokenHandler->getUserBadgeFrom($accessToken); - if (null === $userBadge->getUserLoader() && $this->userProvider) { + if ($this->userProvider) { $userBadge->setUserLoader($this->userProvider->loadUserByIdentifier(...)); } diff --git a/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/UserBadge.php b/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/UserBadge.php index 701ae6a2e7f76..14c8852a11e98 100644 --- a/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/UserBadge.php +++ b/src/Symfony/Component/Security/Http/Authenticator/Passport/Badge/UserBadge.php @@ -34,6 +34,7 @@ class UserBadge implements BadgeInterface /** @var callable|null */ private $userLoader; private UserInterface $user; + private ?array $attributes; /** * Initializes the user badge. @@ -48,7 +49,7 @@ class UserBadge implements BadgeInterface * is thrown). If this is not set, the default user provider will be used with * $userIdentifier as username. */ - public function __construct(string $userIdentifier, callable $userLoader = null) + public function __construct(string $userIdentifier, callable $userLoader = null, array $attributes = null) { if (\strlen($userIdentifier) > self::MAX_USERNAME_LENGTH) { throw new BadCredentialsException('Username too long.'); @@ -56,6 +57,7 @@ public function __construct(string $userIdentifier, callable $userLoader = null) $this->userIdentifier = $userIdentifier; $this->userLoader = $userLoader; + $this->attributes = $attributes; } public function getUserIdentifier(): string @@ -63,6 +65,11 @@ public function getUserIdentifier(): string return $this->userIdentifier; } + public function getAttributes(): ?array + { + return $this->attributes; + } + /** * @throws AuthenticationException when the user cannot be found */ @@ -76,7 +83,11 @@ public function getUser(): UserInterface throw new \LogicException(sprintf('No user loader is configured, did you forget to register the "%s" listener?', UserProviderListener::class)); } - $user = ($this->userLoader)($this->userIdentifier); + if (null === $this->getAttributes()) { + $user = ($this->userLoader)($this->userIdentifier); + } else { + $user = ($this->userLoader)($this->userIdentifier, $this->getAttributes()); + } // No user has been found via the $this->userLoader callback if (null === $user) { diff --git a/src/Symfony/Component/Security/Http/Authenticator/Passport/Passport.php b/src/Symfony/Component/Security/Http/Authenticator/Passport/Passport.php index f6b5afa58f321..0158ee5ba5e8b 100644 --- a/src/Symfony/Component/Security/Http/Authenticator/Passport/Passport.php +++ b/src/Symfony/Component/Security/Http/Authenticator/Passport/Passport.php @@ -66,11 +66,23 @@ public function getUser(): UserInterface * This method replaces the current badge if it is already set on this * passport. * + * @param string|null $badgeFqcn A FQCN to which the badge should be mapped to. + * This allows replacing a built-in badge by a custom one using + *. e.g. addBadge(new MyCustomUserBadge(), UserBadge::class) + * * @return $this */ - public function addBadge(BadgeInterface $badge): static + public function addBadge(BadgeInterface $badge/* , string $badgeFqcn = null */): static { - $this->badges[$badge::class] = $badge; + $badgeFqcn = $badge::class; + if (2 === \func_num_args()) { + $badgeFqcn = func_get_arg(1); + if (!\is_string($badgeFqcn)) { + throw new \LogicException(sprintf('Second argument of "%s" must be a string.', __METHOD__)); + } + } + + $this->badges[$badgeFqcn] = $badge; return $this; } diff --git a/src/Symfony/Component/Security/Http/CHANGELOG.md b/src/Symfony/Component/Security/Http/CHANGELOG.md index 489da50312e83..47a8553486686 100644 --- a/src/Symfony/Component/Security/Http/CHANGELOG.md +++ b/src/Symfony/Component/Security/Http/CHANGELOG.md @@ -7,6 +7,10 @@ CHANGELOG * Add `RememberMeBadge` to `JsonLoginAuthenticator` and enable reading parameter in JSON request body * Add argument `$exceptionCode` to `#[IsGranted]` * Deprecate passing a secret as the 2nd argument to the constructor of `Symfony\Component\Security\Http\RememberMe\PersistentRememberMeHandler` + * Add `OidcUserInfoTokenHandler` and `OidcTokenHandler` with OIDC support for `AccessTokenAuthenticator` + * Add `attributes` optional array argument in `UserBadge` + * Call `UserBadge::userLoader` with attributes if the argument is set + * Allow to override badge fqcn on `Passport::addBadge` 6.2 --- diff --git a/src/Symfony/Component/Security/Http/Tests/AccessToken/Oidc/OidcTokenHandlerTest.php b/src/Symfony/Component/Security/Http/Tests/AccessToken/Oidc/OidcTokenHandlerTest.php new file mode 100644 index 0000000000000..50cc3c7c15b14 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/AccessToken/Oidc/OidcTokenHandlerTest.php @@ -0,0 +1,173 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\AccessToken\Oidc; + +use Jose\Component\Core\AlgorithmManager; +use Jose\Component\Core\JWK; +use Jose\Component\Signature\Algorithm\ES256; +use Jose\Component\Signature\JWSBuilder; +use Jose\Component\Signature\Serializer\CompactSerializer; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\Security\Core\User\OidcUser; +use Symfony\Component\Security\Http\AccessToken\Oidc\OidcTokenHandler; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; + +class OidcTokenHandlerTest extends TestCase +{ + private const AUDIENCE = 'Symfony OIDC'; + + /** + * @dataProvider getClaims + */ + public function testGetsUserIdentifierFromSignedToken(string $claim, string $expected) + { + $time = time(); + $claims = [ + 'iat' => $time, + 'nbf' => $time, + 'exp' => $time + 3600, + 'iss' => 'https://www.example.com/', + 'aud' => self::AUDIENCE, + 'sub' => 'e21bf182-1538-406e-8ccb-e25a17aba39f', + 'email' => 'foo@example.com', + ]; + $token = $this->buildJWS(json_encode($claims)); + $expectedUser = new OidcUser(...$claims); + + $loggerMock = $this->createMock(LoggerInterface::class); + $loggerMock->expects($this->never())->method('error'); + + $userBadge = (new OidcTokenHandler( + new ES256(), + $this->getJWK(), + $loggerMock, + $claim, + self::AUDIENCE + ))->getUserBadgeFrom($token); + $actualUser = $userBadge->getUserLoader()(); + + $this->assertEquals(new UserBadge($expected, fn () => $expectedUser, $claims), $userBadge); + $this->assertInstanceOf(OidcUser::class, $actualUser); + $this->assertEquals($expectedUser, $actualUser); + $this->assertEquals($claims, $userBadge->getAttributes()); + $this->assertEquals($claims['sub'], $actualUser->getUserIdentifier()); + } + + public function getClaims(): iterable + { + yield ['sub', 'e21bf182-1538-406e-8ccb-e25a17aba39f']; + yield ['email', 'foo@example.com']; + } + + /** + * @dataProvider getInvalidTokens + */ + public function testThrowsAnErrorIfTokenIsInvalid(string $token) + { + $this->expectException(BadCredentialsException::class); + $this->expectExceptionMessage('Invalid credentials.'); + + $loggerMock = $this->createMock(LoggerInterface::class); + $loggerMock->expects($this->once())->method('error'); + + (new OidcTokenHandler( + new ES256(), + $this->getJWK(), + $loggerMock, + 'sub', + self::AUDIENCE + ))->getUserBadgeFrom($token); + } + + public function getInvalidTokens(): iterable + { + // Invalid token + yield ['invalid']; + // Token is expired + yield [ + $this->buildJWS(json_encode([ + 'iat' => time() - 3600, + 'nbf' => time() - 3600, + 'exp' => time() - 3590, + 'iss' => 'https://www.example.com/', + 'aud' => self::AUDIENCE, + 'sub' => 'e21bf182-1538-406e-8ccb-e25a17aba39f', + 'email' => 'foo@example.com', + ])), + ]; + // Invalid audience + yield [ + $this->buildJWS(json_encode([ + 'iat' => time(), + 'nbf' => time(), + 'exp' => time() + 3590, + 'iss' => 'https://www.example.com/', + 'aud' => 'invalid', + 'sub' => 'e21bf182-1538-406e-8ccb-e25a17aba39f', + 'email' => 'foo@example.com', + ])), + ]; + } + + public function testThrowsAnErrorIfUserPropertyIsMissing() + { + $this->expectException(BadCredentialsException::class); + $this->expectExceptionMessage('Invalid credentials.'); + + $loggerMock = $this->createMock(LoggerInterface::class); + $loggerMock->expects($this->once())->method('error'); + + $time = time(); + $claims = [ + 'iat' => $time, + 'nbf' => $time, + 'exp' => $time + 3600, + 'iss' => 'https://www.example.com/', + 'aud' => self::AUDIENCE, + 'sub' => 'e21bf182-1538-406e-8ccb-e25a17aba39f', + ]; + $token = $this->buildJWS(json_encode($claims)); + + (new OidcTokenHandler( + new ES256(), + $this->getJWK(), + $loggerMock, + 'email', + self::AUDIENCE + ))->getUserBadgeFrom($token); + } + + private function buildJWS(string $payload): string + { + return (new CompactSerializer())->serialize((new JWSBuilder(new AlgorithmManager([ + new ES256(), + ])))->create() + ->withPayload($payload) + ->addSignature($this->getJWK(), ['alg' => 'ES256']) + ->build() + ); + } + + private function getJWK(): JWK + { + // tip: use https://mkjwk.org/ to generate a JWK + return new JWK([ + 'kty' => 'EC', + 'crv' => 'P-256', + 'x' => '0QEAsI1wGI-dmYatdUZoWSRWggLEpyzopuhwk-YUnA4', + 'y' => 'KYl-qyZ26HobuYwlQh-r0iHX61thfP82qqEku7i0woo', + 'd' => 'iA_TV2zvftni_9aFAQwFO_9aypfJFCSpcCyevDvz220', + ]); + } +} diff --git a/src/Symfony/Component/Security/Http/Tests/AccessToken/Oidc/OidcUserInfoTokenHandlerTest.php b/src/Symfony/Component/Security/Http/Tests/AccessToken/Oidc/OidcUserInfoTokenHandlerTest.php new file mode 100644 index 0000000000000..3183ef0e397a1 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/AccessToken/Oidc/OidcUserInfoTokenHandlerTest.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\AccessToken\Oidc; + +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\Security\Core\User\OidcUser; +use Symfony\Component\Security\Http\AccessToken\Oidc\OidcUserInfoTokenHandler; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; + +class OidcUserInfoTokenHandlerTest extends TestCase +{ + /** + * @dataProvider getClaims + */ + public function testGetsUserIdentifierFromOidcServerResponse(string $claim, string $expected) + { + $accessToken = 'a-secret-token'; + $claims = [ + 'sub' => 'e21bf182-1538-406e-8ccb-e25a17aba39f', + 'email' => 'foo@example.com', + ]; + $expectedUser = new OidcUser(...$claims); + + $responseMock = $this->createMock(ResponseInterface::class); + $responseMock->expects($this->once()) + ->method('toArray') + ->willReturn($claims); + + $clientMock = $this->createMock(HttpClientInterface::class); + $clientMock->expects($this->once()) + ->method('request')->with('GET', '', ['auth_bearer' => $accessToken]) + ->willReturn($responseMock); + + $userBadge = (new OidcUserInfoTokenHandler($clientMock, null, $claim))->getUserBadgeFrom($accessToken); + $actualUser = $userBadge->getUserLoader()(); + + $this->assertEquals(new UserBadge($expected, fn () => $expectedUser, $claims), $userBadge); + $this->assertInstanceOf(OidcUser::class, $actualUser); + $this->assertEquals($expectedUser, $actualUser); + $this->assertEquals($claims, $userBadge->getAttributes()); + $this->assertEquals($claims['sub'], $actualUser->getUserIdentifier()); + } + + public function getClaims(): iterable + { + yield ['sub', 'e21bf182-1538-406e-8ccb-e25a17aba39f']; + yield ['email', 'foo@example.com']; + } + + public function testThrowsAnExceptionIfUserPropertyIsMissing() + { + $this->expectException(BadCredentialsException::class); + $this->expectExceptionMessage('Invalid credentials.'); + + $response = ['foo' => 'bar']; + + $responseMock = $this->createMock(ResponseInterface::class); + $responseMock->expects($this->once()) + ->method('toArray') + ->willReturn($response); + + $clientMock = $this->createMock(HttpClientInterface::class); + $clientMock->expects($this->once()) + ->method('request')->with('GET', '', ['auth_bearer' => 'a-secret-token']) + ->willReturn($responseMock); + + $loggerMock = $this->createMock(LoggerInterface::class); + $loggerMock->expects($this->once()) + ->method('error'); + + $handler = new OidcUserInfoTokenHandler($clientMock, $loggerMock); + $handler->getUserBadgeFrom('a-secret-token'); + } +} diff --git a/src/Symfony/Component/Security/Http/composer.json b/src/Symfony/Component/Security/Http/composer.json index 94ccbdcd403d3..6fdea560a67c5 100644 --- a/src/Symfony/Component/Security/Http/composer.json +++ b/src/Symfony/Component/Security/Http/composer.json @@ -27,14 +27,18 @@ "require-dev": { "symfony/cache": "^5.4|^6.0", "symfony/expression-language": "^5.4|^6.0", + "symfony/http-client-contracts": "^3.0", "symfony/rate-limiter": "^5.4|^6.0", "symfony/routing": "^5.4|^6.0", "symfony/security-csrf": "^5.4|^6.0", "symfony/translation": "^5.4|^6.0", - "psr/log": "^1|^2|^3" + "psr/log": "^1|^2|^3", + "web-token/jwt-checker": "^3.1", + "web-token/jwt-signature-algorithm-ecdsa": "^3.1" }, "conflict": { "symfony/event-dispatcher": "<5.4.9|>=6,<6.0.9", + "symfony/http-client-contracts": "<3.0", "symfony/security-bundle": "<5.4", "symfony/security-csrf": "<5.4" }, pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy