From bf825126c6dc116f603a4505cedac7efc8bc1915 Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 11 Aug 2026 11:41:50 -0700 Subject: [PATCH] fix(auth): let Microsoft sign-in link via Entra's domain-verified email claim (#6546) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- apps/sim/app/oauth-error/page.tsx | 10 ++++++++ apps/sim/lib/auth/auth.ts | 13 ++++++++++- apps/sim/lib/oauth/microsoft.test.ts | 34 +++++++++++++++++++++++++++- apps/sim/lib/oauth/microsoft.ts | 29 ++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/oauth-error/page.tsx b/apps/sim/app/oauth-error/page.tsx index 513a77368b..3818f2eeae 100644 --- a/apps/sim/app/oauth-error/page.tsx +++ b/apps/sim/app/oauth-error/page.tsx @@ -33,6 +33,16 @@ const FRIENDLY: Record = { */ 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 { diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 5717d00db9..50585c6403 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -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, }, }), }, diff --git a/apps/sim/lib/oauth/microsoft.test.ts b/apps/sim/lib/oauth/microsoft.test.ts index e4bbf1f138..a4109652de 100644 --- a/apps/sim/lib/oauth/microsoft.test.ts +++ b/apps/sim/lib/oauth/microsoft.test.ts @@ -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) diff --git a/apps/sim/lib/oauth/microsoft.ts b/apps/sim/lib/oauth/microsoft.ts index 8da533eee7..323e125212 100644 --- a/apps/sim/lib/oauth/microsoft.ts +++ b/apps/sim/lib/oauth/microsoft.ts @@ -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): 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 +): { emailVerified: true } | Record { + 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.