Skip to content

Commit

Permalink
feat(security): OIDC discovery
Browse files Browse the repository at this point in the history
  • Loading branch information
vincentchalamon committed Feb 26, 2025
1 parent c0c0a8f commit 93f369a
Show file tree
Hide file tree
Showing 10 changed files with 271 additions and 14 deletions.
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

0 comments on commit 93f369a

Please sign in to comment.