Skip to content

Commit 4ee96eb

Browse files
committed
[Security] OAuth2 Introspection Endpoint (RFC7662)
In addition to the excellent work of @vincentchalamon symfony#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 36a920e commit 4ee96eb

File tree

13 files changed

+382
-1
lines changed

13 files changed

+382
-1
lines changed

src/Symfony/Bundle/SecurityBundle/CHANGELOG.md

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

77
* Add `Security::isGrantedForUser()` to test user authorization without relying on the session. For example, users not currently logged in, or while processing a message from a message queue
8+
* Add `OAuth2TokenHandlerFactory` for `AccessTokenFactory`
89

910
7.2
1011
---
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

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+
service('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: 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;
@@ -341,6 +342,22 @@ public function testMultipleTokenHandlersSet()
341342
$this->processConfig($config, $factory);
342343
}
343344

345+
public function testOAuth2TokenHandlerConfiguration()
346+
{
347+
$container = new ContainerBuilder();
348+
$config = [
349+
'token_handler' => ['oauth2' => true],
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+
}
360+
344361
public function testNoTokenHandlerSet()
345362
{
346363
$this->expectException(InvalidConfigurationException::class);
@@ -400,6 +417,7 @@ private function createTokenHandlerFactories(): array
400417
new OidcUserInfoTokenHandlerFactory(),
401418
new OidcTokenHandlerFactory(),
402419
new CasTokenHandlerFactory(),
420+
new OAuth2TokenHandlerFactory(),
403421
];
404422
}
405423
}
Lines 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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ CHANGELOG
77
* Add `UserAuthorizationChecker::isGrantedForUser()` to test user authorization without relying on the session.
88
For example, users not currently logged in, or while processing a message from a message queue.
99
* Add `OfflineTokenInterface` to mark tokens that do not represent the currently logged-in user
10+
* Add `OAuth2User` with OAuth2 Access Token Introspection support for `OAuth2TokenHandler`
1011

1112
7.2
1213
---
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: 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+
}
Lines 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(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+
}

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy