Skip to content

Commit 9412313

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 78f4d9a commit 9412313

File tree

14 files changed

+417
-11
lines changed

14 files changed

+417
-11
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: 36 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,40 @@ public function testMultipleTokenHandlersSet()
341342
$this->processConfig($config, $factory);
342343
}
343344

345+
public function testOAuth2TokenHandlerConfigurationWithExistingClient()
346+
{
347+
$container = new ContainerBuilder();
348+
$config = [
349+
'token_handler' => ['oauth2' => ['client' => 'oauth2.client']],
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+
$this->assertFalse($container->hasDefinition('http_client.security.access_token_handler.oauth2'));
360+
}
361+
362+
public function testOAuth2TokenHandlerConfigurationWithClientCreation()
363+
{
364+
$container = new ContainerBuilder();
365+
$config = [
366+
'token_handler' => ['oauth2' => ['client' => ['base_uri' => 'https://www.example.com/realms/demo/protocol/openid-connect/userinfo']]],
367+
];
368+
369+
$factory = new AccessTokenFactory($this->createTokenHandlerFactories());
370+
$finalizedConfig = $this->processConfig($config, $factory);
371+
372+
$factory->createAuthenticator($container, 'firewall1', $finalizedConfig, 'userprovider');
373+
374+
$this->assertTrue($container->hasDefinition('security.authenticator.access_token.firewall1'));
375+
$this->assertTrue($container->hasDefinition('security.access_token_handler.firewall1'));
376+
$this->assertTrue($container->hasDefinition('http_client.security.access_token_handler.oauth2'));
377+
}
378+
344379
public function testNoTokenHandlerSet()
345380
{
346381
$this->expectException(InvalidConfigurationException::class);
@@ -400,6 +435,7 @@ private function createTokenHandlerFactories(): array
400435
new OidcUserInfoTokenHandlerFactory(),
401436
new OidcTokenHandlerFactory(),
402437
new CasTokenHandlerFactory(),
438+
new OAuth2TokenHandlerFactory(),
403439
];
404440
}
405441
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
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+
client: 'oauth2.client'
31+
token_extractors: 'header'
32+
realm: 'My API'
33+
34+
access_control:
35+
- { 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+
}

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