10000 [Security] OAuth2 Introspection Endpoint (RFC7662) · symfony/symfony@432fdf1 · GitHub
[go: up one dir, main page]

Skip to content

Commit 432fdf1

Browse files
committed
[Security] OAuth2 Introspection Endpoint (RFC7662)
In addition to the excellent work of @vincentchalamon #48272, this PR allows getting the data from the OAuth2 Introspection Endpoint. This endpoint is defined in the [RFC7662](https://datatracker.ietf.org/doc/html/rfc7662). It returns the following information that is used to retrieve the user: * If the access token is active * A set of claims that are similar to the OIDC one, including the `sub` or the `username`.
1 parent 1a16ebc commit 432fdf1

File tree

15 files changed

+499
-3
lines changed

15 files changed

+499
-3
lines changed

src/Symfony/Bundle/SecurityBundle/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,14 @@ CHANGELOG
2424
6.4
2525
---
2626

27+
* Add `OAuth2TokenHandlerFactory` for `AccessTokenFactory`
2728
* Deprecate `Security::ACCESS_DENIED_ERROR`, `AUTHENTICATION_ERROR` and `LAST_USERNAME` constants, use the ones on `SecurityRequestAttributes` instead
2829
* Allow an array of `pattern` in firewall configuration
2930
* Add `$badges` argument to `Security::login`
3031
* Deprecate the `require_previous_session` config option. Setting it has no effect anymore
3132
* Add `LogoutRouteLoader`
3233

34+
3335
6.3
3436
---
3537

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken;
13+
14+
use Symfony\Component\Config\Definition\Builder\NodeBuilder;
15+
use Symfony\Component\DependencyInjection\ChildDefinition;
16+
use Symfony\Component\DependencyInjection\ContainerBuilder;
17+
use Symfony\Component\DependencyInjection\Reference;
18+
use Symfony\Component\HttpClient\HttpClient;
19+
20+
/**
21+
* Configures a token handler for an OAuth2 Token Introspection endpoint.
22+
*
23+
* @internal
24+
*/
25+
class OAuth2TokenHandlerFactory implements TokenHandlerFactoryInterface
26+
{
27+
public function create(ContainerBuilder $container, string $id, array|string $config): void
28+
{
29+
$tokenHandlerDefinition = $container->setDefinition($id, new ChildDefinition('security.access_token_handler.oauth2'));
30+
31+
// Create the client service
32+
if (!isset($config['client']['id'])) {
33+
$clientDefinitionId = 'http_client.security.access_token_handler.oauth2';
34+
if (!ContainerBuilder::willBeAvailable('symfony/http-client', HttpClient::class, ['symfony/security-bundle'])) {
35+
$container->register($clientDefinitionId, 'stdClass')
36+
->addError('You cannot use the "oauth2" token handler since the HttpClient component is not installed. Try running "composer require symfony/http-client".');
37+
} else {
38+
$container->register($clientDefinitionId, HttpClient::class)
39+
->setFactory([HttpClient::class, 'create'])
40+
->setArguments([$config['client']])
41+
->addTag('http_client.client');
42+
}
43+
}
44+
45+
$tokenHandlerDefinition->replaceArgument(0, new Reference($config['client']['id'] ?? $clientDefinitionId));
46+
}
47+
48+
public function getKey(): string
49+
{
50+
return 'oauth2';
51+
}
52+
53+
public function addConfiguration(NodeBuilder $node): void
54+
{
55+
$node
56+
->arrayNode($this->getKey())
57+
->fixXmlConfig($this->getKey())
58+
->children()
59+
->arrayNode('client')
60+
->info('HttpClient to call the Introspection Endpoint.')
61+
->isRequired()
62+
->beforeNormalization()
63+
->ifString()
64+
->then(static function ($v): array { return ['id' => $v]; })
65+
->end()
66+
->prototype('scalar')->end()
67+
->end()
68+
->end()
69+
->end()
70+
;
71+
}
72+
}

src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator_access_token.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
use Symfony\Component\Security\Http\AccessToken\ChainAccessTokenExtractor;
2828
use Symfony\Component\Security\Http\AccessToken\FormEncodedBodyExtractor;
2929
use Symfony\Component\Security\Http\AccessToken\HeaderAccessTokenExtractor;
30+
use Symfony\Component\Security\Http\AccessToken\OAuth2\Oauth2TokenHandler;
3031
use Symfony\Component\Security\Http\AccessToken\Oidc\OidcTokenHandler;
3132
use Symfony\Component\Security\Http\AccessToken\Oidc\OidcUserInfoTokenHandler;
3233
use Symfony\Component\Security\Http\AccessToken\QueryAccessTokenExtractor;
@@ -135,5 +136,13 @@
135136

136137
->set('security.access_token_handler.oidc.signature.PS512', PS512::class)
137138
->tag('security.access_token_handler.oidc.signature_algorithm')
139+
140+
// OAuth2 Introspection (RFC 7662)
141+
->set('security.access_token_handler.oauth2', Oauth2TokenHandler::class)
142+
->abstract()
143+
->args([
144+
abstract_arg('http client'),
145+
service('logger')->nullOnInvalid(),
146+
])
138147
;
139148
};

src/Symfony/Bundle/SecurityBundle/SecurityBundle.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\ReplaceDecoratedRememberMeHandlerPass;
2525
use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\SortFirewallListenersPass;
2626
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken\CasTokenHandlerFactory;
27+
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken\OAuth2TokenHandlerFactory;
2728
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken\OidcTokenHandlerFactory;
2829
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken\OidcUserInfoTokenHandlerFactory;
2930
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken\ServiceTokenHandlerFactory;
@@ -80,6 +81,7 @@ public function build(ContainerBuilder $container): void
8081
new OidcUserInfoTokenHandlerFactory(),
8182
new OidcTokenHandlerFactory(),
8283
new CasTokenHandlerFactory(),
84+
new OAuth2TokenHandlerFactory(),
8385
]));
8486

8587
$extension->addUserProviderFactory(new InMemoryFactory());

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
use PHPUnit\Framework\TestCase;
1515
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken\CasTokenHandlerFactory;
16+
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken\OAuth2TokenHandlerFactory;
1617
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken\OidcTokenHandlerFactory;
1718
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken\OidcUserInfoTokenHandlerFactory;
1819
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken\ServiceTokenHandlerFactory;
@@ -341,6 +342,40 @@ public function testMultipleTokenHandlersSet()
341342
$this->processConfig($config, $factory);
342343
}
343344

345+
public function testOAuth2TokenHandlerConfigurationWithExistingClient()
346+
{
347+
$container = new ContainerBuilder();
348+
$config = [
349+
'token_handler' => ['oauth2' => ['client' => 'oauth2.client']],
350+
];
351+
352+
$factory = new AccessTokenFactory($this->createTokenHandlerFactories());
353+
$finalizedConfig = $this->processConfig($config, $factory);
354+
355+
$factory->createAuthenticator($container, 'firewall1', $finalizedConfig, 'userprovider');
356+
357+
$this->assertTrue($container->hasDefinition('security.authenticator.access_token.firewall1'));
358+
$this->assertTrue($container->hasDefinition('security.access_token_handler.firewall1'));
359+
$this->assertFalse($container->hasDefinition('http_client.security.access_token_handler.oauth2'));
360+
}
361+
362+
public function testOAuth2TokenHandlerConfigurationWithClientCreation()
363+
{
364+
$container = new ContainerBuilder();
365+
$config = [
366+
'token_handler' => ['oauth2' => ['client' => ['base_uri' => 'https://www.example.com/realms/demo/protocol/openid-connect/userinfo']]],
367+
];
368+
369+
$factory = new AccessTokenFactory($this->createTokenHandlerFactories());
370+
$finalizedConfig = $this->processConfig($config, $factory);
371+
372+
$factory->createAuthenticator($container, 'firewall1', $finalizedConfig, 'userprovider');
373+
374+
$this->assertTrue($container->hasDefinition('security.authenticator.access_token.firewall1'));
375+
$this->assertTrue($container->hasDefinition('security.access_token_handler.firewall1'));
376+
$this->assertTrue($container->hasDefinition('http_client.security.access_token_handler.oauth2'));
377+
}
378+
344379
public function testNoTokenHandlerSet()
345380
{
346381
$this->expectException(InvalidConfigurationException::class);
@@ -400,6 +435,7 @@ private function createTokenHandlerFactories(): array
400435
new OidcUserInfoTokenHandlerFactory(),
401436
new OidcTokenHandlerFactory(),
402437
new CasTokenHandlerFactory(),
438+
new OAuth2TokenHandlerFactory(),
403439
];
404440
}
405441
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
imports:
2+
- { resource: ./../config/framework.yml }
3+
4+
framework:
5+
http_method_override: false
6+
serializer: ~
7+
http_client:
8+
scoped_clients:
9+
oauth2.client:
10+
scope: 'https://authorization-server\.example\.com'
11+
headers:
12+
Authorization: 'Basic Y2xpZW50OnBhc3N3b3Jk'
13+
14+
security:
15+
password_hashers:
16+
Symfony\Component\Security\Core\User\InMemoryUser: plaintext
17+
18+
providers:
19+
in_memory:
20+
memory:
21+
users:
22+
dunglas: { password: foo, roles: [ROLE_USER] }
23+
24+
firewalls:
25+
main:
26+
pattern: ^/
27+
access_token:
28+
token_handler:
29+
oauth2:
30+
client: 'oauth2.client'
31+
token_extractors: 'header'
32+
realm: 'My API'
33+
34+
access_control:
35+
- { path: ^/foo, roles: ROLE_USER }

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

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

