@@ -82,6 +82,51 @@ 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+ 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+
85130const 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