10000 feat(security): OIDC discovery · symfony/symfony@7762b2b · GitHub
[go: up one dir, main page]

Skip to content

Commit 7762b2b

Browse files
feat(security): OIDC discovery
1 parent a86878f commit 7762b2b

File tree

10 files changed

+268
-12
lines changed

10 files changed

+268
-12
lines changed

UPGRADE-7.2.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ Security
7676
* Deprecate argument `$secret` of `RememberMeToken` and `RememberMeAuthenticator`
7777
* Deprecate passing an empty string as `$userIdentifier` argument to `UserBadge` constructor
7878
* Deprecate returning an empty string in `UserInterface::getUserIdentifier()`
79+
* Add discovery support to `OidcTokenHandler` and `OidcUserInfoTokenHandler`
7980

8081
Serializer
8182
----------

src/Symfony/Bundle/SecurityBundle/CHANGELOG.md

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

77
* Allow configuring the secret used to sign login links
88
* Allow passing optional passport attributes to `Security::login()`
9+
* Add discovery support to `OidcTokenHandler` and `OidcUserInfoTokenHandler`
910

1011
7.1
1112
---

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

4668
public function getKey(): string
@@ -58,8 +80,8 @@ public function addConfiguration(NodeBuilder $node): void
5880
->thenInvalid('You must set either "algorithm" or "algorithms".')
5981
->end()
6082
->validate()
61-
->ifTrue(static fn ($v) => !isset($v['key']) && !isset($v['keyset']))
62-
->thenInvalid('You must set either "key" or "keyset".')
83+
->ifTrue< 10000 /span>(static fn ($v) => !isset($v['discovery']) && !isset($v['key']) && !isset($v['keyset']))
84+
->thenInvalid('You must set either "discovery" or "key" or "keyset".')
6385
->end()
6486
->beforeNormalization()
6587
->ifTrue(static fn ($v) => isset($v['algorithm']) && \is_string($v['algorithm']))
@@ -85,6 +107,25 @@ public function addConfiguration(NodeBuilder $node): void
85107
})
86108
->end()
87109
->children()
110+
->arrayNode('discovery')
111+
->info('Enable the OIDC discovery.')
112+
->children()
113+
->scalarNode('base_uri')
114+
->info('Base URI of the OIDC server.')
115+
->isRequired()
116+
->cannotBeEmpty()
117+
->end()
118+
->arrayNode('cache')
119+
->children()
120+
->scalarNode('id')
121+
->info('Cache service id to use to cache the OIDC discovery configuration.')
122+
->isRequired()
123+
->cannotBeEmpty()
124+
->end()
125+
->end()
126+
->end()
127+
->end()
128+
->end()
88129
->scalarNode('claim')
89130
->info('Claim which contains the user identifier (e.g.: sub, email..).')
90131
->defaultValue('sub')
@@ -113,7 +154,6 @@ public function addConfiguration(NodeBuilder $node): void
113154
->end()
114155
->scalarNode('keyset')
115156
->info('JSON-encoded JWKSet used to sign the token (must contain a list of valid keys).')
116-
->isRequired()
117157
->end()
118158
->end()
119159
->end()

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
@@ -82,6 +82,11 @@
8282
service('clock'),
8383
])
8484

85+
->set('security.access_token_handler.oidc_discovery.http_client', HttpClientInterface::class)
86+
->abstract()
87+
->factory([service('http_client'), 'withOptions'])
88+
->args([abstract_arg('http client options')])
89+
8590
->set('security.access_token_handler.oidc.jwk', JWK::class)
8691
->abstract()
8792
->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
@@ -113,7 +113,7 @@ public function testInvalidOidcTokenHandlerConfigurationKeyMissing()
113113
$factory = new AccessTokenFactory($this->createTokenHandlerFactories());
114114

115115
$this->expectException(InvalidConfigurationException::class);
116-
$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 keys).');
116+
$this->expectExceptionMessage('You must set either "discovery" or "key" or "keyset".');
117117

118118
$this->processConfig($config, $factory);
119119
}
@@ -257,6 +257,58 @@ public function testOidcTokenHandlerConfigurationWithMultipleAlgorithms()
257257
$this->assertEquals($expected, $container->getDefinition('security.access_token_handler.firewall1')->getArguments());
258258
}
259259

