Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/sim/app/oauth-error/page.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 {
Expand Down
13 changes: 12 additions & 1 deletion apps/sim/lib/auth/auth.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -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,
},
}),
},
Expand Down
34 changes: 33 additions & 1 deletion apps/sim/lib/oauth/microsoft.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)
Expand Down
29 changes: 29 additions & 0 deletions apps/sim/lib/oauth/microsoft.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand Down
Loading