Skip to content

[Security] OAuth2 Introspection Endpoint (RFC7662) #50027

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Feb 26, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Symfony/Bundle/SecurityBundle/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ CHANGELOG
* Add `expose_security_errors` config option to display `AccountStatusException`
* Deprecate the `security.hide_user_not_found` config option in favor of `security.expose_security_errors`
* Add ability to fetch LDAP roles
* Add `OAuth2TokenHandlerFactory` for `AccessTokenFactory`

7.2
---
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken;

use Symfony\Component\Config\Definition\Builder\NodeBuilder;
use Symfony\Component\DependencyInjection\ChildDefinition;
use Symfony\Component\DependencyInjection\ContainerBuilder;

/**
* Configures a token handler for an OAuth2 Token Introspection endpoint.
*
* @internal
*/
class OAuth2TokenHandlerFactory implements TokenHandlerFactoryInterface
{
public function create(ContainerBuilder $container, string $id, array|string $config): void
{
$container->setDefinition($id, new ChildDefinition('security.access_token_handler.oauth2'));
}

public function getKey(): string
{
return 'oauth2';
}

public function addConfiguration(NodeBuilder $node): void
{
$node->scalarNode($this->getKey())->end();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
use Symfony\Component\Security\Http\AccessToken\ChainAccessTokenExtractor;
use Symfony\Component\Security\Http\AccessToken\FormEncodedBodyExtractor;
use Symfony\Component\Security\Http\AccessToken\HeaderAccessTokenExtractor;
use Symfony\Component\Security\Http\AccessToken\OAuth2\Oauth2TokenHandler;
use Symfony\Component\Security\Http\AccessToken\Oidc\OidcTokenHandler;
use Symfony\Component\Security\Http\AccessToken\Oidc\OidcUserInfoTokenHandler;
use Symfony\Component\Security\Http\AccessToken\QueryAccessTokenExtractor;
Expand Down Expand Up @@ -186,5 +187,13 @@

->set('security.access_token_handler.oidc.encryption.A256GCM', A256GCM::class)
->tag('security.access_token_handler.oidc.encryption_algorithm')

// OAuth2 Introspection (RFC 7662)
->set('security.access_token_handler.oauth2', Oauth2TokenHandler::class)
->abstract()
->args([
service('http_client'),
service('logger')->nullOnInvalid(),
])
;
};
2 changes: 2 additions & 0 deletions src/Symfony/Bundle/SecurityBundle/SecurityBundle.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\ReplaceDecoratedRememberMeHandlerPass;
use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\SortFirewallListenersPass;
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken\CasTokenHandlerFactory;
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken\OAuth2TokenHandlerFactory;
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken\OidcTokenHandlerFactory;
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken\OidcUserInfoTokenHandlerFactory;
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken\ServiceTokenHandlerFactory;
Expand Down Expand Up @@ -80,6 +81,7 @@ public function build(ContainerBuilder $container): void
new OidcUserInfoTokenHandlerFactory(),
new OidcTokenHandlerFactory(),
new CasTokenHandlerFactory(),
new OAuth2TokenHandlerFactory(),
]));

$extension->addUserProviderFactory(new InMemoryFactory());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken\CasTokenHandlerFactory;
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken\OAuth2TokenHandlerFactory;
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken\OidcTokenHandlerFactory;
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken\OidcUserInfoTokenHandlerFactory;
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken\ServiceTokenHandlerFactory;
Expand Down Expand Up @@ -423,6 +424,22 @@ public function testMultipleTokenHandlersSet()
$this->processConfig($config, $factory);
}

public function testOAuth2TokenHandlerConfiguration()
{
$container = new ContainerBuilder();
$config = [
'token_handler' => ['oauth2' => true],
];

$factory = new AccessTokenFactory($this->createTokenHandlerFactories());
$finalizedConfig = $this->processConfig($config, $factory);

$factory->createAuthenticator($container, 'firewall1', $finalizedConfig, 'userprovider');

$this->assertTrue($container->hasDefinition('security.authenticator.access_token.firewall1'));
$this->assertTrue($container->hasDefinition('security.access_token_handler.firewall1'));
}

public function testNoTokenHandlerSet()
{
$this->expectException(InvalidConfigurationException::class);
Expand Down Expand Up @@ -482,6 +499,7 @@ private function createTokenHandlerFactories(): array
new OidcUserInfoTokenHandlerFactory(),
new OidcTokenHandlerFactory(),
new CasTokenHandlerFactory(),
new OAuth2TokenHandlerFactory(),
];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
imports:
- { resource: ./../config/framework.yml }

framework:
http_method_override: false
serializer: ~
http_client:
scoped_clients:
oauth2.client:
scope: 'https://authorization-server\.example\.com'
headers:
Authorization: 'Basic Y2xpZW50OnBhc3N3b3Jk'

security:
password_hashers:
Symfony\Component\Security\Core\User\InMemoryUser: plaintext

providers:
in_memory:
memory:
users:
dunglas: { password: foo, roles: [ROLE_USER] }

firewalls:
main:
pattern: ^/
access_token:
token_handler:
oauth2: ~
token_extractors: 'header'
realm: 'My API'

access_control:
- { path: ^/foo, roles: ROLE_USER }
1 change: 1 addition & 0 deletions src/Symfony/Component/Security/Core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ CHANGELOG
erase credentials e.g. using `__serialize()` instead
* Add ability for voters to explain their vote
* Add support for voting on closures
* Add `OAuth2User` with OAuth2 Access Token Introspection support for `OAuth2TokenHandler`

7.2
---
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Security\Core\Tests\User;

use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\User\OAuth2User;

