Skip to content

Commit 0af71b6

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 3728d69 commit 0af71b6

File tree

3 files changed

+153
-0
lines changed

3 files changed

+153
-0
lines changed
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(\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+
}

CHANGELOG.md

Lines 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
---
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
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\Tests\AccessToken\OAuth2;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\HttpClient\MockHttpClient;
16+
use Symfony\Component\HttpClient\Response\MockResponse;
17+
use Symfony\Component\Security\Core\User\OAuth2User;
18+
use Symfony\Component\Security\Http\AccessToken\OAuth2\Oauth2TokenHandler;
19+
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
20+
21+
class OAuth2TokenHandlerTest extends TestCase
22+
{
23+
public static function testGetsUserIdentifierFromOAuth2ServerResponse(): void
24+
{
25+
$accessToken = 'a-secret-token';
26+
$claims = [
27+
'active' => true,
28+
'client_id' => 'l238j323ds-23ij4',
29+
'username' => 'jdoe',
30+
'scope' => 'read write dolphin',
31+
'sub' => 'Z5O3upPC88QrAjx00dis',
32+
'aud' => 'https://protected.example.net/resource',
33+
'iss' => 'https://server.example.com/',
34+
'exp' => 1419356238,
35+
'iat' => 1419350238,
36+
'extension_field' => 'twenty-seven',
37+
];
38+
$expectedUser = new OAuth2User(...$claims);
39+
40+
$client = new MockHttpClient([
41+
new MockResponse(json_encode($claims, \JSON_THROW_ON_ERROR)),
42+
]);
43+
44+
$userBadge = (new Oauth2TokenHandler($client))->getUserBadgeFrom($accessToken);
45+
$actualUser = $userBadge->getUserLoader()();
46+
47+
self::assertEquals(new UserBadge('Z5O3upPC88QrAjx00dis', fn () => $expectedUser, $claims), $userBadge);
48+
self::assertInstanceOf(OAuth2User::class, $actualUser);
49+
self::assertSame($claims, $userBadge->getAttributes());
50+
self::assertSame($claims['sub'], $actualUser->getUserIdentifier());
51+
}
52+
}

0 commit comments

Comments
 (0)