diff --git a/apps/sim/app/oauth-error/page.tsx b/apps/sim/app/oauth-error/page.tsx index 513a77368b2..3818f2eeae9 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 5717d00db96..50585c6403b 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 e4bbf1f138d..a4109652dea 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 8da533eee72..323e1252122 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.