Skip to content

Commit fd498be

Browse files
committed
feature #60660 [Security] Add security:oidc-token:generate command (Jean-Beru)
This PR was squashed before being merged into the 7.4 branch. Discussion ---------- [Security] Add security:oidc-token:generate command | Q | A | ------------- | --- | Branch? | 7.4 | Bug fix? | no | New feature? | yes (should I create the CHANGELOG-7.4.md file?) | Deprecations? | no | Issues | | License | MIT The `OidcTokenHandler`, [introduced in Symfony 6.3](https://symfony.com/blog/new-in-symfony-6-3-openid-connect-token-handler), allows to decode a JWT token, validate it and retrieve the user info from it. This pull request introduces a new Symfony command, `bin/console security:oidc-token:generate`, designed to facilitate the generation of JWTs. It can be useful for generating a token for testing purpose. ### Argument and options ``` Description: Generate an OIDC token for a given user Usage: security:oidc-token:generate [options] [--] <user-identifier> Arguments: user-identifier User identifier Options: --firewall=FIREWALL Firewall --algorithm=ALGORITHM Algorithm name to use to sign --issuer=ISSUER Set the Issuer claim (iss) --ttl=TTL Set the Expiration Time claim (exp) (time to live in seconds) --not-before=NOT-BEFORE Set the Not Before claim (nbf) ``` ### Usage ```bash php bin/console security:oidc-token:generate jane.doe@example.com \ --firewall="api" \ --algorithm="HS256" \ --issuer="https://example.com" \ --ttl=7200 \ --not-before=tomorrow ``` > [!TIP] > When there is only one value, both "firewall", "algorithm" and "issuer" are not required.. Commits ------- 5c18b1a [Security] Add security:oidc-token:generate command
2 parents 5779263 + 5c18b1a commit fd498be

File tree

6 files changed

+399
-0
lines changed

6 files changed

+399
-0
lines changed

src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/AccessToken/OidcTokenHandlerFactory.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use Symfony\Component\DependencyInjection\ContainerBuilder;
1919
use Symfony\Component\DependencyInjection\Exception\LogicException;
2020
use Symfony\Component\DependencyInjection\Reference;
21+
use Symfony\Component\Security\Http\Command\OidcTokenGenerateCommand;
2122
use Symfony\Contracts\HttpClient\HttpClientInterface;
2223

2324
/**
@@ -79,6 +80,33 @@ public function create(ContainerBuilder $container, string $id, array|string $co
7980
]
8081
);
8182
}
83+
84+
// Generate command
85+
if (!class_exists(OidcTokenGenerateCommand::class)) {
86+
return;
87+
}
88+
89+
if (!$container->hasDefinition('security.access_token_handler.oidc.command.generate')) {
90+
$container
91+
->register('security.access_token_handler.oidc.command.generate', OidcTokenGenerateCommand::class)
92+
->addTag('console.command')
93+
;
94+
}
95+
96+
$firewall = substr($id, strlen('security.access_token_handler.'));
97+
$container->getDefinition('security.access_token_handler.oidc.command.generate')
98+
->addMethodCall('addGenerator', [
99+
$firewall,
100+
(new ChildDefinition('security.access_token_handler.oidc.generator'))
101+
->replaceArgument(0, (new ChildDefinition('security.access_token_handler.oidc.signature'))->replaceArgument(0, $config['algorithms']))
102+
->replaceArgument(1, (new ChildDefinition('security.access_token_handler.oidc.jwkset'))->replaceArgument(0, $config['keyset']))
103+
->replaceArgument(2, $config['audience'])
104+
->replaceArgument(3, $config['issuers'])
105+
->replaceArgument(4, $config['claim']),
106+
$config['algorithms'],
107+
$config['issuers'],
108+
])
109+
;
82110
}
83111

84112
public function getKey(): string

src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator_access_token.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,12 @@
3737
use Symfony\Component\Security\Http\AccessToken\FormEncodedBodyExtractor;
3838
use Symfony\Component\Security\Http\AccessToken\HeaderAccessTokenExtractor;
3939
use Symfony\Component\Security\Http\AccessToken\OAuth2\Oauth2TokenHandler;
40+
use Symfony\Component\Security\Http\AccessToken\Oidc\OidcTokenGenerator;
4041
use Symfony\Component\Security\Http\AccessToken\Oidc\OidcTokenHandler;
4142
use Symfony\Component\Security\Http\AccessToken\Oidc\OidcUserInfoTokenHandler;
4243
use Symfony\Component\Security\Http\AccessToken\QueryAccessTokenExtractor;
4344
use Symfony\Component\Security\Http\Authenticator\AccessTokenAuthenticator;
45+
use Symfony\Component\Security\Http\Command\OidcTokenGenerateCommand;
4446
use Symfony\Contracts\HttpClient\HttpClientInterface;
4547

4648
return static function (ContainerConfigurator $container) {
@@ -200,5 +202,16 @@
200202
service('http_client'),
201203
service('logger')->nullOnInvalid(),
202204
])
205+
206+
->set('security.access_token_handler.oidc.generator', OidcTokenGenerator::class)
207+
->abstract()
208+
->args([
209+
abstract_arg('signature algorithm'),
210+
abstract_arg('signature key'),
211+
abstract_arg('audience'),
212+
abstract_arg('issuers'),
213+
abstract_arg('claim'),
214+
service('clock'),
215+
])
203216
;
204217
};

src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Security/Factory/AccessTokenFactoryTest.php

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
use Symfony\Component\DependencyInjection\ContainerBuilder;
2828
use Symfony\Component\DependencyInjection\Exception\LogicException;
2929
use Symfony\Component\DependencyInjection\Reference;
30+
use Symfony\Component\Security\Http\AccessToken\Oidc\OidcTokenGenerator;
3031
use Symfony\Contracts\HttpClient\HttpClientInterface;
3132

3233
class AccessTokenFactoryTest extends TestCase
@@ -596,4 +597,52 @@ private function createTokenHandlerFactories(): array
596597
new OAuth2TokenHandlerFactory(),
597598
];
598599
}
600+
601+
public function testOidcTokenGenerator()
602+
{
603+
if (!class_exists(OidcTokenGenerator::class)) {
604+
$this->markTestSkipped('OidcTokenGenerator not available.');
605+
}
606+
607+
$container = new ContainerBuilder();
608+
$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"}]}';
609+
$config = [
610+
'token_handler' => [
611+
'oidc' => [
612+
'algorithms' => ['RS256', 'ES256'],
613+
'issuers' => ['https://www.example.com'],
614+
'audience' => 'audience',
615+
'keyset' => $jwkset,
616+
],
617+
],
618+
];
619+
620+
$factory = new AccessTokenFactory($this->createTokenHandlerFactories());
621+
$finalizedConfig = $this->processConfig($config, $factory);
622+
623+
$factory->createAuthenticator($container, 'firewall1', $finalizedConfig, 'userprovider');
624+
625+
$this->assertTrue($container->hasDefinition('security.access_token_handler.oidc.command.generate'));
626+
$this->assertTrue($container->getDefinition('security.access_token_handler.oidc.command.generate')->hasMethodCall('addGenerator'));
627+
}
628+
629+
public function testOidcTokenGeneratorCommandWithNoTokenHandler()
630+
{
631+
$container = new ContainerBuilder();
632+
$config = [
633+
'token_handler' => [
634+
'oidc_user_info' => [
635+
'base_uri' => 'https://www.example.com/realms/demo/protocol/openid-connect/userinfo',
636+
'client' => 'oidc.client',
637+
],
638+
],
639+
];
640+
641+
$factory = new AccessTokenFactory($this->createTokenHandlerFactories());
642+
$finalizedConfig = $this->processConfig($config, $factory);
643+
644+
$factory->createAuthenticator($container, 'firewall1', $finalizedConfig, 'userprovider');
645+
646+
$this->assertFalse($container->hasDefinition('security.access_token_handler.oidc.command.generate'));
647+
}
599648
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Security\Http\AccessToken\Oidc;
13+
14+
use Jose\Component\Core\Algorithm;
15+
use Jose\Component\Core\AlgorithmManager;
16+
use Jose\Component\Core\JWKSet;
17+
use Jose\Component\Signature\JWSBuilder;
18+
use Jose\Component\Signature\Serializer\CompactSerializer;
19+
use Psr\Clock\ClockInterface;
20+
use Symfony\Component\Clock\Clock;
21+
22+
class OidcTokenGenerator
23+
{
24+
public function __construct(
25+
private readonly AlgorithmManager $algorithmManager,
26+
private readonly JWKSet $jwkset,
27+
private readonly string $audience,
28+
private readonly array $issuers,
29+
private readonly string $claim = 'sub',
30+
private readonly ClockInterface $clock = new Clock(),
31+
) {
32+
}
33+
34+
public function generate(string $userIdentifier, ?string $algorithmAlias = null, ?string $issuer = null, ?int $ttl = null, ?\DateTimeImmutable $notBefore = null): string
35+
{
36+
$algorithm = $this->getAlgorithm($algorithmAlias);
37+
38+
if (!$jwk = $this->jwkset->selectKey('sig', $algorithm)) {
39+
throw new \InvalidArgumentException(\sprintf('No JWK found to sign with "%s" algorithm.', $algorithm->name()));
40+
}
41+
42+
$jwsBuilder = new JWSBuilder($this->algorithmManager);
43+
44+
$now = $this->clock->now();
45+
$payload = [
46+
$this->claim => $userIdentifier,
47+
'iat' => $now->getTimestamp(), # https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.6
48+
'aud' => $this->audience, # https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.3
49+
'iss' => $this->getIssuer($issuer), # https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.1
50+
];
51+
if ($ttl) {
52+
if (0 > $ttl) {
53+
throw new \InvalidArgumentException('Time to live must be a positive integer.');
54+
}
55+
56+
$payload['exp'] = $now->add(new \DateInterval("PT{$ttl}S"))->getTimestamp(); # https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.4
57+
}
58+
if ($notBefore) {
59+
$payload['nbf'] = $notBefore->getTimestamp(); # https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.5
60+
}
61+
62+
$jws = $jwsBuilder
63+
->create()
64+
->withPayload(json_encode($payload, flags: \JSON_THROW_ON_ERROR))
65+
->addSignature($jwk, ['alg' => $algorithm->name()])
66+
->build();
67+
68+
$serializer = new CompactSerializer();
69+
70+
return $serializer->serialize($jws, 0);
71+
}
72+
73+
private function getAlgorithm(?string $alias): Algorithm
74+
{
75+
if ($alias) {
76+
if (!$this->algorithmManager->has($alias)) {
77+
throw new \InvalidArgumentException(sprintf('"%s" is not a valid algorithm. Available algorithms: "%s".', $alias, implode('", "', $this->algorithmManager->list())));
78+
}
79+
return $this->algorithmManager->get($alias);
80+
}
81+
82+
if (1 !== count($list = $this->algorithmManager->list())) {
83+
throw new \InvalidArgumentException(sprintf('Please choose an algorithm. Available algorithms: "%s".', implode('", "', $list)));
84+
}
85+
86+
return $this->algorithmManager->get($list[0]);
87+
}
88+
89+
private function getIssuer(?string $issuer): string
90+
{
91+
if ($issuer) {
92+
if (!in_array($issuer, $this->issuers, true)) {
93+
throw new \InvalidArgumentException(sprintf('"%s" is not a valid issuer. Available issuers: "%s".', $issuer, implode('", "', $this->issuers)));
94+
}
95+
96+
return $issuer;
97+
}
98+
99+
if (1 !== count($this->issuers)) {
100+
throw new \InvalidArgumentException(sprintf('Please choose an issuer. Available issuers: "%s".', implode('", "', $this->issuers)));
101+
}
102+
103+
return $this->issuers[0];
104+
}
105+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Security\Http\Command;
13+
14+
use Symfony\Component\Console\Attribute\AsCommand;
15+
use Symfony\Component\Console\Command\Command;
16+
use Symfony\Component\Console\Completion\CompletionInput;
17+
use Symfony\Component\Console\Completion\CompletionSuggestions;
18+
use Symfony\Component\Console\Completion\Suggestion;
19+
use Symfony\Component\Console\Input\InputArgument;
20+
use Symfony\Component\Console\Input\InputInterface;
21+
use Symfony\Component\Console\Input\InputOption;
22+
use Symfony\Component\Console\Output\OutputInterface;
23+
use Symfony\Component\Security\Http\AccessToken\Oidc\OidcTokenGenerator;
24+
25+
#[AsCommand(name: 'security:oidc:generate-token', description: 'Generate an OIDC token for a given user')]
26+
final class OidcTokenGenerateCommand extends Command
27+
{
28+
/** @var array<string, OidcTokenGenerator> */
29+
private array $generators = [];
30+
/** @var array<string, list<string>> */
31+
private array $algorithms;
32+
/** @var array<string, list<string>> */
33+
private array $issuers;
34+
35+
protected function configure(): void
36+
{
37+
$this
38+
->addArgument('user-identifier', InputArgument::REQUIRED, 'User identifier')
39+
->addOption('firewall', null, InputOption::VALUE_REQUIRED, 'Firewall')
40+
->addOption('algorithm', null, InputOption::VALUE_REQUIRED, 'Algorithm name to use to sign')
41+
->addOption('issuer', null, InputOption::VALUE_REQUIRED, 'Set the Issuer claim (iss)')
42+
->addOption('ttl', null, InputOption::VALUE_REQUIRED, 'Set the Expiration Time claim (exp) (time to live in seconds)')
43+
->addOption('not-before', null, InputOption::VALUE_REQUIRED, 'Set the Not Before claim (nbf)')
44+
;
45+
}
46+
47+
48+
/**
49+
* @params array<string, list<string>> $algorithms
50+
* @params array<string, list<string>> $issuers
51+
*/
52+
public function addGenerator(string $firewall, OidcTokenGenerator $oidcTokenGenerator, array $algorithms, array $issuers): void
53+
{
54+
$this->generators[$firewall] = $oidcTokenGenerator;
55+
foreach ($algorithms as $algorithm) {
56+
$this->algorithms[$algorithm] ??= [];
57+
$this->algorithms[$algorithm][] = $firewall;
58+
}
59+
foreach ($issuers as $issuer) {
60+
$this->issuers[$issuer] ??= [];
61+
$this->issuers[$issuer][] = $firewall;
62+
}
63+
}
64+
65+
protected function execute(InputInterface $input, OutputInterface $output): int
66+
{
67+
$generator = $this->getGenerator($input->getOption('firewall'));
68+
$token = $generator->generate(
69+
$input->getArgument('user-identifier'),
70+
$input->getOption('algorithm'),
71+
$input->getOption('issuer'),
72+
$input->getOption('ttl'),
73+
($nbf = $input->getOption('not-before')) ? new \DateTimeImmutable($nbf) : null,
74+
);
75+
76+
$output->writeln($token);
77+
78+
return self::SUCCESS;
79+
}
80+
81+
private function getGenerator(?string $firewall): OidcTokenGenerator
82+
{
83+
if (0 === count($this->generators)) {
84+
throw new \InvalidArgumentException('No OIDC token generator configured.');
85+
}
86+
87+
if ($firewall) {
88+
return $this->generators[$firewall] ?? throw new \InvalidArgumentException(sprintf('Invalid firewall. Available firewalls: "%s".', implode('", "', array_keys($this->generators))));
89+
}
90+
91+
if (1 === count($this->generators)) {
92+
return end($this->generators);
93+
}
94+
95+
throw new \InvalidArgumentException(sprintf('Please choose an firewall. Available firewalls: "%s".', implode('", "', array_keys($this->generators))));
96+
}
97+
98+
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
99+
{
100+
if ($input->mustSuggestOptionValuesFor('firewall')) {
101+
$suggestions->suggestValues(array_keys($this->generators));
102+
}
103+
104+
if ($input->mustSuggestOptionValuesFor('algorithm')) {
105+
foreach ($this->algorithms as $algorithm => $firewalls) {
106+
$suggestions->suggestValue(new Suggestion($algorithm, sprintf('Available firewalls: "%s".', implode('", "', $firewalls))));
107+
}
108+
}
109+
110+
if ($input->mustSuggestOptionValuesFor('issuer')) {
111+
foreach ($this->issuers as $issuer => $firewalls) {
112+
$suggestions->suggestValue(new Suggestion($issuer, sprintf('Available firewalls: "%s".', implode('", "', $firewalls))));
113+
}
114+
}
115+
}
116+
}

0 commit comments

Comments
 (0)