8000 feat(security): OIDC discovery · symfony/symfony@1060bbe · GitHub
[go: up one dir, main page]

Skip to content

Commit 1060bbe

Browse files
feat(security): OIDC discovery
1 parent 0051b15 commit 1060bbe

File tree

10 files changed

+269
-13
lines changed

10 files changed

+269
-13
lines changed

UPGRADE-7.2.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ Security
129129
* Deprecate argument `$secret` of `RememberMeToken` and `RememberMeAuthenticator`, the argument is unused
130130
* Deprecate passing an empty string as `$userIdentifier` argument to `UserBadge` constructor
131131
* Deprecate returning an empty string in `UserInterface::getUserIdentifier()`
132+
* Add discovery support to `OidcTokenHandler` and `OidcUserInfoTokenHandler`
132133

133134
Serializer
134135
----------

src/Symfony/Bundle/SecurityBundle/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ CHANGELOG
1212

1313
* Allow configuring the secret used to sign login links
1414
* Allow passing optional passport attributes to `Security::login()`
15+
* Add discovery support to `OidcTokenHandler` and `OidcUserInfoTokenHandler`
1516

1617
7.1
1718
---

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

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
use Symfony\Component\DependencyInjection\ChildDefinition;
1818
use Symfony\Component\DependencyInjection\ContainerBuilder;
1919
use Symfony\Component\DependencyInjection\Exception\LogicException;
20+
use Symfony\Component\DependencyInjection\Reference;
21+
use Symfony\Contracts\HttpClient\HttpClientInterface;
2022

