8000 [Security][SecurityBundle] OIDC discovery by vincentchalamon · Pull Request #54932 · symfony/symfony · GitHub
[go: up one dir, main page]

Skip to content

[Security][SecurityBundle] OIDC discovery #54932

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Mar 1, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions UPGRADE-7.3.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ Security
* Add argument `$accessDecision` to `AccessDecisionManagerInterface::decide()` and `AuthorizationCheckerInterface::isGranted()`;
it should be used to report the reason of a decision, including all the related votes.

* Add discovery support to `OidcTokenHandler` and `OidcUserInfoTokenHandler`

Console
-------

Expand Down
1 change: 1 addition & 0 deletions src/Symfony/Bundle/SecurityBundle/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ CHANGELOG
* Deprecate the `security.hide_user_not_found` config option in favor of `security.expose_security_errors`
* Add ability to fetch LDAP roles
* Add `OAuth2TokenHandlerFactory` for `AccessTokenFactory`
* Add discovery support to `OidcTokenHandler` and `OidcUserInfoTokenHandler`

7.2
---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
use Symfony\Component\DependencyInjection\ChildDefinition;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Exception\LogicException;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Contracts\HttpClient\HttpClientInterface;

/**
* Configures a token handler for decoding and validating an OIDC token.
Expand All @@ -38,9 +40,29 @@ public function create(ContainerBuilder $container, string $id, array|string $co
$tokenHandlerDefinition->replaceArgument(0, (new ChildDefinition('security.access_token_handler.oidc.signature'))
->replaceArgument(0, $config['algorithms']));

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

// disable JWKSet argument
$tokenHandlerDefinition->replaceArgument(1, null);
$tokenHandlerDefinition->addMethodCall(
'enableDiscovery',
[
new Reference($config['discovery']['cache']['id']),
(new ChildDefinition('security.access_token_handler.oidc_discovery.http_client'))
->replaceArgument(0, ['base_uri' => $config['discovery']['base_uri']]),
"$id.oidc_configuration",
"$id.oidc_jwk_set",
]
);

return;
}

$tokenHandlerDefinition->replaceArgument(1, (new ChildDefinition('security.access_token_handler.oidc.jwkset'))
->replaceArgument(0, $config['keyset'])
);
->replaceArgument(0, $config['keyset']));

if ($config['encryption']['enabled']) {
$algorithmManager = (new ChildDefinition('security.access_token_handler.oidc.encryption'))
Expand Down Expand Up @@ -74,8 +96,8 @@ public function addConfiguration(NodeBuilder $node): void
->thenInvalid('You must set either "algorithm" or "algorithms".')
->end()
->validate()
->ifTrue(static fn ($v) => !isset($v['key']) && !isset($v['keyset']))
->thenInvalid('You must set either "key" or "keyset".')
->ifTrue(static fn ($v) => !isset($v['discovery']) && !isset($v['key']) && !isset($v['keyset']))
->thenInvalid('You must set either "discovery" or "key" or "keyset".')
->end()
->beforeNormalization()
->ifTrue(static fn ($v) => isset($v['algorithm']) && \is_string($v['algorithm']))
Expand All @@ -101,6 +123,25 @@ public function addConfiguration(NodeBuilder $node): void
})
->end()
->children()
->arrayNode('discovery')
->info('Enable the OIDC discovery.')
->children()
->scalarNode('base_uri')
->info('Base URI of the OIDC server.')
->isRequired()
->cannotBeEmpty()
->end()
->arrayNode('cache')
->children()
->scalarNode('id')
->info('Cache service id to use to cache the OIDC discovery configuration.')
->isRequired()
->cannotBeEmpty()
->end()
->end()
->end()
->end()
->end()
->scalarNode('claim')
->info('Claim which contains the user identifier (e.g.: sub, email..).')
->defaultValue('sub')
Expand Down Expand Up @@ -129,7 +170,6 @@ public function addConfiguration(NodeBuilder $node): void
->end()
->scalarNode('keyset')
->info('JSON-encoded JWKSet used to sign the token (must contain a list of valid public keys).')
->isRequired()
->end()
->arrayNode('encryption')
->canBeEnabled()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Exception\LogicException;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

/**
Expand All @@ -34,9 +35,23 @@ public function create(ContainerBuilder $container, string $id, array|string $co
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".');
}

$container->setDefinition($id, new ChildDefinition('security.access_token_handler.oidc_user_info'))
$tokenHandlerDefinition = $container->setDefinition($id, new ChildDefinition('security.access_token_handler.oidc_user_info'))
->replaceArgument(0, $clientDefinition)
->replaceArgument(2, $config['claim']);

if (isset($config['discovery'])) {
if (!ContainerBuilder::willBeAvailable('symfony/cache', CacheInterface::class, ['symfony/security-bundle'])) {
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".');
}

$tokenHandlerDefinition->addMethodCall(
'enableDiscovery',
[
new Reference($config['discovery']['cache']['id']),
"$id.oidc_configuration",
]
);
}
}

public function getKey(): string
Expand All @@ -55,10 +70,24 @@ public function addConfiguration(NodeBuilder $node): void
->end()
->children()
->scalarNode('base_uri')
->info('Base URI of the userinfo endpoint on the OIDC server.')
->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).')
->isRequired()
->cannotBeEmpty()
->end()
->arrayNode('discovery')
->info('Enable the OIDC discovery.')
->children()
->arrayNode('cache')
->children()
->scalarNode('id')
->info('Cache service id to use to cache the OIDC discovery configuration.')
->isRequired()
->cannotBeEmpty()
->end()
->end()
->end()
->end()
->end()
->scalarNode('claim')
->info('Claim which contains the user identifier (e.g. sub, email, etc.).')
->defaultValue('sub')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@
service('clock'),
])

->set('security.access_token_handler.oidc_discovery.http_client', HttpClientInterface::class)
->abstract()
->factory([service('http_client'), 'withOptions'])
->args([abstract_arg('http client options')])

->set('security.access_token_handler.oidc.jwk', JWK::class)
->abstract()
->deprecate('symfony/security-http', '7.1', 'The "%service_id%" service is deprecated. Please use "security.access_token_handler.oidc.jwkset" instead')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ public function testInvalidOidcTokenHandlerConfigurationKeyMissing()
$factory = new AccessTokenFactory($this->createTokenHandlerFactories());

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

$this->processConfig($config, $factory);
}
Expand Down Expand Up @@ -340,6 +340,58 @@ public function testInvalidOidcTokenHandlerConfigurationMissingAlgorithm()
$this->processConfig($config, $factory);
}

public function testOidcTokenHandlerConfigurationWithDiscovery()
{
$container = new ContainerBuilder();
$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"}]}';
$config = [
'token_handler' => [
'oidc' => [
'discovery' => [
'base_uri' => 'https://www.example.com/realms/demo/',
'cache' => [
'id' => 'oidc_cache',
],
],
'algorithms' => ['RS256', 'ES256'],
'issuers' => ['https://www.example.com'],
'audience' => 'audience',
],
],
];

$factory = new AccessTokenFactory($this->createTokenHandlerFactories());
$finalizedConfig = $this->processConfig($config, $factory);

$factory->createAuthenticator($container, 'firewall1', $finalizedConfig, 'userprovider');

$this->assertTrue($container->hasDefinition('security.authenticator.access_token.firewall1'));
$this->assertTrue($container->hasDefinition('security.access_token_handler.firewall1'));

$expectedArgs = [
'index_0' => (new ChildDefinition('security.access_token_handler.oidc.signature'))
->replaceArgument(0, ['RS256', 'ES256']),
'index_1' => null,
'index_2' => 'audience',
'index_3' => ['https://www.example.com'],
'index_4' => 'sub',
];
$expectedCalls = [
[
'enableDiscovery',
[
new Reference('oidc_cache'),
(new ChildDefinition('security.access_token_handler.oidc_discovery.http_client'))
->replaceArgument(0, ['base_uri' => 'https://www.example.com/realms/demo/']),
'security.access_token_handler.firewall1.oidc_configuration',
'security.access_token_handler.firewall1.oidc_jwk_set',
],
],
];
$this->assertEquals($expectedArgs, $container->getDefinition('security.access_token_handler.firewall1')->getArguments());
$this->assertEquals($expectedCalls, $container->getDefinition('security.access_token_handler.firewall1')->getMethodCalls());
}

public function testOidcUserInfoTokenHandlerConfigurationWithExistingClient()
{
$container = new ContainerBuilder();
Expand Down Expand Up @@ -407,6 +459,48 @@ public static function getOidcUserInfoConfiguration(): iterable
yield ['https://www.example.com/realms/demo/protocol/openid-connect/userinfo'];
}

public function testOidcUserInfoTokenHandlerConfigurationWithDiscovery()
{
$container = new ContainerBuilder();
$config = [
'token_handler' => [
'oidc_user_info' => [
'discovery' => [
'cache' => [
'id' => 'oidc_cache',
],
],
'base_uri' => 'https://www.example.com/realms/demo/protocol/openid-connect/userinfo',
],
],
];

$factory = new AccessTokenFactory($this->createTokenHandlerFactories());
$finalizedConfig = $this->processConfig($config, $factory);

$factory->createAuthenticator($container, 'firewall1', $finalizedConfig, 'userprovider');

$this->assertTrue($container->hasDefinition('security.authenticator.access_token.firewall1'));
$this->assertTrue($container->hasDefinition('security.access_token_handler.firewall1'));

$expectedArgs = [
'index_0' => (new ChildDefinition('security.access_token_handler.oidc_user_info.http_client'))
->replaceArgument(0, ['base_uri' => 'https://www.example.com/realms/demo/protocol/openid-connect/userinfo']),
'index_2' => 'sub',
];
$expectedCalls = [
[
'enableDiscovery',
[
new Reference('oidc_cache'),
'security.access_token_handler.firewall1.oidc_configuration',
],
],
];
$this->assertEquals($expectedArgs, $container->getDefinition('security.access_token_handler.firewall1')->getArguments());
$this->assertEquals($expectedCalls, $container->getDefinition('security.access_token_handler.firewall1')->getMethodCalls());
}

public function testMultipleTokenHandlersSet()
{
$config = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ security:
claim: 'username'
audience: 'Symfony OIDC'
issuers: [ 'https://www.example.com' ]
algorithm: 'ES256'
algorithms: [ 'ES256' ]
# tip: use https://mkjwk.org/ to generate a JWK
keyset: '{"keys":[{"kty":"EC","d":"iA_TV2zvftni_9aFAQwFO_9aypfJFCSpcCyevDvz220","crv":"P-256","x":"0QEAsI1wGI-dmYatdUZoWSRWggLEpyzopuhwk-YUnA4","y":"KYl-qyZ26HobuYwlQh-r0iHX61thfP82qqEku7i0woo"}]}'
encryption:
Expand Down
Loading
Loading
0