1919
* Make `PersistentToken` immutable
2020
* Deprecate accepting only `DateTime` for `TokenProviderInterface::updateToken()`, use `DateTimeInterface` instead
21+
* Add `OAuth2User` with OAuth2 Access Token Introspection support for `OAuth2TokenHandler`
2122

2223
6.3
2324
---
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Security\Core\Tests\User;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\Security\Core\User\OAuth2User;
16+
17+
class OAuth2UserTest extends TestCase
18+
{
19+
public function testCannotCreateUserWithoutSubProperty()
20+
{
21+
$this->expectException(\InvalidArgumentException::class);
22+
$this->expectExceptionMessage('The claim "sub" or "username" must be provided.');
23+
24+
new OAuth2User();
25+
}
26+
27+
public function testCreateFullUserWithAdditionalClaimsUsingPositionalParameters()
28+
{
29+
$this->assertEquals(new OAuth2User(
30+
scope: 'read write dolphin',
31+
username: 'jdoe',
32+
exp: 1419356238,
33+
iat: 1419350238,
34+
sub: 'Z5O3upPC88QrAjx00dis',
35+
aud: 'https://protected.example.net/resource',
36+
iss: 'https://server.example.com/',
37+
client_id: 'l238j323ds-23ij4',
38+
extension_field: 'twenty-seven'
39+
), new OAuth2User(...[
40+
'client_id' => 'l238j323ds-23ij4',
41+
'username' => 'jdoe',
42+
'scope' => 'read write dolphin',
43+
'sub' => 'Z5O3upPC88QrAjx00dis',
44+
'aud' => 'https://protected.example.net/resource',
45+
'iss' => 'https://server.example.com/',
46+
'exp' => 1419356238,
47+
'iat' => 1419350238,
48+
'extension_field' => 'twenty-seven',
49+
]));
50+
}
51+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Security\Core\User;
13+
14+
/**
15+
* UserInterface implementation used by the access-token security workflow with an OIDC server.
16+
*
17+
* @experimental
18+
*/
19+
class OAuth2User implements UserInterface
20+
{
21+
public readonly array $additionalClaims;
22+
23+
public function __construct(
24+
private array $roles = ['ROLE_USER'],
25+
// Standard Claims (https://datatracker.ietf.org/doc/html/rfc7662#section-2.2)
26+
public readonly ?string $scope = null,
27+
public readonly ?string $clientId = null,
28+
public readonly ?string $username = null,
29+
public readonly ?string $tokenType = null,
30+
public readonly ?int $exp = null,
31+
public readonly ?int $iat = null,
32+
public readonly ?int $nbf = null,
33+
public readonly ?string $sub = null,
34+
public readonly ?string $aud = null,
35+
public readonly ?string $iss = null,
36+
public readonly ?string $jti = null,
37+
38+
// Additional Claims ("
39+
// Specific implementations MAY extend this structure with
40+
// their own service-specific response names as top-level members
41+
// of this JSON object.
42+
// ")
43+
...$additionalClaims
44+
) {
45+
if ((null === $sub || '' === $sub) && (null === $username || '' === $username)) {
46+
throw new \InvalidArgumentException('The claim "sub" or "username" must be provided.');
47+
}
48+
49+
$this->additionalClaims = $additionalClaims['additionalClaims'] ?? $additionalClaims;
50+
}
51+
52+
/**
53+
* OIDC or OAuth specs don't have any "role" notion.
54+
*
55+
* If you want to implement "roles" from your OIDC server,
56+
* send a "roles" constructor argument to this object
57+
* (e.g.: using a custom UserProvider).
58+
*/
59+
public function getRoles(): array
60+
{
61+
return $this->roles;
62+
}
63+
64+
public function getUserIdentifier(): string
65+
{
66+
return (string) ($this->sub ?? $this->username);
67+
}
68+
69+
public function eraseCredentials(): void
70+
{
71+
}
72+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Security\Http\AccessToken\OAuth2\Exception;
13+
14+
use Symfony\Component\Security\Core\Exception\AuthenticationException;
15+
16+
/**
17+
* This exception is thrown when the user is invalid on the OIDC server (e.g.: "email" property is not in the scope).
18+
*
19+
* @experimental
20+
*/
21+
class InvalidClaimException extends AuthenticationException
22+
{
23+
public function getMessageKey(): string
24+
{
25+
return 'Inactive access token.';
26+
}
27+
}

0 commit comments

Comments
 (0)
0