Skip to content

Commit 22a030a

Browse files
committed
fix(oauth): decode ID token instead of calling Graph API for Microsoft providers
1 parent 288aa08 commit 22a030a

File tree

1 file changed

+39
-187
lines changed

1 file changed

+39
-187
lines changed

apps/sim/lib/auth/auth.ts

Lines changed: 39 additions & 187 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,37 @@ 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+
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8'))
105+
const now = new Date()
106+
return {
107+
id: `${payload.oid || payload.sub}-${crypto.randomUUID()}`,
108+
name: payload.name || 'Microsoft User',
109+
email: payload.preferred_username || payload.email || payload.upn,
110+
emailVerified: true,
111+
createdAt: now,
112+
updatedAt: now,
113+
}
114+
}
115+
85116
const blockedSignupDomains = env.BLOCKED_SIGNUP_DOMAINS
86117
? new Set(env.BLOCKED_SIGNUP_DOMAINS.split(',').map((d) => d.trim().toLowerCase()))
87118
: null
@@ -1291,29 +1322,7 @@ export const auth = betterAuth({
12911322
pkce: true,
12921323
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/microsoft-ad`,
12931324
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-
}
1325+
return getMicrosoftUserInfoFromIdToken(tokens, 'microsoft-ad')
13171326
},
13181327
},
13191328

@@ -1331,29 +1340,7 @@ export const auth = betterAuth({
13311340
pkce: true,
13321341
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/microsoft-teams`,
13331342
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-
}
1343+
return getMicrosoftUserInfoFromIdToken(tokens, 'microsoft-teams')
13571344
},
13581345
},
13591346

@@ -1371,29 +1358,7 @@ export const auth = betterAuth({
13711358
pkce: true,
13721359
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/microsoft-excel`,
13731360
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-
}
1361+
return getMicrosoftUserInfoFromIdToken(tokens, 'microsoft-excel')
13971362
},
13981363
},
13991364
{
@@ -1410,32 +1375,7 @@ export const auth = betterAuth({
14101375
pkce: true,
14111376
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/microsoft-dataverse`,
14121377
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-
}
1378+
return getMicrosoftUserInfoFromIdToken(tokens, 'microsoft-dataverse')
14391379
},
14401380
},
14411381
{
@@ -1452,29 +1392,7 @@ export const auth = betterAuth({
14521392
pkce: true,
14531393
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/microsoft-planner`,
14541394
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-
}
1395+
return getMicrosoftUserInfoFromIdToken(tokens, 'microsoft-planner')
14781396
},
14791397
},
14801398

@@ -1492,29 +1410,7 @@ export const auth = betterAuth({
14921410
pkce: true,
14931411
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/outlook`,
14941412
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-
}
1413+
return getMicrosoftUserInfoFromIdToken(tokens, 'outlook')
15181414
},
15191415
},
15201416

@@ -1532,29 +1428,7 @@ export const auth = betterAuth({
15321428
pkce: true,
15331429
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/onedrive`,
15341430
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-
}
1431+
return getMicrosoftUserInfoFromIdToken(tokens, 'onedrive')
15581432
},
15591433
},
15601434

@@ -1572,29 +1446,7 @@ export const auth = betterAuth({
15721446
pkce: true,
15731447
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/sharepoint`,
15741448
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-
}
1449+
return getMicrosoftUserInfoFromIdToken(tokens, 'sharepoint')
15981450
},
15991451
},
16001452

0 commit comments

Comments
 (0)