fix(auth): accept Entra ID xms_edov claim as verified OIDC email

Microsoft Entra ID never sends email_verified, so existing accounts could
not be linked through OIDC. Treat a boolean true xms_edov claim (email
domain owner verified) as equivalent, via a shared
OidcUser::claimsVerifyEmail() helper used for both ID token and userinfo
claims.

Document the Entra ID issuer URL and required optional claims in the
OIDC settings helper text, and add tests covering xms_edov handling.
This commit is contained in:
Andras Bacsai
2026-10-02 18:51:16 +02:00
parent f95ddd97fa
commit c0d81d4c9c
4 changed files with 48 additions and 3 deletions
+12 -1
View File
@@ -25,8 +25,19 @@ class OidcUser extends SocialiteUser
$this->idTokenClaims = $claims;
$this->issuer = is_string($claims['iss'] ?? null) ? $claims['iss'] : null;
$this->subject = is_string($claims['sub'] ?? null) ? $claims['sub'] : null;
$this->emailVerified = ($claims['email_verified'] ?? false) === true;
$this->emailVerified = self::claimsVerifyEmail($claims);
return $this;
}
/**
* Microsoft Entra ID never sends email_verified. Its optional xms_edov
* claim ("email domain owner verified") is the documented replacement.
*
* @param array<string, mixed> $claims
*/
public static function claimsVerifyEmail(array $claims): bool
{
return ($claims['email_verified'] ?? null) === true || ($claims['xms_edov'] ?? null) === true;
}
}
+1 -1
View File
@@ -220,7 +220,7 @@ class OauthLoginService
{
[$issuer, $subject] = $this->oidcIssuerAndSubject($oauthUser);
$emailVerified = ($oauthUser instanceof OidcUser && $oauthUser->emailVerified)
|| data_get($oauthUser->user, 'email_verified') === true;
|| OidcUser::claimsVerifyEmail(is_array($oauthUser->user ?? null) ? $oauthUser->user : []);
if (! is_string($issuer) || $issuer === '' || ! is_string($subject) || $subject === '') {
throw new HttpException(403, 'OIDC provider did not return issuer and subject claims');
@@ -61,7 +61,7 @@
placeholder="{{ oauth_default_redirect_uri($provider) }}" label="Redirect URI" />
<x-forms.input canGate="update" :canResource="$settings"
id="oauth_settings_map.{{ $provider }}.base_url" label="Issuer URL" required
helper="OpenID Provider issuer URL, for example https://example.okta.com. Coolify uses it to discover the authorization, token, userinfo, and JWKS endpoints." />
helper="OpenID Provider issuer URL, for example https://example.okta.com. Coolify uses it to discover the authorization, token, userinfo, and JWKS endpoints. For Microsoft Entra ID, use https://login.microsoftonline.com/&lt;tenant ID&gt;/v2.0 and add the optional ID token claims email and xms_edov to the app registration." />
<x-forms.input canGate="update" :canResource="$settings"
id="oauth_settings_map.{{ $provider }}.client_id" label="Client ID" required />
<x-forms.input canGate="update" :canResource="$settings"
+34
View File
@@ -304,3 +304,37 @@ it('does not mark a newly provisioned oidc account verified without a verified e
$user = User::whereEmail('unverified@example.com')->firstOrFail();
expect($user->email_verified_at)->toBeNull();
});
it('links an existing account when Microsoft Entra ID sends xms_edov instead of email_verified', function () {
$user = User::factory()->create(['email' => 'member@example.com']);
fakeOidcProvider(['email' => 'member@example.com', 'email_verified' => null, 'xms_edov' => true]);
$this->get(route('auth.callback', 'oidc'))->assertRedirect('/');
$this->assertAuthenticatedAs($user);
$this->assertDatabaseHas('oauth_identities', ['user_id' => $user->id, 'provider' => 'oidc']);
});
it('rejects linking an existing account when Microsoft Entra ID sends a false xms_edov', function () {
$user = User::factory()->create(['email' => 'member@example.com']);
fakeOidcProvider(['email' => 'member@example.com', 'email_verified' => null, 'xms_edov' => false]);
$this->from('/login')->get(route('auth.callback', 'oidc'))->assertRedirect('/login');
$this->assertGuest();
$this->assertDatabaseMissing('oauth_identities', ['user_id' => $user->id]);
});
it('treats only a boolean true email_verified or xms_edov id token claim as verified', function (array $claims, bool $expected) {
$oidcUser = (new OidcUser)->setIdTokenClaims(['iss' => 'https://idp.example.com', 'sub' => 'user-1', ...$claims]);
expect($oidcUser->emailVerified)->toBe($expected);
})->with([
'email_verified true' => [['email_verified' => true], true],
'xms_edov true' => [['xms_edov' => true], true],
'xms_edov false' => [['xms_edov' => false], false],
'xms_edov string' => [['xms_edov' => 'true'], false],
'no claim' => [[], false],
]);