From a2e690b0cdd23e8d1a1bebf2147f18bc6884d407 Mon Sep 17 00:00:00 2001 From: Stephen Stack Date: Tue, 10 Feb 2026 11:35:05 +0000 Subject: [PATCH] fix(microsoft): refresh JWKS on unknown kid --- src/Microsoft/Provider.php | 111 +++++++++++++++++++++++++++++++------ src/Microsoft/README.md | 6 ++ 2 files changed, 100 insertions(+), 17 deletions(-) diff --git a/src/Microsoft/Provider.php b/src/Microsoft/Provider.php index 82cd03846..a00382c68 100644 --- a/src/Microsoft/Provider.php +++ b/src/Microsoft/Provider.php @@ -16,6 +16,14 @@ class Provider extends AbstractProvider { public const IDENTIFIER = 'MICROSOFT'; + private const OPENID_CONFIGURATION_CACHE_TTL_SECONDS = 3600; + + private const JWKS_CACHE_TTL_SECONDS = 300; + + private mixed $openIdConfiguration = null; + + private ?array $jwtKeys = null; + /** * The tenant id associated with personal Microsoft accounts (services like Xbox, Teams for Life, or Outlook). * Note: only reported in JWT ID_TOKENs and not in call's to Graph Organization endpoint. @@ -90,7 +98,7 @@ public function getLogoutUrl(?string $redirectUri = null) return $redirectUri === null ? $logoutUrl : - $logoutUrl.'?'.http_build_query(['post_logout_redirect_uri' => $redirectUri], '', '&', $this->encodingType); + $logoutUrl . '?' . http_build_query(['post_logout_redirect_uri' => $redirectUri], '', '&', $this->encodingType); } /** @@ -103,7 +111,7 @@ protected function getUserByToken($token) [ RequestOptions::HEADERS => [ 'Accept' => 'application/json', - 'Authorization' => 'Bearer '.$token, + 'Authorization' => 'Bearer ' . $token, ], RequestOptions::QUERY => [ '$select' => implode(',', array_merge(self::DEFAULT_FIELDS_USER, $this->getConfig('fields', []))), @@ -122,7 +130,7 @@ protected function getUserByToken($token) [ RequestOptions::HEADERS => [ 'Accept' => 'image/jpg', - 'Authorization' => 'Bearer '.$token, + 'Authorization' => 'Bearer ' . $token, ], RequestOptions::PROXY => $this->getConfig('proxy'), ] @@ -130,7 +138,7 @@ protected function getUserByToken($token) $formattedResponse['avatar'] = base64_encode($responseAvatar->getBody()->getContents()) ?? null; } catch (ClientException) { - //if exception then avatar does not exist. + // if exception then avatar does not exist. $formattedResponse['avatar'] = null; } } @@ -144,7 +152,7 @@ protected function getUserByToken($token) [ RequestOptions::HEADERS => [ 'Accept' => 'application/json', - 'Authorization' => 'Bearer '.$token, + 'Authorization' => 'Bearer ' . $token, ], RequestOptions::QUERY => [ '$select' => implode(',', array_merge(self::DEFAULT_FIELDS_TENANT, $this->getConfig('tenant_fields', []))), @@ -200,7 +208,6 @@ protected function mapUserToObject(array $user) 'tenant' => Arr::get($user, 'tenant'), ]); - } /** @@ -237,7 +244,6 @@ public function getRoles(): array if ($idToken = $this->parseIdToken($this->credentialsResponseBody)) { $claims = $this->validate($idToken); - } return $claims?->roles ?? []; @@ -283,11 +289,53 @@ protected function parseIdToken($body) */ private function getJWTKeys(): array { - $response = $this->getHttpClient()->get($this->getOpenIdConfiguration()->jwks_uri, [ - RequestOptions::PROXY => $this->getConfig('proxy'), - ]); - - return json_decode((string) $response->getBody(), true); + return $this->getJWTKeysWithCache(false); + } + + /** + * Get public keys to verify id_token from jwks_uri, optionally forcing a refresh. + * + * @throws \GuzzleHttp\Exception\GuzzleException + */ + private function getJWTKeysWithCache(bool $forceRefresh): array + { + if (! $forceRefresh && $this->jwtKeys !== null) { + return $this->jwtKeys; + } + + $jwksUri = $this->getOpenIdConfiguration()->jwks_uri; + $cacheKey = 'socialite:microsoft:jwks:' . sha1((string) $jwksUri); + + $fetch = function () use ($jwksUri, $forceRefresh) { + $options = [ + RequestOptions::PROXY => $this->getConfig('proxy'), + ]; + + if ($forceRefresh) { + $options[RequestOptions::HEADERS] = [ + 'Cache-Control' => 'no-cache', + 'Pragma' => 'no-cache', + ]; + } + + $response = $this->getHttpClient()->get($jwksUri, $options); + + return json_decode((string) $response->getBody(), true); + }; + + if (class_exists(\Illuminate\Support\Facades\Cache::class)) { + if ($forceRefresh) { + \Illuminate\Support\Facades\Cache::forget($cacheKey); + } + + $this->jwtKeys = \Illuminate\Support\Facades\Cache::remember($cacheKey, self::JWKS_CACHE_TTL_SECONDS, $fetch); + + return $this->jwtKeys; + } + + $this->jwtKeys = $fetch(); + + return $this->jwtKeys; } /** @@ -299,6 +347,10 @@ private function getJWTKeys(): array */ private function getOpenIdConfiguration(): mixed { + if ($this->openIdConfiguration !== null) { + return $this->openIdConfiguration; + } + try { // URI Discovery Mechanism for the Provider Configuration URI // @@ -306,12 +358,26 @@ private function getOpenIdConfiguration(): mixed // $discovery = sprintf('https://login.microsoftonline.com/%s/v2.0/.well-known/openid-configuration', $this->getConfig('tenant', 'common')); + $cacheKey = 'socialite:microsoft:openid:' . sha1((string) $discovery); + + if (class_exists(\Illuminate\Support\Facades\Cache::class)) { + $this->openIdConfiguration = \Illuminate\Support\Facades\Cache::remember($cacheKey, self::OPENID_CONFIGURATION_CACHE_TTL_SECONDS, function () use ($discovery) { + $response = $this->getHttpClient()->get($discovery, [RequestOptions::PROXY => $this->getConfig('proxy')]); + + return json_decode((string) $response->getBody()); + }); + + return $this->openIdConfiguration; + } + $response = $this->getHttpClient()->get($discovery, [RequestOptions::PROXY => $this->getConfig('proxy')]); } catch (Exception $ex) { throw new InvalidStateException("Error on getting OpenID Configuration. {$ex}"); } - return json_decode((string) $response->getBody()); + $this->openIdConfiguration = json_decode((string) $response->getBody()); + + return $this->openIdConfiguration; } /** @@ -323,8 +389,10 @@ private function getOpenIdConfiguration(): mixed private function getTokenSigningAlgorithm($jwtHeader): string { return $jwtHeader?->alg ?? (string) collect( - array_merge($this->getOpenIdConfiguration()->id_token_signing_alg_values_supported, - [$this->getConfig('default_algorithm', 'RS256')]) + array_merge( + $this->getOpenIdConfiguration()->id_token_signing_alg_values_supported, + [$this->getConfig('default_algorithm', 'RS256')] + ) )->first(); } @@ -354,7 +422,17 @@ private function validate(string $idToken) // decode body with signature check $alg = $this->getTokenSigningAlgorithm($jwtHeaders); $headers = new \stdClass; - $jwtPayload = JWT::decode($idToken, JWK::parseKeySet($this->getJWTKeys(), $alg), $headers); + try { + $jwtPayload = JWT::decode($idToken, JWK::parseKeySet($this->getJWTKeysWithCache(false), $alg), $headers); + } catch (\UnexpectedValueException $e) { + // During Azure key rotation, tokens may be signed with a key that isn't yet present in the published JWKS. + // Refresh the JWKS once and retry to avoid intermittent validation failures. + if (str_contains($e->getMessage(), '"kid" invalid') && str_contains($e->getMessage(), 'unable to lookup correct key')) { + $jwtPayload = JWT::decode($idToken, JWK::parseKeySet($this->getJWTKeysWithCache(true), $alg), $headers); + } else { + throw $e; + } + } // iss validation - a security token service (STS) URI // Identifies the STS that constructs and returns the token, and the Microsoft Entra tenant of the authenticated user. @@ -377,7 +455,6 @@ private function validate(string $idToken) } return $jwtPayload; - } catch (Exception $e) { throw new InvalidStateException("Error on validating id_token. {$e}"); } diff --git a/src/Microsoft/README.md b/src/Microsoft/README.md index 4e975ebed..f1c505888 100644 --- a/src/Microsoft/README.md +++ b/src/Microsoft/README.md @@ -60,6 +60,12 @@ return Socialite::driver('microsoft')->redirect(); ## Extended features +### ID token validation and key rollover + +When using the `openid` scope, Microsoft returns an `id_token` JWT. This provider validates the `id_token` signature and claims. + +Microsoft (Entra ID / Azure AD) periodically rotates signing keys. During rollover there can be a short window where a token is signed with a new key that is not yet available from the published JWKS endpoints. To reduce intermittent login failures, the provider caches JWKS briefly and will refresh the JWKS and retry validation once when it encounters an unknown `kid`. + ### Roles `Socialite::driver('microsoft')->user()->getRoles()` returns an array of strings containing the names of the Microsoft 365/Azure AD groups the authenticated user belongs to. You can use this information to assign users to application roles at login.