Skip to content

Commit bbecc4d

Browse files
committed
[Security] Allow multiple OIDC discovery base URIs
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 bbecc4d

File tree

6 files changed

+301
-55
lines changed

6 files changed

+301
-55
lines changed

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

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -48,16 +48,19 @@ public function create(ContainerBuilder $container, string $id, array|string $co
4848

4949
// disable JWKSet argument
5050
$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-
);
51+
52+
$clients = [];
53+
foreach ($config['discovery']['base_uri'] as $uri) {
54+
$clients[] = (new ChildDefinition('security.access_token_handler.oidc_discovery.http_client'))
55+
->replaceArgument(0, ['base_uri' => $uri]);
56+
}
57+
58+
$tokenHandlerDefinition->addMethodCall('enableDiscovery', [
59+
new Reference($config['discovery']['cache']['id']),
60+
$clients,
61+
"$id.oidc_configuration",
62+
"$id.oidc_jwk_set",
63+
]);
6164

6265
return;
6366
}
@@ -93,7 +96,7 @@ public function create(ContainerBuilder $container, string $id, array|string $co
9396
;
9497
}
9598

96-
$firewall = substr($id, strlen('security.access_token_handler.'));
99+
$firewall = substr($id, \strlen('security.access_token_handler.'));
97100
$container->getDefinition('security.access_token_handler.oidc.command.generate')
98101
->addMethodCall('addGenerator', [
99102
$firewall,
@@ -157,10 +160,11 @@ public function addConfiguration(NodeBuilder $node): void
157160
->arrayNode('discovery')
158161
->info('Enable the OIDC discovery.')
159162
->children()
160-
->scalarNode('base_uri')
163+
->arrayNode('base_uri')
164+
->beforeNormalization()->castToArray()->end()
161165
->info('Base URI of the OIDC server.')
162166
->isRequired()
163-
->cannotBeEmpty()
167+
->scalarPrototype()->end()
164168
->end()
165169
->arrayNode('cache')
166170
->children()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
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+
]);

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

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -384,8 +384,69 @@ public function testOidcTokenHandlerConfigurationWithDiscovery()
384384
'enableDiscovery',
385385
[
386386
new Reference('oidc_cache'),
387-
(new ChildDefinition('security.access_token_handler.oidc_discovery.http_client'))
387+
[
388+
(new ChildDefinition('security.access_token_handler.oidc_discovery.http_client'))
388389
->replaceArgument(0, ['base_uri' => 'https://www.example.com/realms/demo/']),
390+
],
391+
'security.access_token_handler.firewall1.oidc_configuration',
392+
'security.access_token_handler.firewall1.oidc_jwk_set',
393+
],
394+
],
395+
];
396+
$this->assertEquals($expectedArgs, $container->getDefinition('security.access_token_handler.firewall1')->getArguments());
397+
$this->assertEquals($expectedCalls, $container->getDefinition('security.access_token_handler.firewall1')->getMethodCalls());
398+
}
399+
400+
public function testOidcTokenHandlerConfigurationWithMultipleDiscoveryBaseUri()
401+
{
402+
$container = new ContainerBuilder();
403+
$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"}]}';
404+
$config = [
405+
'token_handler' => [
406+
'oidc' => [
407+
'discovery' => [
408+
'base_uri' => [
409+
'https://www.example.com/realms/demo/',
410+
'https://www.api.com/realms/api/',
411+
],
412+
'cache' => [
413+
'id' => 'oidc_cache',
414+
],
415+
],
416+
'algorithms' => ['RS256', 'ES256'],
417+
'issuers' => ['https://www.example.com'],
418+
'audience' => 'audience',
419+
],
420+
],
421+
];
422+
423+
$factory = new AccessTokenFactory($this->createTokenHandlerFactories());
424+
$finalizedConfig = $this->processConfig($config, $factory);
425+
426+
$factory->createAuthenticator($container, 'firewall1', $finalizedConfig, 'userprovider');
427+
428+
$this->assertTrue($container->hasDefinition('security.authenticator.access_token.firewall1'));
429+
$this->assertTrue($container->hasDefinition('security.access_token_handler.firewall1'));
430+
431+
$expectedArgs = [
432+
'index_0' => (new ChildDefinition('security.access_token_handler.oidc.signature'))
433+
->replaceArgument(0, ['RS256', 'ES256']),
434+
'index_1' => null,
435+
'index_2' => 'audience',
436+
'index_3' => ['https://www.example.com'],
437+
'index_4' => 'sub',
438+
];
439+
$expectedCalls = [
440+
[
441+
'enableDiscovery',
442+
[
443+
new Reference('oidc_cache'),
444+
[
445+
(new ChildDefinition('security.access_token_handler.oidc_discovery.http_client'))
446+
->replaceArgument(0, ['base_uri' => 'https://www.example.com/realms/demo/']),
447+
(new ChildDefinition('security.access_token_handler.oidc_discovery.http_client'))
448+
->replaceArgument(0, ['base_uri' => 'https://www.api.com/realms/api/']),
449+
],
389450
'security.access_token_handler.firewall1.oidc_configuration',
390451
'security.access_token_handler.firewall1.oidc_jwk_set',
391452
],

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

Lines changed: 52 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,14 @@ final class OidcTokenHandler implements AccessTokenHandlerInterface
4848
private bool $enforceEncryption = false;
4949

5050
private ?CacheInterface $discoveryCache = null;
51-
private ?HttpClientInterface $discoveryClient = null;
5251
private ?string $oidcConfigurationCacheKey = null;
5352
private ?string $oidcJWKSetCacheKey = null;
5453

54+
/**
55+
* @var HttpClientInterface[] $discoveryClients
56+
*/
57+
private array $discoveryClients = [];
58+
5559
public function __construct(
5660
private Algorithm|AlgorithmManager $signatureAlgorithm,
5761
private JWK|JWKSet|null $signatureKeyset,
@@ -78,10 +82,13 @@ public function enableJweSupport(JWKSet $decryptionKeyset, AlgorithmManager $dec
7882
$this->enforceEncryption = $enforceEncryption;
7983
}
8084

81-
public function enableDiscovery(CacheInterface $cache, HttpClientInterface $client, string $oidcConfigurationCacheKey, string $oidcJWKSetCacheKey): void
85+
/**
86+
* @param HttpClientInterface|HttpClientInterface[] $client
87+
*/
88+
public function enableDiscovery(CacheInterface $cache, array|HttpClientInterface $client, string $oidcConfigurationCacheKey, string $oidcJWKSetCacheKey): void
8289
{
8390
$this->discoveryCache = $cache;
84-
$this->discoveryClient = $client;
91+
$this->discoveryClients = (array) $client;
8592
$this->oidcConfigurationCacheKey = $oidcConfigurationCacheKey;
8693
$this->oidcJWKSetCacheKey = $oidcJWKSetCacheKey;
8794
}
@@ -92,45 +99,52 @@ 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->discoveryClients && !$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']);
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-
}
107+
if ($this->discoveryClients) {
108+
$json = $this->discoveryCache->get($this->oidcJWKSetCacheKey, function (): string {
109+
try {
110+
$configResponses = [];
111+
foreach ($this->discoveryClients as $discoveryClient) {
112+
$configResponses[] = $discoveryClient->request('GET', '.well-known/openid-configuration');
113+
}
114+
115+
$jwkSetResponses = [];
116+
foreach ($discoveryClient->stream($configResponses) as $response => $chunk) {
117+
if (! $chunk->isLast()) {
118+
continue;
119+
}
120+
121+
$oidcConfiguration = $response->toArray();
122+
$jwkSetResponses[] = $discoveryClient->request('GET', $oidcConfiguration['jwks_uri']);
123+
}
124+
125+
$keys = [];
126+
foreach ($jwkSetResponses as $response) {
127+
foreach ($response->toArray()['keys'] as $key) {
128+
if ('sig' !== $key['use']) {
129+
continue;
130+
}
131+
132+
$keys[] = $key;
133+
}
134+
}
135+
} catch (\Exception $e) {
136+
$this->logger?->error('An error occurred while requesting OIDC certs.', [
137+
'error' => $e->getMessage(),
138+
'trace' => $e->getTraceAsString(),
139+
]);
140+
141+
throw new BadCredentialsException('Invalid credentials.', $e->getCode(), $e);
142+
}
143+
144+
return \json_encode(['keys' => $keys], \JSON_THROW_ON_ERROR);
145+
});
146+
147+
$jwkset = JWKSet::createFromJson($json);
134148
}
135149

136150
try {

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 base URIs
3940

4041
7.3
4142
---

0 commit comments

Comments
 (0)