Skip to content

Commit 1952b23

Browse files
vincentchalamonfabpot
authored andcommitted
[Security] Add OidcUserInfoTokenHandler and OidcUser
1 parent be0bc78 commit 1952b23

File tree

12 files changed

+570
-6
lines changed

12 files changed

+570
-6
lines changed
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
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\Oidc\Exception;
13+
14+
use Symfony\Component\Security\Core\Exception\AuthenticationException;
15+
16+
/**
17+
* This exception is thrown when the token signature is invalid.
18+
*
19+
* @experimental
20+
*/
21+
class InvalidSignatureException extends AuthenticationException
22+
{
23+
public function getMessageKey(): string
24+
{
25+
return 'Invalid token signature.';
26+
}
27+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
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\Oidc\Exception;
13+
14+
use Symfony\Component\Security\Core\Exception\AuthenticationException;
15+
16+
/**
17+
* This exception is thrown when the user is invalid on the OIDC server (e.g.: "email" property is not in the scope).
18+
*
19+
* @experimental
20+
*/
21+
class MissingClaimException extends AuthenticationException
22+
{
23+
public function getMessageKey(): string
24+
{
25+
return 'Missing claim.';
26+
}
27+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
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\Oidc;
13+
14+
use Jose\Component\Checker;
15+
use Jose\Component\Checker\ClaimCheckerManager;
16+
use Jose\Component\Core\Algorithm;
17+
use Jose\Component\Core\AlgorithmManager;
18+
use Jose\Component\Core\JWK;
19+
use Jose\Component\Signature\JWSTokenSupport;
20+
use Jose\Component\Signature\JWSVerifier;
21+
use Jose\Component\Signature\Serializer\CompactSerializer;
22+
use Jose\Component\Signature\Serializer\JWSSerializerManager;
23+
use Psr\Log\LoggerInterface;
24+
use Symfony\Component\Clock\NativeClock;
25+
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
26+
use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface;
27+
use Symfony\Component\Security\Http\AccessToken\Oidc\Exception\InvalidSignatureException;
28+
use Symfony\Component\Security\Http\AccessToken\Oidc\Exception\MissingClaimException;
29+
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
30+
31+
/**
32+
* The token handler decodes and validates the token, and retrieves the user identifier from it.
33+
*
34+
* @experimental
35+
*/
36+
final class OidcTokenHandler implements AccessTokenHandlerInterface
37+
{
38+
use OidcTrait;
39+
40+
public function __construct(
41+
private Algorithm $signatureAlgorithm,
42+
private JWK $jwk,
43+
private ?LoggerInterface $logger = null,
44+
private string $claim = 'sub',
45+
private ?string $audience = null
46+
) {
47+
}
48+
49+
public function getUserBadgeFrom(string $accessToken): UserBadge
50+
{
51+
if (!class_exists(JWSVerifier::class) || !class_exists(Checker\HeaderCheckerManager::class)) {
52+
throw new \LogicException('You cannot use the "oidc" token handler since "web-token/jwt-signature" and "web-token/jwt-checker" are not installed. Try running "composer require web-token/jwt-signature web-token/jwt-checker".');
53+
}
54+
55+
try {
56+
// Decode the token
57+
$jwsVerifier = new JWSVerifier(new AlgorithmManager([$this->signatureAlgorithm]));
58+
$serializerManager = new JWSSerializerManager([new CompactSerializer()]);
59+
$jws = $serializerManager->unserialize($accessToken);
60+
$claims = json_decode($jws->getPayload(), true);
61+
62+
// Verify the signature
63+
if (!$jwsVerifier->verifyWithKey($jws, $this->jwk, 0)) {
64+
throw new InvalidSignatureException();
65+
}
66+
67+
// Verify the headers
68+
$headerCheckerManager = new Checker\HeaderCheckerManager([
69+
new Checker\AlgorithmChecker([$this->signatureAlgorithm->name()]),
70+
], [
71+
new JWSTokenSupport(),
72+
]);
73+
// if this check fails, an InvalidHeaderException is thrown
74+
$headerCheckerManager->check($jws, 0);
75+
76+
// Verify the claims
77+
$clock = class_exists(NativeClock::class) ? new NativeClock() : null;
78+
$checkers = [
79+
new Checker\IssuedAtChecker(0, false, $clock),
80+
new Checker\NotBeforeChecker(0, false, $clock),
81+
new Checker\ExpirationTimeChecker(0, false, $clock),
82+
];
83+
if ($this->audience) {
84+
$checkers[] = new Checker\AudienceChecker($this->audience);
85+
}
86+
$claimCheckerManager = new ClaimCheckerManager($checkers);
87+
// if this check fails, an InvalidClaimException is thrown
88+
$claimCheckerManager->check($claims);
89+
90+
if (empty($claims[$this->claim])) {
91+
throw new MissingClaimException(sprintf('"%s" claim not found.', $this->claim));
92+
}
93+
94+
// UserLoader argument can be overridden by a UserProvider on AccessTokenAuthenticator::authenticate
95+
return new UserBadge($claims[$this->claim], fn () => $this->createUser($claims), $claims);
96+
} catch (\Throwable $e) {
97+
$this->logger?->error('An error while decoding and validating the token.', [
98+
'error' => $e->getMessage(),
99+
'trace' => $e->getTraceAsString(),
100+
]);
101+
102+
throw new BadCredentialsException('Invalid credentials.', $e->getCode(), $e);
103+
}
104+
}
105+
}

AccessToken/Oidc/OidcTrait.php

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
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\Oidc;
13+
14+
use Symfony\Component\Security\Core\User\OidcUser;
15+
16+
use function Symfony\Component\String\u;
17+
18+
/**
19+
* Creates {@see OidcUser} from claims.
20+
*
21+
* @internal
22+
*/
23+
trait OidcTrait
24+
{
25+
private function createUser(array $claims): OidcUser
26+
{
27+
if (!\function_exists(\Symfony\Component\String\u::class)) {
28+
throw new \LogicException('You cannot use the "OidcUserInfoTokenHandler" since the String component is not installed. Try running "composer require symfony/string".');
29+
}
30+
31+
foreach ($claims as $claim => $value) {
32+
unset($claims[$claim]);
33+
if ('' === $value || null === $value) {
34+
continue;
35+
}
36+
$claims[u($claim)->camel()->toString()] = $value;
37+
}
38+
39+
if (isset($claims['updatedAt']) && '' !== $claims['updatedAt']) {
40+
$claims['updatedAt'] = (new \DateTimeImmutable())->setTimestamp($claims['updatedAt']);
41+
}
42+
43+
if (\array_key_exists('emailVerified', $claims) && null !== $claims['emailVerified'] && '' !== $claims['emailVerified']) {
44+
$claims['emailVerified'] = (bool) $claims['emailVerified'];
45+
}
46+
47+
if (\array_key_exists('phoneNumberVerified', $claims) && null !== $claims['phoneNumberVerified'] && '' !== $claims['phoneNumberVerified']) {
48+
$claims['phoneNumberVerified'] = (bool) $claims['phoneNumberVerified'];
49+
}
50+
51+
return new OidcUser(...$claims);
52+
}
53+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
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\Oidc;
13+
14+
use Psr\Log\LoggerInterface;
15+
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
16+
use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface;
17+
use Symfony\Component\Security\Http\AccessToken\Oidc\Exception\MissingClaimException;
18+
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
19+
use Symfony\Contracts\HttpClient\HttpClientInterface;
20+
21+
/**
22+
* The token handler validates the token on the OIDC server and retrieves the user identifier.
23+
*
24+
* @experimental
25+
*/
26+
final class OidcUserInfoTokenHandler implements AccessTokenHandlerInterface
27+
{
28+
use OidcTrait;
29+
30+
public function __construct(
31+
private HttpClientInterface $client,
32+
private ?LoggerInterface $logger = null,
33+
private string $claim = 'sub'
34+
) {
35+
}
36+
37+
public function getUserBadgeFrom(string $accessToken): UserBadge
38+
{
39+
try {
40+
// Call the OIDC server to retrieve the user info
41+
// If the token is invalid or expired, the OIDC server will return an error
42+
$claims = $this->client->request('GET', '', [
43+
'auth_bearer' => $accessToken,
44+
])->toArray();
45+
46+
if (empty($claims[$this->claim])) {
47+
throw new MissingClaimException(sprintf('"%s" claim not found on OIDC server response.', $this->claim));
48+
}
49+
50+
// UserLoader argument can be overridden by a UserProvider on AccessTokenAuthenticator::authenticate
51+
return new UserBadge($claims[$this->claim], fn () => $this->createUser($claims), $claims);
52+
} catch (\Throwable $e) {
53+
$this->logger?->error('An error occurred on OIDC server.', [
54+
'error' => $e->getMessage(),
55+
'trace' => $e->getTraceAsString(),
56+
]);
57+
58+
throw new BadCredentialsException('Invalid credentials.', $e->getCode(), $e);
59+
}
60+
}
61+
}

Authenticator/AccessTokenAuthenticator.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ public function authenticate(Request $request): Passport
5959
}
6060

6161
$userBadge = $this->accessTokenHandler->getUserBadgeFrom($accessToken);
62-
if (null === $userBadge->getUserLoader() && $this->userProvider) {
62+
if ($this->userProvider) {
6363
$userBadge->setUserLoader($this->userProvider->loadUserByIdentifier(...));
6464
}
6565

Authenticator/Passport/Badge/UserBadge.php

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ class UserBadge implements BadgeInterface
3434
/** @var callable|null */
3535
private $userLoader;
3636
private UserInterface $user;
37+
private ?array $attributes;
3738

3839
/**
3940
* Initializes the user badge.
@@ -48,21 +49,27 @@ class UserBadge implements BadgeInterface
4849
* is thrown). If this is not set, the default user provider will be used with
4950
* $userIdentifier as username.
5051
*/
51-
public function __construct(string $userIdentifier, callable $userLoader = null)
52+
public function __construct(string $userIdentifier, callable $userLoader = null, array $attributes = null)
5253
{
5354
if (\strlen($userIdentifier) > self::MAX_USERNAME_LENGTH) {
5455
throw new BadCredentialsException('Username too long.');
5556
}
5657

5758
$this->userIdentifier = $userIdentifier;
5859
$this->userLoader = $userLoader;
60+
$this->attributes = $attributes;
5961
}
6062

6163
public function getUserIdentifier(): string
6264
{
6365
return $this->userIdentifier;
6466
}
6567

68+
public function getAttributes(): ?array
69+
{
70+
return $this->attributes;
71+
}
72+
6673
/**
6774
* @throws AuthenticationException when the user cannot be found
6875
*/
@@ -76,7 +83,11 @@ public function getUser(): UserInterface
7683
throw new \LogicException(sprintf('No user loader is configured, did you forget to register the "%s" listener?', UserProviderListener::class));
7784
}
7885

79-
$user = ($this->userLoader)($this->userIdentifier);
86+
if (null === $this->getAttributes()) {
87+
$user = ($this->userLoader)($this->userIdentifier);
88+
} else {
89+
$user = ($this->userLoader)($this->userIdentifier, $this->getAttributes());
90+
}
8091

8192
// No user has been found via the $this->userLoader callback
8293
if (null === $user) {

Authenticator/Passport/Passport.php

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,23 @@ public function getUser(): UserInterface
6666
* This method replaces the current badge if it is already set on this
6767
* passport.
6868
*
69+
* @param string|null $badgeFqcn A FQCN to which the badge should be mapped to.
70+
* This allows replacing a built-in badge by a custom one using
71+
*. e.g. addBadge(new MyCustomUserBadge(), UserBadge::class)
72+
*
6973
* @return $this
7074
*/
71-
public function addBadge(BadgeInterface $badge): static
75+
public function addBadge(BadgeInterface $badge/* , string $badgeFqcn = null */): static
7276
{
73-
$this->badges[$badge::class] = $badge;
77+
$badgeFqcn = $badge::class;
78+
if (2 === \func_num_args()) {
79+
$badgeFqcn = func_get_arg(1);
80+
if (!\is_string($badgeFqcn)) {
81+
throw new \LogicException(sprintf('Second argument of "%s" must be a string.', __METHOD__));
82+
}
83+
}
84+
85+
$this->badges[$badgeFqcn] = $badge;
7486

7587
return $this;
7688
}

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ CHANGELOG
77
* Add `RememberMeBadge` to `JsonLoginAuthenticator` and enable reading parameter in JSON request body
88
* Add argument `$exceptionCode` to `#[IsGranted]`
99
* Deprecate passing a secret as the 2nd argument to the constructor of `Symfony\Component\Security\Http\RememberMe\PersistentRememberMeHandler`
10+
* Add `OidcUserInfoTokenHandler` and `OidcTokenHandler` with OIDC support for `AccessTokenAuthenticator`
11+
* Add `attributes` optional array argument in `UserBadge`
12+
* Call `UserBadge::userLoader` with attributes if the argument is set
13+
* Allow to override badge fqcn on `Passport::addBadge`
1014

1115
6.2
1216
---

0 commit comments

Comments
 (0)