├── SECURITY.md ├── Features ├── api_anonymous.feature ├── jwt_payload.feature ├── api_admin1.feature ├── api_user1.feature ├── jwt_error_support.feature └── jwt_missing_claims.feature ├── SpomkyLabsLexikJoseBundle.php ├── LICENSE ├── Checker ├── AlgHeaderChecker.php ├── EncHeaderChecker.php └── IssuerChecker.php ├── Resources └── config │ ├── encryption_services.php │ └── services.php ├── DependencyInjection ├── Compiler │ └── EncryptionSupportCompilerPass.php ├── Configuration.php └── SpomkyLabsLexikJoseExtension.php ├── composer.json └── Encoder └── LexikJoseEncoder.php /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Supported | 6 | |---------|--------------------| 7 | | 4.0.x | :white_check_mark: | 8 | | 3.0.x | :white_check_mark: | 9 | | < 3.0.x | :x: | 10 | 11 | ## Reporting a Vulnerability 12 | 13 | If you think you have found a security issue, DO NOT open an issue. You MUST submit your issue at https://gitter.im/Spomky/. 14 | -------------------------------------------------------------------------------- /Features/api_anonymous.feature: -------------------------------------------------------------------------------- 1 | Feature: An anonymous user can access on unprotected routes 2 | 3 | Scenario: The user is not authenticated and access on an unprotected route 4 | When I am on the page "https://www.example.test/api/anonymous" 5 | Then I should see "Hello anonymous!" 6 | 7 | Scenario: The user is not authenticated and access on a protected route 8 | When I am on the page "https://www.example.test/api/hello" 9 | Then the response status code should be 401 10 | 11 | Scenario: The user is not authenticated and access on a protected route 12 | When I am on the page "https://www.example.test/api/admin" 13 | Then the response status code should be 401 14 | -------------------------------------------------------------------------------- /SpomkyLabsLexikJoseBundle.php: -------------------------------------------------------------------------------- 1 | addCompilerPass(new EncryptionSupportCompilerPass()); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-2017 Spomky-Labs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Checker/AlgHeaderChecker.php: -------------------------------------------------------------------------------- 1 | algorithm !== $value) { 24 | throw new InvalidHeaderException(sprintf('The algorithm "%s" is not known.', $value), 'alg', $value); 25 | } 26 | } 27 | 28 | /** 29 | * {@inheritdoc} 30 | */ 31 | public function supportedHeader(): string 32 | { 33 | return 'alg'; 34 | } 35 | 36 | /** 37 | * {@inheritdoc} 38 | */ 39 | public function protectedHeaderOnly(): bool 40 | { 41 | return true; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Checker/EncHeaderChecker.php: -------------------------------------------------------------------------------- 1 | algorithm !== $value) { 24 | throw new InvalidHeaderException(sprintf('The algorithm "%s" is not known.', $value), 'enc', $value); 25 | } 26 | } 27 | 28 | /** 29 | * {@inheritdoc} 30 | */ 31 | public function supportedHeader(): string 32 | { 33 | return 'enc'; 34 | } 35 | 36 | /** 37 | * {@inheritdoc} 38 | */ 39 | public function protectedHeaderOnly(): bool 40 | { 41 | return true; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Features/jwt_payload.feature: -------------------------------------------------------------------------------- 1 | Feature: A user can authenticate against a website 2 | In order to authenticate a user 3 | A JWT is issued after the user credentials are verified by the website 4 | 5 | Background: I am logged in as admin1 and I have a token 6 | Given I am on "https://www.example.test/login" 7 | And I fill in "username" with "admin1" 8 | And I fill in "password" with "admin1" 9 | And I press "login" 10 | And the response status code should be 200 11 | And the response content-type should be "application/json" 12 | And the response should contain a token 13 | And I store the token 14 | 15 | Scenario: The token must contain all claims and custom claims 16 | Given the token must contain the claim "exp" 17 | And the token must contain the claim "jti" 18 | And the token must contain the claim "iat" 19 | And the token must contain the claim "username" with value "admin1" 20 | And the token must contain the claim "ip" with value "127.0.0.1" 21 | And the token must contain the claim "iss" with value "https://my.super-service.org/" 22 | And the token must contain the claim "aud" with value "MyProject1" 23 | -------------------------------------------------------------------------------- /Checker/IssuerChecker.php: -------------------------------------------------------------------------------- 1 | issuer !== $value) { 28 | throw new Exception(sprintf('The issuer "%s" is not allowed.', $value)); 29 | } 30 | } 31 | 32 | public function checkHeader(mixed $value): void 33 | { 34 | $this->checkClaim($value); 35 | } 36 | 37 | /** 38 | * {@inheritdoc} 39 | */ 40 | public function supportedHeader(): string 41 | { 42 | return 'iss'; 43 | } 44 | 45 | /** 46 | * {@inheritdoc} 47 | */ 48 | public function protectedHeaderOnly(): bool 49 | { 50 | return true; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Resources/config/encryption_services.php: -------------------------------------------------------------------------------- 1 | services() 11 | ->defaults() 12 | ->private() 13 | ->autoconfigure() 14 | ->autowire() 15 | ; 16 | 17 | $container->set('spomkylabs_lexik_jose_checker_key_encryption_algorithm') 18 | ->class(AlgHeaderChecker::class) 19 | ->args(['%lexik_jose_bridge.encoder.encryption.key_encryption_algorithm%']) 20 | ->tag('jose.checker.header', [ 21 | 'alias' => 'lexik_jose_key_encryption_algorithm', 22 | ]) 23 | ; 24 | 25 | $container->set('spomkylabs_lexik_jose_checker_content_encryption_algorithm') 26 | ->class(EncHeaderChecker::class) 27 | ->args(['%lexik_jose_bridge.encoder.encryption.content_encryption_algorithm%']) 28 | ->tag('jose.checker.header', [ 29 | 'alias' => 'lexik_jose_content_encryption_algorithm', 30 | ]) 31 | ; 32 | }; 33 | -------------------------------------------------------------------------------- /Features/api_admin1.feature: -------------------------------------------------------------------------------- 1 | Feature: A user can authenticate against a website 2 | In order to authenticate a user 3 | A JWT is issued after the user credentials are verified by the website 4 | 5 | Background: I am logged in as admin1 and I have a token 6 | Given I am on "https://www.example.test/login" 7 | And I fill in "username" with "admin1" 8 | And I fill in "password" with "admin1" 9 | And I press "login" 10 | And the response status code should be 200 11 | And the response content-type should be "application/json" 12 | And the response should contain a token 13 | And I store the token 14 | 15 | Scenario: The user is authenticated and send a valid request 16 | Given I add the token in the authorization header 17 | When I am on the page "https://www.example.test/api/anonymous" 18 | Then I should see "Hello admin1!" 19 | 20 | Scenario: The user is authenticated and send a valid request 21 | Given I add the token in the authorization header 22 | When I am on the page "https://www.example.test/api/hello" 23 | Then I should see "Hello admin1!" 24 | 25 | Scenario: The user is authenticated and send a valid request 26 | Given I add the token in the authorization header 27 | When I am on the page "https://www.example.test/api/admin" 28 | Then I should see "Hello admin1!" 29 | -------------------------------------------------------------------------------- /Features/api_user1.feature: -------------------------------------------------------------------------------- 1 | Feature: A user can authenticate against a website 2 | In order to authenticate a user 3 | A JWT is issued after the user credentials are verified by the website 4 | 5 | Background: I am logged in as user1 and I have a token 6 | Given I am on "https://www.example.test/login" 7 | And I fill in "username" with "user1" 8 | And I fill in "password" with "user1" 9 | And I press "login" 10 | And the response status code should be 200 11 | And the response content-type should be "application/json" 12 | And the response should contain a token 13 | And I store the token 14 | 15 | Scenario: The user is authenticated and send a valid request 16 | Given I add the token in the authorization header 17 | When I am on the page "https://www.example.test/api/anonymous" 18 | Then the response status code should be 200 19 | And I should see "Hello user1!" 20 | 21 | Scenario: The user is authenticated and send a valid request 22 | Given I add the token in the authorization header 23 | When I am on the page "https://www.example.test/api/hello" 24 | Then the response status code should be 200 25 | And I should see "Hello user1!" 26 | 27 | Scenario: The user is authenticated and send a valid request but does not have the role ROLE_ADMIN 28 | Given I add the token in the authorization header 29 | When I am on the page "https://www.example.test/api/admin" 30 | Then the response status code should be 403 31 | -------------------------------------------------------------------------------- /DependencyInjection/Compiler/EncryptionSupportCompilerPass.php: -------------------------------------------------------------------------------- 1 | hasDefinition(LexikJoseEncoder::class) === false || $container->getParameter( 17 | 'lexik_jose_bridge.encoder.encryption.enabled' 18 | ) === false) { 19 | return; 20 | } 21 | 22 | $definition = $container->getDefinition(LexikJoseEncoder::class); 23 | 24 | $definition->addMethodCall('enableEncryptionSupport', [ 25 | new Reference('jose.jwe_builder.lexik_jose'), 26 | new Reference('jose.jwe_decrypter.lexik_jose'), 27 | new Reference('jose.header_checker.lexik_jose_encryption'), 28 | new Reference('jose.key_set.lexik_jose_bridge.encryption'), 29 | $container->getParameter('lexik_jose_bridge.encoder.encryption.key_index'), 30 | $container->getParameter('lexik_jose_bridge.encoder.encryption.key_encryption_algorithm'), 31 | $container->getParameter('lexik_jose_bridge.encoder.encryption.content_encryption_algorithm'), 32 | ]); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Features/jwt_error_support.feature: -------------------------------------------------------------------------------- 1 | Feature: The firewall must be able to detect bad tokens 2 | The tokens may have been modified or may have expired 3 | The firewall MUST reject all request with a bad token 4 | 5 | Scenario: The token expired 6 | Given I have an expired, signed and encrypted token 7 | Given I add the token in the authorization header 8 | When I am on the page "https://www.example.test/api/hello" 9 | Then the response status code should be 401 10 | And print last response 11 | And the response should contain "Expired JWT Token" 12 | And the error listener should receive an expired token event 13 | 14 | Scenario: The token is signed but not encrypted 15 | Given I have a valid signed token 16 | Given I add the token in the authorization header 17 | When I am on the page "https://www.example.test/api/hello" 18 | Then the response status code should be 401 19 | And print last response 20 | And the response should contain "Invalid JWT token" 21 | And the error listener should receive an invalid token event 22 | 23 | Scenario: The token has a wrong issuer 24 | Given I have a signed and encrypted token but with wrong issuer 25 | Given I add the token in the authorization header 26 | When I am on the page "https://www.example.test/api/hello" 27 | Then the response status code should be 401 28 | And print last response 29 | And the response should contain "Invalid JWT token" 30 | And the error listener should receive an invalid token event 31 | 32 | Scenario: The token has a wrong audience 33 | Given I have a signed and encrypted token but with wrong audience 34 | Given I add the token in the authorization header 35 | When I am on the page "https://www.example.test/api/hello" 36 | Then the response status code should be 401 37 | And print last response 38 | And the response should contain "Invalid JWT token" 39 | And the error listener should receive an invalid token event 40 | -------------------------------------------------------------------------------- /Resources/config/services.php: -------------------------------------------------------------------------------- 1 | services() 14 | ->defaults() 15 | ->private() 16 | ->autoconfigure() 17 | ->autowire() 18 | ; 19 | 20 | $container->set(LexikJoseEncoder::class) 21 | ->args([ 22 | service('jose.jws_builder.lexik_jose'), 23 | service('jose.jws_verifier.lexik_jose'), 24 | service('jose.claim_checker.lexik_jose'), 25 | service('jose.header_checker.lexik_jose_signature'), 26 | service('jose.key_set.lexik_jose_bridge.signature'), 27 | '%lexik_jose_bridge.encoder.key_index%', 28 | '%lexik_jose_bridge.encoder.signature_algorithm%', 29 | '%lexik_jose_bridge.encoder.issuer%', 30 | '%lexik_jose_bridge.encoder.audience%', 31 | '%lexik_jose_bridge.encoder.ttl%', 32 | '%lexik_jose_bridge.encoder.mandatory_claims%', 33 | ]) 34 | ; 35 | 36 | $container->set('spomkylabs_lexik_jose_checker_audience') 37 | ->class(AudienceChecker::class) 38 | ->args(['%lexik_jose_bridge.encoder.audience%']) 39 | ->tag('jose.checker.claim', [ 40 | 'alias' => 'lexik_jose_audience', 41 | ]) 42 | ->tag('jose.checker.header', [ 43 | 'alias' => 'lexik_jose_audience', 44 | ]) 45 | ; 46 | 47 | $container->set('spomkylabs_lexik_jose_checker_issuer') 48 | ->class(IssuerChecker::class) 49 | ->args([['%lexik_jose_bridge.encoder.issuer%']]) 50 | ->tag('jose.checker.claim', [ 51 | 'alias' => 'lexik_jose_issuer', 52 | ]) 53 | ->tag('jose.checker.header', [ 54 | 'alias' => 'lexik_jose_issuer', 55 | ]) 56 | ; 57 | $container->set('spomkylabs_lexik_jose_checker_signature_algorithm') 58 | ->class(AlgHeaderChecker::class) 59 | ->args(['%lexik_jose_bridge.encoder.signature_algorithm%']) 60 | ->tag('jose.checker.header', [ 61 | 'alias' => 'lexik_jose_signature_algorithm', 62 | ]) 63 | ; 64 | }; 65 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spomky-labs/lexik-jose-bridge", 3 | "type": "symfony-bundle", 4 | "description": "Bridge to allow the use of web-token/jwt-framework with the Lexik JWT Authentication Bundle", 5 | "keywords": [ 6 | "Jose", 7 | "Bundle", 8 | "Symfony" 9 | ], 10 | "homepage": "https://github.com/Spomky-Labs/lexik-jose-bridge", 11 | "license": "MIT", 12 | "authors": [ 13 | { 14 | "name": "Florent Morselli", 15 | "homepage": "https://github.com/Spomky" 16 | }, 17 | { 18 | "name": "All contributors", 19 | "homepage": "https://github.com/Spomky-Labs/lexik-jose-bridge/contributors" 20 | } 21 | ], 22 | "require": { 23 | "php": ">=8.1", 24 | "lexik/jwt-authentication-bundle": "^2.0", 25 | "psr/event-dispatcher": "^1.0", 26 | "thecodingmachine/safe": "^2.0", 27 | "web-token/jwt-bundle": "^3.0", 28 | "web-token/jwt-checker": "^3.0", 29 | "web-token/jwt-encryption": "^3.0", 30 | "web-token/jwt-key-mgmt": "^3.0", 31 | "web-token/jwt-signature": "^3.0", 32 | "web-token/jwt-signature-algorithm-rsa": "^3.0" 33 | }, 34 | "require-dev": { 35 | "behat/behat": "^3.0", 36 | "caciobanu/behat-deprecation-extension": "^2.1", 37 | "ekino/phpstan-banned-code": "^1.0", 38 | "friends-of-behat/mink-browserkit-driver": "^1.6", 39 | "friends-of-behat/mink-extension": "^2.3", 40 | "friends-of-behat/symfony-extension": "^2.3", 41 | "phpstan/phpstan": "^1.0", 42 | "phpstan/phpstan-beberlei-assert": "^1.0", 43 | "phpstan/phpstan-deprecation-rules": "^1.0", 44 | "phpstan/phpstan-phpunit": "^1.0", 45 | "phpstan/phpstan-strict-rules": "^1.0", 46 | "rector/rector": "^0.12", 47 | "sensio/framework-extra-bundle": "^6.0", 48 | "symfony/dependency-injection": "^6.0", 49 | "symfony/expression-language": "^6.0", 50 | "symfony/finder": "^6.0", 51 | "symfony/form": "^6.0", 52 | "symfony/monolog-bundle": "^3.7", 53 | "symfony/templating": "^6.0", 54 | "symfony/twig-bundle": "^6.0", 55 | "symfony/var-dumper": "^6.0", 56 | "symplify/easy-coding-standard": "^11.0", 57 | "thecodingmachine/phpstan-safe-rule": "^1.0", 58 | "web-token/jwt-encryption-algorithm-aesgcm": "^3.0", 59 | "web-token/jwt-encryption-algorithm-aesgcmkw": "^3.0", 60 | "web-token/jwt-signature-algorithm-hmac": "^3.0" 61 | }, 62 | "autoload": { 63 | "psr-4": { 64 | "SpomkyLabs\\LexikJoseBundle\\": "" 65 | } 66 | }, 67 | "autoload-dev": { 68 | "psr-4": { 69 | "SpomkyLabs\\TestBundle\\": "Tests/Bundle/TestBundle", 70 | "SpomkyLabs\\LexikJoseBundle\\Features\\Context\\": "Tests/Context" 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Features/jwt_missing_claims.feature: -------------------------------------------------------------------------------- 1 | Feature: The firewall must be able to detect bad tokens 2 | The tokens may be missing claims 3 | The firewall MUST reject all request with a bad token 4 | 5 | Scenario: The token is missing the Expiration Time claim 6 | Given I have a signed and encrypted token but without the "exp" claim 7 | Given I add the token in the authorization header 8 | When I am on the page "https://www.example.test/api/hello" 9 | Then the response status code should be 401 10 | And print last response 11 | And the response should contain "Invalid JWT Token" 12 | And the error listener should receive an invalid token event containing an exception with message "The following claims are mandatory: exp." 13 | 14 | Scenario: The token is missing the Issued At claim 15 | Given I have a signed and encrypted token but without the "iat" claim 16 | Given I add the token in the authorization header 17 | When I am on the page "https://www.example.test/api/hello" 18 | Then the response status code should be 401 19 | And print last response 20 | And the response should contain "Invalid JWT Token" 21 | And the error listener should receive an invalid token event containing an exception with message "The following claims are mandatory: iat." 22 | 23 | Scenario: The token is missing the JWT ID claim 24 | Given I have a signed and encrypted token but without the "jti" claim 25 | Given I add the token in the authorization header 26 | When I am on the page "https://www.example.test/api/hello" 27 | Then the response status code should be 401 28 | And print last response 29 | And the response should contain "Invalid JWT Token" 30 | And the error listener should receive an invalid token event containing an exception with message "The following claims are mandatory: jti." 31 | 32 | Scenario: The token is missing the Issuer claim 33 | Given I have a signed and encrypted token but without the "iss" claim 34 | Given I add the token in the authorization header 35 | When I am on the page "https://www.example.test/api/hello" 36 | Then the response status code should be 401 37 | And print last response 38 | And the response should contain "Invalid JWT Token" 39 | And the error listener should receive an invalid token event containing an exception with message "The following claims are mandatory: iss." 40 | 41 | Scenario: The token is missing the Audience claim 42 | Given I have a signed and encrypted token but without the "aud" claim 43 | Given I add the token in the authorization header 44 | When I am on the page "https://www.example.test/api/hello" 45 | Then the response status code should be 401 46 | And print last response 47 | And the response should contain "Invalid JWT Token" 48 | And the error listener should receive an invalid token event containing an exception with message "The following claims are mandatory: aud." 49 | -------------------------------------------------------------------------------- /DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | getRootNode(); 21 | if (! $rootNode instanceof ArrayNodeDefinition) { 22 | throw new RuntimeException('Invalid root node'); 23 | } 24 | 25 | // @phpstan-ignore-next-line 26 | $rootNode 27 | ->validate() 28 | ->ifTrue(static function (array $config): bool { 29 | return ! isset($config['key_set']) && ! isset($config['key_set_remote']); 30 | }) 31 | ->thenInvalid('You must either configure a "key_set" or a "key_set_remote".') 32 | ->end() 33 | ->addDefaultsIfNotSet() 34 | ->children() 35 | ->scalarNode('server_name') 36 | ->info( 37 | 'The name of the server. The recommended value is the server URL. This value will be used to check the issuer of the token.' 38 | ) 39 | ->isRequired() 40 | ->end() 41 | ->scalarNode('audience') 42 | ->info('The audience of the token. If not set `server_name` will be used.') 43 | ->end() 44 | ->integerNode('ttl') 45 | ->info( 46 | 'The lifetime of a token (in second). For security reasons, a value below 1 hour (3600 sec) is recommended.' 47 | ) 48 | ->min(0) 49 | ->defaultValue(1800) 50 | ->end() 51 | ->scalarNode('key_set') 52 | ->info('Private/Shared keys used by this server to validate signed tokens. Must be a JWKSet object.') 53 | ->end() 54 | ->arrayNode('key_set_remote') 55 | ->children() 56 | ->scalarNode('type') 57 | ->info('The type of the remote key set, either `jku` or `x5u`.') 58 | ->end() 59 | ->scalarNode('url') 60 | ->info('The URL from where the key set should be downloaded.') 61 | ->end() 62 | ->end() 63 | ->end() 64 | ->scalarNode('key_index') 65 | ->info('Index of the key in the key set used to sign the tokens. Could be an integer or the key ID.') 66 | ->isRequired() 67 | ->end() 68 | ->scalarNode('signature_algorithm') 69 | ->info('Signature algorithm used to sign the tokens.') 70 | ->isRequired() 71 | ->end() 72 | ->arrayNode('claim_checked') 73 | ->info('List of aliases to claim checkers.') 74 | ->useAttributeAsKey('name') 75 | ->prototype('scalar') 76 | ->end() 77 | ->treatNullLike([]) 78 | ->treatFalseLike([]) 79 | ->end() 80 | ->arrayNode('mandatory_claims') 81 | ->info('List of claims that must be present.') 82 | ->useAttributeAsKey('name') 83 | ->prototype('scalar') 84 | ->end() 85 | ->defaultValue([]) 86 | ->treatNullLike([]) 87 | ->treatFalseLike([]) 88 | ->end() 89 | ->end() 90 | ; 91 | 92 | $this->addEncryptionSection($rootNode); 93 | 94 | return $treeBuilder; 95 | } 96 | 97 | private function addEncryptionSection(ArrayNodeDefinition $node): void 98 | { 99 | // @phpstan-ignore-next-line 100 | $node 101 | ->addDefaultsIfNotSet() 102 | ->children() 103 | ->arrayNode('encryption') 104 | ->addDefaultsIfNotSet() 105 | ->canBeEnabled() 106 | ->children() 107 | ->scalarNode('key_set') 108 | ->info('Private/ Shared keys used by this server to decrypt the tokens. Must be a JWKSet object.') 109 | ->isRequired() 110 | ->end() 111 | ->scalarNode('key_index') 112 | ->isRequired() 113 | ->info('Index of the key in the key set used to encrypt the tokens. Could be an integer or the key ID.') 114 | ->end() 115 | ->scalarNode('key_encryption_algorithm') 116 | ->isRequired() 117 | ->info('Key encryption algorithm used to encrypt the tokens.') 118 | ->end() 119 | ->scalarNode('content_encryption_algorithm') 120 | ->info('Content encryption algorithm used to encrypt the tokens.') 121 | ->isRequired() 122 | ->end() 123 | ->end() 124 | ->end() 125 | ->end() 126 | ; 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /DependencyInjection/SpomkyLabsLexikJoseExtension.php: -------------------------------------------------------------------------------- 1 | processConfiguration($configuration, $configs); 30 | 31 | if (! isset($config['audience'])) { 32 | $config['audience'] = $config['server_name']; 33 | } 34 | 35 | $container->setParameter('lexik_jose_bridge.encoder.key_index', $config['key_index']); 36 | $container->setParameter('lexik_jose_bridge.encoder.signature_algorithm', $config['signature_algorithm']); 37 | $container->setParameter('lexik_jose_bridge.encoder.issuer', $config['server_name']); 38 | $container->setParameter('lexik_jose_bridge.encoder.audience', $config['audience']); 39 | $container->setParameter('lexik_jose_bridge.encoder.ttl', $config['ttl']); 40 | $container->setParameter('lexik_jose_bridge.encoder.claim_checked', $config['claim_checked']); 41 | $container->setParameter('lexik_jose_bridge.encoder.mandatory_claims', $config['mandatory_claims']); 42 | 43 | $container->setParameter('lexik_jose_bridge.encoder.encryption.enabled', $config['encryption']['enabled']); 44 | if ($config['encryption']['enabled'] === true) { 45 | $container->setParameter( 46 | 'lexik_jose_bridge.encoder.encryption.key_index', 47 | $config['encryption']['key_index'] 48 | ); 49 | $container->setParameter( 50 | 'lexik_jose_bridge.encoder.encryption.key_encryption_algorithm', 51 | $config['encryption']['key_encryption_algorithm'] 52 | ); 53 | $container->setParameter( 54 | 'lexik_jose_bridge.encoder.encryption.content_encryption_algorithm', 55 | $config['encryption']['content_encryption_algorithm'] 56 | ); 57 | $this->loadEncryptionServices($container); 58 | } 59 | 60 | $this->loadServices($container); 61 | } 62 | 63 | public function loadServices(ContainerBuilder $container): void 64 | { 65 | $loader = new PhpFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config')); 66 | $loader->load('services.php'); 67 | } 68 | 69 | public function loadEncryptionServices(ContainerBuilder $container): void 70 | { 71 | $loader = new PhpFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config')); 72 | $loader->load('encryption_services.php'); 73 | } 74 | 75 | public function prepend(ContainerBuilder $container): void 76 | { 77 | $isDebug = (bool) $container->getParameter('kernel.debug'); 78 | $bridgeConfig = $container->getExtensionConfig($this->getAlias()); 79 | 80 | $resolvingBag = $container->getParameterBag(); 81 | $bridgeConfig = $resolvingBag->resolveValue($bridgeConfig); 82 | 83 | $bridgeConfig = $this->processConfiguration(new Configuration(), $bridgeConfig); 84 | 85 | if (! array_key_exists('claim_checked', $bridgeConfig)) { 86 | $bridgeConfig['claim_checked'] = []; 87 | } 88 | $claim_aliases = array_merge( 89 | $bridgeConfig['claim_checked'], 90 | ['exp', 'iat', 'lexik_jose_audience', 'lexik_jose_issuer'] 91 | ); 92 | ConfigurationHelper::addJWSBuilder( 93 | $container, 94 | $this->getAlias(), 95 | [$bridgeConfig['signature_algorithm']], 96 | $isDebug 97 | ); 98 | ConfigurationHelper::addJWSVerifier( 99 | $container, 100 | $this->getAlias(), 101 | [$bridgeConfig['signature_algorithm']], 102 | $isDebug 103 | ); 104 | ConfigurationHelper::addClaimChecker($container, $this->getAlias(), $claim_aliases, $isDebug); 105 | ConfigurationHelper::addHeaderChecker( 106 | $container, 107 | $this->getAlias() . '_signature', 108 | ['lexik_jose_signature_algorithm'] 109 | ); 110 | 111 | if (isset($bridgeConfig['key_set_remote'])) { 112 | ConfigurationHelper::addKeyset( 113 | $container, 114 | 'lexik_jose_bridge.signature', 115 | $bridgeConfig['key_set_remote']['type'], 116 | [ 117 | 'url' => $bridgeConfig['key_set_remote']['url'], 118 | 'is_public' => $isDebug, 119 | ] 120 | ); 121 | } elseif (isset($bridgeConfig['key_set'])) { 122 | ConfigurationHelper::addKeyset($container, 'lexik_jose_bridge.signature', 'jwkset', [ 123 | 'value' => $bridgeConfig['key_set'], 124 | 'is_public' => $isDebug, 125 | ]); 126 | } 127 | 128 | if (isset($bridgeConfig['encryption']['enabled']) && ($bridgeConfig['encryption']['enabled'] === true)) { 129 | $this->enableEncryptionSupport($container, $bridgeConfig, $isDebug); 130 | } 131 | 132 | $lexikConfig = [ 133 | 'encoder' => [ 134 | 'service' => LexikJoseEncoder::class, 135 | ], 136 | ]; 137 | $container->prependExtensionConfig('lexik_jwt_authentication', $lexikConfig); 138 | } 139 | 140 | private function enableEncryptionSupport(ContainerBuilder $container, array $bridgeConfig, bool $isDebug): void 141 | { 142 | ConfigurationHelper::addJWEBuilder( 143 | $container, 144 | $this->getAlias(), 145 | [$bridgeConfig['encryption']['key_encryption_algorithm']], 146 | [$bridgeConfig['encryption']['content_encryption_algorithm']], 147 | ['DEF'], 148 | $isDebug 149 | ); 150 | ConfigurationHelper::addJWEDecrypter( 151 | $container, 152 | $this->getAlias(), 153 | [$bridgeConfig['encryption']['key_encryption_algorithm']], 154 | [$bridgeConfig['encryption']['content_encryption_algorithm']], 155 | ['DEF'], 156 | $isDebug 157 | ); 158 | ConfigurationHelper::addHeaderChecker( 159 | $container, 160 | $this->getAlias() . '_encryption', 161 | ['lexik_jose_audience', 162 | 'lexik_jose_issuer', 163 | 'lexik_jose_key_encryption_algorithm', 164 | 'lexik_jose_content_encryption_algorithm', 165 | ] 166 | ); 167 | ConfigurationHelper::addKeyset($container, 'lexik_jose_bridge.encryption', 'jwkset', [ 168 | 'value' => $bridgeConfig['encryption']['key_set'], 169 | 'is_public' => $isDebug, 170 | ]); 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /Encoder/LexikJoseEncoder.php: -------------------------------------------------------------------------------- 1 | jwsBuilder = $jwsBuilder; 88 | $this->jwsLoader = $jwsLoader; 89 | $this->claimCheckerManager = $claimCheckerManager; 90 | $this->signatureHeaderCheckerManager = $signatureHeaderCheckerManager; 91 | $this->signatureKeyset = $signatureKeyset; 92 | $this->signatureKeyIndex = $signatureKeyIndex; 93 | $this->signatureAlgorithm = $signatureAlgorithm; 94 | $this->issuer = $issuer; 95 | $this->audience = $audience; 96 | $this->ttl = $ttl; 97 | $this->mandatoryClaims = $mandatoryClaims; 98 | } 99 | 100 | /** 101 | * @param int|string $encryptionKeyIndex 102 | */ 103 | public function enableEncryptionSupport( 104 | JWEBuilder $jweBuilder, 105 | JWEDecrypter $jweLoader, 106 | HeaderCheckerManager $encryptionHeaderCheckerManager, 107 | JWKSet $encryptionKeyset, 108 | $encryptionKeyIndex, 109 | string $keyEncryptionAlgorithm, 110 | string $contentEncryptionAlgorithm 111 | ): void { 112 | $this->jweBuilder = $jweBuilder; 113 | $this->jweLoader = $jweLoader; 114 | $this->encryptionKeyset = $encryptionKeyset; 115 | $this->encryptionKeyIndex = $encryptionKeyIndex; 116 | $this->keyEncryptionAlgorithm = $keyEncryptionAlgorithm; 117 | $this->contentEncryptionAlgorithm = $contentEncryptionAlgorithm; 118 | $this->encryptionHeaderCheckerManager = $encryptionHeaderCheckerManager; 119 | } 120 | 121 | /** 122 | * {@inheritdoc} 123 | */ 124 | public function encode(array $payload): string 125 | { 126 | try { 127 | $jwt = $this->sign($payload); 128 | 129 | if ($this->jweBuilder !== null) { 130 | $jwt = $this->encrypt($jwt); 131 | } 132 | 133 | return $jwt; 134 | } catch (Exception $e) { 135 | throw new JWTEncodeFailureException( 136 | 'encoding_error', 137 | 'An error occurred while trying to encode the JWT token: ' . $e->getMessage(), 138 | $e 139 | ); 140 | } 141 | } 142 | 143 | public function encrypt(string $jws): string 144 | { 145 | if ($this->jweBuilder === null || $this->encryptionKeyset === null || $this->encryptionKeyIndex === null) { 146 | throw new JWTDecodeFailureException( 147 | 'decoding_error', 148 | 'The service is not configured for issuing encrypted tokens.' 149 | ); 150 | } 151 | $headers = $this->getEncryptionHeader(); 152 | $encryptionKey = $this->encryptionKeyset->get($this->encryptionKeyIndex); 153 | 154 | if ($encryptionKey->has('kid')) { 155 | $headers['kid'] = $encryptionKey->get('kid'); 156 | } 157 | 158 | $jwe = $this->jweBuilder 159 | ->create() 160 | ->withPayload($jws) 161 | ->withSharedProtectedHeader($headers) 162 | ->addRecipient($encryptionKey) 163 | ->build() 164 | ; 165 | 166 | return (new JWECompactSerializer())->serialize($jwe, 0); 167 | } 168 | 169 | /** 170 | * {@inheritdoc} 171 | */ 172 | public function decode($token): array 173 | { 174 | try { 175 | if ($this->jweBuilder !== null) { 176 | $token = $this->decrypt($token); 177 | } 178 | 179 | return $this->verify($token); 180 | } catch (InvalidClaimException $e) { 181 | $reason = match ($e->getClaim()) { 182 | 'exp' => JWTDecodeFailureException::EXPIRED_TOKEN, 183 | default => JWTDecodeFailureException::INVALID_TOKEN, 184 | }; 185 | 186 | throw new JWTDecodeFailureException($reason, sprintf( 187 | 'Invalid JWT Token. The following claim was not verified: %s.', 188 | $e->getClaim() 189 | )); 190 | } catch (InvalidHeaderException $e) { 191 | throw new JWTDecodeFailureException(JWTDecodeFailureException::INVALID_TOKEN, sprintf( 192 | 'Invalid JWT Token. The following header was not verified: %s.', 193 | $e->getHeader() 194 | )); 195 | } catch (Exception $e) { 196 | throw new JWTDecodeFailureException(JWTDecodeFailureException::INVALID_TOKEN, sprintf( 197 | 'Invalid JWT Token: %s', 198 | $e->getMessage() 199 | ), $e); 200 | } 201 | } 202 | 203 | private function sign(array $payload): string 204 | { 205 | $payload += $this->getAdditionalPayload(); 206 | $headers = $this->getSignatureHeader(); 207 | $signatureKey = $this->signatureKeyset->get($this->signatureKeyIndex); 208 | if ($signatureKey->has('kid')) { 209 | $headers['kid'] = $signatureKey->get('kid'); 210 | } 211 | 212 | $jws = $this->jwsBuilder 213 | ->create() 214 | ->withPayload(JsonConverter::encode($payload)) 215 | ->addSignature($signatureKey, $headers) 216 | ->build() 217 | ; 218 | 219 | return (new JWSCompactSerializer())->serialize($jws, 0); 220 | } 221 | 222 | private function decrypt(string $token): string 223 | { 224 | if ($this->jweLoader === null || $this->encryptionKeyset === null || $this->encryptionHeaderCheckerManager === null) { 225 | throw new JWTDecodeFailureException( 226 | 'decoding_error', 227 | 'The service is not configured for accepting encrypted tokens.' 228 | ); 229 | } 230 | $serializer = new JWECompactSerializer(); 231 | $jwe = $serializer->unserialize($token); 232 | $this->encryptionHeaderCheckerManager->check($jwe, 0); 233 | if ($this->jweLoader->decryptUsingKeySet($jwe, $this->encryptionKeyset, 0) === false) { 234 | throw new JWTDecodeFailureException( 235 | 'decoding_error', 236 | 'An error occurred while trying to decrypt the JWT token.' 237 | ); 238 | } 239 | 240 | return $jwe->getPayload(); 241 | } 242 | 243 | private function verify(string $token): array 244 | { 245 | $serializer = new JWSCompactSerializer(); 246 | $jws = $serializer->unserialize($token); 247 | $this->signatureHeaderCheckerManager->check($jws, 0); 248 | if ($this->jwsLoader->verifyWithKeySet($jws, $this->signatureKeyset, 0) === false) { 249 | throw new JWTDecodeFailureException( 250 | 'decoding_error', 251 | 'An error occurred while trying to verify the JWT token.' 252 | ); 253 | } 254 | $jwt = $jws->getPayload(); 255 | if (! is_string($jwt)) { 256 | throw new JWTDecodeFailureException( 257 | 'decoding_error', 258 | 'An error occurred while trying to verify the JWT token.' 259 | ); 260 | } 261 | 262 | $payload = JsonConverter::decode($jwt); 263 | $this->claimCheckerManager->check($payload, $this->mandatoryClaims); 264 | 265 | return $payload; 266 | } 267 | 268 | private function getAdditionalPayload(): array 269 | { 270 | return [ 271 | 'jti' => Base64UrlSafe::encode(random_bytes(64)), 272 | 'exp' => time() + $this->ttl, 273 | 'iat' => time(), 274 | 'iss' => $this->issuer, 275 | 'aud' => $this->audience, 276 | ]; 277 | } 278 | 279 | private function getSignatureHeader(): array 280 | { 281 | return [ 282 | 'typ' => 'JWT', 283 | 'alg' => $this->signatureAlgorithm, 284 | 'crit' => ['alg'], 285 | ]; 286 | } 287 | 288 | private function getEncryptionHeader(): array 289 | { 290 | return [ 291 | 'typ' => 'JWT', 292 | 'cty' => 'JWT', 293 | 'alg' => $this->keyEncryptionAlgorithm, 294 | 'enc' => $this->contentEncryptionAlgorithm, 295 | 'iss' => $this->issuer, 296 | 'aud' => $this->audience, 297 | 'crit' => ['iss', 'aud', 'alg', 'enc'], 298 | ]; 299 | } 300 | } 301 | --------------------------------------------------------------------------------