Skip to content
Merged
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
240 changes: 53 additions & 187 deletions apps/sim/lib/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,51 @@ const logger = createLogger('Auth')
import { getMicrosoftRefreshTokenExpiry, isMicrosoftProvider } from '@/lib/oauth/microsoft'
import { getCanonicalScopesForProvider } from '@/lib/oauth/utils'

/**
* 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.
* The ID token is always returned when the openid scope is requested.
*/
function getMicrosoftUserInfoFromIdToken(tokens: { accessToken?: string }, providerId: string) {
const idToken = (tokens as Record<string, unknown>).idToken as string | undefined
if (!idToken) {
logger.error(
`Microsoft ${providerId} OAuth: no ID token received. Ensure openid scope is requested.`
)
throw new Error(`Microsoft ${providerId} OAuth requires an ID token (openid scope)`)
}

const parts = idToken.split('.')
if (parts.length !== 3) {
throw new Error(`Microsoft ${providerId} OAuth: malformed ID token`)
}

let payload: Record<string, unknown>
try {
payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8'))
} catch {
throw new Error(`Microsoft ${providerId} OAuth: failed to decode ID token payload`)
}

const email =
(payload.email as string) || (payload.preferred_username as string) || (payload.upn as string)
if (!email) {
throw new Error(
`Microsoft ${providerId} OAuth: ID token contains no email, preferred_username, or upn claim`
)
}

const now = new Date()
return {
id: `${payload.oid || payload.sub}-${crypto.randomUUID()}`,
name: (payload.name as string) || 'Microsoft User',
email,
emailVerified: true,
createdAt: now,
updatedAt: now,
}
}

