Skip to content

Commit 258fd05

Browse files
Fix Microsoft JWT validation during Azure key rotation (refresh JWKS + retry on unknown kid) (#1425)
Co-authored-by: Stephen Stack <[email protected]>
1 parent b4c7923 commit 258fd05

2 files changed

Lines changed: 100 additions & 17 deletions

File tree

src/Microsoft/Provider.php

Lines changed: 94 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,14 @@ class Provider extends AbstractProvider
1616
{
1717
public const IDENTIFIER = 'MICROSOFT';
1818

19+
private const OPENID_CONFIGURATION_CACHE_TTL_SECONDS = 3600;
20+
21+
private const JWKS_CACHE_TTL_SECONDS = 300;
22+
23+
private mixed $openIdConfiguration = null;
24+
25+
private ?array $jwtKeys = null;
26+
1927
/**
2028
* The tenant id associated with personal Microsoft accounts (services like Xbox, Teams for Life, or Outlook).
2129
* 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)
9098

9199
return $redirectUri === null ?
92100
$logoutUrl :
93-
$logoutUrl.'?'.http_build_query(['post_logout_redirect_uri' => $redirectUri], '', '&', $this->encodingType);
101+
$logoutUrl . '?' . http_build_query(['post_logout_redirect_uri' => $redirectUri], '', '&', $this->encodingType);
94102
}
95103

96104
/**
@@ -103,7 +111,7 @@ protected function getUserByToken($token)
103111
[
104112
RequestOptions::HEADERS => [
105113
'Accept' => 'application/json',
106-
'Authorization' => 'Bearer '.$token,
114+
'Authorization' => 'Bearer ' . $token,
107115
],
108116
RequestOptions::QUERY => [
109117
'$select' => implode(',', array_merge(self::DEFAULT_FIELDS_USER, $this->getConfig('fields', []))),
@@ -122,15 +130,15 @@ protected function getUserByToken($token)
122130
[
123131
RequestOptions::HEADERS => [
124132
'Accept' => 'image/jpg',
125-
'Authorization' => 'Bearer '.$token,
133+
'Authorization' => 'Bearer ' . $token,
126134
],
127135
RequestOptions::PROXY => $this->getConfig('proxy'),
128136
]
129137
);
130138

131139
$formattedResponse['avatar'] = base64_encode($responseAvatar->getBody()->getContents()) ?? null;
132140
} catch (ClientException) {
133-
//if exception then avatar does not exist.
141+
// if exception then avatar does not exist.
134142
$formattedResponse['avatar'] = null;
135143
}
136144
}
@@ -144,7 +152,7 @@ protected function getUserByToken($token)
144152
[
145153
RequestOptions::HEADERS => [
146154
'Accept' => 'application/json',
147-
'Authorization' => 'Bearer '.$token,
155+
'Authorization' => 'Bearer ' . $token,
148156
],
149157
RequestOptions::QUERY => [
150158
'$select' => implode(',', array_merge(self::DEFAULT_FIELDS_TENANT, $this->getConfig('tenant_fields', []))),
@@ -200,7 +208,6 @@ protected function mapUserToObject(array $user)
200208

201209
'tenant' => Arr::get($user, 'tenant'),
202210
]);
203-
204211
}
205212

206213
/**
@@ -237,7 +244,6 @@ public function getRoles(): array
237244
if ($idToken = $this->parseIdToken($this->credentialsResponseBody)) {
238245

239246
$claims = $this->validate($idToken);
240-
241247
}
242248

243249
return $claims?->roles ?? [];
@@ -283,11 +289,53 @@ protected function parseIdToken($body)
283289
*/
284290
private function getJWTKeys(): array
285291
{
286-
$response = $this->getHttpClient()->get($this->getOpenIdConfiguration()->jwks_uri, [
287-
RequestOptions::PROXY => $this->getConfig('proxy'),
288-
]);
289-
290-
return json_decode((string) $response->getBody(), true);
292+
return $this->getJWTKeysWithCache(false);
293+
}
294+
295+
/**
296+
* Get public keys to verify id_token from jwks_uri, optionally forcing a refresh.
297+
*
298+
* @throws \GuzzleHttp\Exception\GuzzleException
299+
*/
300+
private function getJWTKeysWithCache(bool $forceRefresh): array
301+
{
302+
if (! $forceRefresh && $this->jwtKeys !== null) {
303+
return $this->jwtKeys;
304+
}
305+
306+
$jwksUri = $this->getOpenIdConfiguration()->jwks_uri;
307+
$cacheKey = 'socialite:microsoft:jwks:' . sha1((string) $jwksUri);
308+
309+
$fetch = function () use ($jwksUri, $forceRefresh) {
310+
$options = [
311+
RequestOptions::PROXY => $this->getConfig('proxy'),
312+
];
313+
314+
if ($forceRefresh) {
315+
$options[RequestOptions::HEADERS] = [
316+
'Cache-Control' => 'no-cache',
317+
'Pragma' => 'no-cache',
318+
];
319+
}
320+
321+
$response = $this->getHttpClient()->get($jwksUri, $options);
322+
323+
return json_decode((string) $response->getBody(), true);
324+
};
325+
326+
if (class_exists(\Illuminate\Support\Facades\Cache::class)) {
327+
if ($forceRefresh) {
328+
\Illuminate\Support\Facades\Cache::forget($cacheKey);
329+
}
330+
331+
$this->jwtKeys = \Illuminate\Support\Facades\Cache::remember($cacheKey, self::JWKS_CACHE_TTL_SECONDS, $fetch);
332+
333+
return $this->jwtKeys;
334+
}
335+
336+
$this->jwtKeys = $fetch();
337+
338+
return $this->jwtKeys;
291339
}
292340

293341
/**
@@ -299,19 +347,37 @@ private function getJWTKeys(): array
299347
*/
300348
private function getOpenIdConfiguration(): mixed
301349
{
350+
if ($this->openIdConfiguration !== null) {
351+
return $this->openIdConfiguration;
352+
}
353+
302354
try {
303355
// URI Discovery Mechanism for the Provider Configuration URI
304356
//
305357
// https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols-oidc#fetch-the-openid-configuration-document
306358
//
307359
$discovery = sprintf('https://login.microsoftonline.com/%s/v2.0/.well-known/openid-configuration', $this->getConfig('tenant', 'common'));
308360

361+
$cacheKey = 'socialite:microsoft:openid:' . sha1((string) $discovery);
362+
363+
if (class_exists(\Illuminate\Support\Facades\Cache::class)) {
364+
$this->openIdConfiguration = \Illuminate\Support\Facades\Cache::remember($cacheKey, self::OPENID_CONFIGURATION_CACHE_TTL_SECONDS, function () use ($discovery) {
365+
$response = $this->getHttpClient()->get($discovery, [RequestOptions::PROXY => $this->getConfig('proxy')]);
366+
367+
return json_decode((string) $response->getBody());
368+
});
369+
370+
return $this->openIdConfiguration;
371+
}
372+
309373
$response = $this->getHttpClient()->get($discovery, [RequestOptions::PROXY => $this->getConfig('proxy')]);
310374
} catch (Exception $ex) {
311375
throw new InvalidStateException("Error on getting OpenID Configuration. {$ex}");
312376
}
313377

314-
return json_decode((string) $response->getBody());
378+
$this->openIdConfiguration = json_decode((string) $response->getBody());
379+
380+
return $this->openIdConfiguration;
315381
}
316382

317383
/**
@@ -323,8 +389,10 @@ private function getOpenIdConfiguration(): mixed
323389
private function getTokenSigningAlgorithm($jwtHeader): string
324390
{
325391
return $jwtHeader?->alg ?? (string) collect(
326-
array_merge($this->getOpenIdConfiguration()->id_token_signing_alg_values_supported,
327-
[$this->getConfig('default_algorithm', 'RS256')])
392+
array_merge(
393+
$this->getOpenIdConfiguration()->id_token_signing_alg_values_supported,
394+
[$this->getConfig('default_algorithm', 'RS256')]
395+
)
328396
)->first();
329397
}
330398

@@ -354,7 +422,17 @@ private function validate(string $idToken)
354422
// decode body with signature check
355423
$alg = $this->getTokenSigningAlgorithm($jwtHeaders);
356424
$headers = new \stdClass;
357-
$jwtPayload = JWT::decode($idToken, JWK::parseKeySet($this->getJWTKeys(), $alg), $headers);
425+
try {
426+
$jwtPayload = JWT::decode($idToken, JWK::parseKeySet($this->getJWTKeysWithCache(false), $alg), $headers);
427+
} catch (\UnexpectedValueException $e) {
428+
// During Azure key rotation, tokens may be signed with a key that isn't yet present in the published JWKS.
429+
// Refresh the JWKS once and retry to avoid intermittent validation failures.
430+
if (str_contains($e->getMessage(), '"kid" invalid') && str_contains($e->getMessage(), 'unable to lookup correct key')) {
431+
$jwtPayload = JWT::decode($idToken, JWK::parseKeySet($this->getJWTKeysWithCache(true), $alg), $headers);
432+
} else {
433+
throw $e;
434+
}
435+
}
358436

359437
// iss validation - a security token service (STS) URI
360438
// 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)
377455
}
378456

379457
return $jwtPayload;
380-
381458
} catch (Exception $e) {
382459
throw new InvalidStateException("Error on validating id_token. {$e}");
383460
}

src/Microsoft/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,12 @@ return Socialite::driver('microsoft')->redirect();
6060

6161
## Extended features
6262

63+
### ID token validation and key rollover
64+
65+
When using the `openid` scope, Microsoft returns an `id_token` JWT. This provider validates the `id_token` signature and claims.
66+
67+
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`.
68+
6369
### Roles
6470

6571
`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.

0 commit comments

Comments
 (0)