mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user