mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-06-26 09:21:33 +00:00
* 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>
215 lines
6.1 KiB
TypeScript
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;
|
|
}
|