Skip to content

Commit 97175fc

Browse files
feat(security): OIDC discovery
1 parent 7633bc9 commit 97175fc

File tree

6 files changed

+178
-9
lines changed

6 files changed

+178
-9
lines changed

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
---

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()

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')

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')

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 = [

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)