diff --git a/app/Auth/Oidc/OidcUser.php b/app/Auth/Oidc/OidcUser.php index 645130e019..2d882b5f36 100644 --- a/app/Auth/Oidc/OidcUser.php +++ b/app/Auth/Oidc/OidcUser.php @@ -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 $claims + */ + public static function claimsVerifyEmail(array $claims): bool + { + return ($claims['email_verified'] ?? null) === true || ($claims['xms_edov'] ?? null) === true; + } } diff --git a/app/Services/Auth/OauthLoginService.php b/app/Services/Auth/OauthLoginService.php index a1522d0f1d..c95b6efdd7 100644 --- a/app/Services/Auth/OauthLoginService.php +++ b/app/Services/Auth/OauthLoginService.php @@ -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'); diff --git a/resources/views/livewire/settings-oauth.blade.php b/resources/views/livewire/settings-oauth.blade.php index 1640f19c56..1f35c83555 100644 --- a/resources/views/livewire/settings-oauth.blade.php +++ b/resources/views/livewire/settings-oauth.blade.php @@ -61,7 +61,7 @@ placeholder="{{ oauth_default_redirect_uri($provider) }}" label="Redirect URI" /> + 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/<tenant ID>/v2.0 and add the optional ID token claims email and xms_edov to the app registration." /> 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], +]);