From 04c53b4bae0557d5f37fc9fc1dd3d5ae8a066e82 Mon Sep 17 00:00:00 2001 From: Florent Morselli Date: Sun, 5 Jan 2025 20:49:23 +0100 Subject: [PATCH] [Security] OAuth2 Introspection Endpoint (RFC7662) In addition to the excellent work of @vincentchalamon #48272, this PR allows getting the data from the OAuth2 Introspection Endpoint. This endpoint is defined in the [RFC7662](https://datatracker.ietf.org/doc/html/rfc7662). It returns the following information that is used to retrieve the user: * If the access token is active * A set of claims that are similar to the OIDC one, including the `sub` or the `username`. --- .../Compiler/UnusedTagsPass.php | 1 + .../Bundle/SecurityBundle/CHANGELOG.md | 1 + .../AccessToken/OidcTokenHandlerFactory.php | 37 +++- .../Resources/config/schema/security-1.0.xsd | 16 ++ .../security_authenticator_access_token.php | 51 ++++++ .../Factory/AccessTokenFactoryTest.php | 84 ++++++++- .../Tests/Functional/AccessTokenTest.php | 161 +++++++++++++++--- .../app/AccessToken/config_oidc.yml | 4 + .../app/AccessToken/config_oidc_jwe.yml | 39 +++++ .../Bundle/SecurityBundle/composer.json | 2 +- .../AccessToken/Oidc/OidcTokenHandler.php | 144 ++++++++++++---- .../Component/Security/Http/CHANGELOG.md | 5 + 12 files changed, 481 insertions(+), 64 deletions(-) create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_oidc_jwe.yml diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php index 45d08a975bd83..c135538c2fba8 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php @@ -85,6 +85,7 @@ class UnusedTagsPass implements CompilerPassInterface 'routing.route_loader', 'scheduler.schedule_provider', 'scheduler.task', + 'security.access_token_handler.oidc.encryption_algorithm', 'security.access_token_handler.oidc.signature_algorithm', 'security.authenticator.login_linker', 'security.expression_language_provider', diff --git a/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md b/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md index ffb44752149b4..ae199536724f0 100644 --- a/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md @@ -5,6 +5,7 @@ CHANGELOG --- * Add `Security::isGrantedForUser()` to test user authorization without relying on the session. For example, users not currently logged in, or while processing a message from a message queue + * Add encryption support to `OidcTokenHandler` (JWE) 7.2 --- diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/AccessToken/OidcTokenHandlerFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/AccessToken/OidcTokenHandlerFactory.php index e3d8db49e14be..0f5bc2895b6d4 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/AccessToken/OidcTokenHandlerFactory.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/AccessToken/OidcTokenHandlerFactory.php @@ -41,6 +41,22 @@ public function create(ContainerBuilder $container, string $id, array|string $co $tokenHandlerDefinition->replaceArgument(1, (new ChildDefinition('security.access_token_handler.oidc.jwkset')) ->replaceArgument(0, $config['keyset']) ); + + if ($config['encryption']['enabled']) { + $algorithmManager = (new ChildDefinition('security.access_token_handler.oidc.encryption')) + ->replaceArgument(0, $config['encryption']['algorithms']); + $keyset = (new ChildDefinition('security.access_token_handler.oidc.jwkset')) + ->replaceArgument(0, $config['encryption']['keyset']); + + $tokenHandlerDefinition->addMethodCall( + 'enabledJweSupport', + [ + $keyset, + $algorithmManager, + $config['encryption']['enforce'], + ] + ); + } } public function getKey(): string @@ -112,9 +128,28 @@ public function addConfiguration(NodeBuilder $node): void ->setDeprecated('symfony/security-bundle', '7.1', 'The "%node%" option is deprecated and will be removed in 8.0. Use the "keyset" option instead.') ->end() ->scalarNode('keyset') - ->info('JSON-encoded JWKSet used to sign the token (must contain a list of valid keys).') + ->info('JSON-encoded JWKSet used to sign the token (must contain a list of valid public keys).') ->isRequired() ->end() + ->arrayNode('encryption') + ->canBeEnabled() + ->children() + ->booleanNode('enforce') + ->info('When enabled, the token shall be encrypted.') + ->defaultFalse() + ->end() + ->arrayNode('algorithms') + ->info('Algorithms used to decrypt the token.') + ->isRequired() + ->requiresAtLeastOneElement() + ->scalarPrototype()->end() + ->end() + ->scalarNode('keyset') + ->info('JSON-encoded JWKSet used to decrypt the token (must contain a list of valid private keys).') + ->isRequired() + ->end() + ->end() + ->end() ->end() ->end() ; diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/schema/security-1.0.xsd b/src/Symfony/Bundle/SecurityBundle/Resources/config/schema/security-1.0.xsd index ef10635e2ff99..ca7d4e8bc98c7 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/schema/security-1.0.xsd +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/schema/security-1.0.xsd @@ -338,6 +338,7 @@ + @@ -345,6 +346,21 @@ + + + + + + + + + + + + + + + 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 c0fced49ae9ca..d3d6f60850ffe 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 @@ -15,6 +15,15 @@ use Jose\Component\Core\AlgorithmManagerFactory; use Jose\Component\Core\JWK; use Jose\Component\Core\JWKSet; +use Jose\Component\Encryption\Algorithm\ContentEncryption\A128CBCHS256; +use Jose\Component\Encryption\Algorithm\ContentEncryption\A128GCM; +use Jose\Component\Encryption\Algorithm\ContentEncryption\A192CBCHS384; +use Jose\Component\Encryption\Algorithm\ContentEncryption\A192GCM; +use Jose\Component\Encryption\Algorithm\ContentEncryption\A256CBCHS512; +use Jose\Component\Encryption\Algorithm\ContentEncryption\A256GCM; +use Jose\Component\Encryption\Algorithm\KeyEncryption\ECDHES; +use Jose\Component\Encryption\Algorithm\KeyEncryption\ECDHSS; +use Jose\Component\Encryption\Algorithm\KeyEncryption\RSAOAEP; use Jose\Component\Signature\Algorithm\ES256; use Jose\Component\Signature\Algorithm\ES384; use Jose\Component\Signature\Algorithm\ES512; @@ -135,5 +144,47 @@ ->set('security.access_token_handler.oidc.signature.PS512', PS512::class) ->tag('security.access_token_handler.oidc.signature_algorithm') + + // Encryption + // Note that - all xxxKW algorithms are not defined as an extra dependency is required + // - The RSA_1.5 is missing as deprecated + ->set('security.access_token_handler.oidc.encryption_algorithm_manager_factory', AlgorithmManagerFactory::class) + ->args([ + tagged_iterator('security.access_token_handler.oidc.encryption_algorithm'), + ]) + + ->set('security.access_token_handler.oidc.encryption', AlgorithmManager::class) + ->abstract() + ->factory([service('security.access_token_handler.oidc.encryption_algorithm_manager_factory'), 'create']) + ->args([ + abstract_arg('encryption algorithms'), + ]) + + ->set('security.access_token_handler.oidc.encryption.RSAOAEP', RSAOAEP::class) + ->tag('security.access_token_handler.oidc.encryption_algorithm') + + ->set('security.access_token_handler.oidc.encryption.ECDHES', ECDHES::class) + ->tag('security.access_token_handler.oidc.encryption_algorithm') + + ->set('security.access_token_handler.oidc.encryption.ECDHSS', ECDHSS::class) + ->tag('security.access_token_handler.oidc.encryption_algorithm') + + ->set('security.access_token_handler.oidc.encryption.A128CBCHS256', A128CBCHS256::class) + ->tag('security.access_token_handler.oidc.encryption_algorithm') + + ->set('security.access_token_handler.oidc.encryption.A192CBCHS384', A192CBCHS384::class) + ->tag('security.access_token_handler.oidc.encryption_algorithm') + + ->set('security.access_token_handler.oidc.encryption.A256CBCHS512', A256CBCHS512::class) + ->tag('security.access_token_handler.oidc.encryption_algorithm') + + ->set('security.access_token_handler.oidc.encryption.A128GCM', A128GCM::class) + ->tag('security.access_token_handler.oidc.encryption_algorithm') + + ->set('security.access_token_handler.oidc.encryption.A192GCM', A192GCM::class) + ->tag('security.access_token_handler.oidc.encryption_algorithm') + + ->set('security.access_token_handler.oidc.encryption.A256GCM', A256GCM::class) + ->tag('security.access_token_handler.oidc.encryption_algorithm') ; }; 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 ce105759d71be..2d59f0ae31496 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Security/Factory/AccessTokenFactoryTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Security/Factory/AccessTokenFactoryTest.php @@ -113,7 +113,7 @@ public function testInvalidOidcTokenHandlerConfigurationKeyMissing() $factory = new AccessTokenFactory($this->createTokenHandlerFactories()); $this->expectException(InvalidConfigurationException::class); - $this->expectExceptionMessage('The child config "keyset" under "access_token.token_handler.oidc" must be configured: JSON-encoded JWKSet used to sign the token (must contain a list of valid keys).'); + $this->expectExceptionMessage('The child config "keyset" under "access_token.token_handler.oidc" must be configured: JSON-encoded JWKSet used to sign the token (must contain a list of valid public keys).'); $this->processConfig($config, $factory); } @@ -257,6 +257,88 @@ public function testOidcTokenHandlerConfigurationWithMultipleAlgorithms() $this->assertEquals($expected, $container->getDefinition('security.access_token_handler.firewall1')->getArguments()); } + public function testOidcTokenHandlerConfigurationWithEncryption() + { + $container = new ContainerBuilder(); + $jwkset = '{"keys":[{"kty":"EC","crv":"P-256","x":"FtgMtrsKDboRO-Zo0XC7tDJTATHVmwuf9GK409kkars","y":"rWDE0ERU2SfwGYCo1DWWdgFEbZ0MiAXLRBBOzBgs_jY","d":"4G7bRIiKih0qrFxc0dtvkHUll19tTyctoCR3eIbOrO0"},{"kty":"EC","crv":"P-256","x":"0QEAsI1wGI-dmYatdUZoWSRWggLEpyzopuhwk-YUnA4","y":"KYl-qyZ26HobuYwlQh-r0iHX61thfP82qqEku7i0woo","d":"iA_TV2zvftni_9aFAQwFO_9aypfJFCSpcCyevDvz220"}]}'; + $config = [ + 'token_handler' => [ + 'oidc' => [ + 'algorithms' => ['RS256', 'ES256'], + 'issuers' => ['https://www.example.com'], + 'audience' => 'audience', + 'keyset' => $jwkset, + 'encryption' => [ + 'enabled' => true, + 'keyset' => $jwkset, + 'algorithms' => ['RSA-OAEP', 'RSA1_5'], + ], + ], + ], + ]; + + $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 testInvalidOidcTokenHandlerConfigurationMissingEncryptionKeyset() + { + $jwkset = '{"keys":[{"kty":"EC","crv":"P-256","x":"FtgMtrsKDboRO-Zo0XC7tDJTATHVmwuf9GK409kkars","y":"rWDE0ERU2SfwGYCo1DWWdgFEbZ0MiAXLRBBOzBgs_jY","d":"4G7bRIiKih0qrFxc0dtvkHUll19tTyctoCR3eIbOrO0"},{"kty":"EC","crv":"P-256","x":"0QEAsI1wGI-dmYatdUZoWSRWggLEpyzopuhwk-YUnA4","y":"KYl-qyZ26HobuYwlQh-r0iHX61thfP82qqEku7i0woo","d":"iA_TV2zvftni_9aFAQwFO_9aypfJFCSpcCyevDvz220"}]}'; + $config = [ + 'token_handler' => [ + 'oidc' => [ + 'algorithms' => ['RS256', 'ES256'], + 'issuers' => ['https://www.example.com'], + 'audience' => 'audience', + 'keyset' => $jwkset, + 'encryption' => [ + 'enabled' => true, + 'algorithms' => ['RSA-OAEP', 'RSA1_5'], + ], + ], + ], + ]; + + $factory = new AccessTokenFactory($this->createTokenHandlerFactories()); + + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('The child config "keyset" under "access_token.token_handler.oidc.encryption" must be configured: JSON-encoded JWKSet used to decrypt the token (must contain a list of valid private keys).'); + + $this->processConfig($config, $factory); + } + + public function testInvalidOidcTokenHandlerConfigurationMissingAlgorithm() + { + $jwkset = '{"keys":[{"kty":"EC","crv":"P-256","x":"FtgMtrsKDboRO-Zo0XC7tDJTATHVmwuf9GK409kkars","y":"rWDE0ERU2SfwGYCo1DWWdgFEbZ0MiAXLRBBOzBgs_jY","d":"4G7bRIiKih0qrFxc0dtvkHUll19tTyctoCR3eIbOrO0"},{"kty":"EC","crv":"P-256","x":"0QEAsI1wGI-dmYatdUZoWSRWggLEpyzopuhwk-YUnA4","y":"KYl-qyZ26HobuYwlQh-r0iHX61thfP82qqEku7i0woo","d":"iA_TV2zvftni_9aFAQwFO_9aypfJFCSpcCyevDvz220"}]}'; + $config = [ + 'token_handler' => [ + 'oidc' => [ + 'algorithms' => ['RS256', 'ES256'], + 'issuers' => ['https://www.example.com'], + 'audience' => 'audience', + 'keyset' => $jwkset, + 'encryption' => [ + 'enabled' => true, + 'keyset' => $jwkset, + 'algorithms' => [], + ], + ], + ], + ]; + + $factory = new AccessTokenFactory($this->createTokenHandlerFactories()); + + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('The path "access_token.token_handler.oidc.encryption.algorithms" should have at least 1 element(s) defined.'); + + $this->processConfig($config, $factory); + } + public function testOidcUserInfoTokenHandlerConfigurationWithExistingClient() { $container = new ContainerBuilder(); diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/AccessTokenTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/AccessTokenTest.php index 8e87cd5495412..aab4c4bc9efa4 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/AccessTokenTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/AccessTokenTest.php @@ -13,9 +13,13 @@ use Jose\Component\Core\AlgorithmManager; use Jose\Component\Core\JWK; +use Jose\Component\Encryption\Algorithm\ContentEncryption\A128GCM; +use Jose\Component\Encryption\Algorithm\KeyEncryption\ECDHES; +use Jose\Component\Encryption\JWEBuilder; +use Jose\Component\Encryption\Serializer\CompactSerializer as JweCompactSerializer; use Jose\Component\Signature\Algorithm\ES256; use Jose\Component\Signature\JWSBuilder; -use Jose\Component\Signature\Serializer\CompactSerializer; +use Jose\Component\Signature\Serializer\CompactSerializer as JwsCompactSerializer; use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\Response\MockResponse; @@ -347,43 +351,55 @@ public function testCustomUserLoader() $this->assertSame(['message' => 'Welcome @dunglas!'], json_decode($response->getContent(), true)); } + /** + * @dataProvider validAccessTokens + */ + public function testOidcSuccess(string $token) + { + $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)); + } + + /** + * @dataProvider invalidAccessTokens + */ + public function testOidcFailure(string $token) + { + $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(401, $response->getStatusCode()); + $this->assertSame('Bearer realm="My API",error="invalid_token",error_description="Invalid credentials."', $response->headers->get('WWW-Authenticate')); + } + /** * @requires extension openssl */ - public function testOidcSuccess() + public function testOidcFailureWithJweEnforced() { - $time = time(); - $claims = [ - 'iat' => $time, - 'nbf' => $time, - 'exp' => $time + 3600, + $client = $this->createClient(['test_case' => 'AccessToken', 'root_config' => 'config_oidc_jwe.yml']); + $token = self::createJws([ + 'iat' => time() - 1, + 'nbf' => time() - 1, + '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)); + $this->assertSame(401, $response->getStatusCode()); + $this->assertSame('Bearer realm="My API",error="invalid_token",error_description="Invalid credentials."', $response->headers->get('WWW-Authenticate')); } public function testCasSuccess() @@ -408,4 +424,97 @@ public function testCasSuccess() $this->assertSame(200, $response->getStatusCode()); $this->assertSame(['message' => 'Welcome @dunglas!'], json_decode($response->getContent(), true)); } + + public function validAccessTokens(): array + { + if (!\extension_loaded('openssl')) { + return []; + } + $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', + ]; + $jws = $this->createJws($claims); + $jwe = $this->createJwe($jws); + + return [ + [$jws], + [$jwe], + ]; + } + + public static function invalidAccessTokens(): array + { + if (!\extension_loaded('openssl')) { + return []; + } + $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', + ]; + + return [ + [self::createJws([...$claims, 'aud' => 'Invalid Audience'])], + [self::createJws([...$claims, 'iss' => 'Invalid Issuer'])], + [self::createJws([...$claims, 'exp' => $time - 3600])], + [self::createJws([...$claims, 'nbf' => $time + 3600])], + [self::createJws([...$claims, 'iat' => $time + 3600])], + [self::createJws([...$claims, 'username' => 'Invalid Username'])], + [self::createJwe(self::createJws($claims), ['exp' => $time - 3600])], + [self::createJwe(self::createJws($claims), ['cty' => 'x-specific'])], + ]; + } + + private static function createJws(array $claims, array $header = []): string + { + return (new JwsCompactSerializer())->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', + ]), [...$header, 'alg' => 'ES256']) + ->build() + ); + } + + private static function createJwe(string $input, array $header = []): string + { + $jwk = new JWK([ + 'kty' => 'EC', + 'use' => 'enc', + 'crv' => 'P-256', + 'kid' => 'enc-1720876375', + 'x' => '4P27-OB2s5ZP3Zt5ExxQ9uFrgnGaMK6wT1oqd5bJozQ', + 'y' => 'CNh-ZbKJBvz6hJ8JOulXclACP2OuoO2PtqT6WC8tLcU', + ]); + + return (new JweCompactSerializer())->serialize( + (new JWEBuilder(new AlgorithmManager([ + new ECDHES(), new A128GCM(), + ])))->create() + ->withPayload($input) + ->withSharedProtectedHeader(['alg' => 'ECDH-ES', 'enc' => 'A128GCM', ...$header]) + // tip: use https://mkjwk.org/ to generate a JWK + ->addRecipient($jwk) + ->build() + ); + } } 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 index 68f8a1f9dd47a..94b46501544dd 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_oidc.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_oidc.yml @@ -27,6 +27,10 @@ security: algorithm: 'ES256' # tip: use https://mkjwk.org/ to generate a JWK keyset: '{"keys":[{"kty":"EC","d":"iA_TV2zvftni_9aFAQwFO_9aypfJFCSpcCyevDvz220","crv":"P-256","x":"0QEAsI1wGI-dmYatdUZoWSRWggLEpyzopuhwk-YUnA4","y":"KYl-qyZ26HobuYwlQh-r0iHX61thfP82qqEku7i0woo"}]}' + encryption: + enabled: true + algorithms: ['ECDH-ES', 'A128GCM'] + keyset: '{"keys": [{"kty": "EC","d": "YG0HnRsaYv2cUj7TpgHcRX1poL9l4cskIuOi1gXv0Dg","use": "enc","crv": "P-256","kid": "enc-1720876375","x": "4P27-OB2s5ZP3Zt5ExxQ9uFrgnGaMK6wT1oqd5bJozQ","y": "CNh-ZbKJBvz6hJ8JOulXclACP2OuoO2PtqT6WC8tLcU","alg": "ECDH-ES"}]}' token_extractors: 'header' realm: 'My API' diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_oidc_jwe.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_oidc_jwe.yml new file mode 100644 index 0000000000000..7d17d073df9cc --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_oidc_jwe.yml @@ -0,0 +1,39 @@ +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' + issuers: [ 'https://www.example.com' ] + algorithm: 'ES256' + # tip: use https://mkjwk.org/ to generate a JWK + keyset: '{"keys":[{"kty":"EC","d":"iA_TV2zvftni_9aFAQwFO_9aypfJFCSpcCyevDvz220","crv":"P-256","x":"0QEAsI1wGI-dmYatdUZoWSRWggLEpyzopuhwk-YUnA4","y":"KYl-qyZ26HobuYwlQh-r0iHX61thfP82qqEku7i0woo"}]}' + encryption: + enabled: true + enforce: true + algorithms: ['ECDH-ES', 'A128GCM'] + keyset: '{"keys": [{"kty": "EC","d": "YG0HnRsaYv2cUj7TpgHcRX1poL9l4cskIuOi1gXv0Dg","use": "enc","crv": "P-256","kid": "enc-1720876375","x": "4P27-OB2s5ZP3Zt5ExxQ9uFrgnGaMK6wT1oqd5bJozQ","y": "CNh-ZbKJBvz6hJ8JOulXclACP2OuoO2PtqT6WC8tLcU","alg": "ECDH-ES"}]}' + 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 2b4d4b0caf9ba..fa5cb52ff04b5 100644 --- a/src/Symfony/Bundle/SecurityBundle/composer.json +++ b/src/Symfony/Bundle/SecurityBundle/composer.json @@ -28,7 +28,7 @@ "symfony/password-hasher": "^6.4|^7.0", "symfony/security-core": "^7.3", "symfony/security-csrf": "^6.4|^7.0", - "symfony/security-http": "^7.2", + "symfony/security-http": "^7.3", "symfony/service-contracts": "^2.5|^3" }, "require-dev": { diff --git a/src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcTokenHandler.php b/src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcTokenHandler.php index 69e739d2fef40..8260470cc2597 100644 --- a/src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcTokenHandler.php +++ b/src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcTokenHandler.php @@ -17,9 +17,13 @@ use Jose\Component\Core\AlgorithmManager; use Jose\Component\Core\JWK; use Jose\Component\Core\JWKSet; +use Jose\Component\Encryption\JWEDecrypter; +use Jose\Component\Encryption\JWETokenSupport; +use Jose\Component\Encryption\Serializer\CompactSerializer as JweCompactSerializer; +use Jose\Component\Encryption\Serializer\JWESerializerManager; use Jose\Component\Signature\JWSTokenSupport; use Jose\Component\Signature\JWSVerifier; -use Jose\Component\Signature\Serializer\CompactSerializer; +use Jose\Component\Signature\Serializer\CompactSerializer as JwsCompactSerializer; use Jose\Component\Signature\Serializer\JWSSerializerManager; use Psr\Clock\ClockInterface; use Psr\Log\LoggerInterface; @@ -37,10 +41,13 @@ final class OidcTokenHandler implements AccessTokenHandlerInterface { use OidcTrait; + private ?JWKSet $decryptionKeyset = null; + private ?AlgorithmManager $decryptionAlgorithms = null; + private bool $enforceEncryption = false; public function __construct( private Algorithm|AlgorithmManager $signatureAlgorithm, - private JWK|JWKSet $jwkset, + private JWK|JWKSet $signatureKeyset, private string $audience, private array $issuers, private string $claim = 'sub', @@ -51,12 +58,19 @@ public function __construct( trigger_deprecation('symfony/security-http', '7.1', 'First argument must be instance of %s, %s given.', AlgorithmManager::class, Algorithm::class); $this->signatureAlgorithm = new AlgorithmManager([$signatureAlgorithm]); } - if ($jwkset instanceof JWK) { + if ($signatureKeyset instanceof JWK) { trigger_deprecation('symfony/security-http', '7.1', 'Second argument must be instance of %s, %s given.', JWKSet::class, JWK::class); - $this->jwkset = new JWKSet([$jwkset]); + $this->signatureKeyset = new JWKSet([$signatureKeyset]); } } + public function enabledJweSupport(JWKSet $decryptionKeyset, AlgorithmManager $decryptionAlgorithms, bool $enforceEncryption): void + { + $this->decryptionKeyset = $decryptionKeyset; + $this->decryptionAlgorithms = $decryptionAlgorithms; + $this->enforceEncryption = $enforceEncryption; + } + public function getUserBadgeFrom(string $accessToken): UserBadge { if (!class_exists(JWSVerifier::class) || !class_exists(Checker\HeaderCheckerManager::class)) { @@ -64,37 +78,9 @@ public function getUserBadgeFrom(string $accessToken): UserBadge } try { - // Decode the token - $jwsVerifier = new JWSVerifier($this->signatureAlgorithm); - $serializerManager = new JWSSerializerManager([new CompactSerializer()]); - $jws = $serializerManager->unserialize($accessToken); - $claims = json_decode($jws->getPayload(), true); - - // Verify the signature - if (!$jwsVerifier->verifyWithKeySet($jws, $this->jwkset, 0)) { - throw new InvalidSignatureException(); - } - - // Verify the headers - $headerCheckerManager = new Checker\HeaderCheckerManager([ - new Checker\AlgorithmChecker($this->signatureAlgorithm->list()), - ], [ - new JWSTokenSupport(), - ]); - // if this check fails, an InvalidHeaderException is thrown - $headerCheckerManager->check($jws, 0); - - // Verify the claims - $checkers = [ - new Checker\IssuedAtChecker(clock: $this->clock, allowedTimeDrift: 0, protectedHeaderOnly: false), - new Checker\NotBeforeChecker(clock: $this->clock, allowedTimeDrift: 0, protectedHeaderOnly: false), - new Checker\ExpirationTimeChecker(clock: $this->clock, allowedTimeDrift: 0, protectedHeaderOnly: false), - new Checker\AudienceChecker($this->audience), - new Checker\IssuerChecker($this->issuers), - ]; - $claimCheckerManager = new ClaimCheckerManager($checkers); - // if this check fails, an InvalidClaimException is thrown - $claimCheckerManager->check($claims); + $accessToken = $this->decryptIfNeeded($accessToken); + $claims = $this->loadAndVerifyJws($accessToken); + $this->verifyClaims($claims); if (empty($claims[$this->claim])) { throw new MissingClaimException(\sprintf('"%s" claim not found.', $this->claim)); @@ -111,4 +97,92 @@ public function getUserBadgeFrom(string $accessToken): UserBadge throw new BadCredentialsException('Invalid credentials.', $e->getCode(), $e); } } + + private function loadAndVerifyJws(string $accessToken): array + { + // Decode the token + $jwsVerifier = new JWSVerifier($this->signatureAlgorithm); + $serializerManager = new JWSSerializerManager([new JwsCompactSerializer()]); + $jws = $serializerManager->unserialize($accessToken); + + // Verify the signature + if (!$jwsVerifier->verifyWithKeySet($jws, $this->signatureKeyset, 0)) { + throw new InvalidSignatureException(); + } + + $headerCheckerManager = new Checker\HeaderCheckerManager([ + new Checker\AlgorithmChecker($this->signatureAlgorithm->list()), + ], [ + new JWSTokenSupport(), + ]); + // if this check fails, an InvalidHeaderException is thrown + $headerCheckerManager->check($jws, 0); + + return json_decode($jws->getPayload(), true); + } + + private function verifyClaims(array $claims): array + { + // Verify the claims + $checkers = [ + new Checker\IssuedAtChecker(clock: $this->clock, allowedTimeDrift: 0, protectedHeaderOnly: true), + new Checker\NotBeforeChecker(clock: $this->clock, allowedTimeDrift: 0, protectedHeaderOnly: true), + new Checker\ExpirationTimeChecker(clock: $this->clock, allowedTimeDrift: 0, protectedHeaderOnly: true), + new Checker\AudienceChecker($this->audience), + new Checker\IssuerChecker($this->issuers), + ]; + $claimCheckerManager = new ClaimCheckerManager($checkers); + + // if this check fails, an InvalidClaimException is thrown + return $claimCheckerManager->check($claims); + } + + private function decryptIfNeeded(string $accessToken): string + { + if (null === $this->decryptionKeyset || null === $this->decryptionAlgorithms) { + $this->logger?->debug('The encrypted tokens (JWE) are not supported. Skipping.'); + + return $accessToken; + } + + $jweHeaderChecker = new Checker\HeaderCheckerManager( + [ + new Checker\AlgorithmChecker($this->decryptionAlgorithms->list()), + new Checker\CallableChecker('enc', fn ($value) => \in_array($value, $this->decryptionAlgorithms->list())), + new Checker\CallableChecker('cty', fn ($value) => 'JWT' === $value), + new Checker\IssuedAtChecker(clock: $this->clock, allowedTimeDrift: 0, protectedHeaderOnly: true), + new Checker\NotBeforeChecker(clock: $this->clock, allowedTimeDrift: 0, protectedHeaderOnly: true), + new Checker\ExpirationTimeChecker(clock: $this->clock, allowedTimeDrift: 0, protectedHeaderOnly: true), + ], + [new JWETokenSupport()] + ); + $jweDecrypter = new JWEDecrypter($this->decryptionAlgorithms, null); + $serializerManager = new JWESerializerManager([new JweCompactSerializer()]); + try { + $jwe = $serializerManager->unserialize($accessToken); + $jweHeaderChecker->check($jwe, 0); + $result = $jweDecrypter->decryptUsingKeySet($jwe, $this->decryptionKeyset, 0); + if (false === $result) { + throw new \RuntimeException('The JWE could not be decrypted.'); + } + + $payload = $jwe->getPayload(); + if (null === $payload) { + throw new \RuntimeException('The JWE payload is empty.'); + } + + return $payload; + } catch (\InvalidArgumentException|\RuntimeException $e) { + if ($this->enforceEncryption) { + $this->logger?->error('An error occurred while decrypting the token.', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + throw new BadCredentialsException('Encrypted token is required.', 0, $e); + } + $this->logger?->debug('The token decryption failed. Skipping as not mandatory.'); + + return $accessToken; + } + } } diff --git a/src/Symfony/Component/Security/Http/CHANGELOG.md b/src/Symfony/Component/Security/Http/CHANGELOG.md index e9985ad13192b..8f6902f29c0e0 100644 --- a/src/Symfony/Component/Security/Http/CHANGELOG.md +++ b/src/Symfony/Component/Security/Http/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +7.3 +--- + + * Add encryption support to `OidcTokenHandler` (JWE) + 7.2 --- 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