| | 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, |
| | }; |
| |
|