From d5b0868f5ee5bab976b613f8e0223cca14a09aca Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Mon, 7 Oct 2024 17:05:13 +0200 Subject: [PATCH 1/5] [Security] Add security:oidc:generate command --- .../AccessToken/OidcTokenHandlerFactory.php | 19 +++ .../security_authenticator_access_token.php | 13 +++ .../Factory/AccessTokenFactoryTest.php | 44 +++++++ .../AccessToken/Oidc/OidcTokenGenerator.php | 109 ++++++++++++++++++ .../Http/Command/OidcTokenGenerateCommand.php | 82 +++++++++++++ .../Oidc/OidcTokenGeneratorTest.php | 98 ++++++++++++++++ 6 files changed, 365 insertions(+) create mode 100644 src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcTokenGenerator.php create mode 100644 src/Symfony/Component/Security/Http/Command/OidcTokenGenerateCommand.php create mode 100644 src/Symfony/Component/Security/Http/Tests/AccessToken/Oidc/OidcTokenGeneratorTest.php diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/AccessToken/OidcTokenHandlerFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/AccessToken/OidcTokenHandlerFactory.php index de53d5e89bc26..e1d634ede48e0 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/AccessToken/OidcTokenHandlerFactory.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/AccessToken/OidcTokenHandlerFactory.php @@ -18,6 +18,7 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Exception\LogicException; use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\Security\Http\Command\OidcTokenGenerateCommand; use Symfony\Contracts\HttpClient\HttpClientInterface; /** @@ -79,6 +80,24 @@ public function create(ContainerBuilder $container, string $id, array|string $co ] ); } + + // Generate command + if (!$container->hasDefinition('security.access_token_handler.oidc.command.generate')) { + $container + ->register('security.access_token_handler.oidc.command.generate', OidcTokenGenerateCommand::class) + ->addTag('console.command') + ; + } + $firewall = substr($id, strlen('security.access_token_handler.')); + $container->getDefinition('security.access_token_handler.oidc.command.generate') + ->addMethodCall('addGenerator', [$firewall, (new ChildDefinition('security.access_token_handler.oidc.generator')) + ->replaceArgument(0, (new ChildDefinition('security.access_token_handler.oidc.signature'))->replaceArgument(0, $config['algorithms'])) + ->replaceArgument(1, (new ChildDefinition('security.access_token_handler.oidc.jwkset'))->replaceArgument(0, $config['keyset'])) + ->replaceArgument(2, $config['audience']) + ->replaceArgument(3, $config['issuers']) + ->replaceArgument(4, $config['claim']) + ]) + ; } public function getKey(): string 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 9099bad41c385..2599e95b69540 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 @@ -37,10 +37,12 @@ use Symfony\Component\Security\Http\AccessToken\FormEncodedBodyExtractor; use Symfony\Component\Security\Http\AccessToken\HeaderAccessTokenExtractor; use Symfony\Component\Security\Http\AccessToken\OAuth2\Oauth2TokenHandler; +use Symfony\Component\Security\Http\AccessToken\Oidc\OidcTokenGenerator; 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; +use Symfony\Component\Security\Http\Command\OidcTokenGenerateCommand; use Symfony\Contracts\HttpClient\HttpClientInterface; return static function (ContainerConfigurator $container) { @@ -200,5 +202,16 @@ service('http_client'), service('logger')->nullOnInvalid(), ]) + + ->set('security.access_token_handler.oidc.generator', OidcTokenGenerator::class) + ->abstract() + ->args([ + abstract_arg('signature algorithm'), + abstract_arg('signature key'), + abstract_arg('audience'), + abstract_arg('issuers'), + abstract_arg('claim'), + service('clock'), + ]) ; }; 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 88b782363dbf9..cbb1b2e662f5c 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Security/Factory/AccessTokenFactoryTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Security/Factory/AccessTokenFactoryTest.php @@ -596,4 +596,48 @@ private function createTokenHandlerFactories(): array new OAuth2TokenHandlerFactory(), ]; } + + public function testOidcTokenGenerator() + { + $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, + ], + ], + ]; + + $factory = new AccessTokenFactory($this->createTokenHandlerFactories()); + $finalizedConfig = $this->processConfig($config, $factory); + + $factory->createAuthenticator($container, 'firewall1', $finalizedConfig, 'userprovider'); + + $this->assertTrue($container->hasDefinition('security.access_token_handler.oidc.command.generate')); + $this->assertTrue($container->getDefinition('security.access_token_handler.oidc.command.generate')->hasMethodCall('addGenerator')); + } + + public function testOidcTokenGeneratorCommandWithNoTokenHandler() + { + $container = new ContainerBuilder(); + $config = [ + 'token_handler' => [ + 'oidc_user_info' => [ + 'base_uri' => 'https://www.example.com/realms/demo/protocol/openid-connect/userinfo', + 'client' => 'oidc.client', + ], + ], + ]; + + $factory = new AccessTokenFactory($this->createTokenHandlerFactories()); + $finalizedConfig = $this->processConfig($config, $factory); + + $factory->createAuthenticator($container, 'firewall1', $finalizedConfig, 'userprovider'); + + $this->assertFalse($container->hasDefinition('security.access_token_handler.oidc.command.generate')); + } } diff --git a/src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcTokenGenerator.php b/src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcTokenGenerator.php new file mode 100644 index 0000000000000..c07eb3b65a332 --- /dev/null +++ b/src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcTokenGenerator.php @@ -0,0 +1,109 @@ + + * + * 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\Core\Algorithm; +use Jose\Component\Core\AlgorithmManager; +use Jose\Component\Core\JWKSet; +use Jose\Component\Signature\JWSBuilder; +use Jose\Component\Signature\Serializer\CompactSerializer; +use Psr\Clock\ClockInterface; +use Symfony\Component\Clock\Clock; + +class OidcTokenGenerator +{ + public function __construct( + private readonly AlgorithmManager $algorithmManager, + private readonly JWKSet $jwkset, + private readonly string $audience, + private readonly array $issuers, + private readonly string $claim = 'sub', + private readonly ClockInterface $clock = new Clock(), + ) { + } + + public function generate(string $userIdentifier, ?string $algorithmAlias = null, ?string $issuer = null, ?int $ttl = null, ?int $notBefore = null): string + { + $algorithm = $this->getAlgorithm($algorithmAlias); + + if (!$jwk = $this->jwkset->selectKey('sig', $algorithm)) { + throw new \InvalidArgumentException(\sprintf('No JWK found to sign with "%s" algorithm.', $algorithm->name())); + } + + $jwsBuilder = new JWSBuilder($this->algorithmManager); + + $payload = [ + $this->claim => $userIdentifier, + 'iat' => $this->clock->now()->getTimestamp(), # https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.6 + 'aud' => $this->audience, # https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.3 + 'iss' => $this->getIssuer($issuer), # https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.1 + ]; + if ($ttl) { + $payload['exp'] = $this->getExpires($ttl); # https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.4 + } + if ($notBefore) { + $payload['nbf'] = $notBefore; # https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.5 + } + + $jws = $jwsBuilder + ->create() + ->withPayload(json_encode($payload, flags: \JSON_THROW_ON_ERROR)) + ->addSignature($jwk, ['alg' => $algorithm->name()]) + ->build(); + + $serializer = new CompactSerializer(); + + return $serializer->serialize($jws, 0); + } + + private function getAlgorithm(?string $alias): Algorithm + { + if ($alias) { + if (!$this->algorithmManager->has($alias)) { + throw new \InvalidArgumentException(sprintf('"%s" is not a valid algorithm. Available algorithms: "%s".', $alias, implode(', ', $this->algorithmManager->list()))); + } + return $this->algorithmManager->get($alias); + } + + if (1 !== count($list = $this->algorithmManager->list())) { + throw new \InvalidArgumentException(sprintf('Please choose an algorithm. Available algorithms: "%s".', implode(', ', $list))); + } + + return $this->algorithmManager->get($list[0]); + } + + private function getIssuer(?string $issuer): string + { + if ($issuer) { + if (!in_array($issuer, $this->issuers, true)) { + throw new \InvalidArgumentException(sprintf('"%s" is not a valid issuer. Available issuers: "%s".', $issuer, implode(', ', $this->issuers))); + } + + return $issuer; + } + + if (1 !== count($this->issuers)) { + throw new \InvalidArgumentException(sprintf('Please choose an issuer. Available issuers: "%s".', implode(', ', $this->issuers))); + } + + return $this->issuers[0]; + } + + private function getExpires(int $ttl): int + { + if (0 > $ttl) { + throw new \InvalidArgumentException('Time to live must be a positive integer.'); + } + + return $this->clock->now()->add(new \DateInterval("PT{$ttl}S"))->getTimestamp(); + } +} diff --git a/src/Symfony/Component/Security/Http/Command/OidcTokenGenerateCommand.php b/src/Symfony/Component/Security/Http/Command/OidcTokenGenerateCommand.php new file mode 100644 index 0000000000000..686cd64f587f8 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Command/OidcTokenGenerateCommand.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Command; + +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Security\Http\AccessToken\Oidc\OidcTokenGenerator; + +/** + * Generates an OIDC token. + * + * @final + */ +#[AsCommand(name: 'security:oidc:generate', description: 'Generate an OIDC token for a given user')] +final class OidcTokenGenerateCommand extends Command +{ + /** @var array */ + private array $generators = []; + + protected function configure(): void + { + $this + ->addArgument('user-identifier', InputArgument::REQUIRED, 'User identifier') + ->addOption('firewall', null, InputOption::VALUE_REQUIRED, 'Firewall') + ->addOption('algorithm', null, InputOption::VALUE_REQUIRED, 'Algorithm name to use to sign') + ->addOption('issuer', null, InputOption::VALUE_REQUIRED, 'Set the Issuer claim (iss)') + ->addOption('ttl', null, InputOption::VALUE_REQUIRED, 'Set the Expiration Time claim (exp) (time to live in seconds)') + ->addOption('not-before', null, InputOption::VALUE_REQUIRED, 'Set the Not Before claim (nbf)') + ; + } + + public function addGenerator(string $firewall, OidcTokenGenerator $oidcTokenGenerator): void + { + $this->generators[$firewall] = $oidcTokenGenerator; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $generator = $this->getGenerator($input->getOption('firewall')); + $token = $generator->generate( + $input->getArgument('user-identifier'), + $input->getOption('algorithm'), + $input->getOption('issuer'), + $input->getOption('ttl'), + $input->getOption('not-before'), + ); + + $output->writeln($token); + + return self::SUCCESS; + } + + private function getGenerator(?string $firewall): OidcTokenGenerator + { + if (0 === count($this->generators)) { + throw new \InvalidArgumentException('No OIDC token generator configured.'); + } + + if ($firewall) { + return $this->generators[$firewall] ?? throw new \InvalidArgumentException(sprintf('Invalid firewall. Available firewalls: "%s".', implode(', ', array_keys($this->generators)))); + } + + if (1 === count($this->generators)) { + return end($this->generators); + } + + throw new \InvalidArgumentException(sprintf('Please choose an firewall. Available firewalls: "%s".', implode(', ', array_keys($this->generators)))); + } +} diff --git a/src/Symfony/Component/Security/Http/Tests/AccessToken/Oidc/OidcTokenGeneratorTest.php b/src/Symfony/Component/Security/Http/Tests/AccessToken/Oidc/OidcTokenGeneratorTest.php new file mode 100644 index 0000000000000..4e65ae42a7e06 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/AccessToken/Oidc/OidcTokenGeneratorTest.php @@ -0,0 +1,98 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AccessToken\Oidc; + +use Jose\Component\Core\AlgorithmManager; +use Jose\Component\Core\JWK; +use Jose\Component\Core\JWKSet; +use Jose\Component\Signature\Algorithm\ES256; +use Jose\Component\Signature\Algorithm\ES512; +use Jose\Component\Signature\JWSBuilder; +use Jose\Component\Signature\Serializer\CompactSerializer; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use Symfony\Component\Clock\MockClock; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\Security\Core\User\OidcUser; +use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface; +use Symfony\Component\Security\Http\AccessToken\Oidc\OidcTokenGenerator; +use Symfony\Component\Security\Http\AccessToken\Oidc\OidcTokenHandler; +use Symfony\Component\Security\Http\Authenticator\FallbackUserLoader; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; + +/** + * @requires extension openssl + */ +class OidcTokenGeneratorTest extends TestCase +{ + + public function testGenerate() + { + $algorithmManager = new AlgorithmManager([new ES256()]); + $audience = 'Symfony OIDC'; + $issuers = ['https://www.example.com']; + $clock = new MockClock('1998-07-12T22:45:00+02:00'); + + $generator = new OidcTokenGenerator($algorithmManager, $this->getJWKSet(), $audience, $issuers, clock: $clock); + $handler = new OidcTokenHandler($algorithmManager, $this->getJWKSet(), $audience, $issuers, clock: $clock); + + $token = $generator->generate('john_doe'); + + $badge = $handler->getUserBadgeFrom($token); + $this->assertSame('john_doe', $badge->getUser()->getUserIdentifier()); + $this->assertSame([ + 'sub' => 'john_doe', + 'iat' => 900276300, + 'aud' => 'Symfony OIDC', + 'iss' => 'https://www.example.com', + ], $badge->getAttributes()); + } + + /** + * @dataProvider provideGenerateWithInvalid + */ + public function testGenerateWithInvalid(?string $algorithm, ?string $issuer, ?int $ttl, ?int $notBefore, string $expectedMessage) + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($expectedMessage); + + $generator = new OidcTokenGenerator( + new AlgorithmManager([new ES256(), new ES512()]), + $this->getJWKSet(), + 'Symfony OIDC', + ['https://www.example1.com', 'https://www.example2.com'], + ); + $generator->generate('john_doe', $algorithm, $issuer, $ttl, $notBefore); + } + + public static function provideGenerateWithInvalid(): iterable + { + yield 'No algorithms' => [null, 'https://www.example1.com', null, null, 'Please choose an algorithm. Available algorithms: ES256, ES512']; + yield 'Invalid algorithm' => ['ES384', 'https://www.example1.com', null, null, '"ES384" is not a valid algorithm. Available algorithms: ES256, ES512']; + yield 'No issuers' => ['ES256', null, null, null, 'Please choose an issuer. Available issuers: https://www.example1.com, https://www.example2.com']; + yield 'Invalid issuer' => ['ES256', 'https://www.invalid.com', null, null, '"https://www.invalid.com" is not a valid issuer. Available issuers: https://www.example1.com, https://www.example2.com']; + yield 'Invalid TTL' => ['ES256', 'https://www.example1.com', -1, null, 'Time to live must be a positive integer.']; + } + + private static function getJWKSet(): JWKSet + { + return new JWKSet([ + new JWK([ + 'kty' => 'EC', + 'crv' => 'P-256', + 'x' => 'FtgMtrsKDboRO-Zo0XC7tDJTATHVmwuf9GK409kkars', + 'y' => 'rWDE0ERU2SfwGYCo1DWWdgFEbZ0MiAXLRBBOzBgs_jY', + 'd' => '4G7bRIiKih0qrFxc0dtvkHUll19tTyctoCR3eIbOrO0', + ]), + ]); + } +} From 1a01d8e6a9173ea8af04f4dd09779f8b60b580c6 Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Fri, 6 Jun 2025 11:08:41 +0200 Subject: [PATCH 2/5] fix tests and review --- .../Security/Http/AccessToken/Oidc/OidcTokenGenerator.php | 8 ++++---- .../Security/Http/Command/OidcTokenGenerateCommand.php | 2 +- .../Tests/AccessToken/Oidc/OidcTokenGeneratorTest.php | 8 ++++---- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcTokenGenerator.php b/src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcTokenGenerator.php index c07eb3b65a332..3e6af76d8252f 100644 --- a/src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcTokenGenerator.php +++ b/src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcTokenGenerator.php @@ -69,13 +69,13 @@ private function getAlgorithm(?string $alias): Algorithm { if ($alias) { if (!$this->algorithmManager->has($alias)) { - throw new \InvalidArgumentException(sprintf('"%s" is not a valid algorithm. Available algorithms: "%s".', $alias, implode(', ', $this->algorithmManager->list()))); + throw new \InvalidArgumentException(sprintf('"%s" is not a valid algorithm. Available algorithms: "%s".', $alias, implode('", "', $this->algorithmManager->list()))); } return $this->algorithmManager->get($alias); } if (1 !== count($list = $this->algorithmManager->list())) { - throw new \InvalidArgumentException(sprintf('Please choose an algorithm. Available algorithms: "%s".', implode(', ', $list))); + throw new \InvalidArgumentException(sprintf('Please choose an algorithm. Available algorithms: "%s".', implode('", "', $list))); } return $this->algorithmManager->get($list[0]); @@ -85,14 +85,14 @@ private function getIssuer(?string $issuer): string { if ($issuer) { if (!in_array($issuer, $this->issuers, true)) { - throw new \InvalidArgumentException(sprintf('"%s" is not a valid issuer. Available issuers: "%s".', $issuer, implode(', ', $this->issuers))); + throw new \InvalidArgumentException(sprintf('"%s" is not a valid issuer. Available issuers: "%s".', $issuer, implode('", "', $this->issuers))); } return $issuer; } if (1 !== count($this->issuers)) { - throw new \InvalidArgumentException(sprintf('Please choose an issuer. Available issuers: "%s".', implode(', ', $this->issuers))); + throw new \InvalidArgumentException(sprintf('Please choose an issuer. Available issuers: "%s".', implode('", "', $this->issuers))); } return $this->issuers[0]; diff --git a/src/Symfony/Component/Security/Http/Command/OidcTokenGenerateCommand.php b/src/Symfony/Component/Security/Http/Command/OidcTokenGenerateCommand.php index 686cd64f587f8..fc35cc86c7add 100644 --- a/src/Symfony/Component/Security/Http/Command/OidcTokenGenerateCommand.php +++ b/src/Symfony/Component/Security/Http/Command/OidcTokenGenerateCommand.php @@ -24,7 +24,7 @@ * * @final */ -#[AsCommand(name: 'security:oidc:generate', description: 'Generate an OIDC token for a given user')] +#[AsCommand(name: 'security:oidc-token:generate', description: 'Generate an OIDC token for a given user')] final class OidcTokenGenerateCommand extends Command { /** @var array */ diff --git a/src/Symfony/Component/Security/Http/Tests/AccessToken/Oidc/OidcTokenGeneratorTest.php b/src/Symfony/Component/Security/Http/Tests/AccessToken/Oidc/OidcTokenGeneratorTest.php index 4e65ae42a7e06..c06bd9aaaf981 100644 --- a/src/Symfony/Component/Security/Http/Tests/AccessToken/Oidc/OidcTokenGeneratorTest.php +++ b/src/Symfony/Component/Security/Http/Tests/AccessToken/Oidc/OidcTokenGeneratorTest.php @@ -76,10 +76,10 @@ public function testGenerateWithInvalid(?string $algorithm, ?string $issuer, ?in public static function provideGenerateWithInvalid(): iterable { - yield 'No algorithms' => [null, 'https://www.example1.com', null, null, 'Please choose an algorithm. Available algorithms: ES256, ES512']; - yield 'Invalid algorithm' => ['ES384', 'https://www.example1.com', null, null, '"ES384" is not a valid algorithm. Available algorithms: ES256, ES512']; - yield 'No issuers' => ['ES256', null, null, null, 'Please choose an issuer. Available issuers: https://www.example1.com, https://www.example2.com']; - yield 'Invalid issuer' => ['ES256', 'https://www.invalid.com', null, null, '"https://www.invalid.com" is not a valid issuer. Available issuers: https://www.example1.com, https://www.example2.com']; + yield 'No algorithms' => [null, 'https://www.example1.com', null, null, 'Please choose an algorithm. Available algorithms: "ES256", "ES512"']; + yield 'Invalid algorithm' => ['ES384', 'https://www.example1.com', null, null, '"ES384" is not a valid algorithm. Available algorithms: "ES256", "ES512"']; + yield 'No issuers' => ['ES256', null, null, null, 'Please choose an issuer. Available issuers: "https://www.example1.com", "https://www.example2.com"']; + yield 'Invalid issuer' => ['ES256', 'https://www.invalid.com', null, null, '"https://www.invalid.com" is not a valid issuer. Available issuers: "https://www.example1.com", "https://www.example2.com"']; yield 'Invalid TTL' => ['ES256', 'https://www.example1.com', -1, null, 'Time to live must be a positive integer.']; } From f27ceb219043a6051ddcd28973b408856e1765f2 Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Mon, 16 Jun 2025 11:18:44 +0200 Subject: [PATCH 3/5] rename and autocomplete --- .../AccessToken/OidcTokenHandlerFactory.php | 21 ++++++--- .../Http/Command/OidcTokenGenerateCommand.php | 47 +++++++++++++++++-- 2 files changed, 58 insertions(+), 10 deletions(-) diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/AccessToken/OidcTokenHandlerFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/AccessToken/OidcTokenHandlerFactory.php index e1d634ede48e0..aaeeb6fb968e0 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/AccessToken/OidcTokenHandlerFactory.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/AccessToken/OidcTokenHandlerFactory.php @@ -82,20 +82,29 @@ public function create(ContainerBuilder $container, string $id, array|string $co } // Generate command + if (!class_exists(OidcTokenGenerateCommand::class)) { + return; + } + if (!$container->hasDefinition('security.access_token_handler.oidc.command.generate')) { $container ->register('security.access_token_handler.oidc.command.generate', OidcTokenGenerateCommand::class) ->addTag('console.command') ; } + $firewall = substr($id, strlen('security.access_token_handler.')); $container->getDefinition('security.access_token_handler.oidc.command.generate') - ->addMethodCall('addGenerator', [$firewall, (new ChildDefinition('security.access_token_handler.oidc.generator')) - ->replaceArgument(0, (new ChildDefinition('security.access_token_handler.oidc.signature'))->replaceArgument(0, $config['algorithms'])) - ->replaceArgument(1, (new ChildDefinition('security.access_token_handler.oidc.jwkset'))->replaceArgument(0, $config['keyset'])) - ->replaceArgument(2, $config['audience']) - ->replaceArgument(3, $config['issuers']) - ->replaceArgument(4, $config['claim']) + ->addMethodCall('addGenerator', [ + $firewall, + (new ChildDefinition('security.access_token_handler.oidc.generator')) + ->replaceArgument(0, (new ChildDefinition('security.access_token_handler.oidc.signature'))->replaceArgument(0, $config['algorithms'])) + ->replaceArgument(1, (new ChildDefinition('security.access_token_handler.oidc.jwkset'))->replaceArgument(0, $config['keyset'])) + ->replaceArgument(2, $config['audience']) + ->replaceArgument(3, $config['issuers']) + ->replaceArgument(4, $config['claim']), + $config['algorithms'], + $config['issuers'], ]) ; } diff --git a/src/Symfony/Component/Security/Http/Command/OidcTokenGenerateCommand.php b/src/Symfony/Component/Security/Http/Command/OidcTokenGenerateCommand.php index fc35cc86c7add..cc24feb28c1f9 100644 --- a/src/Symfony/Component/Security/Http/Command/OidcTokenGenerateCommand.php +++ b/src/Symfony/Component/Security/Http/Command/OidcTokenGenerateCommand.php @@ -13,6 +13,9 @@ use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Completion\CompletionInput; +use Symfony\Component\Console\Completion\CompletionSuggestions; +use Symfony\Component\Console\Completion\Suggestion; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -24,11 +27,15 @@ * * @final */ -#[AsCommand(name: 'security:oidc-token:generate', description: 'Generate an OIDC token for a given user')] +#[AsCommand(name: 'security:oidc:generate-token', description: 'Generate an OIDC token for a given user')] final class OidcTokenGenerateCommand extends Command { /** @var array */ private array $generators = []; + /** @var array> */ + private array $algorithms; + /** @var array> */ + private array $issuers; protected function configure(): void { @@ -42,9 +49,22 @@ protected function configure(): void ; } - public function addGenerator(string $firewall, OidcTokenGenerator $oidcTokenGenerator): void + + /** + * @params array> $algorithms + * @params array> $issuers + */ + public function addGenerator(string $firewall, OidcTokenGenerator $oidcTokenGenerator, array $algorithms, array $issuers): void { $this->generators[$firewall] = $oidcTokenGenerator; + foreach ($algorithms as $algorithm) { + $this->algorithms[$algorithm] ??= []; + $this->algorithms[$algorithm][] = $firewall; + } + foreach ($issuers as $issuer) { + $this->issuers[$issuer] ??= []; + $this->issuers[$issuer][] = $firewall; + } } protected function execute(InputInterface $input, OutputInterface $output): int @@ -70,13 +90,32 @@ private function getGenerator(?string $firewall): OidcTokenGenerator } if ($firewall) { - return $this->generators[$firewall] ?? throw new \InvalidArgumentException(sprintf('Invalid firewall. Available firewalls: "%s".', implode(', ', array_keys($this->generators)))); + return $this->generators[$firewall] ?? throw new \InvalidArgumentException(sprintf('Invalid firewall. Available firewalls: "%s".', implode('", "', array_keys($this->generators)))); } if (1 === count($this->generators)) { return end($this->generators); } - throw new \InvalidArgumentException(sprintf('Please choose an firewall. Available firewalls: "%s".', implode(', ', array_keys($this->generators)))); + throw new \InvalidArgumentException(sprintf('Please choose an firewall. Available firewalls: "%s".', implode('", "', array_keys($this->generators)))); + } + + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void + { + if ($input->mustSuggestOptionValuesFor('firewall')) { + $suggestions->suggestValues(array_keys($this->generators)); + } + + if ($input->mustSuggestOptionValuesFor('algorithm')) { + foreach ($this->algorithms as $algorithm => $firewalls) { + $suggestions->suggestValue(new Suggestion($algorithm, sprintf('Available firewalls: "%s".', implode('", "', $firewalls)))); + } + } + + if ($input->mustSuggestOptionValuesFor('issuer')) { + foreach ($this->issuers as $issuer => $firewalls) { + $suggestions->suggestValue(new Suggestion($issuer, sprintf('Available firewalls: "%s".', implode('", "', $firewalls)))); + } + } } } From 91b8d68102d6f9c0637c40ad311b614037689149 Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Mon, 16 Jun 2025 21:34:34 +0200 Subject: [PATCH 4/5] nbf as \DateTimeImmutable --- .../AccessToken/Oidc/OidcTokenGenerator.php | 22 ++++++++----------- .../Http/Command/OidcTokenGenerateCommand.php | 2 +- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcTokenGenerator.php b/src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcTokenGenerator.php index 3e6af76d8252f..eaba079f68350 100644 --- a/src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcTokenGenerator.php +++ b/src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcTokenGenerator.php @@ -31,7 +31,7 @@ public function __construct( ) { } - public function generate(string $userIdentifier, ?string $algorithmAlias = null, ?string $issuer = null, ?int $ttl = null, ?int $notBefore = null): string + public function generate(string $userIdentifier, ?string $algorithmAlias = null, ?string $issuer = null, ?int $ttl = null, ?\DateTimeImmutable $notBefore = null): string { $algorithm = $this->getAlgorithm($algorithmAlias); @@ -41,17 +41,22 @@ public function generate(string $userIdentifier, ?string $algorithmAlias = null, $jwsBuilder = new JWSBuilder($this->algorithmManager); + $now = $this->clock->now(); $payload = [ $this->claim => $userIdentifier, - 'iat' => $this->clock->now()->getTimestamp(), # https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.6 + 'iat' => $now->getTimestamp(), # https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.6 'aud' => $this->audience, # https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.3 'iss' => $this->getIssuer($issuer), # https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.1 ]; if ($ttl) { - $payload['exp'] = $this->getExpires($ttl); # https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.4 + if (0 > $ttl) { + throw new \InvalidArgumentException('Time to live must be a positive integer.'); + } + + $payload['exp'] = $now->add(new \DateInterval("PT{$ttl}S"))->getTimestamp(); # https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.4 } if ($notBefore) { - $payload['nbf'] = $notBefore; # https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.5 + $payload['nbf'] = $notBefore->getTimestamp(); # https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.5 } $jws = $jwsBuilder @@ -97,13 +102,4 @@ private function getIssuer(?string $issuer): string return $this->issuers[0]; } - - private function getExpires(int $ttl): int - { - if (0 > $ttl) { - throw new \InvalidArgumentException('Time to live must be a positive integer.'); - } - - return $this->clock->now()->add(new \DateInterval("PT{$ttl}S"))->getTimestamp(); - } } diff --git a/src/Symfony/Component/Security/Http/Command/OidcTokenGenerateCommand.php b/src/Symfony/Component/Security/Http/Command/OidcTokenGenerateCommand.php index cc24feb28c1f9..e8adcb7d92511 100644 --- a/src/Symfony/Component/Security/Http/Command/OidcTokenGenerateCommand.php +++ b/src/Symfony/Component/Security/Http/Command/OidcTokenGenerateCommand.php @@ -75,7 +75,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $input->getOption('algorithm'), $input->getOption('issuer'), $input->getOption('ttl'), - $input->getOption('not-before'), + ($nbf = $input->getOption('not-before')) ? new \DateTimeImmutable($nbf) : null, ); $output->writeln($token); From 730512f6ad645d238cd500cf21261a632f8234d7 Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Mon, 16 Jun 2025 21:55:30 +0200 Subject: [PATCH 5/5] skip test if OidcTokenGenerator is not available --- .../Security/Factory/AccessTokenFactoryTest.php | 5 +++++ 1 file changed, 5 insertions(+) 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 cbb1b2e662f5c..508d440960c7d 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Security/Factory/AccessTokenFactoryTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Security/Factory/AccessTokenFactoryTest.php @@ -24,6 +24,7 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Exception\LogicException; use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\Security\Http\AccessToken\Oidc\OidcTokenGenerator; use Symfony\Contracts\HttpClient\HttpClientInterface; class AccessTokenFactoryTest extends TestCase @@ -599,6 +600,10 @@ private function createTokenHandlerFactories(): array public function testOidcTokenGenerator() { + if (!class_exists(OidcTokenGenerator::class)) { + $this->markTestSkipped('OidcTokenGenerator not available.'); + } + $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 = [