Skip to content

Commit e68726f

Browse files
Spomkyfabpot
authored andcommitted
[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 6b02c77 commit e68726f

File tree

12 files changed

+378
-0
lines changed

12 files changed

+378
-0
lines changed

src/Symfony/Bundle/SecurityBundle/CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ CHANGELOG
99
* Add `expose_security_errors` config option to display `AccountStatusException`
1010
* Deprecate the `security.hide_user_not_found` config option in favor of `security.expose_security_errors`
1111
* Add ability to fetch LDAP roles
12+
* Add `OAuth2TokenHandlerFactory` for `AccessTokenFactory`
1213

1314
7.2
1415
---
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
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+
18+
/**
19+
* Configures a token handler for an OAuth2 Token Introspection endpoint.
20+
*
21+
* @internal
22+
*/
23+
class OAuth2TokenHandlerFactory implements TokenHandlerFactoryInterface
24+
{
25+
public function create(ContainerBuilder $container, string $id, array|string $config): void
26+
{
27+
$container->setDefinition($id, new ChildDefinition('security.access_token_handler.oauth2'));
28+
}
29+
30+
public function getKey(): string
31+
{
32+
return 'oauth2';
33+
}
34+
35+
public function addConfiguration(NodeBuilder $node): void
36+
{
37+
$node->scalarNode($this->getKey())->end();
38+
}
39+
}

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

+9
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
use Symfony\Component\Security\Http\AccessToken\ChainAccessTokenExtractor;
3737
use Symfony\Component\Security\Http\AccessToken\FormEncodedBodyExtractor;
3838
use Symfony\Component\Security\Http\AccessToken\HeaderAccessTokenExtractor;
39+
use Symfony\Component\Security\Http\AccessToken\OAuth2\Oauth2TokenHandler;
3940
use Symfony\Component\Security\Http\AccessToken\Oidc\OidcTokenHandler;
4041
use Symfony\Component\Security\Http\AccessToken\Oidc\OidcUserInfoTokenHandler;
4142
use Symfony\Component\Security\Http\AccessToken\QueryAccessTokenExtractor;
@@ -186,5 +187,13 @@
186187

187188
->set('security.access_token_handler.oidc.encryption.A256GCM', A256GCM::class)
188189
->tag('security.access_token_handler.oidc.encryption_algorithm')
190+
191+
// OAuth2 Introspection (RFC 7662)
192+
->set('security.access_token_handler.oauth2', Oauth2TokenHandler::class)
193+
->abstract()
194+
->args([
195+
service('http_client'),
196+
service('logger')->nullOnInvalid(),
197+
])
189198
;
190199
};

src/Symfony/Bundle/SecurityBundle/SecurityBundle.php

+2
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

+18
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;
@@ -423,6 +424,22 @@ public function testMultipleTokenHandlersSet()
423424
$this->processConfig($config, $factory);
424425
}
425426

427+
public function testOAuth2TokenHandlerConfiguration()
428+
{
429+
$container = new ContainerBuilder();
430+
$config = [
431+
'token_handler' => ['oauth2' => true],
432+
];
433+
434+
$factory = new AccessTokenFactory($this->createTokenHandlerFactories());
435+
$finalizedConfig = $this->processConfig($config, $factory);
436+
437+
$factory->createAuthenticator($container, 'firewall1', $finalizedConfig, 'userprovider');
438+
439+
$this->assertTrue($container->hasDefinition('security.authenticator.access_token.firewall1'));
440+
$this->assertTrue($container->hasDefinition('security.access_token_handler.firewall1'));
441+
}
442+
426443
public function testNoTokenHandlerSet()
427444
{
428445
$this->expectException(InvalidConfigurationException::class);
@@ -482,6 +499,7 @@ private function createTokenHandlerFactories(): array
482499
new OidcUserInfoTokenHandlerFactory(),
483500
new OidcTokenHandlerFactory(),
484501
new CasTokenHandlerFactory(),
502+
new OAuth2TokenHandlerFactory(),
485503
];
486504
}
487505
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
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+
token_extractors: 'header'
31+
realm: 'My API'
32+
33+
access_control:
34+
- { path: ^/foo, roles: ROLE_USER }

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

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ CHANGELOG
1010
erase credentials e.g. using `__serialize()` instead
1111
* Add ability for voters to explain their vote
1212
* Add support for voting on closures
13+
* Add `OAuth2User` with OAuth2 Access Token Introspection support for `OAuth2TokenHandler`
1314

