| const client = require('openid-client'); |
| const { isEnabled } = require('@librechat/api'); |
| const { logger } = require('@librechat/data-schemas'); |
| const { CacheKeys } = require('librechat-data-provider'); |
| const { Client } = require('@microsoft/microsoft-graph-client'); |
| const { getOpenIdConfig } = require('~/strategies/openidStrategy'); |
| const getLogStores = require('~/cache/getLogStores'); |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| const entraIdPrincipalFeatureEnabled = (user) => { |
| return ( |
| isEnabled(process.env.USE_ENTRA_ID_FOR_PEOPLE_SEARCH) && |
| isEnabled(process.env.OPENID_REUSE_TOKENS) && |
| user?.provider === 'openid' && |
| user?.openidId |
| ); |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| const createGraphClient = async (accessToken, sub) => { |
| try { |
| |
| const openidConfig = getOpenIdConfig(); |
| const exchangedToken = await exchangeTokenForGraphAccess(openidConfig, accessToken, sub); |
|
|
| const graphClient = Client.init({ |
| authProvider: (done) => { |
| done(null, exchangedToken); |
| }, |
| }); |
|
|
| return graphClient; |
| } catch (error) { |
| logger.error('[createGraphClient] Error creating Graph client:', error); |
| throw error; |
| } |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| const exchangeTokenForGraphAccess = async (config, accessToken, sub) => { |
| try { |
| const tokensCache = getLogStores(CacheKeys.OPENID_EXCHANGED_TOKENS); |
| const cacheKey = `${sub}:graph`; |
|
|
| const cachedToken = await tokensCache.get(cacheKey); |
| if (cachedToken) { |
| return cachedToken.access_token; |
| } |
|
|
| const graphScopes = process.env.OPENID_GRAPH_SCOPES || 'User.Read,People.Read,Group.Read.All'; |
| const scopeString = graphScopes |
| .split(',') |
| .map((scope) => `https://graph.microsoft.com/${scope}`) |
| .join(' '); |
|
|
| const grantResponse = await client.genericGrantRequest( |
| config, |
| 'urn:ietf:params:oauth:grant-type:jwt-bearer', |
| { |
| scope: scopeString, |
| assertion: accessToken, |
| requested_token_use: 'on_behalf_of', |
| }, |
| ); |
|
|
| await tokensCache.set( |
| cacheKey, |
| { |
| access_token: grantResponse.access_token, |
| }, |
| grantResponse.expires_in * 1000, |
| ); |
|
|
| return grantResponse.access_token; |
| } catch (error) { |
| logger.error('[exchangeTokenForGraphAccess] Token exchange failed:', error); |
| throw error; |
| } |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const searchEntraIdPrincipals = async (accessToken, sub, query, type = 'all', limit = 10) => { |
| try { |
| if (!query || query.trim().length < 2) { |
| return []; |
| } |
| const graphClient = await createGraphClient(accessToken, sub); |
| let allResults = []; |
|
|
| if (type === 'users' || type === 'all') { |
| const contactResults = await searchContacts(graphClient, query, limit); |
| allResults.push(...contactResults); |
| } |
| if (allResults.length >= limit) { |
| return allResults.slice(0, limit); |
| } |
|
|
| if (type === 'users') { |
| const userResults = await searchUsers(graphClient, query, limit); |
| allResults.push(...userResults); |
| } else if (type === 'groups') { |
| const groupResults = await searchGroups(graphClient, query, limit); |
| allResults.push(...groupResults); |
| } else if (type === 'all') { |
| const [userResults, groupResults] = await Promise.all([ |
| searchUsers(graphClient, query, limit), |
| searchGroups(graphClient, query, limit), |
| ]); |
|
|
| allResults.push(...userResults, ...groupResults); |
| } |
|
|
| const seenIds = new Set(); |
| const uniqueResults = allResults.filter((result) => { |
| if (seenIds.has(result.idOnTheSource)) { |
| return false; |
| } |
| seenIds.add(result.idOnTheSource); |
| return true; |
| }); |
|
|
| return uniqueResults.slice(0, limit); |
| } catch (error) { |
| logger.error('[searchEntraIdPrincipals] Error searching principals:', error); |
| return []; |
| } |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| |
| const getUserEntraGroups = async (accessToken, sub) => { |
| try { |
| const graphClient = await createGraphClient(accessToken, sub); |
| const response = await graphClient |
| .api('/me/getMemberGroups') |
| .post({ securityEnabledOnly: false }); |
|
|
| const groupIds = Array.isArray(response?.value) ? response.value : []; |
| return [...new Set(groupIds.map((groupId) => String(groupId)))]; |
| } catch (error) { |
| logger.error('[getUserEntraGroups] Error fetching user groups:', error); |
| return []; |
| } |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| |
| const getUserOwnedEntraGroups = async (accessToken, sub) => { |
| try { |
| const graphClient = await createGraphClient(accessToken, sub); |
| const allGroupIds = []; |
| let nextLink = '/me/ownedObjects/microsoft.graph.group'; |
|
|
| while (nextLink) { |
| const response = await graphClient.api(nextLink).select('id').top(999).get(); |
| const groups = response?.value || []; |
| allGroupIds.push(...groups.map((group) => group.id)); |
|
|
| nextLink = response['@odata.nextLink'] |
| ? response['@odata.nextLink'] |
| .replace(/^https:\/\/graph\.microsoft\.com\/v1\.0/, '') |
| .trim() || null |
| : null; |
| } |
|
|
| return allGroupIds; |
| } catch (error) { |
| logger.error('[getUserOwnedEntraGroups] Error fetching user owned groups:', error); |
| return []; |
| } |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| const getGroupMembers = async (accessToken, sub, groupId) => { |
| try { |
| const graphClient = await createGraphClient(accessToken, sub); |
| const allMembers = new Set(); |
| let nextLink = `/groups/${groupId}/transitiveMembers`; |
|
|
| while (nextLink) { |
| const membersResponse = await graphClient.api(nextLink).select('id').top(999).get(); |
|
|
| const members = membersResponse?.value || []; |
| members.forEach((member) => { |
| if (typeof member?.id === 'string' && member['@odata.type'] === '#microsoft.graph.user') { |
| allMembers.add(member.id); |
| } |
| }); |
|
|
| nextLink = membersResponse['@odata.nextLink'] |
| ? membersResponse['@odata.nextLink'] |
| .replace(/^https:\/\/graph\.microsoft\.com\/v1\.0/, '') |
| .trim() || null |
| : null; |
| } |
|
|
| return Array.from(allMembers); |
| } catch (error) { |
| logger.error('[getGroupMembers] Error fetching group members:', error); |
| return []; |
| } |
| }; |
| |
| |
| |
| |
| |
| |
| |
| |
| const getGroupOwners = async (accessToken, sub, groupId) => { |
| try { |
| const graphClient = await createGraphClient(accessToken, sub); |
| const allOwners = []; |
| let nextLink = `/groups/${groupId}/owners`; |
|
|
| while (nextLink) { |
| const ownersResponse = await graphClient.api(nextLink).select('id').top(999).get(); |
|
|
| const owners = ownersResponse.value || []; |
| allOwners.push(...owners.map((member) => member.id)); |
|
|
| nextLink = ownersResponse['@odata.nextLink'] |
| ? ownersResponse['@odata.nextLink'].split('/v1.0')[1] |
| : null; |
| } |
|
|
| return allOwners; |
| } catch (error) { |
| logger.error('[getGroupOwners] Error fetching group owners:', error); |
| return []; |
| } |
| }; |
| |
| |
| |
| |
| |
| |
| |
| |
| const searchContacts = async (graphClient, query, limit = 10) => { |
| try { |
| if (!query || query.trim().length < 2) { |
| return []; |
| } |
| if ( |
| process.env.OPENID_GRAPH_SCOPES && |
| !process.env.OPENID_GRAPH_SCOPES.toLowerCase().includes('people.read') |
| ) { |
| logger.warn('[searchContacts] People.Read scope is not enabled, skipping contact search'); |
| return []; |
| } |
| |
| const filter = "personType/subclass eq 'OrganizationUser'"; |
|
|
| let apiCall = graphClient |
| .api('/me/people') |
| .search(`"${query}"`) |
| .select( |
| 'id,displayName,givenName,surname,userPrincipalName,jobTitle,department,companyName,scoredEmailAddresses,personType,phones', |
| ) |
| .header('ConsistencyLevel', 'eventual') |
| .filter(filter) |
| .top(limit); |
|
|
| const contactsResponse = await apiCall.get(); |
| return (contactsResponse.value || []).map(mapContactToTPrincipalSearchResult); |
| } catch (error) { |
| logger.error('[searchContacts] Error searching contacts:', error); |
| return []; |
| } |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| const searchUsers = async (graphClient, query, limit = 10) => { |
| try { |
| if (!query || query.trim().length < 2) { |
| return []; |
| } |
|
|
| |
| const usersResponse = await graphClient |
| .api('/users') |
| .search( |
| `"displayName:${query}" OR "userPrincipalName:${query}" OR "mail:${query}" OR "givenName:${query}" OR "surname:${query}"`, |
| ) |
| .select( |
| 'id,displayName,givenName,surname,userPrincipalName,jobTitle,department,companyName,mail,phones', |
| ) |
| .header('ConsistencyLevel', 'eventual') |
| .top(limit) |
| .get(); |
|
|
| return (usersResponse.value || []).map(mapUserToTPrincipalSearchResult); |
| } catch (error) { |
| logger.error('[searchUsers] Error searching users:', error); |
| return []; |
| } |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| const searchGroups = async (graphClient, query, limit = 10) => { |
| try { |
| if (!query || query.trim().length < 2) { |
| return []; |
| } |
|
|
| |
| const groupsResponse = await graphClient |
| .api('/groups') |
| .search(`"displayName:${query}" OR "mail:${query}" OR "mailNickname:${query}"`) |
| .select('id,displayName,mail,mailNickname,description,groupTypes,resourceProvisioningOptions') |
| .header('ConsistencyLevel', 'eventual') |
| .top(limit) |
| .get(); |
|
|
| return (groupsResponse.value || []).map(mapGroupToTPrincipalSearchResult); |
| } catch (error) { |
| logger.error('[searchGroups] Error searching groups:', error); |
| return []; |
| } |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| const testGraphApiAccess = async (accessToken, sub) => { |
| try { |
| const graphClient = await createGraphClient(accessToken, sub); |
| const results = { |
| userAccess: false, |
| peopleAccess: false, |
| groupsAccess: false, |
| usersEndpointAccess: false, |
| groupsEndpointAccess: false, |
| errors: [], |
| }; |
|
|
| |
| try { |
| await graphClient.api('/me').select('id,displayName').get(); |
| results.userAccess = true; |
| } catch (error) { |
| results.errors.push(`User.Read: ${error.message}`); |
| } |
|
|
| |
| try { |
| await graphClient |
| .api('/me/people') |
| .filter("personType/subclass eq 'OrganizationUser'") |
| .top(1) |
| .get(); |
| results.peopleAccess = true; |
| } catch (error) { |
| results.errors.push(`People.Read (OrganizationUser): ${error.message}`); |
| } |
|
|
| |
| try { |
| await graphClient |
| .api('/me/people') |
| .filter("personType/subclass eq 'UnifiedGroup'") |
| .top(1) |
| .get(); |
| results.groupsAccess = true; |
| } catch (error) { |
| results.errors.push(`People.Read (UnifiedGroup): ${error.message}`); |
| } |
|
|
| |
| try { |
| await graphClient |
| .api('/users') |
| .search('"displayName:test"') |
| .select('id,displayName,userPrincipalName') |
| .top(1) |
| .get(); |
| results.usersEndpointAccess = true; |
| } catch (error) { |
| results.errors.push(`Users endpoint: ${error.message}`); |
| } |
|
|
| |
| try { |
| await graphClient |
| .api('/groups') |
| .search('"displayName:test"') |
| .select('id,displayName,mail') |
| .top(1) |
| .get(); |
| results.groupsEndpointAccess = true; |
| } catch (error) { |
| results.errors.push(`Groups endpoint: ${error.message}`); |
| } |
|
|
| return results; |
| } catch (error) { |
| logger.error('[testGraphApiAccess] Error testing Graph API access:', error); |
| return { |
| userAccess: false, |
| peopleAccess: false, |
| groupsAccess: false, |
| usersEndpointAccess: false, |
| groupsEndpointAccess: false, |
| errors: [error.message], |
| }; |
| } |
| }; |
|
|
| |
| |
| |
| |
| |
| const mapUserToTPrincipalSearchResult = (user) => { |
| return { |
| id: null, |
| type: 'user', |
| name: user.displayName, |
| email: user.mail || user.userPrincipalName, |
| username: user.userPrincipalName, |
| source: 'entra', |
| idOnTheSource: user.id, |
| }; |
| }; |
|
|
| |
| |
| |
| |
| |
| const mapGroupToTPrincipalSearchResult = (group) => { |
| return { |
| id: null, |
| type: 'group', |
| name: group.displayName, |
| email: group.mail || group.userPrincipalName, |
| description: group.description, |
| source: 'entra', |
| idOnTheSource: group.id, |
| }; |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| const mapContactToTPrincipalSearchResult = (contact) => { |
| const isGroup = contact.personType?.class === 'Group'; |
| const primaryEmail = contact.scoredEmailAddresses?.[0]?.address; |
|
|
| return { |
| id: null, |
| type: isGroup ? 'group' : 'user', |
| name: contact.displayName, |
| email: primaryEmail, |
| username: !isGroup ? contact.userPrincipalName : undefined, |
| source: 'entra', |
| idOnTheSource: contact.id, |
| }; |
| }; |
|
|
| module.exports = { |
| getGroupMembers, |
| getGroupOwners, |
| createGraphClient, |
| getUserEntraGroups, |
| getUserOwnedEntraGroups, |
| testGraphApiAccess, |
| searchEntraIdPrincipals, |
| exchangeTokenForGraphAccess, |
| entraIdPrincipalFeatureEnabled, |
| }; |
|
|