diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/AccessToken/OidcTokenHandlerFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/AccessToken/OidcTokenHandlerFactory.php index de53d5e89bc26..aaeeb6fb968e0 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,33 @@ 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']), + $config['algorithms'], + $config['issuers'], + ]) + ; } 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..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 @@ -596,4 +597,52 @@ private function createTokenHandlerFactories(): array new OAuth2TokenHandlerFactory(), ]; } + + 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 = [ + '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..eaba079f68350 --- /dev/null +++ b/src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcTokenGenerator.php @@ -0,0 +1,105 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\AccessToken\Oidc; + +use Jose\Component\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, ?\DateTimeImmutable $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); + + $now = $this->clock->now(); + $payload = [ + $this->claim => $userIdentifier, + '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) { + 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->getTimestamp(); # 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]; + } +} 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..e8adcb7d92511 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Command/OidcTokenGenerateCommand.php @@ -0,0 +1,121 @@ + + * + * 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\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; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Security\Http\AccessToken\Oidc\OidcTokenGenerator; + +/** + * Generates an OIDC token. + * + * @final + */ +#[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 + { + $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)') + ; + } + + + /** + * @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 + { + $generator = $this->getGenerator($input->getOption('firewall')); + $token = $generator->generate( + $input->getArgument('user-identifier'), + $input->getOption('algorithm'), + $input->getOption('issuer'), + $input->getOption('ttl'), + ($nbf = $input->getOption('not-before')) ? new \DateTimeImmutable($nbf) : null, + ); + + $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)))); + } + + 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)))); + } + } + } +} 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..c06bd9aaaf981 --- /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', + ]), + ]); + } +}