Skip to content

Commit dc6f3db

Browse files
authored
fix(oauth): decode ID token instead of calling Graph API for Microsoft providers (#3727)
* fix(oauth): decode ID token instead of calling Graph API for Microsoft providers * fix(oauth): fix type error in getMicrosoftUserInfoFromIdToken parameter * fix(oauth): address review comments - try-catch JSON.parse, fix email fallback order, guard undefined email * style(oauth): format email fallback chain to single line
1 parent 88bc16b commit dc6f3db

File tree

1 file changed

+53
-187
lines changed

1 file changed

+53
-187
lines changed

apps/sim/lib/auth/auth.ts

Lines changed: 53 additions & 187 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,51 @@ const logger = createLogger('Auth')
8282
import { getMicrosoftRefreshTokenExpiry, isMicrosoftProvider } from '@/lib/oauth/microsoft'
8383
import { getCanonicalScopesForProvider } from '@/lib/oauth/utils'
8484

85+
/**
86+
* Extracts user info from a Microsoft ID token JWT instead of calling Graph API /me.
87+
* This avoids 403 errors for external tenant users whose admin hasn't consented to Graph API scopes.
88+
* The ID token is always returned when the openid scope is requested.
89+
*/
90+
function getMicrosoftUserInfoFromIdToken(tokens: { accessToken?: string }, providerId: string) {
91+
const idToken = (tokens as Record<string, unknown>).idToken as string | undefined
92+
if (!idToken) {
93+
logger.error(
94+
`Microsoft ${providerId} OAuth: no ID token received. Ensure openid scope is requested.`
95+
)
96+
throw new Error(`Microsoft ${providerId} OAuth requires an ID token (openid scope)`)
97+
}
98+
99+
const parts = idToken.split('.')
100+
if (parts.length !== 3) {
101+
throw new Error(`Microsoft ${providerId} OAuth: malformed ID token`)
102+
}
103+
104+
let payload: Record<string, unknown>
105+
try {
106+
payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8'))
107+
} catch {
108+
throw new Error(`Microsoft ${providerId} OAuth: failed to decode ID token payload`)
109+
}
110+
111+
const email =
112+
(payload.email as string) || (payload.preferred_username as string) || (payload.upn as string)
113+
if (!email) {
114+
throw new Error(
115+
`Microsoft ${providerId} OAuth: ID token contains no email, preferred_username, or upn claim`
116+
)
117+
}
118+
119+
const now = new Date()
120+
return {
121+
id: `${payload.oid || payload.sub}-${crypto.randomUUID()}`,
122+
name: (payload.name as string) || 'Microsoft User',
123+
email,
124+
emailVerified: true,
125+
createdAt: now,
126+
updatedAt: now,
127+
}
128+
}
129+
85130
const blockedSignupDomains = env.BLOCKED_SIGNUP_DOMAINS
86131
? new Set(env.BLOCKED_SIGNUP_DOMAINS.split(',').map((d) => d.trim().toLowerCase()))
87132
: null
@@ -1291,29 +1336,7 @@ export const auth = betterAuth({
12911336
pkce: true,
12921337
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/microsoft-ad`,
12931338
getUserInfo: async (tokens) => {
1294-
try {
1295-
const response = await fetch('https://graph.microsoft.com/v1.0/me', {
1296-
headers: { Authorization: `Bearer ${tokens.accessToken}` },
1297-
})
1298-
if (!response.ok) {
1299-
await response.text().catch(() => {})
1300-
logger.error('Failed to fetch Microsoft user info', { status: response.status })
1301-
throw new Error(`Failed to fetch Microsoft user info: ${response.statusText}`)
1302-
}
1303-
const profile = await response.json()
1304-
const now = new Date()
1305-
return {
1306-
id: `${profile.id}-${crypto.randomUUID()}`,
1307-
name: profile.displayName || 'Microsoft User',
1308-
email: profile.mail || profile.userPrincipalName,
1309-
emailVerified: true,
1310-
createdAt: now,
1311-
updatedAt: now,
1312-
}
1313-
} catch (error) {
1314-
logger.error('Error in Microsoft getUserInfo', { error })
1315-
throw error
1316-
}
1339+
return getMicrosoftUserInfoFromIdToken(tokens, 'microsoft-ad')
13171340
},
13181341
},
13191342

@@ -1331,29 +1354,7 @@ export const auth = betterAuth({
13311354
pkce: true,
13321355
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/microsoft-teams`,
13331356
getUserInfo: async (tokens) => {
1334-
try {
1335-
const response = await fetch('https://graph.microsoft.com/v1.0/me', {
1336-
headers: { Authorization: `Bearer ${tokens.accessToken}` },
1337-
})
1338-
if (!response.ok) {
1339-
await response.text().catch(() => {})
1340-
logger.error('Failed to fetch Microsoft user info', { status: response.status })
1341-
throw new Error(`Failed to fetch Microsoft user info: ${response.statusText}`)
1342-
}
1343-
const profile = await response.json()
1344-
const now = new Date()
1345-
return {
1346-
id: `${profile.id}-${crypto.randomUUID()}`,
1347-
name: profile.displayName || 'Microsoft User',
1348-
email: profile.mail || profile.userPrincipalName,
1349-
emailVerified: true,
1350-
createdAt: now,
1351-
updatedAt: now,
1352-
}
1353-
} catch (error) {
1354-
logger.error('Error in Microsoft getUserInfo', { error })
1355-
throw error
1356-
}
1357+
return getMicrosoftUserInfoFromIdToken(tokens, 'microsoft-teams')
13571358
},
13581359
},
13591360

@@ -1371,29 +1372,7 @@ export const auth = betterAuth({
13711372
pkce: true,
13721373
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/microsoft-excel`,
13731374
getUserInfo: async (tokens) => {
1374-
try {
1375-
const response = await fetch('https://graph.microsoft.com/v1.0/me', {
1376-
headers: { Authorization: `Bearer ${tokens.accessToken}` },
1377-
})
1378-
if (!response.ok) {
1379-
await response.text().catch(() => {})
1380-
logger.error('Failed to fetch Microsoft user info', { status: response.status })
1381-
throw new Error(`Failed to fetch Microsoft user info: ${response.statusText}`)
1382-
}
1383-
const profile = await response.json()
1384-
const now = new Date()
1385-
return {
1386-
id: `${profile.id}-${crypto.randomUUID()}`,
1387-
name: profile.displayName || 'Microsoft User',
1388-
email: profile.mail || profile.userPrincipalName,
1389-
emailVerified: true,
1390-
createdAt: now,
1391-
updatedAt: now,
1392-
}
1393-
} catch (error) {
1394-
logger.error('Error in Microsoft getUserInfo', { error })
1395-
throw error
1396-
}
1375+
return getMicrosoftUserInfoFromIdToken(tokens, 'microsoft-excel')
13971376
},
13981377
},
13991378
{
@@ -1410,32 +1389,7 @@ export const auth = betterAuth({
14101389
pkce: true,
14111390
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/microsoft-dataverse`,
14121391
getUserInfo: async (tokens) => {
1413-
// Dataverse access tokens target dynamics.microsoft.com, not graph.microsoft.com,
1414-
// so we cannot call the Graph API /me endpoint. Instead, we decode the ID token JWT
1415-
// which is always returned when the openid scope is requested.
1416-
const idToken = (tokens as Record<string, unknown>).idToken as string | undefined
1417-
if (!idToken) {
1418-
logger.error(
1419-
'Microsoft Dataverse OAuth: no ID token received. Ensure openid scope is requested.'
1420-
)
1421-
throw new Error('Microsoft Dataverse OAuth requires an ID token (openid scope)')
1422-
}
1423-
1424-
const parts = idToken.split('.')
1425-
if (parts.length !== 3) {
1426-
throw new Error('Microsoft Dataverse OAuth: malformed ID token')
1427-
}
1428-
1429-
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8'))
1430-
const now = new Date()
1431-
return {
1432-
id: `${payload.oid || payload.sub}-${crypto.randomUUID()}`,
1433-
name: payload.name || 'Microsoft User',
1434-
email: payload.preferred_username || payload.email || payload.upn,
1435-
emailVerified: true,
1436-
createdAt: now,
1437-
updatedAt: now,
1438-
}
1392+
return getMicrosoftUserInfoFromIdToken(tokens, 'microsoft-dataverse')
14391393
},
14401394
},
14411395
{
@@ -1452,29 +1406,7 @@ export const auth = betterAuth({
14521406
pkce: true,
14531407
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/microsoft-planner`,
14541408
getUserInfo: async (tokens) => {
1455-
try {
1456-
const response = await fetch('https://graph.microsoft.com/v1.0/me', {
1457-
headers: { Authorization: `Bearer ${tokens.accessToken}` },
1458-
})
1459-
if (!response.ok) {
1460-
await response.text().catch(() => {})
1461-
logger.error('Failed to fetch Microsoft user info', { status: response.status })
1462-
throw new Error(`Failed to fetch Microsoft user info: ${response.statusText}`)
1463-
}
1464-
const profile = await response.json()
1465-
const now = new Date()
1466-
return {
1467-
id: `${profile.id}-${crypto.randomUUID()}`,
1468-
name: profile.displayName || 'Microsoft User',
1469-
email: profile.mail || profile.userPrincipalName,
1470-
emailVerified: true,
1471-
createdAt: now,
1472-
updatedAt: now,
1473-
}
1474-
} catch (error) {
1475-
logger.error('Error in Microsoft getUserInfo', { error })
1476-
throw error
1477-
}
1409+
return getMicrosoftUserInfoFromIdToken(tokens, 'microsoft-planner')
14781410
},
14791411
},
14801412

@@ -1492,29 +1424,7 @@ export const auth = betterAuth({
14921424
pkce: true,
14931425
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/outlook`,
14941426
getUserInfo: async (tokens) => {
1495-
try {
1496-
const response = await fetch('https://graph.microsoft.com/v1.0/me', {
1497-
headers: { Authorization: `Bearer ${tokens.accessToken}` },
1498-
})
1499-
if (!response.ok) {
1500-
await response.text().catch(() => {})
1501-
logger.error('Failed to fetch Microsoft user info', { status: response.status })
1502-
throw new Error(`Failed to fetch Microsoft user info: ${response.statusText}`)
1503-
}
1504-
const profile = await response.json()
1505-
const now = new Date()
1506-
return {
1507-
id: `${profile.id}-${crypto.randomUUID()}`,
1508-
name: profile.displayName || 'Microsoft User',
1509-
email: profile.mail || profile.userPrincipalName,
1510-
emailVerified: true,
1511-
createdAt: now,
1512-
updatedAt: now,
1513-
}
1514-
} catch (error) {
1515-
logger.error('Error in Microsoft getUserInfo', { error })
1516-
throw error
1517-
}
1427+
return getMicrosoftUserInfoFromIdToken(tokens, 'outlook')
15181428
},
15191429
},
15201430

@@ -1532,29 +1442,7 @@ export const auth = betterAuth({
15321442
pkce: true,
15331443
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/onedrive`,
15341444
getUserInfo: async (tokens) => {
1535-
try {
1536-
const response = await fetch('https://graph.microsoft.com/v1.0/me', {
1537-
headers: { Authorization: `Bearer ${tokens.accessToken}` },
1538-
})
1539-
if (!response.ok) {
1540-
await response.text().catch(() => {})
1541-
logger.error('Failed to fetch Microsoft user info', { status: response.status })
1542-
throw new Error(`Failed to fetch Microsoft user info: ${response.statusText}`)
1543-
}
1544-
const profile = await response.json()
1545-
const now = new Date()
1546-
return {
1547-
id: `${profile.id}-${crypto.randomUUID()}`,
1548-
name: profile.displayName || 'Microsoft User',
1549-
email: profile.mail || profile.userPrincipalName,
1550-
emailVerified: true,
1551-
createdAt: now,
1552-
updatedAt: now,
1553-
}
1554-
} catch (error) {
1555-
logger.error('Error in Microsoft getUserInfo', { error })
1556-
throw error
1557-
}
1445+
return getMicrosoftUserInfoFromIdToken(tokens, 'onedrive')
15581446
},
15591447
},
15601448

@@ -1572,29 +1460,7 @@ export const auth = betterAuth({
15721460
pkce: true,
15731461
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/sharepoint`,
15741462
getUserInfo: async (tokens) => {
1575-
try {
1576-
const response = await fetch('https://graph.microsoft.com/v1.0/me', {
1577-
headers: { Authorization: `Bearer ${tokens.accessToken}` },
1578-
})
1579-
if (!response.ok) {
1580-
await response.text().catch(() => {})
1581-
logger.error('Failed to fetch Microsoft user info', { status: response.status })
1582-
throw new Error(`Failed to fetch Microsoft user info: ${response.statusText}`)
1583-
}
1584-
const profile = await response.json()
1585-
const now = new Date()
1586-
return {
1587-
id: `${profile.id}-${crypto.randomUUID()}`,
1588-
name: profile.displayName || 'Microsoft User',
1589-
email: profile.mail || profile.userPrincipalName,
1590-
emailVerified: true,
1591-
createdAt: now,
1592-
updatedAt: now,
1593-
}
1594-
} catch (error) {
1595-
logger.error('Error in Microsoft getUserInfo', { error })
1596-
throw error
1597-
}
1463+
return getMicrosoftUserInfoFromIdToken(tokens, 'sharepoint')
15981464
},
15991465
},
16001466

0 commit comments

Comments
 (0)