diff --git a/api/server/services/MCP.js b/api/server/services/MCP.js index 220c1f7d7e..df1e637b1b 100644 --- a/api/server/services/MCP.js +++ b/api/server/services/MCP.js @@ -29,6 +29,7 @@ const { getMCPManager, } = require('~/config'); const { findToken, createToken, updateToken } = require('~/models'); +const { getGraphApiToken } = require('./GraphTokenService'); const { reinitMCPServer } = require('./Tools/mcp'); const { getAppConfig } = require('./Config'); const { getLogStores } = require('~/cache'); @@ -501,6 +502,7 @@ function createToolInstance({ }, oauthStart, oauthEnd, + graphTokenResolver: getGraphApiToken, }); if (isAssistantsEndpoint(provider) && Array.isArray(result)) { diff --git a/api/server/services/MCP.spec.js b/api/server/services/MCP.spec.js index cb2f0081a3..5d7eb093be 100644 --- a/api/server/services/MCP.spec.js +++ b/api/server/services/MCP.spec.js @@ -120,6 +120,10 @@ jest.mock('./Tools/mcp', () => ({ reinitMCPServer: jest.fn(), })); +jest.mock('./GraphTokenService', () => ({ + getGraphApiToken: jest.fn(), +})); + describe('tests for the new helper functions used by the MCP connection status endpoints', () => { let mockGetMCPManager; let mockGetFlowStateManager; diff --git a/packages/api/src/mcp/MCPManager.ts b/packages/api/src/mcp/MCPManager.ts index fbd5bd050d..0b9dce7061 100644 --- a/packages/api/src/mcp/MCPManager.ts +++ b/packages/api/src/mcp/MCPManager.ts @@ -3,6 +3,7 @@ import { logger } from '@librechat/data-schemas'; import { CallToolResultSchema, ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; import type { RequestOptions } from '@modelcontextprotocol/sdk/shared/protocol.js'; import type { TokenMethods, IUser } from '@librechat/data-schemas'; +import type { GraphTokenResolver } from '~/utils/graph'; import type { FlowStateManager } from '~/flow/manager'; import type { MCPOAuthTokens } from './oauth'; import type { RequestBody } from '~/types'; @@ -12,6 +13,7 @@ import { ConnectionsRepository } from './ConnectionsRepository'; import { MCPServerInspector } from './registry/MCPServerInspector'; import { MCPServersInitializer } from './registry/MCPServersInitializer'; import { MCPServersRegistry } from './registry/MCPServersRegistry'; +import { preProcessGraphTokens } from '~/utils/graph'; import { formatToolContent } from './parsers'; import { MCPConnection } from './connection'; import { processMCPEnv } from '~/utils/env'; @@ -160,6 +162,10 @@ Please follow these instructions when using tools from the respective MCP server * Calls a tool on an MCP server, using either a user-specific connection * (if userId is provided) or an app-level connection. Updates the last activity timestamp * for user-specific connections upon successful call initiation. + * + * @param graphTokenResolver - Optional function to resolve Graph API tokens via OBO flow. + * When provided and the server config contains `{{LIBRECHAT_GRAPH_ACCESS_TOKEN}}` placeholders, + * they will be resolved to actual Graph API tokens before the tool call. */ async callTool({ user, @@ -174,6 +180,7 @@ Please follow these instructions when using tools from the respective MCP server oauthStart, oauthEnd, customUserVars, + graphTokenResolver, }: { user?: IUser; serverName: string; @@ -187,6 +194,7 @@ Please follow these instructions when using tools from the respective MCP server flowManager: FlowStateManager; oauthStart?: (authURL: string) => Promise; oauthEnd?: () => Promise; + graphTokenResolver?: GraphTokenResolver; }): Promise { /** User-specific connection */ let connection: MCPConnection | undefined; @@ -220,9 +228,16 @@ Please follow these instructions when using tools from the respective MCP server serverName, userId, )) as t.MCPOptions; + + // Pre-process Graph token placeholders (async) before sync processMCPEnv + const graphProcessedConfig = await preProcessGraphTokens(rawConfig, { + user, + graphTokenResolver, + scopes: process.env.GRAPH_API_SCOPES, + }); const currentOptions = processMCPEnv({ user, - options: rawConfig, + options: graphProcessedConfig, customUserVars: customUserVars, body: requestBody, }); diff --git a/packages/api/src/mcp/__tests__/MCPManager.test.ts b/packages/api/src/mcp/__tests__/MCPManager.test.ts index e75cd09b33..f210fcb63a 100644 --- a/packages/api/src/mcp/__tests__/MCPManager.test.ts +++ b/packages/api/src/mcp/__tests__/MCPManager.test.ts @@ -1,10 +1,13 @@ import { logger } from '@librechat/data-schemas'; +import type { IUser } from '@librechat/data-schemas'; +import type { GraphTokenResolver } from '~/utils/graph'; import type * as t from '~/mcp/types'; -import { MCPManager } from '~/mcp/MCPManager'; import { MCPServersInitializer } from '~/mcp/registry/MCPServersInitializer'; import { MCPServerInspector } from '~/mcp/registry/MCPServerInspector'; import { ConnectionsRepository } from '~/mcp/ConnectionsRepository'; -import { MCPConnection } from '../connection'; +import { MCPConnection } from '~/mcp/connection'; +import { MCPManager } from '~/mcp/MCPManager'; +import * as graphUtils from '~/utils/graph'; // Mock external dependencies jest.mock('@librechat/data-schemas', () => ({ @@ -16,6 +19,15 @@ jest.mock('@librechat/data-schemas', () => ({ }, })); +jest.mock('~/utils/graph', () => ({ + ...jest.requireActual('~/utils/graph'), + preProcessGraphTokens: jest.fn(), +})); + +jest.mock('~/utils/env', () => ({ + processMCPEnv: jest.fn((params) => params.options), +})); + const mockRegistryInstance = { getServerConfig: jest.fn(), getAllServerConfigs: jest.fn(), @@ -389,4 +401,390 @@ describe('MCPManager', () => { ); }); }); + + describe('callTool - Graph Token Integration', () => { + const mockUser: Partial = { + id: 'user-123', + provider: 'openid', + openidId: 'oidc-sub-456', + }; + + const mockFlowManager = { + getState: jest.fn(), + setState: jest.fn(), + clearState: jest.fn(), + }; + + const mockConnection = { + isConnected: jest.fn().mockResolvedValue(true), + setRequestHeaders: jest.fn(), + timeout: 30000, + client: { + request: jest.fn().mockResolvedValue({ + content: [{ type: 'text', text: 'Tool result' }], + isError: false, + }), + }, + } as unknown as MCPConnection; + + const mockGraphTokenResolver: GraphTokenResolver = jest.fn().mockResolvedValue({ + access_token: 'resolved-graph-token', + token_type: 'Bearer', + expires_in: 3600, + scope: 'https://graph.microsoft.com/.default', + }); + + function createServerConfigWithGraphPlaceholder(): t.SSEOptions { + return { + type: 'sse', + url: 'https://api.example.com', + headers: { + Authorization: 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}', + 'Content-Type': 'application/json', + }, + }; + } + + beforeEach(() => { + jest.clearAllMocks(); + + // Mock preProcessGraphTokens to simulate token resolution + (graphUtils.preProcessGraphTokens as jest.Mock).mockImplementation( + async (options, graphOptions) => { + if ( + options.headers?.Authorization?.includes('{{LIBRECHAT_GRAPH_ACCESS_TOKEN}}') && + graphOptions.graphTokenResolver + ) { + return { + ...options, + headers: { + ...options.headers, + Authorization: 'Bearer resolved-graph-token', + }, + }; + } + return options; + }, + ); + }); + + it('should call preProcessGraphTokens with graphTokenResolver when provided', async () => { + const serverConfig = createServerConfigWithGraphPlaceholder(); + + mockAppConnections({ + get: jest.fn().mockResolvedValue(mockConnection), + }); + + (mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue(serverConfig); + + const manager = await MCPManager.createInstance(newMCPServersConfig()); + + await manager.callTool({ + user: mockUser as IUser, + serverName, + toolName: 'test_tool', + provider: 'openai', + flowManager: mockFlowManager as unknown as Parameters< + typeof manager.callTool + >[0]['flowManager'], + graphTokenResolver: mockGraphTokenResolver, + }); + + expect(graphUtils.preProcessGraphTokens).toHaveBeenCalledWith( + serverConfig, + expect.objectContaining({ + user: mockUser, + graphTokenResolver: mockGraphTokenResolver, + }), + ); + }); + + it('should resolve graph token placeholders in headers before tool call', async () => { + const serverConfig = createServerConfigWithGraphPlaceholder(); + + mockAppConnections({ + get: jest.fn().mockResolvedValue(mockConnection), + }); + + (mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue(serverConfig); + + const manager = await MCPManager.createInstance(newMCPServersConfig()); + + await manager.callTool({ + user: mockUser as IUser, + serverName, + toolName: 'test_tool', + provider: 'openai', + flowManager: mockFlowManager as unknown as Parameters< + typeof manager.callTool + >[0]['flowManager'], + graphTokenResolver: mockGraphTokenResolver, + }); + + // Verify the connection received the resolved headers + expect(mockConnection.setRequestHeaders).toHaveBeenCalledWith( + expect.objectContaining({ + Authorization: 'Bearer resolved-graph-token', + }), + ); + }); + + it('should pass options unchanged when no graphTokenResolver is provided', async () => { + const serverConfig: t.SSEOptions = { + type: 'sse', + url: 'https://api.example.com', + headers: { + Authorization: 'Bearer static-token', + }, + }; + + // Reset mock to return options unchanged + (graphUtils.preProcessGraphTokens as jest.Mock).mockImplementation( + async (options) => options, + ); + + mockAppConnections({ + get: jest.fn().mockResolvedValue(mockConnection), + }); + + (mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue(serverConfig); + + const manager = await MCPManager.createInstance(newMCPServersConfig()); + + await manager.callTool({ + user: mockUser as IUser, + serverName, + toolName: 'test_tool', + provider: 'openai', + flowManager: mockFlowManager as unknown as Parameters< + typeof manager.callTool + >[0]['flowManager'], + // No graphTokenResolver provided + }); + + // Verify preProcessGraphTokens was still called (to check for placeholders) + expect(graphUtils.preProcessGraphTokens).toHaveBeenCalledWith( + serverConfig, + expect.objectContaining({ + user: mockUser, + graphTokenResolver: undefined, + }), + ); + }); + + it('should handle graph token resolution failure gracefully', async () => { + const serverConfig = createServerConfigWithGraphPlaceholder(); + + // Simulate resolution failure - returns original value unchanged + (graphUtils.preProcessGraphTokens as jest.Mock).mockImplementation( + async (options) => options, + ); + + mockAppConnections({ + get: jest.fn().mockResolvedValue(mockConnection), + }); + + (mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue(serverConfig); + + const manager = await MCPManager.createInstance(newMCPServersConfig()); + + // Should not throw, even when token resolution fails + await expect( + manager.callTool({ + user: mockUser as IUser, + serverName, + toolName: 'test_tool', + provider: 'openai', + flowManager: mockFlowManager as unknown as Parameters< + typeof manager.callTool + >[0]['flowManager'], + graphTokenResolver: mockGraphTokenResolver, + }), + ).resolves.toBeDefined(); + + // Headers should contain the unresolved placeholder + expect(mockConnection.setRequestHeaders).toHaveBeenCalledWith( + expect.objectContaining({ + Authorization: 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}', + }), + ); + }); + + it('should resolve graph tokens in env variables', async () => { + const serverConfig: t.StdioOptions = { + type: 'stdio', + command: 'node', + args: ['server.js'], + env: { + GRAPH_TOKEN: '{{LIBRECHAT_GRAPH_ACCESS_TOKEN}}', + OTHER_VAR: 'static-value', + }, + }; + + // Mock resolution for env variables + (graphUtils.preProcessGraphTokens as jest.Mock).mockImplementation(async (options) => { + if (options.env?.GRAPH_TOKEN?.includes('{{LIBRECHAT_GRAPH_ACCESS_TOKEN}}')) { + return { + ...options, + env: { + ...options.env, + GRAPH_TOKEN: 'resolved-graph-token', + }, + }; + } + return options; + }); + + mockAppConnections({ + get: jest.fn().mockResolvedValue(mockConnection), + }); + + (mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue(serverConfig); + + const manager = await MCPManager.createInstance(newMCPServersConfig()); + + await manager.callTool({ + user: mockUser as IUser, + serverName, + toolName: 'test_tool', + provider: 'openai', + flowManager: mockFlowManager as unknown as Parameters< + typeof manager.callTool + >[0]['flowManager'], + graphTokenResolver: mockGraphTokenResolver, + }); + + expect(graphUtils.preProcessGraphTokens).toHaveBeenCalledWith( + serverConfig, + expect.objectContaining({ + graphTokenResolver: mockGraphTokenResolver, + }), + ); + }); + + it('should resolve graph tokens in URL', async () => { + const serverConfig: t.SSEOptions = { + type: 'sse', + url: 'https://api.example.com?token={{LIBRECHAT_GRAPH_ACCESS_TOKEN}}', + }; + + // Mock resolution for URL + (graphUtils.preProcessGraphTokens as jest.Mock).mockImplementation(async (options) => { + if (options.url?.includes('{{LIBRECHAT_GRAPH_ACCESS_TOKEN}}')) { + return { + ...options, + url: 'https://api.example.com?token=resolved-graph-token', + }; + } + return options; + }); + + mockAppConnections({ + get: jest.fn().mockResolvedValue(mockConnection), + }); + + (mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue(serverConfig); + + const manager = await MCPManager.createInstance(newMCPServersConfig()); + + await manager.callTool({ + user: mockUser as IUser, + serverName, + toolName: 'test_tool', + provider: 'openai', + flowManager: mockFlowManager as unknown as Parameters< + typeof manager.callTool + >[0]['flowManager'], + graphTokenResolver: mockGraphTokenResolver, + }); + + expect(graphUtils.preProcessGraphTokens).toHaveBeenCalledWith( + serverConfig, + expect.objectContaining({ + graphTokenResolver: mockGraphTokenResolver, + }), + ); + }); + + it('should pass scopes from environment variable to preProcessGraphTokens', async () => { + const originalEnv = process.env.GRAPH_API_SCOPES; + process.env.GRAPH_API_SCOPES = 'custom.scope.read custom.scope.write'; + + const serverConfig = createServerConfigWithGraphPlaceholder(); + + mockAppConnections({ + get: jest.fn().mockResolvedValue(mockConnection), + }); + + (mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue(serverConfig); + + const manager = await MCPManager.createInstance(newMCPServersConfig()); + + await manager.callTool({ + user: mockUser as IUser, + serverName, + toolName: 'test_tool', + provider: 'openai', + flowManager: mockFlowManager as unknown as Parameters< + typeof manager.callTool + >[0]['flowManager'], + graphTokenResolver: mockGraphTokenResolver, + }); + + expect(graphUtils.preProcessGraphTokens).toHaveBeenCalledWith( + serverConfig, + expect.objectContaining({ + scopes: 'custom.scope.read custom.scope.write', + }), + ); + + // Restore environment + if (originalEnv !== undefined) { + process.env.GRAPH_API_SCOPES = originalEnv; + } else { + delete process.env.GRAPH_API_SCOPES; + } + }); + + it('should work correctly when config has no graph token placeholders', async () => { + const serverConfig: t.SSEOptions = { + type: 'sse', + url: 'https://api.example.com', + headers: { + Authorization: 'Bearer static-token', + }, + }; + + // Mock to return unchanged options when no placeholders + (graphUtils.preProcessGraphTokens as jest.Mock).mockImplementation( + async (options) => options, + ); + + mockAppConnections({ + get: jest.fn().mockResolvedValue(mockConnection), + }); + + (mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue(serverConfig); + + const manager = await MCPManager.createInstance(newMCPServersConfig()); + + const result = await manager.callTool({ + user: mockUser as IUser, + serverName, + toolName: 'test_tool', + provider: 'openai', + flowManager: mockFlowManager as unknown as Parameters< + typeof manager.callTool + >[0]['flowManager'], + graphTokenResolver: mockGraphTokenResolver, + }); + + expect(result).toBeDefined(); + expect(mockConnection.setRequestHeaders).toHaveBeenCalledWith( + expect.objectContaining({ + Authorization: 'Bearer static-token', + }), + ); + }); + }); }); diff --git a/packages/api/src/mcp/__tests__/mcp.spec.ts b/packages/api/src/mcp/__tests__/mcp.spec.ts index f33db2f5c1..d64f9f3afa 100644 --- a/packages/api/src/mcp/__tests__/mcp.spec.ts +++ b/packages/api/src/mcp/__tests__/mcp.spec.ts @@ -4,12 +4,23 @@ import { StreamableHTTPOptionsSchema, } from 'librechat-data-provider'; import type { TUser } from 'librechat-data-provider'; +import type { IUser } from '@librechat/data-schemas'; +import type { GraphTokenResolver } from '~/utils/graph'; +import { preProcessGraphTokens } from '~/utils/graph'; import { processMCPEnv } from '~/utils/env'; +import * as oidcUtils from '~/utils/oidc'; + +// Mock oidc utilities for graph token tests +jest.mock('~/utils/oidc', () => ({ + ...jest.requireActual('~/utils/oidc'), + extractOpenIDTokenInfo: jest.fn(), + isOpenIDTokenValid: jest.fn(), +})); // Helper function to create test user objects function createTestUser( overrides: Partial & Record = {}, -): TUser & Record { +): Omit | undefined { return { id: 'test-user-id', username: 'testuser', @@ -18,8 +29,6 @@ function createTestUser( avatar: 'https://example.com/avatar.png', provider: 'email', role: 'user', - createdAt: new Date('2021-01-01').toISOString(), - updatedAt: new Date('2021-01-01').toISOString(), ...overrides, }; } @@ -858,5 +867,270 @@ describe('Environment Variable Extraction (MCP)', () => { 'Content-Type': 'application/json', }); }); + + it('should leave {{LIBRECHAT_GRAPH_ACCESS_TOKEN}} unchanged (resolved by preProcessGraphTokens)', () => { + const user = createTestUser({ id: 'user-123' }); + const options: MCPOptions = { + type: 'sse', + url: 'https://example.com', + headers: { + Authorization: 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}', + 'X-User-Id': '{{LIBRECHAT_USER_ID}}', + }, + }; + + const result = processMCPEnv({ options, user }); + + expect('headers' in result && result.headers).toEqual({ + // Graph token placeholder remains - it should be resolved by preProcessGraphTokens before processMCPEnv + Authorization: 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}', + // User ID is resolved by processMCPEnv + 'X-User-Id': 'user-123', + }); + }); + }); + + describe('preProcessGraphTokens and processMCPEnv working in tandem', () => { + const mockExtractOpenIDTokenInfo = oidcUtils.extractOpenIDTokenInfo as jest.Mock; + const mockIsOpenIDTokenValid = oidcUtils.isOpenIDTokenValid as jest.Mock; + + const mockGraphTokenResolver: GraphTokenResolver = jest.fn().mockResolvedValue({ + access_token: 'resolved-graph-api-token', + token_type: 'Bearer', + expires_in: 3600, + scope: 'https://graph.microsoft.com/.default', + }); + + beforeEach(() => { + // Set up mocks to simulate valid OpenID token + mockExtractOpenIDTokenInfo.mockReturnValue({ accessToken: 'test-access-token' }); + mockIsOpenIDTokenValid.mockReturnValue(true); + (mockGraphTokenResolver as jest.Mock).mockClear(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should resolve both graph tokens and user placeholders in sequence', async () => { + const user = createTestUser({ + id: 'user-123', + email: 'test@example.com', + provider: 'openid', + openidId: 'oidc-sub-456', + }) as unknown as IUser; + + const options: MCPOptions = { + type: 'sse', + url: 'https://graph.microsoft.com/v1.0/me', + headers: { + Authorization: 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}', + 'X-User-Id': '{{LIBRECHAT_USER_ID}}', + 'X-User-Email': '{{LIBRECHAT_USER_EMAIL}}', + 'Content-Type': 'application/json', + }, + }; + + // Step 1: preProcessGraphTokens resolves graph token placeholders (async) + const graphProcessedConfig = await preProcessGraphTokens(options, { + user, + graphTokenResolver: mockGraphTokenResolver, + }); + + // Step 2: processMCPEnv resolves user and env placeholders (sync) + const finalConfig = processMCPEnv({ + options: graphProcessedConfig, + user, + }); + + expect('headers' in finalConfig && finalConfig.headers).toEqual({ + Authorization: 'Bearer resolved-graph-api-token', + 'X-User-Id': 'user-123', + 'X-User-Email': 'test@example.com', + 'Content-Type': 'application/json', + }); + }); + + it('should resolve graph tokens in env and user placeholders in headers', async () => { + const user = createTestUser({ + id: 'user-456', + username: 'johndoe', + provider: 'openid', + }) as unknown as IUser; + + const options: MCPOptions = { + command: 'node', + args: ['mcp-server.js', '--user', '{{LIBRECHAT_USER_USERNAME}}'], + env: { + GRAPH_ACCESS_TOKEN: '{{LIBRECHAT_GRAPH_ACCESS_TOKEN}}', + USER_ID: '{{LIBRECHAT_USER_ID}}', + API_KEY: '${TEST_API_KEY}', + }, + }; + + // Step 1: preProcessGraphTokens + const graphProcessedConfig = await preProcessGraphTokens(options, { + user, + graphTokenResolver: mockGraphTokenResolver, + }); + + // Step 2: processMCPEnv + const finalConfig = processMCPEnv({ + options: graphProcessedConfig, + user, + }); + + expect('env' in finalConfig && finalConfig.env).toEqual({ + GRAPH_ACCESS_TOKEN: 'resolved-graph-api-token', + USER_ID: 'user-456', + API_KEY: 'test-api-key-value', + }); + + expect('args' in finalConfig && finalConfig.args).toEqual([ + 'mcp-server.js', + '--user', + 'johndoe', + ]); + }); + + it('should resolve graph tokens in URL alongside other placeholders', async () => { + const user = createTestUser({ + id: 'user-789', + provider: 'openid', + }) as unknown as IUser; + + const customUserVars = { + TENANT_ID: 'my-tenant-123', + }; + + const options: MCPOptions = { + type: 'sse', + url: 'https://{{TENANT_ID}}.example.com/api?token={{LIBRECHAT_GRAPH_ACCESS_TOKEN}}&user={{LIBRECHAT_USER_ID}}', + }; + + // Step 1: preProcessGraphTokens + const graphProcessedConfig = await preProcessGraphTokens(options, { + user, + graphTokenResolver: mockGraphTokenResolver, + }); + + // Step 2: processMCPEnv with customUserVars + const finalConfig = processMCPEnv({ + options: graphProcessedConfig, + user, + customUserVars, + }); + + expect('url' in finalConfig && finalConfig.url).toBe( + 'https://my-tenant-123.example.com/api?token=resolved-graph-api-token&user=user-789', + ); + }); + + it('should handle config with no graph token placeholders (only user placeholders)', async () => { + const user = createTestUser({ + id: 'user-abc', + email: 'user@company.com', + }) as unknown as IUser; + + const options: MCPOptions = { + type: 'sse', + url: 'https://api.example.com', + headers: { + 'X-User-Id': '{{LIBRECHAT_USER_ID}}', + 'X-User-Email': '{{LIBRECHAT_USER_EMAIL}}', + }, + }; + + // Step 1: preProcessGraphTokens (no-op since no graph placeholders) + const graphProcessedConfig = await preProcessGraphTokens(options, { + user, + graphTokenResolver: mockGraphTokenResolver, + }); + + // Step 2: processMCPEnv + const finalConfig = processMCPEnv({ + options: graphProcessedConfig, + user, + }); + + expect('headers' in finalConfig && finalConfig.headers).toEqual({ + 'X-User-Id': 'user-abc', + 'X-User-Email': 'user@company.com', + }); + + // graphTokenResolver should not have been called + expect(mockGraphTokenResolver).not.toHaveBeenCalled(); + }); + + it('should handle config with only graph token placeholders (no user placeholders)', async () => { + const user = createTestUser({ + id: 'user-xyz', + provider: 'openid', + }) as unknown as IUser; + + const options: MCPOptions = { + type: 'sse', + url: 'https://graph.microsoft.com/v1.0/me', + headers: { + Authorization: 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}', + 'Content-Type': 'application/json', + }, + }; + + // Reset mock call count + (mockGraphTokenResolver as jest.Mock).mockClear(); + + // Step 1: preProcessGraphTokens + const graphProcessedConfig = await preProcessGraphTokens(options, { + user, + graphTokenResolver: mockGraphTokenResolver, + }); + + // Step 2: processMCPEnv + const finalConfig = processMCPEnv({ + options: graphProcessedConfig, + user, + }); + + expect('headers' in finalConfig && finalConfig.headers).toEqual({ + Authorization: 'Bearer resolved-graph-api-token', + 'Content-Type': 'application/json', + }); + }); + + it('should not mutate original options through the tandem processing', async () => { + const user = createTestUser({ + id: 'user-immutable', + provider: 'openid', + }) as unknown as IUser; + + const originalOptions: MCPOptions = { + type: 'sse', + url: 'https://api.example.com', + headers: { + Authorization: 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}', + 'X-User-Id': '{{LIBRECHAT_USER_ID}}', + }, + }; + + // Store original values + const originalAuth = originalOptions.headers?.Authorization; + const originalUserId = originalOptions.headers?.['X-User-Id']; + + // Step 1 & 2: Process through both functions + const graphProcessedConfig = await preProcessGraphTokens(originalOptions, { + user, + graphTokenResolver: mockGraphTokenResolver, + }); + + processMCPEnv({ + options: graphProcessedConfig, + user, + }); + + // Original should be unchanged + expect(originalOptions.headers?.Authorization).toBe(originalAuth); + expect(originalOptions.headers?.['X-User-Id']).toBe(originalUserId); + }); }); }); diff --git a/packages/api/src/utils/env.ts b/packages/api/src/utils/env.ts index 5a8ea19ac3..f4fd2b1c78 100644 --- a/packages/api/src/utils/env.ts +++ b/packages/api/src/utils/env.ts @@ -46,10 +46,10 @@ type SafeUser = Pick; * if (headerValue.startsWith('b64:')) { * const decoded = Buffer.from(headerValue.slice(4), 'base64').toString('utf8'); * } - * + * * @param value - The string value to encode * @returns ASCII-safe string (encoded if necessary) - * + * * @example * encodeHeaderValue("José") // Returns "José" (é = 233, safe) * encodeHeaderValue("Marić") // Returns "b64:TWFyacSH" (ć = 263, needs encoding) @@ -59,17 +59,17 @@ export function encodeHeaderValue(value: string): string { if (!value || typeof value !== 'string') { return ''; } - + // Check if string contains extended Unicode characters (> 255) // Characters 0-255 (ASCII + Latin-1) are safe and don't need encoding // Characters > 255 (e.g., ć=263, đ=272, ł=322) need Base64 encoding // eslint-disable-next-line no-control-regex const hasExtendedUnicode = /[^\u0000-\u00FF]/.test(value); - + if (!hasExtendedUnicode) { return value; // Safe to pass through } - + // Encode to Base64 for extended Unicode characters const base64 = Buffer.from(value, 'utf8').toString('base64'); return `b64:${base64}`; @@ -118,7 +118,11 @@ const ALLOWED_BODY_FIELDS = ['conversationId', 'parentMessageId', 'messageId'] a * @param isHeader - Whether this value will be used in an HTTP header * @returns The processed string with placeholders replaced (and encoded if necessary) */ -function processUserPlaceholders(value: string, user?: IUser, isHeader: boolean = false): string { +function processUserPlaceholders( + value: string, + user?: Partial, + isHeader: boolean = false, +): string { if (!user || typeof value !== 'string') { return value; } @@ -208,7 +212,7 @@ function processSingleValue({ }: { originalValue: string; customUserVars?: Record; - user?: IUser; + user?: Partial; body?: RequestBody; isHeader?: boolean; }): string { @@ -255,7 +259,7 @@ function processSingleValue({ */ export function processMCPEnv(params: { options: Readonly; - user?: IUser; + user?: Partial; customUserVars?: Record; body?: RequestBody; }): MCPOptions { diff --git a/packages/api/src/utils/graph.spec.ts b/packages/api/src/utils/graph.spec.ts new file mode 100644 index 0000000000..4f1fa14983 --- /dev/null +++ b/packages/api/src/utils/graph.spec.ts @@ -0,0 +1,467 @@ +import type { TUser } from 'librechat-data-provider'; +import type { GraphTokenResolver, GraphTokenOptions } from './graph'; +import { + containsGraphTokenPlaceholder, + recordContainsGraphTokenPlaceholder, + mcpOptionsContainGraphTokenPlaceholder, + resolveGraphTokenPlaceholder, + resolveGraphTokensInRecord, + preProcessGraphTokens, +} from './graph'; + +// Mock the logger +jest.mock('@librechat/data-schemas', () => ({ + logger: { + warn: jest.fn(), + error: jest.fn(), + }, +})); + +// Mock the oidc module +jest.mock('./oidc', () => ({ + GRAPH_TOKEN_PLACEHOLDER: '{{LIBRECHAT_GRAPH_ACCESS_TOKEN}}', + DEFAULT_GRAPH_SCOPES: 'https://graph.microsoft.com/.default', + extractOpenIDTokenInfo: jest.fn(), + isOpenIDTokenValid: jest.fn(), +})); + +import { extractOpenIDTokenInfo, isOpenIDTokenValid } from './oidc'; + +const mockExtractOpenIDTokenInfo = extractOpenIDTokenInfo as jest.Mock; +const mockIsOpenIDTokenValid = isOpenIDTokenValid as jest.Mock; + +describe('Graph Token Utilities', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('containsGraphTokenPlaceholder', () => { + it('should return true when string contains the placeholder', () => { + const value = 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}'; + expect(containsGraphTokenPlaceholder(value)).toBe(true); + }); + + it('should return false when string does not contain the placeholder', () => { + const value = 'Bearer some-static-token'; + expect(containsGraphTokenPlaceholder(value)).toBe(false); + }); + + it('should return false for empty string', () => { + expect(containsGraphTokenPlaceholder('')).toBe(false); + }); + + it('should return false for non-string values', () => { + expect(containsGraphTokenPlaceholder(123 as unknown as string)).toBe(false); + expect(containsGraphTokenPlaceholder(null as unknown as string)).toBe(false); + expect(containsGraphTokenPlaceholder(undefined as unknown as string)).toBe(false); + }); + + it('should detect placeholder in the middle of a string', () => { + const value = 'prefix-{{LIBRECHAT_GRAPH_ACCESS_TOKEN}}-suffix'; + expect(containsGraphTokenPlaceholder(value)).toBe(true); + }); + }); + + describe('recordContainsGraphTokenPlaceholder', () => { + it('should return true when any value contains the placeholder', () => { + const record = { + Authorization: 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}', + 'Content-Type': 'application/json', + }; + expect(recordContainsGraphTokenPlaceholder(record)).toBe(true); + }); + + it('should return false when no value contains the placeholder', () => { + const record = { + Authorization: 'Bearer static-token', + 'Content-Type': 'application/json', + }; + expect(recordContainsGraphTokenPlaceholder(record)).toBe(false); + }); + + it('should return false for undefined record', () => { + expect(recordContainsGraphTokenPlaceholder(undefined)).toBe(false); + }); + + it('should return false for null record', () => { + expect(recordContainsGraphTokenPlaceholder(null as unknown as Record)).toBe( + false, + ); + }); + + it('should return false for empty record', () => { + expect(recordContainsGraphTokenPlaceholder({})).toBe(false); + }); + + it('should return false for non-object values', () => { + expect(recordContainsGraphTokenPlaceholder('string' as unknown as Record)).toBe( + false, + ); + }); + }); + + describe('mcpOptionsContainGraphTokenPlaceholder', () => { + it('should return true when url contains the placeholder', () => { + const options = { + url: 'https://api.example.com?token={{LIBRECHAT_GRAPH_ACCESS_TOKEN}}', + }; + expect(mcpOptionsContainGraphTokenPlaceholder(options)).toBe(true); + }); + + it('should return true when headers contain the placeholder', () => { + const options = { + headers: { + Authorization: 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}', + }, + }; + expect(mcpOptionsContainGraphTokenPlaceholder(options)).toBe(true); + }); + + it('should return true when env contains the placeholder', () => { + const options = { + env: { + GRAPH_TOKEN: '{{LIBRECHAT_GRAPH_ACCESS_TOKEN}}', + }, + }; + expect(mcpOptionsContainGraphTokenPlaceholder(options)).toBe(true); + }); + + it('should return false when no field contains the placeholder', () => { + const options = { + url: 'https://api.example.com', + headers: { Authorization: 'Bearer static-token' }, + env: { API_KEY: 'some-key' }, + }; + expect(mcpOptionsContainGraphTokenPlaceholder(options)).toBe(false); + }); + + it('should return false for empty options', () => { + expect(mcpOptionsContainGraphTokenPlaceholder({})).toBe(false); + }); + }); + + describe('resolveGraphTokenPlaceholder', () => { + const mockUser: Partial = { + id: 'user-123', + provider: 'openid', + openidId: 'oidc-sub-456', + }; + + const mockGraphTokenResolver: GraphTokenResolver = jest.fn().mockResolvedValue({ + access_token: 'resolved-graph-token', + token_type: 'Bearer', + expires_in: 3600, + scope: 'https://graph.microsoft.com/.default', + }); + + it('should return original value when no placeholder is present', async () => { + const value = 'Bearer static-token'; + const result = await resolveGraphTokenPlaceholder(value, { + user: mockUser as TUser, + graphTokenResolver: mockGraphTokenResolver, + }); + expect(result).toBe('Bearer static-token'); + }); + + it('should return original value when user is not provided', async () => { + const value = 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}'; + const result = await resolveGraphTokenPlaceholder(value, { + graphTokenResolver: mockGraphTokenResolver, + }); + expect(result).toBe(value); + }); + + it('should return original value when graphTokenResolver is not provided', async () => { + const value = 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}'; + const result = await resolveGraphTokenPlaceholder(value, { + user: mockUser as TUser, + }); + expect(result).toBe(value); + }); + + it('should return original value when token info is invalid', async () => { + mockExtractOpenIDTokenInfo.mockReturnValue(null); + + const value = 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}'; + const result = await resolveGraphTokenPlaceholder(value, { + user: mockUser as TUser, + graphTokenResolver: mockGraphTokenResolver, + }); + expect(result).toBe(value); + }); + + it('should return original value when token is not valid', async () => { + mockExtractOpenIDTokenInfo.mockReturnValue({ accessToken: 'access-token' }); + mockIsOpenIDTokenValid.mockReturnValue(false); + + const value = 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}'; + const result = await resolveGraphTokenPlaceholder(value, { + user: mockUser as TUser, + graphTokenResolver: mockGraphTokenResolver, + }); + expect(result).toBe(value); + }); + + it('should return original value when access token is missing', async () => { + mockExtractOpenIDTokenInfo.mockReturnValue({ userId: 'user-123' }); + mockIsOpenIDTokenValid.mockReturnValue(true); + + const value = 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}'; + const result = await resolveGraphTokenPlaceholder(value, { + user: mockUser as TUser, + graphTokenResolver: mockGraphTokenResolver, + }); + expect(result).toBe(value); + }); + + it('should resolve placeholder with graph token', async () => { + mockExtractOpenIDTokenInfo.mockReturnValue({ accessToken: 'access-token' }); + mockIsOpenIDTokenValid.mockReturnValue(true); + + const value = 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}'; + const result = await resolveGraphTokenPlaceholder(value, { + user: mockUser as TUser, + graphTokenResolver: mockGraphTokenResolver, + }); + expect(result).toBe('Bearer resolved-graph-token'); + }); + + it('should resolve multiple placeholders in a string', async () => { + mockExtractOpenIDTokenInfo.mockReturnValue({ accessToken: 'access-token' }); + mockIsOpenIDTokenValid.mockReturnValue(true); + + const value = + 'Primary: {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}, Secondary: {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}'; + const result = await resolveGraphTokenPlaceholder(value, { + user: mockUser as TUser, + graphTokenResolver: mockGraphTokenResolver, + }); + expect(result).toBe('Primary: resolved-graph-token, Secondary: resolved-graph-token'); + }); + + it('should return original value when graph token exchange fails', async () => { + mockExtractOpenIDTokenInfo.mockReturnValue({ accessToken: 'access-token' }); + mockIsOpenIDTokenValid.mockReturnValue(true); + const failingResolver: GraphTokenResolver = jest.fn().mockRejectedValue(new Error('Exchange failed')); + + const value = 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}'; + const result = await resolveGraphTokenPlaceholder(value, { + user: mockUser as TUser, + graphTokenResolver: failingResolver, + }); + expect(result).toBe(value); + }); + + it('should return original value when graph token response has no access_token', async () => { + mockExtractOpenIDTokenInfo.mockReturnValue({ accessToken: 'access-token' }); + mockIsOpenIDTokenValid.mockReturnValue(true); + const emptyResolver: GraphTokenResolver = jest.fn().mockResolvedValue({}); + + const value = 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}'; + const result = await resolveGraphTokenPlaceholder(value, { + user: mockUser as TUser, + graphTokenResolver: emptyResolver, + }); + expect(result).toBe(value); + }); + + it('should use provided scopes', async () => { + mockExtractOpenIDTokenInfo.mockReturnValue({ accessToken: 'access-token' }); + mockIsOpenIDTokenValid.mockReturnValue(true); + + const value = 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}'; + await resolveGraphTokenPlaceholder(value, { + user: mockUser as TUser, + graphTokenResolver: mockGraphTokenResolver, + scopes: 'custom-scope', + }); + + expect(mockGraphTokenResolver).toHaveBeenCalledWith( + mockUser, + 'access-token', + 'custom-scope', + true, + ); + }); + }); + + describe('resolveGraphTokensInRecord', () => { + const mockUser: Partial = { + id: 'user-123', + provider: 'openid', + }; + + const mockGraphTokenResolver: GraphTokenResolver = jest.fn().mockResolvedValue({ + access_token: 'resolved-graph-token', + token_type: 'Bearer', + expires_in: 3600, + scope: 'https://graph.microsoft.com/.default', + }); + + const options: GraphTokenOptions = { + user: mockUser as TUser, + graphTokenResolver: mockGraphTokenResolver, + }; + + beforeEach(() => { + mockExtractOpenIDTokenInfo.mockReturnValue({ accessToken: 'access-token' }); + mockIsOpenIDTokenValid.mockReturnValue(true); + }); + + it('should return undefined for undefined record', async () => { + const result = await resolveGraphTokensInRecord(undefined, options); + expect(result).toBeUndefined(); + }); + + it('should return record unchanged when no placeholders present', async () => { + const record = { + Authorization: 'Bearer static-token', + 'Content-Type': 'application/json', + }; + const result = await resolveGraphTokensInRecord(record, options); + expect(result).toEqual(record); + }); + + it('should resolve placeholders in record values', async () => { + const record = { + Authorization: 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}', + 'Content-Type': 'application/json', + }; + const result = await resolveGraphTokensInRecord(record, options); + expect(result).toEqual({ + Authorization: 'Bearer resolved-graph-token', + 'Content-Type': 'application/json', + }); + }); + + it('should handle non-string values gracefully', async () => { + const record = { + Authorization: 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}', + numericValue: 123 as unknown as string, + }; + const result = await resolveGraphTokensInRecord(record, options); + expect(result).toEqual({ + Authorization: 'Bearer resolved-graph-token', + numericValue: 123, + }); + }); + }); + + describe('preProcessGraphTokens', () => { + const mockUser: Partial = { + id: 'user-123', + provider: 'openid', + }; + + const mockGraphTokenResolver: GraphTokenResolver = jest.fn().mockResolvedValue({ + access_token: 'resolved-graph-token', + token_type: 'Bearer', + expires_in: 3600, + scope: 'https://graph.microsoft.com/.default', + }); + + const graphOptions: GraphTokenOptions = { + user: mockUser as TUser, + graphTokenResolver: mockGraphTokenResolver, + }; + + beforeEach(() => { + mockExtractOpenIDTokenInfo.mockReturnValue({ accessToken: 'access-token' }); + mockIsOpenIDTokenValid.mockReturnValue(true); + }); + + it('should return options unchanged when no placeholders present', async () => { + const options = { + url: 'https://api.example.com', + headers: { Authorization: 'Bearer static-token' }, + env: { API_KEY: 'some-key' }, + }; + const result = await preProcessGraphTokens(options, graphOptions); + expect(result).toEqual(options); + }); + + it('should resolve placeholder in url', async () => { + const options = { + url: 'https://api.example.com?token={{LIBRECHAT_GRAPH_ACCESS_TOKEN}}', + }; + const result = await preProcessGraphTokens(options, graphOptions); + expect(result.url).toBe('https://api.example.com?token=resolved-graph-token'); + }); + + it('should resolve placeholder in headers', async () => { + const options = { + headers: { + Authorization: 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}', + 'Content-Type': 'application/json', + }, + }; + const result = await preProcessGraphTokens(options, graphOptions); + expect(result.headers).toEqual({ + Authorization: 'Bearer resolved-graph-token', + 'Content-Type': 'application/json', + }); + }); + + it('should resolve placeholder in env', async () => { + const options = { + env: { + GRAPH_TOKEN: '{{LIBRECHAT_GRAPH_ACCESS_TOKEN}}', + OTHER_VAR: 'static-value', + }, + }; + const result = await preProcessGraphTokens(options, graphOptions); + expect(result.env).toEqual({ + GRAPH_TOKEN: 'resolved-graph-token', + OTHER_VAR: 'static-value', + }); + }); + + it('should resolve placeholders in all fields simultaneously', async () => { + const options = { + url: 'https://api.example.com?token={{LIBRECHAT_GRAPH_ACCESS_TOKEN}}', + headers: { + Authorization: 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}', + }, + env: { + GRAPH_TOKEN: '{{LIBRECHAT_GRAPH_ACCESS_TOKEN}}', + }, + }; + const result = await preProcessGraphTokens(options, graphOptions); + expect(result.url).toBe('https://api.example.com?token=resolved-graph-token'); + expect(result.headers).toEqual({ + Authorization: 'Bearer resolved-graph-token', + }); + expect(result.env).toEqual({ + GRAPH_TOKEN: 'resolved-graph-token', + }); + }); + + it('should not mutate the original options object', async () => { + const options = { + url: 'https://api.example.com?token={{LIBRECHAT_GRAPH_ACCESS_TOKEN}}', + headers: { + Authorization: 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}', + }, + }; + const originalUrl = options.url; + const originalAuth = options.headers.Authorization; + + await preProcessGraphTokens(options, graphOptions); + + expect(options.url).toBe(originalUrl); + expect(options.headers.Authorization).toBe(originalAuth); + }); + + it('should preserve additional properties in generic type', async () => { + const options = { + url: 'https://api.example.com?token={{LIBRECHAT_GRAPH_ACCESS_TOKEN}}', + customProperty: 'custom-value', + anotherProperty: 42, + }; + const result = await preProcessGraphTokens(options, graphOptions); + expect(result.customProperty).toBe('custom-value'); + expect(result.anotherProperty).toBe(42); + expect(result.url).toBe('https://api.example.com?token=resolved-graph-token'); + }); + }); +}); diff --git a/packages/api/src/utils/graph.ts b/packages/api/src/utils/graph.ts new file mode 100644 index 0000000000..0ff3fc3583 --- /dev/null +++ b/packages/api/src/utils/graph.ts @@ -0,0 +1,215 @@ +import { logger } from '@librechat/data-schemas'; +import type { IUser } from '@librechat/data-schemas'; +import { + GRAPH_TOKEN_PLACEHOLDER, + DEFAULT_GRAPH_SCOPES, + extractOpenIDTokenInfo, + isOpenIDTokenValid, +} from './oidc'; + +/** + * Pre-computed regex for matching the Graph token placeholder. + * Escapes curly braces in the placeholder string for safe regex use. + */ +const GRAPH_TOKEN_REGEX = new RegExp( + GRAPH_TOKEN_PLACEHOLDER.replace(/[{}]/g, '\\$&'), + 'g', +); + +/** + * Response from a Graph API token exchange. + */ +export interface GraphTokenResponse { + access_token: string; + token_type: string; + expires_in: number; + scope: string; +} + +/** + * Function type for resolving Graph API tokens via OBO flow. + * This function is injected from the main API layer since it requires + * access to OpenID configuration and caching services. + */ +export type GraphTokenResolver = ( + user: IUser, + accessToken: string, + scopes: string, + fromCache?: boolean, +) => Promise; + +/** + * Options for processing Graph token placeholders. + */ +export interface GraphTokenOptions { + user?: IUser; + graphTokenResolver?: GraphTokenResolver; + scopes?: string; +} + +/** + * Checks if a string contains the Graph token placeholder. + * @param value - The string to check + * @returns True if the placeholder is present + */ +export function containsGraphTokenPlaceholder(value: string): boolean { + return typeof value === 'string' && value.includes(GRAPH_TOKEN_PLACEHOLDER); +} + +/** + * Checks if any value in a record contains the Graph token placeholder. + * @param record - The record to check (e.g., headers, env vars) + * @returns True if any value contains the placeholder + */ +export function recordContainsGraphTokenPlaceholder( + record: Record | undefined, +): boolean { + if (!record || typeof record !== 'object') { + return false; + } + return Object.values(record).some(containsGraphTokenPlaceholder); +} + +/** + * Checks if MCP options contain the Graph token placeholder in headers, env, or url. + * @param options - The MCP options object + * @returns True if any field contains the placeholder + */ +export function mcpOptionsContainGraphTokenPlaceholder(options: { + headers?: Record; + env?: Record; + url?: string; +}): boolean { + if (options.url && containsGraphTokenPlaceholder(options.url)) { + return true; + } + if (recordContainsGraphTokenPlaceholder(options.headers)) { + return true; + } + if (recordContainsGraphTokenPlaceholder(options.env)) { + return true; + } + return false; +} + +/** + * Asynchronously resolves Graph token placeholders in a string. + * This function must be called before the synchronous processMCPEnv pipeline. + * + * @param value - The string containing the placeholder + * @param options - Options including user and graph token resolver + * @returns The string with Graph token placeholder replaced + */ +export async function resolveGraphTokenPlaceholder( + value: string, + options: GraphTokenOptions, +): Promise { + if (!containsGraphTokenPlaceholder(value)) { + return value; + } + + const { user, graphTokenResolver, scopes } = options; + + if (!user || !graphTokenResolver) { + logger.warn( + '[resolveGraphTokenPlaceholder] User or graphTokenResolver not provided, cannot resolve Graph token', + ); + return value; + } + + const tokenInfo = extractOpenIDTokenInfo(user); + if (!tokenInfo || !isOpenIDTokenValid(tokenInfo)) { + logger.warn( + '[resolveGraphTokenPlaceholder] No valid OpenID token available for Graph token exchange', + ); + return value; + } + + if (!tokenInfo.accessToken) { + logger.warn('[resolveGraphTokenPlaceholder] No access token available for OBO exchange'); + return value; + } + + try { + const graphScopes = scopes || process.env.GRAPH_API_SCOPES || DEFAULT_GRAPH_SCOPES; + const graphTokenResponse = await graphTokenResolver( + user, + tokenInfo.accessToken, + graphScopes, + true, // Use cache + ); + + if (graphTokenResponse?.access_token) { + return value.replace(GRAPH_TOKEN_REGEX, graphTokenResponse.access_token); + } + + logger.warn('[resolveGraphTokenPlaceholder] Graph token exchange did not return an access token'); + return value; + } catch (error) { + logger.error('[resolveGraphTokenPlaceholder] Failed to exchange token for Graph API:', error); + return value; + } +} + +/** + * Asynchronously resolves Graph token placeholders in a record of string values. + * + * @param record - The record containing placeholders (e.g., headers) + * @param options - Options including user and graph token resolver + * @returns The record with Graph token placeholders replaced + */ +export async function resolveGraphTokensInRecord( + record: Record | undefined, + options: GraphTokenOptions, +): Promise | undefined> { + if (!record || typeof record !== 'object') { + return record; + } + + if (!recordContainsGraphTokenPlaceholder(record)) { + return record; + } + + const resolved: Record = {}; + for (const [key, value] of Object.entries(record)) { + resolved[key] = await resolveGraphTokenPlaceholder(value, options); + } + return resolved; +} + +/** + * Pre-processes MCP options to resolve Graph token placeholders. + * This must be called before processMCPEnv since Graph token resolution is async. + * + * @param options - The MCP options object + * @param graphOptions - Options for Graph token resolution + * @returns The options with Graph token placeholders resolved + */ +export async function preProcessGraphTokens; + env?: Record; + url?: string; +}>( + options: T, + graphOptions: GraphTokenOptions, +): Promise { + if (!mcpOptionsContainGraphTokenPlaceholder(options)) { + return options; + } + + const result = { ...options }; + + if (result.url && containsGraphTokenPlaceholder(result.url)) { + result.url = await resolveGraphTokenPlaceholder(result.url, graphOptions); + } + + if (result.headers) { + result.headers = await resolveGraphTokensInRecord(result.headers, graphOptions); + } + + if (result.env) { + result.env = await resolveGraphTokensInRecord(result.env, graphOptions); + } + + return result; +} diff --git a/packages/api/src/utils/index.ts b/packages/api/src/utils/index.ts index 947e566ce0..d4351eb5a0 100644 --- a/packages/api/src/utils/index.ts +++ b/packages/api/src/utils/index.ts @@ -7,11 +7,13 @@ export * from './env'; export * from './events'; export * from './files'; export * from './generators'; +export * from './graph'; export * from './path'; export * from './key'; export * from './latex'; export * from './llm'; export * from './math'; +export * from './oidc'; export * from './openid'; export * from './promise'; export * from './sanitizeTitle'; diff --git a/packages/api/src/utils/oidc.ts b/packages/api/src/utils/oidc.ts index cebda91e69..dbf41818c4 100644 --- a/packages/api/src/utils/oidc.ts +++ b/packages/api/src/utils/oidc.ts @@ -34,7 +34,22 @@ const OPENID_TOKEN_FIELDS = [ 'EXPIRES_AT', ] as const; -export function extractOpenIDTokenInfo(user: IUser | null | undefined): OpenIDTokenInfo | null { +/** + * Placeholder for Microsoft Graph API access token. + * This placeholder is resolved asynchronously via OBO (On-Behalf-Of) flow + * and requires special handling outside the synchronous processMCPEnv pipeline. + */ +export const GRAPH_TOKEN_PLACEHOLDER = '{{LIBRECHAT_GRAPH_ACCESS_TOKEN}}'; + +/** + * Default Microsoft Graph API scopes for OBO token exchange. + * Can be overridden via GRAPH_API_SCOPES environment variable. + */ +export const DEFAULT_GRAPH_SCOPES = 'https://graph.microsoft.com/.default'; + +export function extractOpenIDTokenInfo( + user: Partial | null | undefined, +): OpenIDTokenInfo | null { if (!user) { return null; }