fix(auth): let Microsoft sign-in link via Entra's domain-verified email claim (#6546)

* fix(auth): let Microsoft sign-in link via Entra's domain-verified email claim

Microsoft is excluded from accountLinking.trustedProviders because the email
claim is attacker-controllable on /common/ (nOAuth). Entra never emits
email_verified for work/school accounts, so Better Auth refused to link a
Microsoft identity onto any existing user row, permanently stranding those
users on account_not_linked.

Derive emailVerified from the xms_edov optional claim, which Entra emits only
when the email's domain belongs to the user's tenant and an admin verified it
— the one email signal a hostile tenant cannot forge. Microsoft stays
untrusted; the guard now passes on its own merits.

The mapper returns an empty object when unverified, so it can only ever
promote unverified to verified, never downgrade.

* chore(auth): drop the unused MICROSOFT_TENANT_ID knob

Hosted Sim serves many Entra tenants, so it must stay on the multi-tenant
endpoint — pinning is only meaningful for a self-hoster restricting sign-in to
their own directory, and nobody is asking for that yet. The xms_edov fix is
independent of the tenant setting, so this removes surface without touching
behavior.

* chore(auth): tighten the Microsoft linking comments
This commit is contained in:
Waleed
2026-08-11 11:41:50 -07:00
committed by GitHub
parent c365b14f73
commit bf825126c6
4 changed files with 84 additions and 2 deletions
+10
View File
@@ -33,6 +33,16 @@ const FRIENDLY: Record<string, string> = {
*/
signup_disabled:
'Account creation is disabled on this instance. Ask your admin to create an account for you.',
/**
* Better Auth refuses to link an untrusted provider onto an existing account
* (`accountLinking.trustedProviders`). Retrying reproduces it exactly, so the
* generic "try again" strands the user — name the recovery path instead.
*/
account_not_linked:
'An account already exists for this email address. Sign in using the method you originally signed up with.',
/** The provider returned no email claim, so there is nothing to sign in as. */
email_not_found:
'Your identity provider didn’t share an email address with us, so we couldn’t complete sign-in. Please contact your administrator.',
}
function messageForError(code: string | undefined): string {
+12 -1
View File
@@ -96,7 +96,11 @@ import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { validateSignupEmailMx } from '@/lib/messaging/email/validation.server'
import { isEmailVerificationEffectivelyEnabled } from '@/lib/messaging/email/verification'
import { scheduleLifecycleEmail } from '@/lib/messaging/lifecycle'
import { getMicrosoftRefreshTokenExpiry, isMicrosoftProvider } from '@/lib/oauth/microsoft'
import {
getMicrosoftRefreshTokenExpiry,
isMicrosoftProvider,
mapMicrosoftProfileToUser,
} from '@/lib/oauth/microsoft'
import {
isSalesforceLoginOrigin,
isSalesforceOAuthProviderId,
@@ -756,6 +760,13 @@ export const auth = betterAuth({
clientId: env.MICROSOFT_CLIENT_ID,
clientSecret: env.MICROSOFT_CLIENT_SECRET,
scope: ['openid', 'profile', 'email'],
/**
* `/common/` otherwise silently reuses whichever Microsoft session
* the browser holds, stranding the user on an orphan Sim account
* under their personal address.
*/
prompt: 'select_account' as const,
mapProfileToUser: mapMicrosoftProfileToUser,
},
}),
},
+33 -1
View File
@@ -2,10 +2,42 @@
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { deriveMicrosoftEmailVerified, isMicrosoftProvider } from '@/lib/oauth/microsoft'
import {
deriveMicrosoftEmailVerified,
isMicrosoftProvider,
mapMicrosoftProfileToUser,
} from '@/lib/oauth/microsoft'
const EMAIL = 'user@contoso.com'
describe('mapMicrosoftProfileToUser', () => {
it('marks the email verified when Entra asserts domain ownership', () => {
expect(mapMicrosoftProfileToUser({ email: EMAIL, xms_edov: true })).toEqual({
emailVerified: true,
})
})
it('accepts the string and numeric encodings Entra uses for xms_edov', () => {
for (const edov of ['true', '1', 1]) {
expect(mapMicrosoftProfileToUser({ email: EMAIL, xms_edov: edov })).toEqual({
emailVerified: true,
})
}
})
/** nOAuth: a hostile tenant can set `email` but cannot verify the domain. */
it('does not vouch for an email the tenant has not verified', () => {
expect(mapMicrosoftProfileToUser({ email: 'victim@target.com' })).toEqual({})
expect(mapMicrosoftProfileToUser({ email: 'victim@target.com', xms_edov: false })).toEqual({})
expect(mapMicrosoftProfileToUser({ email: 'victim@target.com', xms_edov: '0' })).toEqual({})
})
/** The spread must leave `emailVerified` alone, not force it to `false`. */
it('returns no key at all when unverified, so it can never downgrade', () => {
expect('emailVerified' in mapMicrosoftProfileToUser({ email: EMAIL })).toBe(false)
})
})
describe('deriveMicrosoftEmailVerified', () => {
it('honors an explicit email_verified=true claim', () => {
expect(deriveMicrosoftEmailVerified({ email_verified: true }, EMAIL)).toBe(true)
+29
View File
@@ -54,6 +54,35 @@ export function deriveMicrosoftEmailVerified(
)
}
/**
* True when Entra's `xms_edov` optional claim asserts the email's domain is
* owned by the user's own tenant and admin-verified — the one email signal a
* hostile tenant cannot forge, and Microsoft's documented nOAuth mitigation.
* Requires `xms_edov` and `email` as optional claims on the app registration.
*
* @see https://learn.microsoft.com/en-us/entra/identity-platform/optional-claims-reference
*/
function isMicrosoftEmailDomainVerified(claims: Record<string, unknown>): boolean {
const edov = claims.xms_edov
return edov === true || edov === 'true' || edov === 1 || edov === '1'
}
/**
* Raises `emailVerified` for Microsoft sign-in only when Entra asserts domain
* ownership. Better Auth spreads this over its own derived profile, so the
* empty object leaves that computation untouched — this can promote unverified
* to verified, never the reverse.
*
* Without it, `microsoft` being absent from `accountLinking.trustedProviders`
* (and Entra never emitting `email_verified` for work accounts) permanently
* locks anyone with an existing Sim account out of the Microsoft button.
*/
export function mapMicrosoftProfileToUser(
profile: Record<string, unknown>
): { emailVerified: true } | Record<string, never> {
return isMicrosoftEmailDomainVerified(profile) ? { emailVerified: true } : {}
}
/**
* Extracts user info from a Microsoft ID token JWT instead of calling Graph API /me.
* This avoids 403 errors for external tenant users whose admin hasn't consented to Graph API scopes.