Compare commits

...

2 Commits

View File

@@ -82,6 +82,37 @@ 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`)
}
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.email || payload.preferred_username || payload.upn,
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
@@ -1291,29 +1322,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')
},
},
@@ -1331,29 +1340,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')
},
},
@@ -1371,29 +1358,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')
},
},
{
@@ -1410,32 +1375,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')
},
},
{
@@ -1452,29 +1392,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')
},
},
@@ -1492,29 +1410,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')
},
},
@@ -1532,29 +1428,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')
},
},
@@ -1572,29 +1446,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')
},
},