Thanks to visit codestin.com
Credit goes to github.com

Skip to content

[Security] Improve error handling in OIDC access token handlers #50695

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

Closed
wants to merge 1 commit into from
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@
abstract_arg('audience'),
abstract_arg('issuers'),
'sub',
service('logger')->nullOnInvalid(),
service('clock'),
])

Expand Down
2 changes: 1 addition & 1 deletion src/Symfony/Bundle/SecurityBundle/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"symfony/password-hasher": "^5.4|^6.0|^7.0",
"symfony/security-core": "^6.2|^7.0",
"symfony/security-csrf": "^5.4|^6.0|^7.0",
"symfony/security-http": "^6.3.4|^7.0"
"symfony/security-http": "^6.4|^7.0"
},
"require-dev": {
"symfony/asset": "^5.4|^6.0|^7.0",
Expand Down

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,6 @@
use Symfony\Component\Clock\Clock;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface;
use Symfony\Component\Security\Http\AccessToken\Oidc\Exception\InvalidSignatureException;
use Symfony\Component\Security\Http\AccessToken\Oidc\Exception\MissingClaimException;
use Symfony\Component\Security\Http\Authenticator\FallbackUserLoader;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;

Expand All @@ -43,9 +41,12 @@ public function __construct(
private string $audience,
private array $issuers,
private string $claim = 'sub',
private ?LoggerInterface $logger = null,
private ClockInterface $clock = new Clock()
private ClockInterface|LoggerInterface|null $clock = new Clock()
) {
if (!$clock || $clock instanceof LoggerInterface) {
$this->clock = new Clock();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You also need to account for the case where the clock is pass explicitly in the old position instead of ignoring it and assuming that the default clock is used.

trigger_deprecation('symfony/security-http', '6.4', 'Passing a logger to the "%s" constructor is deprecated.', __CLASS__);
}
}

public function getUserBadgeFrom(string $accessToken): UserBadge
Expand All @@ -54,52 +55,49 @@ public function getUserBadgeFrom(string $accessToken): UserBadge
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".');
}

$jwsVerifier = new JWSVerifier(new AlgorithmManager([$this->signatureAlgorithm]));
$serializerManager = new JWSSerializerManager([new CompactSerializer()]);

try {
// Decode the token
$jwsVerifier = new JWSVerifier(new AlgorithmManager([$this->signatureAlgorithm]));
$serializerManager = new JWSSerializerManager([new CompactSerializer()]);
$jws = $serializerManager->unserialize($accessToken);
$claims = json_decode($jws->getPayload(), true);
$claims = json_decode($jws->getPayload(), true, 512, \JSON_THROW_ON_ERROR);
} catch (\InvalidArgumentException|\JsonException $e) {
throw new BadCredentialsException('Unable to parse the token.', 0, $e);
}

// Verify the signature
if (!$jwsVerifier->verifyWithKey($jws, $this->jwk, 0)) {
throw new InvalidSignatureException();
Copy link
Member Author

@chalasr chalasr Jun 18, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

throwing specific exceptions to catch them immediately is pointless, the BadCredentialsException's message (not obfuscated messageKey) is enough. If we want to provide extension points to allow e.g. fixing the token on the fly, we can do it by dispatching specific events.

}
if (!$jwsVerifier->verifyWithKey($jws, $this->jwk, 0)) {
throw new BadCredentialsException('The token signature is invalid.');
}

// Verify the headers
$headerCheckerManager = new Checker\HeaderCheckerManager([
new Checker\AlgorithmChecker([$this->signatureAlgorithm->name()]),
], [
new JWSTokenSupport(),
]);
// if this check fails, an InvalidHeaderException is thrown
$headerCheckerManager->check($jws, 0);
$headerCheckerManager = new Checker\HeaderCheckerManager([
new Checker\AlgorithmChecker([$this->signatureAlgorithm->name()]),
], [new JWSTokenSupport()]);

