diff --git a/src/socialite/src/Two/OpenIdProvider.php b/src/socialite/src/Two/OpenIdProvider.php index d7b3d369c..a1498d14b 100644 --- a/src/socialite/src/Two/OpenIdProvider.php +++ b/src/socialite/src/Two/OpenIdProvider.php @@ -6,6 +6,7 @@ use Firebase\JWT\JWK; use Firebase\JWT\JWT; +use Firebase\JWT\SignatureInvalidException; use GuzzleHttp\RequestOptions; use Hypervel\Http\RedirectResponse; use Hypervel\Socialite\Two\Exceptions\ConfigurationFetchingException; @@ -15,6 +16,7 @@ use Hypervel\Socialite\Two\Exceptions\InvalidUserInfoUrlException; use Hypervel\Support\Str; use Throwable; +use UnexpectedValueException; abstract class OpenIdProvider extends AbstractProvider { @@ -34,6 +36,16 @@ abstract class OpenIdProvider extends AbstractProvider */ protected ?array $jwks = null; + /** + * The timestamp of the last forced JWKS refresh attempt. + */ + protected ?int $jwksRefreshAttemptedAt = null; + + /** + * The minimum seconds between forced JWKS refreshes. + */ + protected int $jwksRefreshCooldownSeconds = 10; + /** * Get the base URL for the OIDC provider. */ @@ -101,9 +113,9 @@ protected function getUserInfoUrl(): ?string /** * Get the jwks URI for the provider. */ - protected function getJwksUri(): string + protected function getJwksUri(bool $refresh = false): string { - return $this->getOpenIdConfig()['jwks_uri']; + return $this->getOpenIdConfig($refresh)['jwks_uri']; } /** @@ -153,9 +165,9 @@ protected function getCurrentNonce(): ?string /** * @throws ConfigurationFetchingException */ - protected function getOpenIdConfig(): array + protected function getOpenIdConfig(bool $refresh = false): array { - if ($this->openidConfig) { + if ($this->openidConfig && ! $refresh) { return $this->openidConfig; } @@ -182,20 +194,38 @@ protected function getOpenIdConfigUrl(): string /** * Get the JSON Web Key Set (JWKS) for the provider. */ - protected function getJwks(): array + protected function getJwks(bool $refresh = false): array { - if ($this->jwks) { + if ($this->jwks && ! $refresh) { return $this->jwks; } + if ($this->jwks && ! $this->canRefreshJwks($refresh)) { + return $this->jwks; + } + + if ($refresh) { + $this->jwksRefreshAttemptedAt = time(); + } + $response = $this->getHttpClient() - ->get($this->getJwksUri()); + ->get($this->getJwksUri($refresh)); return $this->jwks = JWK::parseKeySet( json_decode((string) $response->getBody(), true) ); } + /** + * Determine if the JWKS can be force-refreshed. + */ + protected function canRefreshJwks(bool $refresh): bool + { + return ! $refresh + || $this->jwksRefreshAttemptedAt === null + || (time() - $this->jwksRefreshAttemptedAt) >= $this->jwksRefreshCooldownSeconds; + } + /** * Receive data from auth/callback route * code, id_token, scope, state, session_state. @@ -243,9 +273,20 @@ protected function isInvalidNonce(string $nonce): bool */ protected function getUserByOIDCToken(string $token): ?array { - $this->validateOIDCPayload( - $data = (array) JWT::decode($token, $this->getJwks()) - ); + try { + $data = (array) JWT::decode($token, $this->getJwks()); + } catch (SignatureInvalidException) { + // Some providers briefly replace key material under an existing kid. + $data = (array) JWT::decode($token, $this->getJwks(refresh: true)); + } catch (UnexpectedValueException $e) { + if (! str_contains($e->getMessage(), '"kid" invalid')) { + throw $e; + } + + $data = (array) JWT::decode($token, $this->getJwks(refresh: true)); + } + + $this->validateOIDCPayload($data); return $data; } diff --git a/tests/Socialite/Fixtures/VerifyingOpenIdTestProviderStub.php b/tests/Socialite/Fixtures/VerifyingOpenIdTestProviderStub.php new file mode 100644 index 000000000..36805f853 --- /dev/null +++ b/tests/Socialite/Fixtures/VerifyingOpenIdTestProviderStub.php @@ -0,0 +1,67 @@ +getUserByOIDCToken($token); + } + + public function setJwksRefreshCooldownSeconds(int $seconds): void + { + $this->jwksRefreshCooldownSeconds = $seconds; + } + + protected function getBaseUrl(): string + { + return 'http://base.url'; + } + + protected function getAuthUrl(?string $state, ?string $nonce = null): string + { + return $this->buildAuthUrlFromBase('http://auth.url', $state, $nonce); + } + + protected function getTokenUrl(): string + { + return 'http://token.url'; + } + + protected function getUserByToken(string $token): array + { + return ['id' => 'foo']; + } + + protected function mapUserToObject(array $user): User + { + return (new User)->map(['id' => $user['sub']]); + } + + /** + * Get a fresh instance of the Guzzle HTTP client. + * + * @return \GuzzleHttp\Client|\Mockery\MockInterface + */ + protected function getHttpClient(): Client + { + if ($this->http) { + return $this->http; + } + + return $this->http = m::mock(Client::class); + } +} diff --git a/tests/Socialite/OpenIdProviderTest.php b/tests/Socialite/OpenIdProviderTest.php index abd875802..09ec29e74 100644 --- a/tests/Socialite/OpenIdProviderTest.php +++ b/tests/Socialite/OpenIdProviderTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Socialite; +use Firebase\JWT\JWT; use GuzzleHttp\Client; use GuzzleHttp\Psr7\Response; use Hypervel\Contracts\Session\Session as SessionContract; @@ -12,11 +13,13 @@ use Hypervel\Socialite\Two\Exceptions\InvalidAudienceException; use Hypervel\Socialite\Two\User; use Hypervel\Tests\Socialite\Fixtures\OpenIdTestProviderStub; +use Hypervel\Tests\Socialite\Fixtures\VerifyingOpenIdTestProviderStub; use Hypervel\Tests\TestCase; use Mockery as m; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\StreamInterface; use ReflectionMethod; +use UnexpectedValueException; class OpenIdProviderTest extends TestCase { @@ -173,4 +176,197 @@ public function testSetConfigOverridesAudienceValidationFail() 'iss' => 'http://base.url', ]); } + + public function testOidcJwksRefreshesWhenTokenKidIsMissingFromCachedKeys() + { + $oldKey = $this->createRsaKeyPair('old-key'); + $newKey = $this->createRsaKeyPair('new-key'); + $provider = $this->createVerifyingProvider(); + + $this->expectOpenIdConfigRequests($provider->http, 2); + $this->expectJwksRequests($provider->http, [ + $this->jwks($oldKey), + $this->jwks($newKey), + ]); + + $user = $provider->verifyToken($this->createSignedToken($newKey)); + + $this->assertSame('foo', $user['sub']); + } + + public function testOidcJwksRemainCachedWhenTokenKidIsPresent() + { + $key = $this->createRsaKeyPair('current-key'); + $provider = $this->createVerifyingProvider(); + + $this->expectOpenIdConfigRequests($provider->http, 1); + $this->expectJwksRequests($provider->http, [ + $this->jwks($key), + ]); + + $firstUser = $provider->verifyToken($this->createSignedToken($key)); + $secondUser = $provider->verifyToken($this->createSignedToken($key)); + + $this->assertSame('foo', $firstUser['sub']); + $this->assertSame('foo', $secondUser['sub']); + } + + public function testOidcJwksDoesNotRefreshForTokenWithoutKid() + { + $key = $this->createRsaKeyPair('current-key'); + $provider = $this->createVerifyingProvider(); + + $this->expectOpenIdConfigRequests($provider->http, 1); + $this->expectJwksRequests($provider->http, [ + $this->jwks($key), + ]); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('"kid" empty'); + + $provider->verifyToken($this->createSignedToken($key, includeKid: false)); + } + + public function testOidcJwksRefreshCooldownPreventsRepeatedUnknownKidFetches() + { + $oldKey = $this->createRsaKeyPair('old-key'); + $newKey = $this->createRsaKeyPair('new-key'); + $provider = $this->createVerifyingProvider(); + $provider->setJwksRefreshCooldownSeconds(60); + + $this->expectOpenIdConfigRequests($provider->http, 2); + $this->expectJwksRequests($provider->http, [ + $this->jwks($oldKey), + $this->jwks($oldKey), + ]); + + $failures = 0; + $token = $this->createSignedToken($newKey); + + for ($i = 0; $i < 2; ++$i) { + try { + $provider->verifyToken($token); + } catch (UnexpectedValueException $e) { + $this->assertStringContainsString('"kid" invalid', $e->getMessage()); + ++$failures; + } + } + + $this->assertSame(2, $failures); + } + + public function testOidcJwksRefreshesWhenCachedKeyMaterialIsStale() + { + $oldKey = $this->createRsaKeyPair('shared-key'); + $newKey = $this->createRsaKeyPair('shared-key'); + $provider = $this->createVerifyingProvider(); + + $this->expectOpenIdConfigRequests($provider->http, 2); + $this->expectJwksRequests($provider->http, [ + $this->jwks($oldKey), + $this->jwks($newKey), + ]); + + $user = $provider->verifyToken($this->createSignedToken($newKey)); + + $this->assertSame('foo', $user['sub']); + } + + private function createVerifyingProvider(): VerifyingOpenIdTestProviderStub + { + $request = m::mock(Request::class); + $request->shouldReceive('session') + ->andReturn($session = m::mock(SessionContract::class)); + $session->allows('has')->with('nonce')->andReturns(true); + $session->allows('get')->with('nonce')->andReturns('nonce'); + + $provider = new VerifyingOpenIdTestProviderStub( + $request, + 'client_id', + 'client_secret', + 'redirect' + ); + $provider->http = m::mock(Client::class); + + return $provider; + } + + private function expectOpenIdConfigRequests(Client $http, int $times): void + { + $http->shouldReceive('get') + ->with('http://base.url/.well-known/openid-configuration') + ->times($times) + ->andReturn(new Response( + body: json_encode([ + 'issuer' => 'http://base.url', + 'token_endpoint' => 'http://token.url', + 'jwks_uri' => 'http://jwks.url', + ]) + )); + } + + private function expectJwksRequests(Client $http, array $jwksResponses): void + { + $http->shouldReceive('get') + ->with('http://jwks.url') + ->times(count($jwksResponses)) + ->andReturn(...array_map( + fn (array $jwks): Response => new Response(body: json_encode($jwks)), + $jwksResponses + )); + } + + private function createSignedToken(array $key, bool $includeKid = true): string + { + return JWT::encode([ + 'iss' => 'http://base.url', + 'sub' => 'foo', + 'aud' => 'client_id', + 'nonce' => 'nonce', + 'iat' => time(), + 'exp' => time() + 3600, + ], $key['private'], 'RS256', $includeKid ? $key['kid'] : null); + } + + private function createRsaKeyPair(string $kid): array + { + $key = openssl_pkey_new([ + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + + if ($key === false) { + $this->fail('Unable to generate RSA key pair for OIDC test.'); + } + + openssl_pkey_export($key, $privateKey); + $details = openssl_pkey_get_details($key); + + return [ + 'kid' => $kid, + 'private' => $privateKey, + 'jwk' => [ + 'kid' => $kid, + 'kty' => 'RSA', + 'use' => 'sig', + 'alg' => 'RS256', + 'n' => $this->base64UrlEncode($details['rsa']['n']), + 'e' => $this->base64UrlEncode($details['rsa']['e']), + ], + ]; + } + + private function jwks(array $key): array + { + return [ + 'keys' => [ + $key['jwk'], + ], + ]; + } + + private function base64UrlEncode(string $value): string + { + return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); + } }