class OAuth2UserTest extends TestCase
{
public function testCannotCreateUserWithoutSubProperty()
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('The claim "sub" or "username" must be provided.');

new OAuth2User();
}

public function testCreateFullUserWithAdditionalClaimsUsingPositionalParameters()
{
$this->assertEquals(new OAuth2User(
scope: 'read write dolphin',
username: 'jdoe',
exp: 1419356238,
iat: 1419350238,
sub: 'Z5O3upPC88QrAjx00dis',
aud: 'https://protected.example.net/resource',
iss: 'https://server.example.com/',
client_id: 'l238j323ds-23ij4',
extension_field: 'twenty-seven'
), new OAuth2User(...[
'client_id' => 'l238j323ds-23ij4',
'username' => 'jdoe',
'scope' => 'read write dolphin',
'sub' => 'Z5O3upPC88QrAjx00dis',
'aud' => 'https://protected.example.net/resource',
'iss' => 'https://server.example.com/',
'exp' => 1419356238,
'iat' => 1419350238,
'extension_field' => 'twenty-seven',
]));
}
}
70 changes: 70 additions & 0 deletions src/Symfony/Component/Security/Core/User/OAuth2User.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Security\Core\User;

/**
* UserInterface implementation used by the access-token security workflow with an OIDC server.
*/
class OAuth2User implements UserInterface
{
public readonly array $additionalClaims;

public function __construct(
private array $roles = ['ROLE_USER'],
// Standard Claims (https://datatracker.ietf.org/doc/html/rfc7662#section-2.2)
public readonly ?string $scope = null,
public readonly ?string $clientId = null,
public readonly ?string $username = null,
public readonly ?string $tokenType = null,
public readonly ?int $exp = null,
public readonly ?int $iat = null,
public readonly ?int $nbf = null,
public readonly ?string $sub = null,
public readonly ?string $aud = null,
public readonly ?string $iss = null,
public readonly ?string $jti = null,

// Additional Claims ("
// Specific implementations MAY extend this structure with
// their own service-specific response names as top-level members
// of this JSON object.
// ")
...$additionalClaims,
) {
if ((null === $sub || '' === $sub) && (null === $username || '' === $username)) {
throw new \InvalidArgumentException('The claim "sub" or "username" must be provided.');
}

$this->additionalClaims = $additionalClaims['additionalClaims'] ?? $additionalClaims;
}

/**
* OIDC or OAuth specs don't have any "role" notion.
*
* If you want to implement "roles" from your OIDC server,
* send a "roles" constructor argument to this object
* (e.g.: using a custom UserProvider).
*/
public function getRoles(): array
{
return $this->roles;
}

public function getUserIdentifier(): string
{
return (string) ($this->sub ?? $this->username);
}

public function eraseCredentials(): void
{
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Security\Http\AccessToken\OAuth2;

use Psr\Log\LoggerInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Core\User\OAuth2User;
use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Contracts\HttpClient\HttpClientInterface;

use function Symfony\Component\String\u;

/**
* The token handler validates the token on the authorization server and the Introspection Endpoint.
*
* @see https://tools.ietf.org/html/rfc7662
*
* @internal
*/
final class Oauth2TokenHandler implements AccessTokenHandlerInterface
{
public function __construct(
private readonly HttpClientInterface $client,
private readonly ?LoggerInterface $logger = null,
) {
}

public function getUserBadgeFrom(string $accessToken): UserBadge
{
try {
// Call the Authorization server to retrieve the resource owner details
// If the token is invalid or expired, the Authorization server will return an error
$claims = $this->client->request('POST', '', [
'body' => [
'token' => $accessToken,
'token_type_hint' => 'access_token',
],
])->toArray();

$sub = $claims['sub'] ?? null;
$username = $claims['username'] ?? null;
if (!$sub && !$username) {
throw new BadCredentialsException('"sub" and "username" claims not found on the authorization server response. At least one is required.');
}
$active = $claims['active'] ?? false;
if (!$active) {
throw new BadCredentialsException('The claim "active" was not found on the authorization server response or is set to false.');
}

return new UserBadge($sub ?? $username, fn () => $this->createUser($claims), $claims);
} catch (AuthenticationException $e) {
$this->logger?->error('An error occurred on the authorization server.', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);

throw new BadCredentialsException('Invalid credentials.', $e->getCode(), $e);
}
}

private function createUser(array $claims): OAuth2User
{
if (!\function_exists(\Symfony\Component\String\u::class)) {
throw new \LogicException('You cannot use the "OAuth2TokenHandler" since the String component is not installed. Try running "composer require symfony/string".');
}

foreach ($claims as $claim => $value) {
unset($claims[$claim]);
if ('' === $value || null === $value) {
continue;
}
$claims[u($claim)->camel()->toString()] = $value;
}

if ('' !== ($claims['updatedAt'] ?? '')) {
$claims['updatedAt'] = (new \DateTimeImmutable())->setTimestamp($claims['updatedAt']);
}

if ('' !== ($claims['emailVerified'] ?? '')) {
$claims['emailVerified'] = (bool) $claims['emailVerified'];
}

if ('' !== ($claims['phoneNumberVerified'] ?? '')) {
$claims['phoneNumberVerified'] = (bool) $claims['phoneNumberVerified'];
}

return new OAuth2User(...$claims);
}
}
1 change: 1 addition & 0 deletions src/Symfony/Component/Security/Http/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ CHANGELOG
* Add argument `$identifierNormalizer` to `UserBadge::__construct()` to allow normalizing the identifier
* Support hashing the hashed password using crc32c when putting the user in the session
* Add support for closures in `#[IsGranted]`
* Add `OAuth2TokenHandler` with OAuth2 Token Introspection support for `AccessTokenAuthenticator`

7.2
---
Expand Down
Loading
Loading