// Verify the claims
$checkers = [
new Checker\IssuedAtChecker(0, false, $this->clock),
new Checker\NotBeforeChecker(0, false, $this->clock),
new Checker\ExpirationTimeChecker(0, false, $this->clock),
new Checker\AudienceChecker($this->audience),
new Checker\IssuerChecker($this->issuers),
];
$claimCheckerManager = new ClaimCheckerManager($checkers);
// if this check fails, an InvalidClaimException is thrown
$claimCheckerManager->check($claims);
try {
$headerCheckerManager->check($jws, 0);
} catch (Checker\InvalidHeaderException|\InvalidArgumentException $e) {
throw new BadCredentialsException('The token header is invalid.', 0, $e);
}

if (empty($claims[$this->claim])) {
throw new MissingClaimException(sprintf('"%s" claim not found.', $this->claim));
}
$claimCheckerManager = new ClaimCheckerManager([
new Checker\IssuedAtChecker(0, false, $this->clock),
new Checker\NotBeforeChecker(0, false, $this->clock),
new Checker\ExpirationTimeChecker(0, false, $this->clock),
new Checker\AudienceChecker($this->audience),
new Checker\IssuerChecker($this->issuers),
]);

// UserLoader argument can be overridden by a UserProvider on AccessTokenAuthenticator::authenticate
return new UserBadge($claims[$this->claim], new FallbackUserLoader(fn () => $this->createUser($claims)), $claims);
} catch (\Exception $e) {
$this->logger?->error('An error occurred while decoding and validating the token.', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
try {
$claimCheckerManager->check($claims);
} catch (Checker\ClaimExceptionInterface $e) {
throw new BadCredentialsException('At least one of the expected token claims is invalid or missing.', 0, $e);
}

throw new BadCredentialsException('Invalid credentials.', $e->getCode(), $e);
if (!($claims[$this->claim] ?? false)) {
throw new BadCredentialsException(sprintf('The "%s" claim is missing from the token.', $this->claim));
}

// UserLoader argument can be overridden by a UserProvider on AccessTokenAuthenticator::authenticate
return new UserBadge($claims[$this->claim], new FallbackUserLoader(fn () => $this->createUser($claims)), $claims);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@
use Psr\Log\LoggerInterface;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface;
use Symfony\Component\Security\Http\AccessToken\Oidc\Exception\MissingClaimException;
use Symfony\Component\Security\Http\Authenticator\FallbackUserLoader;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

/**
Expand All @@ -41,20 +41,20 @@ public function getUserBadgeFrom(string $accessToken): UserBadge
$claims = $this->client->request('GET', '', [
'auth_bearer' => $accessToken,
])->toArray();

if (empty($claims[$this->claim])) {
throw new MissingClaimException(sprintf('"%s" claim not found on OIDC server response.', $this->claim));
}

// UserLoader argument can be overridden by a UserProvider on AccessTokenAuthenticator::authenticate
return new UserBadge($claims[$this->claim], new FallbackUserLoader(fn () => $this->createUser($claims)), $claims);
} catch (\Exception $e) {
$this->logger?->error('An error occurred on OIDC server.', [
} catch (HttpExceptionInterface $e) {
$this->logger?->debug('An error occurred while trying to access the UserInfo endpoint on the OIDC server.', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);

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

if (!($claims[$this->claim] ?? false)) {
throw new BadCredentialsException(sprintf('The "%s" claim is missing from the OIDC UserInfo response.', $this->claim));
}

// UserLoader argument can be overridden by a UserProvider on AccessTokenAuthenticator::authenticate
return new UserBadge($claims[$this->claim], new FallbackUserLoader(fn () => $this->createUser($claims)), $claims);
}
}
2 changes: 2 additions & 0 deletions src/Symfony/Component/Security/Http/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ CHANGELOG

* `UserValueResolver` no longer implements `ArgumentValueResolverInterface`
* Deprecate calling the constructor of `DefaultLoginRateLimiter` with an empty secret
* [BC BREAK] Remove experimental `InvalidSignatureException` and `MissingClaimException` classes from the `AccessToken\Oidc\Exception` namespace
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This BC break is not worth it IMO. you could deprecate this 2 exceptions instead and remove them in 7.0

* Deprecate passing a logger to the `OidcTokenHandler` constructor

6.3
---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
use Jose\Component\Signature\JWSBuilder;
use Jose\Component\Signature\Serializer\CompactSerializer;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Core\User\OidcUser;
use Symfony\Component\Security\Http\AccessToken\Oidc\OidcTokenHandler;
Expand Down Expand Up @@ -49,16 +48,12 @@ public function testGetsUserIdentifierFromSignedToken(string $claim, string $exp
$token = $this->buildJWS(json_encode($claims));
$expectedUser = new OidcUser(...$claims);

$loggerMock = $this->createMock(LoggerInterface::class);
$loggerMock->expects($this->never())->method('error');

$userBadge = (new OidcTokenHandler(
new ES256(),
$this->getJWK(),
self::AUDIENCE,
['https://www.example.com'],
$claim,
$loggerMock,
))->getUserBadgeFrom($token);
$actualUser = $userBadge->getUserLoader()();

Expand All @@ -78,28 +73,24 @@ public static function getClaims(): iterable
/**
* @dataProvider getInvalidTokens
*/
public function testThrowsAnErrorIfTokenIsInvalid(string $token)
public function testThrowsAnErrorIfTokenIsInvalid(string $token, $expectedMessage)
{
$this->expectException(BadCredentialsException::class);
$this->expectExceptionMessage('Invalid credentials.');

$loggerMock = $this->createMock(LoggerInterface::class);
$loggerMock->expects($this->once())->method('error');
$this->expectExceptionMessage($expectedMessage);

(new OidcTokenHandler(
new ES256(),
$this->getJWK(),
self::AUDIENCE,
['https://www.example.com'],
'sub',
$loggerMock,
))->getUserBadgeFrom($token);
}

public static function getInvalidTokens(): iterable
{
// Invalid token
yield ['invalid'];
yield ['invalid', 'Unable to parse the token.'];
// Token is expired
yield [
self::buildJWS(json_encode([
Expand All @@ -111,6 +102,7 @@ public static function getInvalidTokens(): iterable
'sub' => 'e21bf182-1538-406e-8ccb-e25a17aba39f',
'email' => '[email protected]',
])),
'At least one of the expected token claims is invalid or missing.',
];
// Invalid audience
yield [
Expand All @@ -123,16 +115,14 @@ public static function getInvalidTokens(): iterable
'sub' => 'e21bf182-1538-406e-8ccb-e25a17aba39f',
'email' => '[email protected]',
])),
'At least one of the expected token claims is invalid or missing.',
];
}

