Skip to content

Commit d12c036

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 ------- 5c18b1acaa5 [Security] Add security:oidc-token:generate command
2 parents b046e99 + e11069a commit d12c036

File tree

3 files changed

+309
-0
lines changed

3 files changed

+309
-0
lines changed
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+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
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 AccessToken\Oidc;
13+
14+
use Jose\Component\Core\AlgorithmManager;
15+
use Jose\Component\Core\JWK;
16+
use Jose\Component\Core\JWKSet;
17+
use Jose\Component\Signature\Algorithm\ES256;
18+
use Jose\Component\Signature\Algorithm\ES512;
19+
use PHPUnit\Framework\Attributes\DataProvider;
20+
use PHPUnit\Framework\TestCase;
21+
use Symfony\Component\Clock\MockClock;
22+
use Symfony\Component\Security\Http\AccessToken\Oidc\OidcTokenGenerator;
23+
use Symfony\Component\Security\Http\AccessToken\Oidc\OidcTokenHandler;
24+
25+
/**
26+
* @requires extension openssl
27+
*/
28+
class OidcTokenGeneratorTest extends TestCase
29+
{
30+
public function testGenerate()
31+
{
32+
$algorithmManager = new AlgorithmManager([new ES256()]);
33+
$audience = 'Symfony OIDC';
34+
$issuers = ['https://www.example.com'];
35+
$clock = new MockClock('1998-07-12T22:45:00+02:00');
36+
37+
$generator = new OidcTokenGenerator($algorithmManager, $this->getJWKSet(), $audience, $issuers, clock: $clock);
38+
$handler = new OidcTokenHandler($algorithmManager, $this->getJWKSet(), $audience, $issuers, clock: $clock);
39+
40+
$token = $generator->generate('john_doe');
41+
42+
$badge = $handler->getUserBadgeFrom($token);
43+
$this->assertSame('john_doe', $badge->getUser()->getUserIdentifier());
44+
$this->assertSame([
45+
'sub' => 'john_doe',
46+
'iat' => 900276300,
47+
'aud' => 'Symfony OIDC',
48+
'iss' => 'https://www.example.com',
49+
], $badge->getAttributes());
50+
}
51+
52+
#[DataProvider('provideGenerateWithInvalid')]
53+
public function testGenerateWithInvalid(?string $algorithm, ?string $issuer, ?int $ttl, ?int $notBefore, string $expectedMessage)
54+
{
55+
$this->expectException(\InvalidArgumentException::class);
56+
$this->expectExceptionMessage($expectedMessage);
57+
58+
$generator = new OidcTokenGenerator(
59+
new AlgorithmManager([new ES256(), new ES512()]),
60+
$this->getJWKSet(),
61+
'Symfony OIDC',
62+
['https://www.example1.com', 'https://www.example2.com'],
63+
);
64+
$generator->generate('john_doe', $algorithm, $issuer, $ttl, $notBefore);
65+
}
66+
67+
public static function provideGenerateWithInvalid(): iterable
68+
{
69+
yield 'No algorithms' => [null, 'https://www.example1.com', null, null, 'Please choose an algorithm. Available algorithms: "ES256", "ES512"'];
70+
yield 'Invalid algorithm' => ['ES384', 'https://www.example1.com', null, null, '"ES384" is not a valid algorithm. Available algorithms: "ES256", "ES512"'];
71+
yield 'No issuers' => ['ES256', null, null, null, 'Please choose an issuer. Available issuers: "https://www.example1.com", "https://www.example2.com"'];
72+
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"'];
73+
yield 'Invalid TTL' => ['ES256', 'https://www.example1.com', -1, null, 'Time to live must be a positive integer.'];
74+
}
75+
76+
private static function getJWKSet(): JWKSet
77+
{
78+
return new JWKSet([
79+
new JWK([
80+
'kty' => 'EC',
81+
'crv' => 'P-256',
82+
'x' => 'FtgMtrsKDboRO-Zo0XC7tDJTATHVmwuf9GK409kkars',
83+
'y' => 'rWDE0ERU2SfwGYCo1DWWdgFEbZ0MiAXLRBBOzBgs_jY',
84+
'd' => '4G7bRIiKih0qrFxc0dtvkHUll19tTyctoCR3eIbOrO0',
85+
]),
86+
]);
87+
}
88+
}

0 commit comments

Comments
 (0)