@@ -82,6 +82,37 @@ const logger = createLogger('Auth')
8282import { getMicrosoftRefreshTokenExpiry , isMicrosoftProvider } from '@/lib/oauth/microsoft'
8383import { 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+
85116const 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