260+
public function testOidcTokenHandlerConfigurationWithDiscovery()
261+
{
262+
$container = new ContainerBuilder();
263+
$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"}]}';
264+
$config = [
265+
'token_handler' => [
266+
'oidc' => [
267+
'discovery' => [
268+
'base_uri' => 'https://www.example.com/realms/demo/',
269+
'cache' => [
270+
'id' => 'oidc_cache',
271+
],
272+
],
273+
'algorithms' => ['RS256', 'ES256'],
274+
'issuers' => ['https://www.example.com'],
275+
'audience' => 'audience',
276+
],
277+
],
278+
];
279+
280+
$factory = new AccessTokenFactory($this->createTokenHandlerFactories());
281+
$finalizedConfig = $this->processConfig($config, $factory);
282+
283+
$factory->createAuthenticator($container, 'firewall1', $finalizedConfig, 'userprovider');
284+
285+
$this->assertTrue($container->hasDefinition('security.authenticator.access_token.firewall1'));
286+
$this->assertTrue($container->hasDefinition('security.access_token_handler.firewall1'));
287+
288+
$expectedArgs = [
289+
'index_0' => (new ChildDefinition('security.access_token_handler.oidc.signature'))
290+
->replaceArgument(0, ['RS256', 'ES256']),
291+
'index_1' => null,
292+
'index_2' => 'audience',
293+
'index_3' => ['https://www.example.com'],
294+
'index_4' => 'sub',
295+
];
296+
$expectedCalls = [
297+
[
298+
'enableDiscovery',
299+
[
300+
new Reference('oidc_cache'),
301+
(new ChildDefinition('security.access_token_handler.oidc_discovery.http_client'))
302+
->replaceArgument(0, ['base_uri' => 'https://www.example.com/realms/demo/']),
303+
'security.access_token_handler.firewall1.oidc_configuration',
304+
'security.access_token_handler.firewall1.oidc_jwk_set',
305+
],
306+
],
307+
];
308+
$this->assertEquals($expectedArgs, $container->getDefinition('security.access_token_handler.firewall1')->getArguments());
309+
$this->assertEquals($expectedCalls, $container->getDefinition('security.access_token_handler.firewall1')->getMethodCalls());
310+
}
311+
260312
public function testOidcUserInfoTokenHandlerConfigurationWithExistingClient()
261313
{
262314
$container = new ContainerBuilder();
@@ -324,6 +376,48 @@ public static function getOidcUserInfoConfiguration(): iterable
324376
yield ['https://www.example.com/realms/demo/protocol/openid-connect/userinfo'];
325377
}
326378