const blockedSignupDomains = env.BLOCKED_SIGNUP_DOMAINS
? new Set(env.BLOCKED_SIGNUP_DOMAINS.split(',').map((d) => d.trim().toLowerCase()))
: null
Expand Down Expand Up @@ -1291,29 +1336,7 @@ export const auth = betterAuth({
pkce: true,
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/microsoft-ad`,
getUserInfo: async (tokens) => {
try {
const response = await fetch('https://graph.microsoft.com/v1.0/me', {
headers: { Authorization: `Bearer ${tokens.accessToken}` },
})
if (!response.ok) {
await response.text().catch(() => {})
logger.error('Failed to fetch Microsoft user info', { status: response.status })
throw new Error(`Failed to fetch Microsoft user info: ${response.statusText}`)
}
const profile = await response.json()
const now = new Date()
return {
id: `${profile.id}-${crypto.randomUUID()}`,
name: profile.displayName || 'Microsoft User',
email: profile.mail || profile.userPrincipalName,
emailVerified: true,
createdAt: now,
updatedAt: now,
}
} catch (error) {
logger.error('Error in Microsoft getUserInfo', { error })
throw error
}
return getMicrosoftUserInfoFromIdToken(tokens, 'microsoft-ad')
},
},

Expand All @@ -1331,29 +1354,7 @@ export const auth = betterAuth({
pkce: true,
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/microsoft-teams`,
getUserInfo: async (tokens) => {
try {
const response = await fetch('https://graph.microsoft.com/v1.0/me', {
headers: { Authorization: `Bearer ${tokens.accessToken}` },
})
if (!response.ok) {
await response.text().catch(() => {})
logger.error('Failed to fetch Microsoft user info', { status: response.status })
throw new Error(`Failed to fetch Microsoft user info: ${response.statusText}`)
}
const profile = await response.json()
const now = new Date()
return {
id: `${profile.id}-${crypto.randomUUID()}`,
name: profile.displayName || 'Microsoft User',
email: profile.mail || profile.userPrincipalName,
emailVerified: true,
createdAt: now,
updatedAt: now,
}
} catch (error) {
logger.error('Error in Microsoft getUserInfo', { error })
throw error
}
return getMicrosoftUserInfoFromIdToken(tokens, 'microsoft-teams')
},
},

Expand All @@ -1371,29 +1372,7 @@ export const auth = betterAuth({
pkce: true,
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/microsoft-excel`,
getUserInfo: async (tokens) => {
try {
const response = await fetch('https://graph.microsoft.com/v1.0/me', {
headers: { Authorization: `Bearer ${tokens.accessToken}` },
})
if (!response.ok) {
await response.text().catch(() => {})
logger.error('Failed to fetch Microsoft user info', { status: response.status })
throw new Error(`Failed to fetch Microsoft user info: ${response.statusText}`)
}
const profile = await response.json()
const now = new Date()
return {
id: `${profile.id}-${crypto.randomUUID()}`,
name: profile.displayName || 'Microsoft User',
email: profile.mail || profile.userPrincipalName,
emailVerified: true,
createdAt: now,
updatedAt: now,
}
} catch (error) {
logger.error('Error in Microsoft getUserInfo', { error })
throw error
}
return getMicrosoftUserInfoFromIdToken(tokens, 'microsoft-excel')
},
},
{
Expand All @@ -1410,32 +1389,7 @@ export const auth = betterAuth({
pkce: true,
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/microsoft-dataverse`,
getUserInfo: async (tokens) => {
// Dataverse access tokens target dynamics.microsoft.com, not graph.microsoft.com,
// so we cannot call the Graph API /me endpoint. Instead, we decode the ID token JWT
// which is always returned when the openid scope is requested.
const idToken = (tokens as Record<string, unknown>).idToken as string | undefined
if (!idToken) {
logger.error(
'Microsoft Dataverse OAuth: no ID token received. Ensure openid scope is requested.'
)
throw new Error('Microsoft Dataverse OAuth requires an ID token (openid scope)')
}

const parts = idToken.split('.')
if (parts.length !== 3) {
throw new Error('Microsoft Dataverse OAuth: malformed ID token')
}

const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8'))
const now = new Date()
return {
id: `${payload.oid || payload.sub}-${crypto.randomUUID()}`,
name: payload.name || 'Microsoft User',
email: payload.preferred_username || payload.email || payload.upn,
emailVerified: true,
createdAt: now,
updatedAt: now,
}
return getMicrosoftUserInfoFromIdToken(tokens, 'microsoft-dataverse')
},
},
{
Expand All @@ -1452,29 +1406,7 @@ export const auth = betterAuth({
pkce: true,
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/microsoft-planner`,
getUserInfo: async (tokens) => {
try {
const response = await fetch('https://graph.microsoft.com/v1.0/me', {
headers: { Authorization: `Bearer ${tokens.accessToken}` },
})
if (!response.ok) {
await response.text().catch(() => {})
logger.error('Failed to fetch Microsoft user info', { status: response.status })
throw new Error(`Failed to fetch Microsoft user info: ${response.statusText}`)
}
const profile = await response.json()
const now = new Date()
return {
id: `${profile.id}-${crypto.randomUUID()}`,
name: profile.displayName || 'Microsoft User',
email: profile.mail || profile.userPrincipalName,
emailVerified: true,
createdAt: now,
updatedAt: now,
}
} catch (error) {
logger.error('Error in Microsoft getUserInfo', { error })
throw error
}
return getMicrosoftUserInfoFromIdToken(tokens, 'microsoft-planner')
},
},

Expand All @@ -1492,29 +1424,7 @@ export const auth = betterAuth({
pkce: true,
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/outlook`,
getUserInfo: async (tokens) => {
try {
const response = await fetch('https://graph.microsoft.com/v1.0/me', {
headers: { Authorization: `Bearer ${tokens.accessToken}` },
})
if (!response.ok) {
await response.text().catch(() => {})
logger.error('Failed to fetch Microsoft user info', { status: response.status })
throw new Error(`Failed to fetch Microsoft user info: ${response.statusText}`)
}
const profile = await response.json()
const now = new Date()
return {
id: `${profile.id}-${crypto.randomUUID()}`,
name: profile.displayName || 'Microsoft User',
email: profile.mail || profile.userPrincipalName,
emailVerified: true,
createdAt: now,
updatedAt: now,
}
} catch (error) {
logger.error('Error in Microsoft getUserInfo', { error })
throw error
}
return getMicrosoftUserInfoFromIdToken(tokens, 'outlook')
},
},

Expand All @@ -1532,29 +1442,7 @@ export const auth = betterAuth({
pkce: true,
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/onedrive`,
getUserInfo: async (tokens) => {
try {
const response = await fetch('https://graph.microsoft.com/v1.0/me', {
headers: { Authorization: `Bearer ${tokens.accessToken}` },
})
if (!response.ok) {
await response.text().catch(() => {})
logger.error('Failed to fetch Microsoft user info', { status: response.status })
throw new Error(`Failed to fetch Microsoft user info: ${response.statusText}`)
}
const profile = await response.json()
const now = new Date()
return {
id: `${profile.id}-${crypto.randomUUID()}`,
name: profile.displayName || 'Microsoft User',
email: profile.mail || profile.userPrincipalName,
emailVerified: true,
createdAt: now,
updatedAt: now,
}
} catch (error) {
logger.error('Error in Microsoft getUserInfo', { error })
throw error
}
return getMicrosoftUserInfoFromIdToken(tokens, 'onedrive')
},
},

Expand All @@ -1572,29 +1460,7 @@ export const auth = betterAuth({
pkce: true,
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/sharepoint`,
getUserInfo: async (tokens) => {
try {
const response = await fetch('https://graph.microsoft.com/v1.0/me', {
headers: { Authorization: `Bearer ${tokens.accessToken}` },
})
if (!response.ok) {
await response.text().catch(() => {})
logger.error('Failed to fetch Microsoft user info', { status: response.status })
throw new Error(`Failed to fetch Microsoft user info: ${response.statusText}`)
}
const profile = await response.json()
const now = new Date()
return {
id: `${profile.id}-${crypto.randomUUID()}`,
name: profile.displayName || 'Microsoft User',
email: profile.mail || profile.userPrincipalName,
emailVerified: true,
createdAt: now,
updatedAt: now,
}
} catch (error) {
logger.error('Error in Microsoft getUserInfo', { error })
throw error
}
return getMicrosoftUserInfoFromIdToken(tokens, 'sharepoint')
},
},

Expand Down
Loading