From 99a35f0fc32a7b5250aab5530129bae318c95209 Mon Sep 17 00:00:00 2001 From: Vincent Chalamon Date: Mon, 21 Nov 2022 18:27:04 +0100 Subject: [PATCH] [Security] Add OidcUserInfoTokenHandler and OidcUser --- composer.json | 5 +- .../Bundle/SecurityBundle/CHANGELOG.md | 1 + .../AccessToken/OidcTokenHandlerFactory.php | 89 +++++++++ .../OidcUserInfoTokenHandlerFactory.php | 77 ++++++++ .../ServiceTokenHandlerFactory.php | 41 ++++ .../TokenHandlerFactoryInterface.php | 38 ++++ .../Security/Factory/AccessTokenFactory.php | 59 +++++- .../Factory/SignatureAlgorithmFactory.php | 51 +++++ .../security_authenticator_access_token.php | 21 ++ .../Bundle/SecurityBundle/SecurityBundle.php | 9 +- .../Factory/AccessTokenFactoryTest.php | 102 +++++++++- .../Tests/Functional/AccessTokenTest.php | 41 ++++ .../app/AccessToken/config_oidc.yml | 34 ++++ .../Bundle/SecurityBundle/composer.json | 8 +- .../Component/Security/Core/CHANGELOG.md | 6 + .../Security/Core/Tests/User/OidcUserTest.php | 176 +++++++++++++++++ .../AttributesBasedUserProviderInterface.php | 32 +++ .../Component/Security/Core/User/OidcUser.php | 184 ++++++++++++++++++ .../Component/Security/Core/composer.json | 1 + .../Exception/InvalidSignatureException.php | 27 +++ .../Oidc/Exception/MissingClaimException.php | 27 +++ .../AccessToken/Oidc/OidcTokenHandler.php | 105 ++++++++++ .../Http/AccessToken/Oidc/OidcTrait.php | 53 +++++ .../Oidc/OidcUserInfoTokenHandler.php | 61 ++++++ .../AccessTokenAuthenticator.php | 2 +- .../Passport/Badge/UserBadge.php | 15 +- .../Http/Authenticator/Passport/Passport.php | 16 +- .../Component/Security/Http/CHANGELOG.md | 4 + .../AccessToken/Oidc/OidcTokenHandlerTest.php | 173 ++++++++++++++++ .../Oidc/OidcUserInfoTokenHandlerTest.php | 87 +++++++++ .../Component/Security/Http/composer.json | 6 +- 31 files changed, 1534 insertions(+), 17 deletions(-) create mode 100644 src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/AccessToken/OidcTokenHandlerFactory.php create mode 100644 src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/AccessToken/OidcUserInfoTokenHandlerFactory.php create mode 100644 src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/AccessToken/ServiceTokenHandlerFactory.php create mode 100644 src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/AccessToken/TokenHandlerFactoryInterface.php create mode 100644 src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/SignatureAlgorithmFactory.php create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_oidc.yml create mode 100644 src/Symfony/Component/Security/Core/Tests/User/OidcUserTest.php create mode 100644 src/Symfony/Component/Security/Core/User/AttributesBasedUserProviderInterface.php create mode 100644 src/Symfony/Component/Security/Core/User/OidcUser.php create mode 100644 src/Symfony/Component/Security/Http/AccessToken/Oidc/Exception/InvalidSignatureException.php create mode 100644 src/Symfony/Component/Security/Http/AccessToken/Oidc/Exception/MissingClaimException.php create mode 100644 src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcTokenHandler.php create mode 100644 src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcTrait.php create mode 100644 src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcUserInfoTokenHandler.php create mode 100644 src/Symfony/Component/Security/Http/Tests/AccessToken/Oidc/OidcTokenHandlerTest.php create mode 100644 src/Symfony/Component/Security/Http/Tests/AccessToken/Oidc/OidcUserInfoTokenHandlerTest.php 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" },