From 50d3498c47e160544429d88c0b2cd900cac104bf Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 15 May 2026 05:43:46 +0000 Subject: [PATCH 1/3] Refresh OIDC JWKS on unknown kid or signature mismatch Adds a refresh flag to OpenIdProvider::getOpenIdConfig() and getJwks() so the discovery doc and key set can be re-fetched when verification fails for a key reason. getUserByOIDCToken() retries decode once after a forced refresh on UnexpectedValueException matching the "kid" invalid path, and on SignatureInvalidException for the rare case where a provider replaces key material under an existing kid. A 10-second forced-refresh cooldown bounds the refresh rate so a flood of tokens with unknown kids cannot turn the verification path into a JWKS endpoint hammer. The timestamp is set before the HTTP call, so a failed fetch also counts toward cooldown. --- src/socialite/src/Two/OpenIdProvider.php | 61 ++++++++++++++++++++---- 1 file changed, 51 insertions(+), 10 deletions(-) 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; } From a3fe039ffc900ea722caed21bdd0811ce47bec29 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 15 May 2026 05:44:20 +0000 Subject: [PATCH 2/3] Add OIDC verifying provider test stub Concrete OpenIdProvider used by the JWKS rotation tests. Exposes the real JWT verification path via verifyToken() and allows tests to override jwksRefreshCooldownSeconds and the Guzzle client directly, avoiding the per-request httpClient context plumbing for the JWKS rotation cases under test. --- .../VerifyingOpenIdTestProviderStub.php | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 tests/Socialite/Fixtures/VerifyingOpenIdTestProviderStub.php 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); + } +} From 78ebe0b4839243807a771e7eb1b67708fbcdc835 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 15 May 2026 05:44:27 +0000 Subject: [PATCH 3/3] Add OIDC JWKS rotation tests Five tests covering the refresh-on-miss + cooldown behavior using real RSA keypairs and end-to-end Firebase JWT::encode / JWT::decode rather than mock-shaped fakes: - unknown new kid refreshes JWKS and succeeds - cached valid kid does not refetch - token without kid fails without triggering refresh - cooldown bounds repeated unknown-kid refreshes - same kid with stale key material refreshes once and succeeds --- tests/Socialite/OpenIdProviderTest.php | 196 +++++++++++++++++++++++++ 1 file changed, 196 insertions(+) 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), '+/', '-_'), '='); + } }