From 7b01abd1e6adc70ba2e29a9113ce5cdda2af6c52 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 11 Aug 2026 11:10:01 -0700 Subject: [PATCH 1/3] fix(auth): let Microsoft sign-in link via Entra's domain-verified email claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/sim/app/oauth-error/page.tsx | 15 +++++++++++ apps/sim/lib/auth/auth.ts | 15 ++++++++++- apps/sim/lib/core/config/env.ts | 1 + apps/sim/lib/oauth/microsoft.test.ts | 40 +++++++++++++++++++++++++++- apps/sim/lib/oauth/microsoft.ts | 39 +++++++++++++++++++++++++++ 5 files changed, 108 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/oauth-error/page.tsx b/apps/sim/app/oauth-error/page.tsx index 513a77368b2..0cf7c79e8c1 100644 --- a/apps/sim/app/oauth-error/page.tsx +++ b/apps/sim/app/oauth-error/page.tsx @@ -33,6 +33,21 @@ const FRIENDLY: Record = { */ signup_disabled: 'Account creation is disabled on this instance. Ask your admin to create an account for you.', + /** + * An account already exists for this email but the provider doesn't assert a + * verified email, so Better Auth refuses to link (see + * `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 — for Microsoft work accounts, the + * directory's mail attribute is unset or the `email` optional claim isn't + * configured on the app registration. + */ + 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 5717d00db96..68a70f2aded 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,15 @@ export const auth = betterAuth({ clientId: env.MICROSOFT_CLIENT_ID, clientSecret: env.MICROSOFT_CLIENT_SECRET, scope: ['openid', 'profile', 'email'], + ...(env.MICROSOFT_TENANT_ID ? { tenantId: env.MICROSOFT_TENANT_ID } : {}), + /** + * Without this, `/common/` silently reuses whichever Microsoft + * session the browser already holds, so someone signed into a + * personal account never gets to pick their work account — and + * lands on an orphan Sim account under the wrong address. + */ + prompt: 'select_account' as const, + mapProfileToUser: mapMicrosoftProfileToUser, }, }), }, diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 27bc956b605..a3794b0e397 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -440,6 +440,7 @@ export const env = createEnv({ DOCUSIGN_CLIENT_SECRET: z.string().optional(), // DocuSign OAuth client secret MICROSOFT_CLIENT_ID: z.string().optional(), // Microsoft OAuth client ID for Office 365/Teams MICROSOFT_CLIENT_SECRET: z.string().optional(), // Microsoft OAuth client secret + MICROSOFT_TENANT_ID: z.string().optional(), // Microsoft sign-in tenant: a GUID, 'organizations' (work/school only), or 'common' (default) HUBSPOT_CLIENT_ID: z.string().optional(), // HubSpot OAuth client ID HUBSPOT_CLIENT_SECRET: z.string().optional(), // HubSpot OAuth client secret SALESFORCE_CLIENT_ID: z.string().optional(), // Salesforce OAuth client ID diff --git a/apps/sim/lib/oauth/microsoft.test.ts b/apps/sim/lib/oauth/microsoft.test.ts index e4bbf1f138d..6b93ef0b4ff 100644 --- a/apps/sim/lib/oauth/microsoft.test.ts +++ b/apps/sim/lib/oauth/microsoft.test.ts @@ -2,10 +2,48 @@ * @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, + }) + } + }) + + /** + * The nOAuth case: a hostile tenant sets `email` to a victim's address but + * cannot verify that domain, so `xms_edov` is absent or false. + */ + 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({}) + }) + + /** + * Better Auth spreads this over its own derived profile, so an empty object + * must leave `emailVerified` alone rather than forcing 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 8da533eee72..98db7bab1e0 100644 --- a/apps/sim/lib/oauth/microsoft.ts +++ b/apps/sim/lib/oauth/microsoft.ts @@ -54,6 +54,45 @@ export function deriveMicrosoftEmailVerified( ) } +/** + * True when Entra asserts the token's email is domain-verified via the + * `xms_edov` optional claim — emitted only when the email's domain belongs to + * the user's own tenant and a tenant admin verified that domain. This is the + * one email signal a hostile tenant cannot forge (the nOAuth attack turns on + * `email` being freely settable), and Microsoft's documented mitigation. + * + * The claim requires `xms_edov` and `email` to be configured as optional + * claims on the app registration; when absent this returns `false` and callers + * fall back to the prior behavior unchanged. + * + * @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* when — and only when — Entra + * asserts domain ownership. Better Auth spreads this result over its own + * derived profile, so returning an empty object leaves its computation + * untouched: this can promote an unverified email to verified, never the + * reverse. + * + * Why it matters: `microsoft` is deliberately absent from + * `accountLinking.trustedProviders`, so Better Auth refuses to link a Microsoft + * identity onto an existing user row unless the IdP asserts a verified email. + * Entra never emits `email_verified` for work/school accounts, so without this + * every user who already has a Sim account is permanently locked out of the + * Microsoft button with an `account_not_linked` error. `xms_edov` is what lets + * the honest case through while still refusing the forged one. + */ +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. From 7cc91c44e41531eed9ceedd87e68068a9af1d04d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 11 Aug 2026 11:27:52 -0700 Subject: [PATCH 2/3] chore(auth): drop the unused MICROSOFT_TENANT_ID knob MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/sim/lib/auth/auth.ts | 1 - apps/sim/lib/core/config/env.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 68a70f2aded..c8cabaf3696 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -760,7 +760,6 @@ export const auth = betterAuth({ clientId: env.MICROSOFT_CLIENT_ID, clientSecret: env.MICROSOFT_CLIENT_SECRET, scope: ['openid', 'profile', 'email'], - ...(env.MICROSOFT_TENANT_ID ? { tenantId: env.MICROSOFT_TENANT_ID } : {}), /** * Without this, `/common/` silently reuses whichever Microsoft * session the browser already holds, so someone signed into a diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index a3794b0e397..27bc956b605 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -440,7 +440,6 @@ export const env = createEnv({ DOCUSIGN_CLIENT_SECRET: z.string().optional(), // DocuSign OAuth client secret MICROSOFT_CLIENT_ID: z.string().optional(), // Microsoft OAuth client ID for Office 365/Teams MICROSOFT_CLIENT_SECRET: z.string().optional(), // Microsoft OAuth client secret - MICROSOFT_TENANT_ID: z.string().optional(), // Microsoft sign-in tenant: a GUID, 'organizations' (work/school only), or 'common' (default) HUBSPOT_CLIENT_ID: z.string().optional(), // HubSpot OAuth client ID HUBSPOT_CLIENT_SECRET: z.string().optional(), // HubSpot OAuth client secret SALESFORCE_CLIENT_ID: z.string().optional(), // Salesforce OAuth client ID From 56759342934851578d37fdf49f295d47be756e24 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 11 Aug 2026 11:32:01 -0700 Subject: [PATCH 3/3] chore(auth): tighten the Microsoft linking comments --- apps/sim/app/oauth-error/page.tsx | 11 +++------- apps/sim/lib/auth/auth.ts | 7 +++--- apps/sim/lib/oauth/microsoft.test.ts | 10 ++------- apps/sim/lib/oauth/microsoft.ts | 32 ++++++++++------------------ 4 files changed, 19 insertions(+), 41 deletions(-) diff --git a/apps/sim/app/oauth-error/page.tsx b/apps/sim/app/oauth-error/page.tsx index 0cf7c79e8c1..3818f2eeae9 100644 --- a/apps/sim/app/oauth-error/page.tsx +++ b/apps/sim/app/oauth-error/page.tsx @@ -34,18 +34,13 @@ const FRIENDLY: Record = { signup_disabled: 'Account creation is disabled on this instance. Ask your admin to create an account for you.', /** - * An account already exists for this email but the provider doesn't assert a - * verified email, so Better Auth refuses to link (see - * `accountLinking.trustedProviders`). Retrying reproduces it exactly, so the + * 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 — for Microsoft work accounts, the - * directory's mail attribute is unset or the `email` optional claim isn't - * configured on the app registration. - */ + /** 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.', } diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index c8cabaf3696..50585c6403b 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -761,10 +761,9 @@ export const auth = betterAuth({ clientSecret: env.MICROSOFT_CLIENT_SECRET, scope: ['openid', 'profile', 'email'], /** - * Without this, `/common/` silently reuses whichever Microsoft - * session the browser already holds, so someone signed into a - * personal account never gets to pick their work account — and - * lands on an orphan Sim account under the wrong address. + * `/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 6b93ef0b4ff..a4109652dea 100644 --- a/apps/sim/lib/oauth/microsoft.test.ts +++ b/apps/sim/lib/oauth/microsoft.test.ts @@ -25,20 +25,14 @@ describe('mapMicrosoftProfileToUser', () => { } }) - /** - * The nOAuth case: a hostile tenant sets `email` to a victim's address but - * cannot verify that domain, so `xms_edov` is absent or false. - */ + /** 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({}) }) - /** - * Better Auth spreads this over its own derived profile, so an empty object - * must leave `emailVerified` alone rather than forcing it to `false`. - */ + /** 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) }) diff --git a/apps/sim/lib/oauth/microsoft.ts b/apps/sim/lib/oauth/microsoft.ts index 98db7bab1e0..323e1252122 100644 --- a/apps/sim/lib/oauth/microsoft.ts +++ b/apps/sim/lib/oauth/microsoft.ts @@ -55,15 +55,10 @@ export function deriveMicrosoftEmailVerified( } /** - * True when Entra asserts the token's email is domain-verified via the - * `xms_edov` optional claim — emitted only when the email's domain belongs to - * the user's own tenant and a tenant admin verified that domain. This is the - * one email signal a hostile tenant cannot forge (the nOAuth attack turns on - * `email` being freely settable), and Microsoft's documented mitigation. - * - * The claim requires `xms_edov` and `email` to be configured as optional - * claims on the app registration; when absent this returns `false` and callers - * fall back to the prior behavior unchanged. + * 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 */ @@ -73,19 +68,14 @@ function isMicrosoftEmailDomainVerified(claims: Record): boolea } /** - * Raises `emailVerified` for Microsoft *sign-in* when — and only when — Entra - * asserts domain ownership. Better Auth spreads this result over its own - * derived profile, so returning an empty object leaves its computation - * untouched: this can promote an unverified email to verified, never the - * reverse. + * 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. * - * Why it matters: `microsoft` is deliberately absent from - * `accountLinking.trustedProviders`, so Better Auth refuses to link a Microsoft - * identity onto an existing user row unless the IdP asserts a verified email. - * Entra never emits `email_verified` for work/school accounts, so without this - * every user who already has a Sim account is permanently locked out of the - * Microsoft button with an `account_not_linked` error. `xms_edov` is what lets - * the honest case through while still refusing the forged one. + * 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