1415
7.2
1516
---
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+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
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+
class OAuth2User implements UserInterface
18+
{
19+
public readonly array $additionalClaims;
20+
21+
public function __construct(
22+
private array $roles = ['ROLE_USER'],
23+
// Standard Claims (https://datatracker.ietf.org/doc/html/rfc7662#section-2.2)
24+
public readonly ?string $scope = null,
25+
public readonly ?string $clientId = null,
26+
public readonly ?string $username = null,
27+
public readonly ?string $tokenType = null,
28+
public readonly ?int $exp = null,
29+
public readonly ?int $iat = null,
30+
public readonly ?int $nbf = null,
31+
public readonly ?string $sub = null,
32+
public readonly ?string $aud = null,
33+
public readonly ?string $iss = null,
34+
public readonly ?string $jti = null,
35+
36+
// Additional Claims ("
37+
// Specific implementations MAY extend this structure with
38+
// their own service-specific response names as top-level members
39+
// of this JSON object.
40+
// ")
41+
...$additionalClaims,
42+
) {
43+
if ((null === $sub || '' === $sub) && (null === $username || '' === $username)) {
44+
throw new \InvalidArgumentException('The claim "sub" or "username" must be provided.');
45+
}
46+
47+
$this->additionalClaims = $additionalClaims['additionalClaims'] ?? $additionalClaims;
48+
}
49+
50+
/**
51+
* OIDC or OAuth specs don't have any "role" notion.
52+
*
53+
* If you want to implement "roles" from your OIDC server,
54+
* send a "roles" constructor argument to this object
55+
* (e.g.: using a custom UserProvider).
56+
*/
57+
public function getRoles(): array
58+
{
59+
return $this->roles;
60+
}
61+
62+
public function getUserIdentifier(): string
63+
{
64+
return (string) ($this->sub ?? $this->username);
65+
}
66+
67+
public function eraseCredentials(): void
68+
{
69+
}
70+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
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;
13+
14+
use Psr\Log\LoggerInterface;
15+
use Symfony\Component\Security\Core\Exception\AuthenticationException;
16+
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
17+
use Symfony\Component\Security\Core\User\OAuth2User;
18+
use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface;
19+
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
20+
use Symfony\Contracts\HttpClient\HttpClientInterface;
21+
22+
use function Symfony\Component\String\u;
23+
24+
/**
25+
* The token handler validates the token on the authorization server and the Introspection Endpoint.
26+
*
27+
* @see https://tools.ietf.org/html/rfc7662
28+
*
29+
* @internal
30+
*/
31+
final class Oauth2TokenHandler implements AccessTokenHandlerInterface
32+
{
33+
public function __construct(
34+
private readonly HttpClientInterface $client,
35+
private readonly ?LoggerInterface $logger = null,
36+
) {
37+
}
38+
39+
public function getUserBadgeFrom(string $accessToken): UserBadge
40+
{
41+
try {
42+
// Call the Authorization server to retrieve the resource owner details
43+
// If the token is invalid or expired, the Authorization server will return an error
44+
$claims = $this->client->request('POST', '', [
45+
'body' => [
46+
'token' => $accessToken,
47+
'token_type_hint' => 'access_token',
48+
],
49+
])->toArray();
50+
51+
$sub = $claims['sub'] ?? null;
52+
$username = $claims['username'] ?? null;
53+
if (!$sub && !$username) {
54+
throw new BadCredentialsException('"sub" and "username" claims not found on the authorization server response. At least one is required.');
55+
}
56+
$active = $claims['active'] ?? false;
57+
if (!$active) {
58+
throw new BadCredentialsException('The claim "active" was not found on the authorization server response or is set to false.');
59+
}
60+
61+
return new UserBadge($sub ?? $username, fn () => $this->createUser($claims), $claims);
62+
} catch (AuthenticationException $e) {
63+
$this->logger?->error('An error occurred on the authorization server.', [
64+
'error' => $e->getMessage(),
65+
'trace' => $e->getTraceAsString(),
66+
]);
67+
68+
throw new BadCredentialsException('Invalid credentials.', $e->getCode(), $e);
69+
}
70+
}
71+
72+
private function createUser(array $claims): OAuth2User
73+
{
74+
if (!\function_exists(\Symfony\Component\String\u::class)) {
75+
throw new \LogicException('You cannot use the "OAuth2TokenHandler" since the String component is not installed. Try running "composer require symfony/string".');
76+
}
77+
78+
foreach ($claims as $claim => $value) {
79+
unset($claims[$claim]);
80+
if ('' === $value || null === $value) {
81+
continue;
82+
}
83+
$claims[u($claim)->camel()->toString()] = $value;
84+
}
85+
86+
if ('' !== ($claims['updatedAt'] ?? '')) {
87+
$claims['updatedAt'] = (new \DateTimeImmutable())->setTimestamp($claims['updatedAt']);
88+
}
89+
90+
if ('' !== ($claims['emailVerified'] ?? '')) {
91+
$claims['emailVerified'] = (bool) $claims['emailVerified'];
92+
}
93+
94+
if ('' !== ($claims['phoneNumberVerified'] ?? '')) {
95+
$claims['phoneNumberVerified'] = (bool) $claims['phoneNumberVerified'];
96+
}
97+
98+
return new OAuth2User(...$claims);
99+
}
100+
}

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

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ CHANGELOG
99
* Add argument `$identifierNormalizer` to `UserBadge::__construct()` to allow normalizing the identifier
1010
* Support hashing the hashed password using crc32c when putting the user in the session
1111
* Add support for closures in `#[IsGranted]`
12+
* Add `OAuth2TokenHandler` with OAuth2 Token Introspection support for `AccessTokenAuthenticator`
1213

1314
7.2
1415
---

0 commit comments

Comments
 (0)