Skip to content

Commit c6bcc32

Browse files
committed
[Security] Allow multiple OIDC discovery endpoints
When a firewall accepts tokens from multiple identity providers, it needs to validate tokens against different OIDC discovery endpoints. This change allows configuring multiple named discovery servers instead of just one, while keeping backward compatibility.
1 parent 570cefa commit c6bcc32

File tree

6 files changed

+329
-68
lines changed

6 files changed

+329
-68
lines changed

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

Lines changed: 38 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ class OidcTokenHandlerFactory implements TokenHandlerFactoryInterface
2828
{
2929
public function create(ContainerBuilder $container, string $id, array|string $config): void
3030
{
31+
\assert(\is_array($config));
32+
3133
$tokenHandlerDefinition = $container->setDefinition($id, (new ChildDefinition('security.access_token_handler.oidc'))
3234
->replaceArgument(2, $config['audience'])
3335
->replaceArgument(3, $config['issuers'])
@@ -41,23 +43,26 @@ public function create(ContainerBuilder $container, string $id, array|string $co
4143
$tokenHandlerDefinition->replaceArgument(0, (new ChildDefinition('security.access_token_handler.oidc.signature'))
4244
->replaceArgument(0, $config['algorithms']));
4345

44-
if (isset($config['discovery'])) {
46+
if ($config['discovery']) {
4547
if (!ContainerBuilder::willBeAvailable('symfony/http-client', HttpClientInterface::class, ['symfony/security-bundle'])) {
4648
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".');
4749
}
4850

4951
// disable JWKSet argument
5052
$tokenHandlerDefinition->replaceArgument(1, null);
51-
$tokenHandlerDefinition->addMethodCall(
52-
'enableDiscovery',
53-
[
54-
new Reference($config['discovery']['cache']['id']),
55-
(new ChildDefinition('security.access_token_handler.oidc_discovery.http_client'))
56-
->replaceArgument(0, ['base_uri' => $config['discovery']['base_uri']]),
57-
"$id.oidc_configuration",
58-
"$id.oidc_jwk_set",
59-
]
60-
);
53+
54+
foreach ($config['discovery'] as $name => $discovery) {
55+
$tokenHandlerDefinition->addMethodCall(
56+
'enableDiscovery',
57+
[
58+
new Reference($discovery['cache']['id']),
59+
(new ChildDefinition('security.access_token_handler.oidc_discovery.http_client'))
60+
->replaceArgument(0, ['base_uri' => $discovery['base_uri']]),
61+
'default' === $name && 1 === \count($config['discovery']) ? "$id.oidc_configuration" : "$id.$name.oidc_configuration",
62+
'default' === $name && 1 === \count($config['discovery']) ? "$id.oidc_jwk_set" : "$id.$name.oidc_jwk_set",
63+
]
64+
);
65+
}
6166

6267
return;
6368
}
@@ -93,7 +98,7 @@ public function create(ContainerBuilder $container, string $id, array|string $co
9398
;
9499
}
95100

96-
$firewall = substr($id, strlen('security.access_token_handler.'));
101+
$firewall = substr($id, \strlen('security.access_token_handler.'));
97102
$container->getDefinition('security.access_token_handler.oidc.command.generate')
98103
->addMethodCall('addGenerator', [
99104
$firewall,
@@ -123,7 +128,7 @@ public function addConfiguration(NodeBuilder $node): void
123128
->thenInvalid('You must set either "algorithm" or "algorithms".')
124129
->end()
125130
->validate()
126-
->ifTrue(static fn ($v) => !isset($v['discovery']) && !isset($v['key']) && !isset($v['keyset']))
131+
->ifTrue(static fn ($v) => !$v['discovery'] && !isset($v['key']) && !isset($v['keyset']))
127132
->thenInvalid('You must set either "discovery" or "key" or "keyset".')
128133
->end()
129134
->beforeNormalization()
@@ -150,24 +155,30 @@ public function addConfiguration(NodeBuilder $node): void
150155
$v['keyset'] = \sprintf('{"keys":[%s]}', $v['key']);
151156
}
152157

158+
if (isset($v['discovery']['base_uri'])) {
159+
$v['discovery'] = ['default' => $v['discovery']];
160+
}
161+
153162
return $v;
154163
})
155164
->end()
156165
->children()
157-
->arrayNode('discovery')
158-
->info('Enable the OIDC discovery.')
159-
->children()
160-
->scalarNode('base_uri')
161-
->info('Base URI of the OIDC server.')
162-
->isRequired()
163-
->cannotBeEmpty()
164-
->end()
165-
->arrayNode('cache')
166-
->children()
167-
->scalarNode('id')
168-
->info('Cache service id to use to cache the OIDC discovery configuration.')
169-
->isRequired()
170-
->cannotBeEmpty()
166+
->arrayNode('discovery')
167+
->useAttributeAsKey('name')
168+
->prototype('array')
169+
->children()
170+
->scalarNode('base_uri')
171+
->info('Base URI of the OIDC server.')
172+
->isRequired()
173+
->cannotBeEmpty()
174+
->end()
175+
->arrayNode('cache')
176+
->children()
177+
->scalarNode('id')
178+
->info('Cache service id to use to cache the OIDC discovery configuration.')
179+
->isRequired()
180+
->cannotBeEmpty()
181+
->end()
171182
->end()
172183
->end()
173184
->end()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
$container->loadFromExtension('security', [
4+
'providers' => [
5+
'default' => [
6+
'memory' => null,
7+
],
8+
],
9+
'firewalls' => [
10+
'firewall1' => [
11+
'provider' => 'default',
12+
'access_token' => [
13+
'token_handler' => [
14+
'oidc_user_info' => [
15+
'example' => [
16+
'base_uri' => 'https://www.example.com/realms/demo/protocol/openid-connect/userinfo',
17+
'discovery' => [
18+
'cache' => [
19+
'id' => 'oidc_cache',
20+
],
21+
],
22+
],
23+
'github' => [
24+
'base_uri' => 'https://www.github.com/realms/demo/protocol/openid-connect/userinfo',
25+
'discovery' => [
26+
'cache' => [
27+
'id' => 'oidc_cache_github',
28+
],
29+
],
30+
],
31+
],
32+
],
33+
],
34+
],
35+
],
36+
]);
37+

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

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,76 @@ public function testOidcTokenHandlerConfigurationWithDiscovery()
395395
$this->assertEquals($expectedCalls, $container->getDefinition('security.access_token_handler.firewall1')->getMethodCalls());
396396
}
397397

398+
public function testOidcTokenHandlerConfigurationWithMultipleDiscovery()
399+
{
400+
$container = new ContainerBuilder();
401+
$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"}]}';
402+
$config = [
403+
'token_handler' => [
404+
'oidc' => [
405+
'discovery' => [
406+
'demo' => [
407+
'base_uri' => 'https://www.example.com/realms/demo/',
408+
'cache' => [
409+
'id' => 'oidc_cache',
410+
],
411+
],
412+
'api' => [
413+
'base_uri' => 'https://www.api.com/realms/api/',
414+
'cache' => [
415+
'id' => 'oidc_cache_for_api',
416+
],
417+
],
418+
],
419+
'algorithms' => ['RS256', 'ES256'],
420+
'issuers' => ['https://www.example.com'],
421+
'audience' => 'audience',
422+
],
423+
],
424+
];
425+
426+
$factory = new AccessTokenFactory($this->createTokenHandlerFactories());
427+
$finalizedConfig = $this->processConfig($config, $factory);
428+
429+
$factory->createAuthenticator($container, 'firewall1', $finalizedConfig, 'userprovider');
430+
431+
$this->assertTrue($container->hasDefinition('security.authenticator.access_token.firewall1'));
432+
$this->assertTrue($container->hasDefinition('security.access_token_handler.firewall1'));
433+
434+
$expectedArgs = [
435+
'index_0' => (new ChildDefinition('security.access_token_handler.oidc.signature'))
436+
->replaceArgument(0, ['RS256', 'ES256']),
437+
'index_1' => null,
438+
'index_2' => 'audience',
439+
'index_3' => ['https://www.example.com'],
440+
'index_4' => 'sub',
441+
];
442+
$expectedCalls = [
443+
[
444+
'enableDiscovery',
445+
[
446+
new Reference('oidc_cache'),
447+
(new ChildDefinition('security.access_token_handler.oidc_discovery.http_client'))
448+
->replaceArgument(0, ['base_uri' => 'https://www.example.com/realms/demo/']),
449+
'security.access_token_handler.firewall1.demo.oidc_configuration',
450+
'security.access_token_handler.firewall1.demo.oidc_jwk_set',
451+
],
452+
],
453+
[
454+
'enableDiscovery',
455+
[
456+
new Reference('oidc_cache_for_api'),
457+
(new ChildDefinition('security.access_token_handler.oidc_discovery.http_client'))
458+
->replaceArgument(0, ['base_uri' => 'https://www.api.com/realms/api/']),
459+
'security.access_token_handler.firewall1.api.oidc_configuration',
460+
'security.access_token_handler.firewall1.api.oidc_jwk_set',
461+
],
462+
],
463+
];
464+
$this->assertEquals($expectedArgs, $container->getDefinition('security.access_token_handler.firewall1')->getArguments());
465+
$this->assertEquals($expectedCalls, $container->getDefinition('security.access_token_handler.firewall1')->getMethodCalls());
466+
}
467+
398468
public function testOidcUserInfoTokenHandlerConfigurationWithExistingClient()
399469
{
400470
$container = new ContainerBuilder();

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

Lines changed: 52 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,15 @@ final class OidcTokenHandler implements AccessTokenHandlerInterface
4747
private ?AlgorithmManager $decryptionAlgorithms = null;
4848
private bool $enforceEncryption = false;
4949

50-
private ?CacheInterface $discoveryCache = null;
51-
private ?HttpClientInterface $discoveryClient = null;
52-
private ?string $oidcConfigurationCacheKey = null;
53-
private ?string $oidcJWKSetCacheKey = null;
50+
/**
51+
* @var list<array{
52+
* cache: CacheInterface,
53+
* client: HttpClientInterface,
54+
* oidcConfigurationCacheKey: string,
55+
* oidcJWKSetCacheKey: string
56+
* }>
57+
*/
58+
private array $discovery = [];
5459

5560
public function __construct(
5661
private Algorithm|AlgorithmManager $signatureAlgorithm,
@@ -80,10 +85,12 @@ public function enableJweSupport(JWKSet $decryptionKeyset, AlgorithmManager $dec
8085

8186
public function enableDiscovery(CacheInterface $cache, HttpClientInterface $client, string $oidcConfigurationCacheKey, string $oidcJWKSetCacheKey): void
8287
{
83-
$this->discoveryCache = $cache;
84-
$this->discoveryClient = $client;
85-
$this->oidcConfigurationCacheKey = $oidcConfigurationCacheKey;
86-
$this->oidcJWKSetCacheKey = $oidcJWKSetCacheKey;
88+
$this->discovery[] = [
89+
'cache' => $cache,
90+
'client' => $client,
91+
'oidcConfigurationCacheKey' => $oidcConfigurationCacheKey,
92+
'oidcJWKSetCacheKey' => $oidcJWKSetCacheKey,
93+
];
8794
}
8895

8996
public function getUserBadgeFrom(string $accessToken): UserBadge
@@ -92,44 +99,51 @@ public function getUserBadgeFrom(string $accessToken): UserBadge
9299
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".');
93100
}
94101

95-
if (!$this->discoveryCache && !$this->signatureKeyset) {
102+
if (!$this->discovery && !$this->signatureKeyset) {
96103
throw new \LogicException('You cannot use the "oidc" token handler without JWKSet nor "discovery". Please configure JWKSet in the constructor, or call "enableDiscovery" method.');
97104
}
98105

99106
$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']);
107+
if ($this->discovery) {
108+
$jwkset = new JWKSet([]);
109+
foreach ($this->discovery as ['cache' => $discoveryCache, 'client' => $discoveryClient, 'oidcConfigurationCacheKey' => $oidcConfigurationCacheKey, 'oidcJWKSetCacheKey' => $oidcJWKSetCacheKey]) {
110+
try {
111+
$oidcConfiguration = json_decode($discoveryCache->get($oidcConfigurationCacheKey, function () use ($discoveryClient): string {
112+
$response = $discoveryClient->request('GET', '.well-known/openid-configuration');
113+
114+
return $response->getContent();
115+
}), true, 512, \JSON_THROW_ON_ERROR);
116+
} catch (\Throwable $e) {
117+
$this->logger?->error('An error occurred while requesting OIDC configuration.', [
118+
'error' => $e->getMessage(),
119+
'trace' => $e->getTraceAsString(),
120+
]);
121+
122+
throw new BadCredentialsException('Invalid credentials.', $e->getCode(), $e);
123+
}
124+
125+
try {
126+
$json = $discoveryCache->get($oidcJWKSetCacheKey, function () use (
127+
$discoveryClient,
128+
$oidcConfiguration): string {
129+
$response = $discoveryClient->request('GET', $oidcConfiguration['jwks_uri']);
120130
// we only need signature key
121131
$keys = array_filter($response->toArray()['keys'], static fn (array $key) => 'sig' === $key['use']);
122132

123133
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);
134+
});
135+
136+
foreach (JWKSet::createFromJson($json) as $key) {
137+
$jwkset = $jwkset->with($key);
138+
}
139+
} catch (\Throwable $e) {
140+
$this->logger?->error('An error occurred while requesting OIDC certs.', [
141+
'error' => $e->getMessage(),
142+
'trace' => $e->getTraceAsString(),
143+
]);
144+
145+
throw new BadCredentialsException('Invalid credentials.', $e->getCode(), $e);
146+
}
133147
}
134148
}
135149

src/Symfony/Component/Security/Http/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ CHANGELOG
3636
* Allow subclassing `#[IsGranted]`
3737
* Add `$tokenSource` argument to `#[IsCsrfTokenValid]` to support reading tokens from the query string or headers
3838
* Deprecate `RememberMeDetails::getUserFqcn()`, the user FQCN will be removed from the remember-me cookie in 8.0
39+
* Allow configuring multiple OIDC discovery endpoints
3940

4041
7.3
4142
---

0 commit comments

Comments
 (0)