379+
public function testOidcUserInfoTokenHandlerConfigurationWithDiscovery()
380+
{
381+
$container = new ContainerBuilder();
382+
$config = [
383+
'token_handler' => [
384+
'oidc_user_info' => [
385+
'discovery' => [
386+
'cache' => [
387+
'id' => 'oidc_cache',
388+
],
389+
],
390+
'base_uri' => 'https://www.example.com/realms/demo/protocol/openid-connect/userinfo',
391+
],
392+
],
393+
];
394+
395+
$factory = new AccessTokenFactory($this->createTokenHandlerFactories());
396+
$finalizedConfig = $this->processConfig($config, $factory);
397+
398+
$factory->createAuthenticator($container, 'firewall1', $finalizedConfig, 'userprovider');
399+
400+
$this->assertTrue($container->hasDefinition('security.authenticator.access_token.firewall1'));
401+
$this->assertTrue($container->hasDefinition('security.access_token_handler.firewall1'));
402+
403+
$expectedArgs = [
404+
'index_0' => (new ChildDefinition('security.access_token_handler.oidc_user_info.http_client'))
405+
->replaceArgument(0, ['base_uri' => 'https://www.example.com/realms/demo/protocol/openid-connect/userinfo']),
406+
'index_2' => 'sub',
407+
];
408+
$expectedCalls = [
409+
[
410+
'enableDiscovery',
411+
[
412+
new Reference('oidc_cache'),
413+
'security.access_token_handler.firewall1.oidc_configuration',
414+
],
415+
],
416+
];
417+
$this->assertEquals($expectedArgs, $container->getDefinition('security.access_token_handler.firewall1')->getArguments());
418+
$this->assertEquals($expectedCalls, $container->getDefinition('security.access_token_handler.firewall1')->getMethodCalls());
419+
}
420+
327421
public function testMultipleTokenHandlersSet()
328422
{
329423
$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
token_extractors: 'header'

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

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
use Symfony\Component\Security\Http\AccessToken\Oidc\Exception\MissingClaimException;
3131
use Symfony\Component\Security\Http\Authenticator\FallbackUserLoader;
3232
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
33+
use Symfony\Contracts\Cache\CacheInterface;
34+
use Symfony\Contracts\HttpClient\HttpClientInterface;
3335

3436
/**
3537
* The token handler decodes and validates the token, and retrieves the user identifier from it.
@@ -38,9 +40,14 @@ final class OidcTokenHandler implements AccessTokenHandlerInterface
3840
{
3941
use OidcTrait;
4042

43+
private ?CacheInterface $discoveryCache = null;
44+
private ?HttpClientInterface $discoveryClient = null;
45+
private ?string $oidcConfigurationCacheKey = null;
46+
private ?string $oidcJWKSetCacheKey = null;
47+
4148
public function __construct(
4249
private Algorithm|AlgorithmManager $signatureAlgorithm,
43-
private JWK|JWKSet $jwkset,
50+
private JWK|JWKSet|null $jwkset,
4451
private string $audience,
4552
private array $issuers,
4653
private string $claim = 'sub',
@@ -57,12 +64,61 @@ public function __construct(
5764
}
5865
}
5966

67+
public function enabledDiscovery(CacheInterface $cache, HttpClientInterface $client, string $oidcConfigurationCacheKey, string $oidcJWKSetCacheKey): void
68+
{
69+
$this->discoveryCache = $cache;
70+
$this->discoveryClient = $client;
71+
$this->oidcConfigurationCacheKey = $oidcConfigurationCacheKey;
72+
$this->oidcJWKSetCacheKey = $oidcJWKSetCacheKey;
73+
}
74+
6075
public function getUserBadgeFrom(string $accessToken): UserBadge
6176
{
6277
if (!class_exists(JWSVerifier::class) || !class_exists(Checker\HeaderCheckerManager::class)) {
6378
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".');
6479
}
6580

81+
if (!$this->discoveryCache && !$this->jwkset) {
82+
throw new \LogicException('You cannot use the "oidc" token handler without JWKSet nor "discovery". Please configure JWKSet in the constructor, or call "enableDiscovery" method.');
83+
}
84+
85+
$jwkset = $this->jwkset;
86+
if ($this->discoveryCache) {
87+
try {
88+
$oidcConfiguration = json_decode($this->discoveryCache->get($this->oidcConfigurationCacheKey, function (): string {
89+
$response = $this->discoveryClient->request('GET', '.well-known/openid-configuration');
90+
91+
return $response->getContent();
92+
}), true, 512, \JSON_THROW_ON_ERROR);
93+
} catch (\Throwable $e) {
94+
$this->logger?->error('An error occurred while requesting OIDC configuration.', [
95+
'error' => $e->getMessage(),
96+
'trace' => $e->getTraceAsString(),
97+
]);
98+
99+
throw new BadCredentialsException('Invalid credentials.', $e->getCode(), $e);
100+
}
101+
102+
try {
103+
$jwkset = JWKSet::createFromJson(
104+
$this->discoveryCache->get($this->oidcJWKSetCacheKey, function () use ($oidcConfiguration): string {
105+
$response = $this->discoveryClient->request('GET', $oidcConfiguration['jwks_uri']);
106+
// we only need signature key
107+
$keys = array_filter($response->toArray()['keys'], static fn (array $key) => 'sig' === $key['use']);
108+
109+
return json_encode(['keys' => $keys]);
110+
})
111+
);
112+
} catch (\Throwable $e) {
113+
$this->logger?->error('An error occurred while requesting OIDC certs.', [
114+
'error' => $e->getMessage(),
115+
'trace' => $e->getTraceAsString(),
116+
]);
117+
118+
throw new BadCredentialsException('Invalid credentials.', $e->getCode(), $e);
119+
}
120+
}
121+
66122
try {
67123
// Decode the token
68124
$jwsVerifier = new JWSVerifier($this->signatureAlgorithm);
@@ -71,7 +127,7 @@ public function getUserBadgeFrom(string $accessToken): UserBadge
71127
$claims = json_decode($jws->getPayload(), true);
72128

73129
// Verify the signature
74-
if (!$jwsVerifier->verifyWithKeySet($jws, $this->jwkset, 0)) {
130+
if (!$jwsVerifier->verifyWithKeySet($jws, $jwkset, 0)) {
75131
throw new InvalidSignatureException();
76132
}
77133

0 commit comments

Comments
 (0)
0