@@ -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 }
0 commit comments