public function testThrowsAnErrorIfUserPropertyIsMissing()
{
$this->expectException(BadCredentialsException::class);
$this->expectExceptionMessage('Invalid credentials.');

$loggerMock = $this->createMock(LoggerInterface::class);
$loggerMock->expects($this->once())->method('error');
$this->expectExceptionMessage('The "email" claim is missing from the token.');

$time = time();
$claims = [
Expand All @@ -151,7 +141,6 @@ public function testThrowsAnErrorIfUserPropertyIsMissing()
self::AUDIENCE,
['https://www.example.com'],
'email',
$loggerMock,
))->getUserBadgeFrom($token);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
namespace Symfony\Component\Security\Http\Tests\AccessToken\Oidc;

use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Core\User\OidcUser;
use Symfony\Component\Security\Http\AccessToken\Oidc\OidcUserInfoTokenHandler;
Expand Down Expand Up @@ -64,7 +63,7 @@ public static function getClaims(): iterable
public function testThrowsAnExceptionIfUserPropertyIsMissing()
{
$this->expectException(BadCredentialsException::class);
$this->expectExceptionMessage('Invalid credentials.');
$this->expectExceptionMessage('The "sub" claim is missing from the OIDC UserInfo response.');

$response = ['foo' => 'bar'];

Expand All @@ -78,11 +77,7 @@ public function testThrowsAnExceptionIfUserPropertyIsMissing()
->method('request')->with('GET', '', ['auth_bearer' => 'a-secret-token'])
->willReturn($responseMock);

$loggerMock = $this->createMock(LoggerInterface::class);
$loggerMock->expects($this->once())
->method('error');

$handler = new OidcUserInfoTokenHandler($clientMock, $loggerMock);
$handler = new OidcUserInfoTokenHandler($clientMock);
$handler->getUserBadgeFrom('a-secret-token');
}
}