-
-
Notifications
You must be signed in to change notification settings - Fork 9.6k
[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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
This file was deleted.
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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; | ||
|
||
|
@@ -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(); | ||
trigger_deprecation('symfony/security-http', '6.4', 'Passing a logger to the "%s" constructor is deprecated.', __CLASS__); | ||
} | ||
} | ||
|
||
public function getUserBadgeFrom(string $accessToken): UserBadge | ||
|
@@ -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(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
---|---|---|
|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
--- | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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; | ||
|
@@ -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()(); | ||
|
||
|
@@ -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([ | ||
|
@@ -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 [ | ||
|
@@ -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 = [ | ||
|
@@ -151,7 +141,6 @@ public function testThrowsAnErrorIfUserPropertyIsMissing() | |
self::AUDIENCE, | ||
['https://www.example.com'], | ||
'email', | ||
$loggerMock, | ||
))->getUserBadgeFrom($token); | ||
} | ||
|
||
|
There was a problem hiding this comment.
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.