Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit 1f5ff48

Browse filesBrowse files
committed
feature #50027 [Security] OAuth2 Introspection Endpoint (RFC7662) (Spomky)
This PR was merged into the 7.3 branch. Discussion ---------- [Security] OAuth2 Introspection Endpoint (RFC7662) | Q | A | ------------- | --- | Branch? | 7.3 | Bug fix? | no | New feature? | yes | Deprecations? | no | Tickets | none | License | MIT | Doc PR | symfony/symfony-docs#[**TODO**] 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`. Example of configuration: ```yaml framework: http_client: scoped_clients: oauth2.client: base_uri: 'https://authorization-server.example.com/introspection' scope: 'https://authorization-server\.example\.com' headers: Authorization: 'Basic Y2xpZW50OnBhc3N3b3Jk' # Introspection Endpoint usually requires client authentication security: firewalls: main: pattern: ^/ access_token: token_handler: oauth2: ~ token_extractors: 'header' realm: 'My API' ``` Commits ------- e68726f [Security] OAuth2 Introspection Endpoint (RFC7662)
2 parents 6b02c77 + e68726f commit 1f5ff48
Copy full SHA for 1f5ff48

File tree

12 files changed

+378
-0
lines changed
Filter options

12 files changed

+378
-0
lines changed

‎src/Symfony/Bundle/SecurityBundle/CHANGELOG.md

Copy file name to clipboardExpand all lines: src/Symfony/Bundle/SecurityBundle/CHANGELOG.md
+1Lines changed: 1 addition & 0 deletions
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

Copy file name to clipboardExpand all lines: src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator_access_token.php
+9Lines changed: 9 additions & 0 deletions
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

Copy file name to clipboardExpand all lines: src/Symfony/Bundle/SecurityBundle/SecurityBundle.php
+2Lines 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

Copy file name to clipboardExpand all lines: src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Security/Factory/AccessTokenFactoryTest.php
+18Lines changed: 18 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;
@@ -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
}
+34Lines changed: 34 additions & 0 deletions
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

Copy file name to clipboardExpand all lines: src/Symfony/Component/Security/Core/CHANGELOG.md
+1Lines changed: 1 addition & 0 deletions
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
---
+51Lines 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+
}
+70Lines changed: 70 additions & 0 deletions
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+
}
+100Lines changed: 100 additions & 0 deletions
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

Copy file name to clipboardExpand all lines: src/Symfony/Component/Security/Http/CHANGELOG.md
+1Lines changed: 1 addition & 0 deletions
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 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.