2123
/**
2224
* Configures a token handler for decoding and validating an OIDC token.
@@ -38,9 +40,29 @@ public function create(ContainerBuilder $container, string $id, array|string $co
3840
$tokenHandlerDefinition->replaceArgument(0, (new ChildDefinition('security.access_token_handler.oidc.signature'))
3941
->replaceArgument(0, $config['algorithms']));
4042

43+
if (isset($config['discovery'])) {
44+
if (!ContainerBuilder::willBeAvailable('symfony/http-client', HttpClientInterface::class, ['symfony/security-bundle'])) {
45+
throw new LogicException('You cannot use the "oidc" token handler with "discovery" since the HttpClient component is not installed. Try running "composer require symfony/http-client".');
46+
}
47+
48+
// disable JWKSet argument
49+
$tokenHandlerDefinition->replaceArgument(1, null);
50+
$tokenHandlerDefinition->addMethodCall(
51+
'enableDiscovery',
52+
[
53+
new Reference($config['discovery']['cache']['id']),
54+
(new ChildDefinition('security.access_token_handler.oidc_discovery.http_client'))
55+
->replaceArgument(0, ['base_uri' => $config['discovery']['base_uri']]),
56+
"$id.oidc_configuration",
57+
"$id.oidc_jwk_set",
58+
]
59+
);
60+
61+
return;
62+
}
63+
4164
$tokenHandlerDefinition->replaceArgument(1, (new ChildDefinition('security.access_token_handler.oidc.jwkset'))
42-
->replaceArgument(0, $config['keyset'])
43-
);
65+
->replaceArgument(0, $config['keyset']));
4466

4567
if ($config['encryption']['enabled']) {
4668
$algorithmManager = (new ChildDefinition('security.access_token_handler.oidc.encryption'))
@@ -74,8 +96,8 @@ public function addConfiguration(NodeBuilder $node): void
7496
->thenInvalid('You must set either "algorithm" or "algorithms".')
7597
->end()
7698
->validate()
77-
->ifTrue(static fn ($v) => !isset($v['key']) && !isset($v['keyset']))
78-
->thenInvalid('You must set either "key" or "keyset".')
99+
->ifTrue(static fn ($v) => !isset($v['discovery']) && !isset($v['key']) && !isset($v['keyset']))
100+
->thenInvalid('You must set either "discovery" or "key" or "keyset".')
79101
->end()
80102
->beforeNormalization()
81103
->ifTrue(static fn ($v) => isset($v['algorithm']) && \is_string($v['algorithm']))
@@ -101,6 +123,25 @@ public function addConfiguration(NodeBuilder $node): void
101123
})
102124
->end()
103125
->children()
126+
->arrayNode('discovery')
127+
->info('Enable the OIDC discovery.')
128+
->children()
129+
->scalarNode('base_uri')
130+
->info('Base URI of the OIDC server.')
131+
->isRequired()
132+
->cannotBeEmpty()
133+
->end()
134+
->arrayNode('cache')
135+
->children()
136+
->scalarNode('id')
137+
->info('Cache service id to use to cache the OIDC discovery configuration.')
138+
->isRequired()
139+
->cannotBeEmpty()
140+
->end()
141+
->end()
142+
->end()
143+
->end()
144+
->end()
104145
->scalarNode('claim')
105146
->info('Claim which contains the user identifier (e.g.: sub, email..).')
106147
->defaultValue('sub')
@@ -129,7 +170,6 @@ public function addConfiguration(NodeBuilder $node): void
129170
->end()
130171
->scalarNode('keyset')
131172
->info('JSON-encoded JWKSet used to sign the token (must contain a list of valid public keys).')
132-
->isRequired()
133173
->end()
134174
->arrayNode('encryption')
135175
->canBeEnabled()

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

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use Symfony\Component\DependencyInjection\ContainerBuilder;
1717
use Symfony\Component\DependencyInjection\Exception\LogicException;
1818
use Symfony\Component\DependencyInjection\Reference;
19+
use Symfony\Contracts\Cache\CacheInterface;
1920
use Symfony\Contracts\HttpClient\HttpClientInterface;
2021

2122
/**
@@ -34,9 +35,23 @@ public function create(ContainerBuilder $container, string $id, array|string $co
3435
throw new LogicException('You cannot use the "oidc_user_info" token handler since the HttpClient component is not installed. Try running "composer require symfony/http-client".');
3536
}
3637

37-
$container->setDefinition($id, new ChildDefinition('security.access_token_handler.oidc_user_info'))
38+
$tokenHandlerDefinition = $container->setDefinition($id, new ChildDefinition('security.access_token_handler.oidc_user_info'))
3839
->replaceArgument(0, $clientDefinition)
3940
->replaceArgument(2, $config['claim']);
41+
42+
if (isset($config['discovery'])) {
43+
if (!ContainerBuilder::willBeAvailable('symfony/cache', CacheInterface::class, ['symfony/security-bundle'])) {
44+
throw new LogicException('You cannot use the "oidc_user_info" token handler with "discovery" since the Cache component is not installed. Try running "composer require symfony/cache".');
45+
}
46+
47+
$tokenHandlerDefinition->addMethodCall(
48+
'enableDiscovery',
49+
[
50+
new Reference($config['discovery']['cache']['id']),
51+
"$id.oidc_configuration",
52+
]
53+
);
54+
}
4055
}
4156

4257
public function getKey(): string
@@ -55,10 +70,24 @@ public function addConfiguration(NodeBuilder $node): void
5570
->end()
5671
->children()
5772
->scalarNode('base_uri')
58-
->info('Base URI of the userinfo endpoint on the OIDC server.')
73+
->info('Base URI of the userinfo endpoint on the OIDC server, or the OIDC server URI to use the discovery (require "discovery" to be configured).')
5974
->isRequired()
6075
->cannotBeEmpty()
6176
->end()
77+
->arrayNode('discovery')
78+
->info('Enable the OIDC discovery.')
79+
->children()
80+
->arrayNode('cache')
81+
->children()
82+
->scalarNode('id')
83+
->info('Cache service id to use to cache the OIDC discovery configuration.')
84+
->isRequired()
85+
->cannotBeEmpty()
86+
->end()
87+
->end()
88+
->end()
89+
->end()
90+
->end()
6291
->scalarNode('claim')
6392
->info('Claim which contains the user identifier (e.g. sub, email, etc.).')
6493
->defaultValue('sub')

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,11 @@
9191
service('clock'),
9292
])
9393

94+
->set('security.access_token_handler.oidc_discovery.http_client', HttpClientInterface::class)
95+
->abstract()
96+
->factory([service('http_client'), 'withOptions'])
97+
->args([abstract_arg('http client options')])
98+
9499
->set('security.access_token_handler.oidc.jwk', JWK::class)
95100
->abstract()
96101
->deprecate('symfony/security-http', '7.1', 'The "%service_id%" service is deprecated. Please use "security.access_token_handler.oidc.jwkset" instead')

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

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,58 @@ public function testInvalidOidcTokenHandlerConfigurationMissingAlgorithm()
339339
$this->processConfig($config, $factory);
340340
}
341341

342+
public function testOidcTokenHandlerConfigurationWithDiscovery()
343+
{
344+
$container = new ContainerBuilder();
345+
$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"}]}';
346+
$config = [
347+
'token_handler' => [
348+
'oidc' => [
349+
'discovery' => [
350+
'base_uri' => 'https://www.example.com/realms/demo/',
351+
'cache' => [
352+
'id' => 'oidc_cache',
353+
],
354+
],
355+
'algorithms' => ['RS256', 'ES256'],
356+
'issuers' => ['https://www.example.com'],
357+
'audience' => 'audience',
358+
],
359+
],
360+
];
361+
362+
$factory = new AccessTokenFactory($this->createTokenHandlerFactories());
363+
$finalizedConfig = $this->processConfig($config, $factory);
364+
365+
$factory->createAuthenticator($container, 'firewall1', $finalizedConfig, 'userprovider');
366+
367+
$this->assertTrue($container->hasDefinition('security.authenticator.access_token.firewall1'));
368+
$this->assertTrue($container->hasDefinition('security.access_token_handler.firewall1'));
369+
370+
$expectedArgs = [
371+
'index_0' => (new ChildDefinition('security.access_token_handler.oidc.signature'))
372+
->replaceArgument(0, ['RS256', 'ES256']),
373+
'index_1' => null,
374+
'index_2' => 'audience',
375+
'index_3' => ['https://www.example.com'],
376+
'index_4' => 'sub',
377+
];
378+
$expectedCalls = [
379+
[
380+
'enableDiscovery',
381+
[
382+
new Reference('oidc_cache'),
383+
(new ChildDefinition('security.access_token_handler.oidc_discovery.http_client'))
384+
->replaceArgument(0, ['base_uri' => 'https://www.example.com/realms/demo/']),
385+
'security.access_token_handler.firewall1.oidc_configuration',
386+
'security.access_token_handler.firewall1.oidc_jwk_set',
387+
],
388+
],
389+
];
390+
$this->assertEquals($expectedArgs, $container->getDefinition('security.access_token_handler.firewall1')->getArguments());
391+
$this->assertEquals($expectedCalls, $container->getDefinition('security.access_token_handler.firewall1')->getMethodCalls());
392+
}
393+
342394
public function testOidcUserInfoTokenHandlerConfigurationWithExistingClient()
343395
{
344396
$container = new ContainerBuilder();
@@ -406,6 +458,48 @@ public static function getOidcUserInfoConfiguration(): iterable
406458
F438 yield ['https://www.example.com/realms/demo/protocol/openid-connect/userinfo'];
407459
}
408460

461+
public function testOidcUserInfoTokenHandlerConfigurationWithDiscovery()
462+
{
463+
$container = new ContainerBuilder();
464+
$config = [
465+
'token_handler' => [
466+
'oidc_user_info' => [
467+
'discovery' => [
468+
'cache' => [
469+
'id' => 'oidc_cache',
470+
],
471+
],
472+
'base_uri' => 'https://www.example.com/realms/demo/protocol/openid-connect/userinfo',
473+
],
474+
],
475+
];
476+
477+
$factory = new AccessTokenFactory($this->createTokenHandlerFactories());
478+
$finalizedConfig = $this->processConfig($config, $factory);
479+
480+
$factory->createAuthenticator($container, 'firewall1', $finalizedConfig, 'userprovider');
481+
482+
$this->assertTrue($container->hasDefinition('security.authenticator.access_token.firewall1'));
483+
$this->assertTrue($container->hasDefinition('security.access_token_handler.firewall1'));
484+
485+
$expectedArgs = [
486+
'index_0' => (new ChildDefinition('security.access_token_handler.oidc_user_info.http_client'))
487+
->replaceArgument(0, ['base_uri' => 'https://www.example.com/realms/demo/protocol/openid-connect/userinfo']),
488+
'index_2' => 'sub',
489+
];
490+
$expectedCalls = [
491+
[
492+
'enableDiscovery',
493+
[
494+
new Reference('oidc_cache'),
495+
'security.access_token_handler.firewall1.oidc_configuration',
496+
],
497+
],
498+
];
499+
$this->assertEquals($expectedArgs, $container->getDefinition('security.access_token_handler.firewall1')->getArguments());
500+
$this->assertEquals($expectedCalls, $container->getDefinition('security.access_token_handler.firewall1')->getMethodCalls());
501+
}
502+
409503
public function testMultipleTokenHandlersSet()
410504
{
411505
$config = [

src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_oidc.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ security:
2424
claim: 'username'
2525
audience: 'Symfony OIDC'
2626
issuers: [ 'https://www.example.com' ]
27-
algorithm: 'ES256'
27+
algorithms: [ 'ES256' ]
2828
# tip: use https://mkjwk.org/ to generate a JWK
2929
keyset: '{"keys":[{"kty":"EC","d":"iA_TV2zvftni_9aFAQwFO_9aypfJFCSpcCyevDvz220","crv":"P-256","x":"0QEAsI1wGI-dmYatdUZoWSRWggLEpyzopuhwk-YUnA4","y":"KYl-qyZ26HobuYwlQh-r0iHX61thfP82qqEku7i0woo"}]}'
3030
encryption:

src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcTokenHandler.php

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@
3434
use Symfony\Component\Security\Http\AccessToken\Oidc\Exception\MissingClaimException;
3535
use Symfony\Component\Security\Http\Authenticator\FallbackUserLoader;
3636
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
37+
use Symfony\Contracts\Cache\CacheInterface;
38+
use Symfony\Contracts\HttpClient\HttpClientInterface;
3739

3840
/**
3941
* The token handler decodes and validates the token, and retrieves the user identifier from it.
@@ -45,9 +47,14 @@ final class OidcTokenHandler implements AccessTokenHandlerInterface
4547
private ?AlgorithmManager $decryptionAlgorithms = null;
4648
private bool $enforceEncryption = false;
4749

50+
private ?CacheInterface $discoveryCache = null;
51+
private ?HttpClientInterface $discoveryClient = null;
52+
private ?string $oidcConfigurationCacheKey = null;
53+
private ?string $oidcJWKSetCacheKey = null;
54+
4855
public function __construct(
4956
private Algorithm|AlgorithmManager $signatureAlgorithm,
50-
private JWK|JWKSet $signatureKeyset,
57+
private JWK|JWKSet|null $signatureKeyset,
5158
private string $audience,
5259
private array $issuers,
5360
private string $claim = 'sub',
@@ -71,15 +78,64 @@ public function enabledJweSupport(JWKSet $decryptionKeyset, AlgorithmManager $de
7178
$this->enforceEncryption = $enforceEncryption;
7279
}
7380

81+
public function enabledDiscovery(CacheInterface $cache, HttpClientInterface $client, string $oidcConfigurationCacheKey, string $oidcJWKSetCacheKey): void
82+
{
83+
$this->discoveryCache = $cache;
84+
$this->discoveryClient = $client;
85+
$this->oidcConfigurationCacheKey = $oidcConfigurationCacheKey;
86+
$this->oidcJWKSetCacheKey = $oidcJWKSetCacheKey;
87+
}
88+
7489
public function getUserBadgeFrom(string $accessToken): UserBadge
7590
{
7691
if (!class_exists(JWSVerifier::class) || !class_exists(Checker\HeaderCheckerManager::class)) {
7792
throw new \LogicException('You cannot use the "oidc" token handler since "web-token/jwt-signature" and "web-token/jwt-checker" are not installed. Try running "composer require web-token/jwt-signature web-token/jwt-checker".');
7893
}
7994

95+
if (!$this->discoveryCache && !$this->signatureKeyset) {
96+
throw new \LogicException('You cannot use the "oidc" token handler without JWKSet nor "discovery". Please configure JWKSet in the constructor, or call "enableDiscovery" method.');
97+
}
98+
99+
$jwkset = $this->signatureKeyset;
100+
if ($this->discoveryCache) {
101+
try {
102+
$oidcConfiguration = json_decode($this->discoveryCache->get($this->oidcConfigurationCacheKey, function (): string {
103+
$response = $this->discoveryClient->request('GET', '.well-known/openid-configuration');
104+
105+
return $response->getContent();
106+
}), true, 512, \JSON_THROW_ON_ERROR);
107+
} catch (\Throwable $e) {
108+
$this->logger?->error('An error occurred while requesting OIDC configuration.', [
109+
'error' => $e->getMessage(),
110+
'trace' => $e->getTraceAsString(),
111+
]);
112+
113+
throw new BadCredentialsException('Invalid credentials.', $e->getCode(), $e);
114+
}
115+
116+
try {
117+
$jwkset = JWKSet::createFromJson(
118+
$this->discoveryCache->get($this->oidcJWKSetCacheKey, function () use ($oidcConfiguration): string {
119+
$response = $this->discoveryClient->request('GET', $oidcConfiguration['jwks_uri']);
120+
// we only need signature key
121+
$keys = array_filter($response->toArray()['keys'], static fn (array $key) => 'sig' === $key['use']);
122+
123+
return json_encode(['keys' => $keys]);
124+
})
125+
);
126+
} catch (\Throwable $e) {
127+
$this->logger?->error('An error occurred while requesting OIDC certs.', [
128+
'error' => $e->getMessage(),
129+
'trace' => $e->getTraceAsString(),
130+
]);
131+
132+
throw new BadCredentialsException('Invalid credentials.', $e->getCode(), $e);
133+
}
134+
}
135+
80136
try {
81137
$accessToken = $this->decryptIfNeeded($accessToken);
82-
$claims = $this->loadAndVerifyJws($accessToken);
138+
$claims = $this->loadAndVerifyJws($accessToken, $jwkset);
83139
$this->verifyClaims($claims);
84140

85141
if (empty($claims[$this->claim])) {
@@ -98,15 +154,15 @@ public function getUserBadgeFrom(string $accessToken): UserBadge
98154
}
99155
}
100156

101-
private function loadAndVerifyJws(string $accessToken): array
157+
private function loadAndVerifyJws(string $accessToken, JWKSet $jwkset): array
102158
{
103159
// Decode the token
104160
$jwsVerifier = new JWSVerifier($this->signatureAlgorithm);
105161
$serializerManager = new JWSSerializerManager([new JwsCompactSerializer()]);
106162
$jws = $serializerManager->unserialize($accessToken);
107163

108164
// Verify the signature
109-
if (!$jwsVerifier->verifyWithKeySet($jws, $this->signatureKeyset, 0)) {
165+
if (!$jwsVerifier->verifyWithKeySet($jws, $jwkset, 0)) {
110166
throw new InvalidSignatureException();
111167
}
112168

0 commit comments

Comments
 (0)
0