Skip to content

Commit 8b9ed36

Browse files
committed
feature symfony#54932 [Security][SecurityBundle] OIDC discovery (vincentchalamon)
This PR was merged into the 7.3 branch. Discussion ---------- [Security][SecurityBundle] OIDC discovery This PR introduces [OIDC discovery](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse) on `oidc` and `oidc_user_info` token handlers. | Q | A | ------------- | --- | Branch? | 7.2 | Bug fix? | no | New feature? | yes | Deprecations? | yes | Issues | Fix symfony#50433 Fix symfony#50434 | License | MIT | Doc PR | symfony/symfony-docs#20579 | ### TODO - [x] use JWSLoader in OidcTokenHandler - [x] introduce OidcUserInfoDiscoveryTokenHandler - [x] introduce OidcDiscoveryTokenHandler - [x] update src/**/CHANGELOG.md files - [x] update UPGRADE-*.md files - [x] add tests on AccessTokenFactoryTest with discovery - [x] create documentation PR ### What is OIDC Discovery? [OIDC discovery](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse) is a generic endpoint on the OIDC server, which gives any public information such as signature public keys and endpoints URIs (userinfo, token, etc.). An example is available on the API Platform Demo: https://demo.api-platform.com/oidc/realms/demo/.well-known/openid-configuration. Using the OIDC discovery simplifies the `oidc` security configuration, allowing to just configure the discovery and let Symfony store the configuration and the keyset in cache. For instance, if the _userinfo_endpoint_ or _signature keyset_ change on the OIDC server, no need to update the environment variables in the Symfony application, just clear the corresponding cache and it'll retrieve the configuration and the keyset accordingly on the next request. In the `oidc_user_info` security configuration, it does the same logic but only about _userinfo_endpoint_ as this token handler doesn't need the _keyset_. ### How Do I Use This New Feature in Symfony? The current `oidc` token handler configuration requires a `keyset` option which may change on the OIDC server. It is configured as following: ```yaml # config/packages/security.yaml security: firewalls: main: access_token: oidc: claim: 'email' audience: 'symfony' issuers: ['https://example.com/'] algorithms: ['RS256'] keyset: '{"keys":[{"kty":"EC",...}]}' ``` > Note: those parameters should be configured with environment variables. With the `discovery` option, Symfony will retrieve the `keyset` directly from the OIDC discovery URI and store it in a cache: ```yaml # config/packages/security.yaml security: firewalls: main: access_token: oidc: # 'keyset' option is not necessary anymore as it's retrieved from OIDC discovery and stored in cache claim: 'email' audience: 'symfony' issuers: ['https://example.com/'] algorithms: ['RS256'] discovery: base_uri: 'https://example.com/oidc/realms/master/' cache: id: cache.app # require to create this cache in framework.yaml ``` > Note: some other parameters might be retrieven from the OIDC discovery, maybe 'algorithm' or 'issuers'. To discuss. The current `oidc_user_info` token handler required a `base_uri` corresponding to the _userinfo_endpoint_ URI on the OIDC server. This URI may change if it's changed on the OIDC server. Introducing the discovery helps to configure it dynamically. The current configuration looks like the following: ```yaml # config/packages/security.yaml security: firewalls: main: access_token: oidc_user_info: # 'base_uri' is the userinfo_endpoint URI base_uri: 'https://example.com/oidc/realms/master/protocol/openid-connect/userinfo' claim: 'email' ``` With the `discovery`, it will look like this: ```yaml # config/packages/security.yaml security: firewalls: main: access_token: oidc_user_info: # 'base_uri' can be the userinfo_endpoint for backward compatibility # and can be the OIDC server url in addition of 'discovery' option base_uri: 'https://example.com/oidc/realms/master/' claim: 'email' discovery: cache: id: cache.app # require to create this cache in framework.yaml ``` Commits ------- 93f369a feat(security): OIDC discovery
2 parents 46e00d5 + 93f369a commit 8b9ed36

File tree

10 files changed

+271
-14
lines changed

10 files changed

+271
-14
lines changed

UPGRADE-7.3.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ Security
6060
* Add argument `$accessDecision` to `AccessDecisionManagerInterface::decide()` and `AuthorizationCheckerInterface::isGranted()`;
6161
it should be used to report the reason of a decision, including all the related votes.
6262

63+
* Add discovery support to `OidcTokenHandler` and `OidcUserInfoTokenHandler`
64+
6365
Console
6466
-------
6567

src/Symfony/Bundle/SecurityBundle/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ CHANGELOG
1010
* Deprecate the `security.hide_user_not_found` config option in favor of `security.expose_security_errors`
1111
* Add ability to fetch LDAP roles
1212
* Add `OAuth2TokenHandlerFactory` for `AccessTokenFactory`
13+
* Add discovery support to `OidcTokenHandler` and `OidcUserInfoTokenHandler`
1314

1415
7.2
1516
---

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
@@ -92,6 +92,11 @@
9292
service('clock'),
9393
])
9494

95+
->set('security.access_token_handler.oidc_discovery.http_client', HttpClientInterface::class)
96+
->abstract()
97+
->factory([service('http_client'), 'withOptions'])
98+
->args([abstract_arg('http client options')])
99+
95100
->set('security.access_token_handler.oidc.jwk', JWK::class)
96101
->abstract()
97102
->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: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ public function testInvalidOidcTokenHandlerConfigurationKeyMissing()
114114
$factory = new AccessTokenFactory($this->createTokenHandlerFactories());
115115

116116
$this->expectException(InvalidConfigurationException::class);
117-
$this->expectExceptionMessage('The child config "keyset" under "access_token.token_handler.oidc" must be configured: JSON-encoded JWKSet used to sign the token (must contain a list of valid public keys).');
117+
$this->expectExceptionMessage('You must set either "discovery" or "key" or "keyset".');
118118

119119
$this->processConfig($config, $factory);
120120
}
@@ -340,6 +340,58 @@ public function testInvalidOidcTokenHandlerConfigurationMissingAlgorithm()
340340
$this->processConfig($config, $factory);
341341
}
342342

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

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

0 commit comments

Comments
 (0)