LibreChat/packages/api/src/utils/graph.ts
Max Sanna dd4bbd38fc
🪪 feat: Microsoft Graph Access Token Placeholder for MCP Servers (#10867)
* feat: MCP Graph Token env var

* Addressing copilot remarks

* Addressed Copilot review remarks

* Fixed graphtokenservice mock in MCP test suite

* fix: remove unnecessary type check and cast in resolveGraphTokensInRecord

* ci: add Graph Token integration tests in MCPManager

* refactor: update user type definitions to use Partial<IUser> in multiple functions

* test: enhance MCP tests for graph token processing and user placeholder resolution

- Added comprehensive tests to validate the interaction between preProcessGraphTokens and processMCPEnv.
- Ensured correct resolution of graph tokens and user placeholders in various configurations.
- Mocked OIDC utilities to facilitate testing of token extraction and validation.
- Verified that original options remain unchanged after processing.

* chore: import order

* chore: imports

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2026-01-28 17:44:33 -05:00

215 lines
6.1 KiB
TypeScript

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<GraphTokenResponse>;
/**
* 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<string, string> | 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<string, string>;
env?: Record<string, string>;
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<string> {
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<string, string> | undefined,
options: GraphTokenOptions,
): Promise<Record<string, string> | undefined> {
if (!record || typeof record !== 'object') {
return record;
}
if (!recordContainsGraphTokenPlaceholder(record)) {
return record;
}
const resolved: Record<string, string> = {};
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<T extends {
headers?: Record<string, string>;
env?: Record<string, string>;
url?: string;
}>(
options: T,
graphOptions: GraphTokenOptions,
): Promise<T> {
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;
}