diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator_access_token.php b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator_access_token.php index 66716b23ad892..c00aea343873f 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator_access_token.php +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator_access_token.php @@ -71,7 +71,6 @@ abstract_arg('audience'), abstract_arg('issuers'), 'sub', - service('logger')->nullOnInvalid(), service('clock'), ]) diff --git a/src/Symfony/Bundle/SecurityBundle/composer.json b/src/Symfony/Bundle/SecurityBundle/composer.json index e5b7711e90005..5706eec9b0924 100644 --- a/src/Symfony/Bundle/SecurityBundle/composer.json +++ b/src/Symfony/Bundle/SecurityBundle/composer.json @@ -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", diff --git a/src/Symfony/Component/Security/Http/AccessToken/Oidc/Exception/InvalidSignatureException.php b/src/Symfony/Component/Security/Http/AccessToken/Oidc/Exception/InvalidSignatureException.php deleted file mode 100644 index 56f362ed43fb2..0000000000000 --- a/src/Symfony/Component/Security/Http/AccessToken/Oidc/Exception/InvalidSignatureException.php +++ /dev/null @@ -1,25 +0,0 @@ - - * - * 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\Oidc\Exception; - -use Symfony\Component\Security\Core\Exception\AuthenticationException; - -/** - * This exception is thrown when the token signature is invalid. - */ -class InvalidSignatureException extends AuthenticationException -{ - public function getMessageKey(): string - { - return 'Invalid token signature.'; - } -} diff --git a/src/Symfony/Component/Security/Http/AccessToken/Oidc/Exception/MissingClaimException.php b/src/Symfony/Component/Security/Http/AccessToken/Oidc/Exception/MissingClaimException.php deleted file mode 100644 index e178f2b49cf87..0000000000000 --- a/src/Symfony/Component/Security/Http/AccessToken/Oidc/Exception/MissingClaimException.php +++ /dev/null @@ -1,25 +0,0 @@ - - * - * 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\Oidc\Exception; - -use Symfony\Component\Security\Core\Exception\AuthenticationException; - -/** - * This exception is thrown when the user is invalid on the OIDC server (e.g.: "email" property is not in the scope). - */ -class MissingClaimException extends AuthenticationException -{ - public function getMessageKey(): string - { - return 'Missing claim.'; - } -} diff --git a/src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcTokenHandler.php b/src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcTokenHandler.php index 774d4f9579a4b..661d872abe568 100644 --- a/src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcTokenHandler.php +++ b/src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcTokenHandler.php @@ -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(); - } + 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); } } diff --git a/src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcUserInfoTokenHandler.php b/src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcUserInfoTokenHandler.php index 58f5041e66bf1..4e2950e61e6e7 100644 --- a/src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcUserInfoTokenHandler.php +++ b/src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcUserInfoTokenHandler.php @@ -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; /** @@ -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); } } diff --git a/src/Symfony/Component/Security/Http/CHANGELOG.md b/src/Symfony/Component/Security/Http/CHANGELOG.md index 3022c8fc63828..b8c6164ccc4ee 100644 --- a/src/Symfony/Component/Security/Http/CHANGELOG.md +++ b/src/Symfony/Component/Security/Http/CHANGELOG.md @@ -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 + * Deprecate passing a logger to the `OidcTokenHandler` constructor 6.3 --- diff --git a/src/Symfony/Component/Security/Http/Tests/AccessToken/Oidc/OidcTokenHandlerTest.php b/src/Symfony/Component/Security/Http/Tests/AccessToken/Oidc/OidcTokenHandlerTest.php index ccf11e49862b6..03b46caf7617d 100644 --- a/src/Symfony/Component/Security/Http/Tests/AccessToken/Oidc/OidcTokenHandlerTest.php +++ b/src/Symfony/Component/Security/Http/Tests/AccessToken/Oidc/OidcTokenHandlerTest.php @@ -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,13 +73,10 @@ 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(), @@ -92,14 +84,13 @@ public function testThrowsAnErrorIfTokenIsInvalid(string $token) 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' => 'foo@example.com', ])), + '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' => 'foo@example.com', ])), + '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); } diff --git a/src/Symfony/Component/Security/Http/Tests/AccessToken/Oidc/OidcUserInfoTokenHandlerTest.php b/src/Symfony/Component/Security/Http/Tests/AccessToken/Oidc/OidcUserInfoTokenHandlerTest.php index 2c8d9ae803f9d..47826d3321c19 100644 --- a/src/Symfony/Component/Security/Http/Tests/AccessToken/Oidc/OidcUserInfoTokenHandlerTest.php +++ b/src/Symfony/Component/Security/Http/Tests/AccessToken/Oidc/OidcUserInfoTokenHandlerTest.php @@ -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; @@ -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']; @@ -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'); } }