Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 94 additions & 17 deletions src/Microsoft/Provider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
}

/**
Expand All @@ -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', []))),
Expand All @@ -122,15 +130,15 @@ protected function getUserByToken($token)
[
RequestOptions::HEADERS => [
'Accept' => 'image/jpg',
'Authorization' => 'Bearer '.$token,
'Authorization' => 'Bearer ' . $token,
],
RequestOptions::PROXY => $this->getConfig('proxy'),
]
);

$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;
}
}
Expand All @@ -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', []))),
Expand Down Expand Up @@ -200,7 +208,6 @@ protected function mapUserToObject(array $user)

'tenant' => Arr::get($user, 'tenant'),
]);

}

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

$claims = $this->validate($idToken);

}

return $claims?->roles ?? [];
Expand Down Expand Up @@ -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;
}

/**
Expand All @@ -299,19 +347,37 @@ private function getJWTKeys(): array
*/
private function getOpenIdConfiguration(): mixed
{
if ($this->openIdConfiguration !== null) {
return $this->openIdConfiguration;
}

try {
// URI Discovery Mechanism for the Provider Configuration URI
//
// https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols-oidc#fetch-the-openid-configuration-document
//
$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;
}

/**
Expand All @@ -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();
}

Expand Down Expand Up @@ -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.
Expand All @@ -377,7 +455,6 @@ private function validate(string $idToken)
}

return $jwtPayload;

} catch (Exception $e) {
throw new InvalidStateException("Error on validating id_token. {$e}");
}
Expand Down
6 changes: 6 additions & 0 deletions src/Microsoft/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down