diff --git a/.env.example b/.env.example index b98b7ac62f..90995be72f 100644 --- a/.env.example +++ b/.env.example @@ -785,3 +785,7 @@ OPENWEATHER_API_KEY= # Cache connection status checks for this many milliseconds to avoid expensive verification # MCP_CONNECTION_CHECK_TTL=60000 + +# Skip code challenge method validation (e.g., for AWS Cognito that supports S256 but doesn't advertise it) +# When set to true, forces S256 code challenge even if not advertised in .well-known/openid-configuration +# MCP_SKIP_CODE_CHALLENGE_CHECK=false diff --git a/.gitignore b/.gitignore index 0796905501..d173d26b60 100644 --- a/.gitignore +++ b/.gitignore @@ -138,3 +138,34 @@ helm/**/.values.yaml /.tabnine/ /.codeium *.local.md + + +# Removed Windows wrapper files per user request +hive-mind-prompt-*.txt + +# Claude Flow generated files +.claude/settings.local.json +.mcp.json +claude-flow.config.json +.swarm/ +.hive-mind/ +.claude-flow/ +memory/ +coordination/ +memory/claude-flow-data.json +memory/sessions/* +!memory/sessions/README.md +memory/agents/* +!memory/agents/README.md +coordination/memory_bank/* +coordination/subtasks/* +coordination/orchestration/* +*.db +*.db-journal +*.db-wal +*.sqlite +*.sqlite-journal +*.sqlite-wal +claude-flow +# Removed Windows wrapper files per user request +hive-mind-prompt-*.txt diff --git a/api/server/controllers/AuthController.js b/api/server/controllers/AuthController.js index 096727e977..dfef2bbfa1 100644 --- a/api/server/controllers/AuthController.js +++ b/api/server/controllers/AuthController.js @@ -82,7 +82,15 @@ const refreshController = async (req, res) => { if (error || !user) { return res.status(401).redirect('/login'); } - const token = setOpenIDAuthTokens(tokenset, res, user._id.toString()); + const token = setOpenIDAuthTokens(tokenset, res, user._id.toString(), refreshToken); + + user.federatedTokens = { + access_token: tokenset.access_token, + id_token: tokenset.id_token, + refresh_token: refreshToken, + expires_at: claims.exp, + }; + return res.status(200).send({ token, user }); } catch (error) { logger.error('[refreshController] OpenID token refresh error', error); diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 5dea281bb0..baa9b7a37a 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -9,6 +9,7 @@ const { logAxiosError, sanitizeTitle, resolveHeaders, + createSafeUser, getBalanceConfig, memoryInstructions, getTransactionsConfig, @@ -856,7 +857,7 @@ class AgentClient extends BaseClient { conversationId: this.conversationId, parentMessageId: this.parentMessageId, }, - user: this.options.req.user, + user: createSafeUser(this.options.req.user), }, recursionLimit: agentsEConfig?.recursionLimit ?? 25, signal: abortController.signal, @@ -932,6 +933,7 @@ class AgentClient extends BaseClient { signal: abortController.signal, customHandlers: this.options.eventHandlers, requestBody: config.configurable.requestBody, + user: createSafeUser(this.options.req?.user), tokenCounter: createTokenCounter(this.getEncoding()), }); @@ -1152,6 +1154,7 @@ class AgentClient extends BaseClient { if (clientOptions?.configuration?.defaultHeaders != null) { clientOptions.configuration.defaultHeaders = resolveHeaders({ headers: clientOptions.configuration.defaultHeaders, + user: createSafeUser(this.options.req?.user), body: { messageId: this.responseMessageId, conversationId: this.conversationId, diff --git a/api/server/services/AuthService.js b/api/server/services/AuthService.js index 66766837a0..72bda67322 100644 --- a/api/server/services/AuthService.js +++ b/api/server/services/AuthService.js @@ -412,7 +412,7 @@ const setAuthTokens = async (userId, res, _session = null) => { * @param {string} [userId] - Optional MongoDB user ID for image path validation * @returns {String} - access token */ -const setOpenIDAuthTokens = (tokenset, res, userId) => { +const setOpenIDAuthTokens = (tokenset, res, userId, existingRefreshToken) => { try { if (!tokenset) { logger.error('[setOpenIDAuthTokens] No tokenset found in request'); @@ -427,11 +427,25 @@ const setOpenIDAuthTokens = (tokenset, res, userId) => { logger.error('[setOpenIDAuthTokens] No tokenset found in request'); return; } - if (!tokenset.access_token || !tokenset.refresh_token) { - logger.error('[setOpenIDAuthTokens] No access or refresh token found in tokenset'); + if (!tokenset.access_token) { + logger.error('[setOpenIDAuthTokens] No access token found in tokenset'); return; } - res.cookie('refreshToken', tokenset.refresh_token, { + + const refreshToken = tokenset.refresh_token || existingRefreshToken; + + if (!refreshToken) { + logger.error('[setOpenIDAuthTokens] No refresh token available'); + return; + } + + res.cookie('refreshToken', refreshToken, { + expires: expirationDate, + httpOnly: true, + secure: isProduction, + sameSite: 'strict', + }); + res.cookie('openid_access_token', tokenset.access_token, { expires: expirationDate, httpOnly: true, secure: isProduction, diff --git a/api/server/services/Endpoints/bedrock/options.js b/api/server/services/Endpoints/bedrock/options.js index 0d02d09b07..4da13a9ffa 100644 --- a/api/server/services/Endpoints/bedrock/options.js +++ b/api/server/services/Endpoints/bedrock/options.js @@ -1,3 +1,4 @@ +const { resolveHeaders } = require('@librechat/api'); const { HttpsProxyAgent } = require('https-proxy-agent'); const { AuthType, @@ -88,6 +89,14 @@ const getOptions = async ({ req, overrideModel, endpointOption }) => { llmConfig.endpointHost = BEDROCK_REVERSE_PROXY; } + if (llmConfig.additionalModelRequestFields) { + llmConfig.additionalModelRequestFields = resolveHeaders({ + headers: llmConfig.additionalModelRequestFields, + user: req.user, + body: req.body, + }); + } + return { /** @type {BedrockClientOptions} */ llmConfig, diff --git a/api/server/services/Endpoints/custom/initialize.js b/api/server/services/Endpoints/custom/initialize.js index e6fbf65e77..5aa8b08a92 100644 --- a/api/server/services/Endpoints/custom/initialize.js +++ b/api/server/services/Endpoints/custom/initialize.js @@ -1,9 +1,4 @@ -const { - resolveHeaders, - isUserProvided, - getOpenAIConfig, - getCustomEndpointConfig, -} = require('@librechat/api'); +const { isUserProvided, getOpenAIConfig, getCustomEndpointConfig } = require('@librechat/api'); const { CacheKeys, ErrorTypes, @@ -34,14 +29,6 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid const CUSTOM_API_KEY = extractEnvVariable(endpointConfig.apiKey); const CUSTOM_BASE_URL = extractEnvVariable(endpointConfig.baseURL); - /** Intentionally excludes passing `body`, i.e. `req.body`, as - * values may not be accurate until `AgentClient` is initialized - */ - let resolvedHeaders = resolveHeaders({ - headers: endpointConfig.headers, - user: req.user, - }); - if (CUSTOM_API_KEY.match(envVarRegex)) { throw new Error(`Missing API Key for ${endpoint}.`); } @@ -108,7 +95,7 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid } const customOptions = { - headers: resolvedHeaders, + headers: endpointConfig.headers, addParams: endpointConfig.addParams, dropParams: endpointConfig.dropParams, customParams: endpointConfig.customParams, diff --git a/api/server/services/Endpoints/custom/initialize.spec.js b/api/server/services/Endpoints/custom/initialize.spec.js index a69ff9ef58..d12906df9a 100644 --- a/api/server/services/Endpoints/custom/initialize.spec.js +++ b/api/server/services/Endpoints/custom/initialize.spec.js @@ -69,17 +69,21 @@ describe('custom/initializeClient', () => { }); }); - it('calls resolveHeaders with headers, user, and body for body placeholder support', async () => { - const { resolveHeaders } = require('@librechat/api'); - await initializeClient({ req: mockRequest, res: mockResponse, optionsOnly: true }); - expect(resolveHeaders).toHaveBeenCalledWith({ - headers: { 'x-user': '{{LIBRECHAT_USER_ID}}', 'x-email': '{{LIBRECHAT_USER_EMAIL}}' }, - user: { id: 'user-123', email: 'test@example.com', role: 'user' }, - /** - * Note: Request-based Header Resolution is deferred until right before LLM request is made - body: { endpoint: 'test-endpoint' }, // body - supports {{LIBRECHAT_BODY_*}} placeholders - */ + it('stores original template headers for deferred resolution', async () => { + /** + * Note: Request-based Header Resolution is deferred until right before LLM request is made + * in the OpenAIClient or AgentClient, not during initialization. + * This test verifies that the initialize function completes successfully with optionsOnly flag, + * and that headers are passed through to be resolved later during the actual LLM request. + */ + const result = await initializeClient({ + req: mockRequest, + res: mockResponse, + optionsOnly: true, }); + // Verify that options are returned for later use + expect(result).toBeDefined(); + expect(result).toHaveProperty('useLegacyContent', true); }); it('throws if endpoint config is missing', async () => { diff --git a/api/strategies/openIdJwtStrategy.js b/api/strategies/openIdJwtStrategy.js index 94685fc86c..998a918c30 100644 --- a/api/strategies/openIdJwtStrategy.js +++ b/api/strategies/openIdJwtStrategy.js @@ -1,3 +1,4 @@ +const cookies = require('cookie'); const jwksRsa = require('jwks-rsa'); const { logger } = require('@librechat/data-schemas'); const { HttpsProxyAgent } = require('https-proxy-agent'); @@ -40,13 +41,18 @@ const openIdJwtLogin = (openIdConfig) => { { jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), secretOrKeyProvider: jwksRsa.passportJwtSecret(jwksRsaOptions), + passReqToCallback: true, }, /** + * @param {import('@librechat/api').ServerRequest} req * @param {import('openid-client').IDToken} payload * @param {import('passport-jwt').VerifyCallback} done */ - async (payload, done) => { + async (req, payload, done) => { try { + const authHeader = req.headers.authorization; + const rawToken = authHeader?.replace('Bearer ', ''); + const { user, error, migration } = await findOpenIDUser({ findUser, email: payload?.email, @@ -77,6 +83,18 @@ const openIdJwtLogin = (openIdConfig) => { await updateUser(user.id, updateData); } + const cookieHeader = req.headers.cookie; + const parsedCookies = cookieHeader ? cookies.parse(cookieHeader) : {}; + const accessToken = parsedCookies.openid_access_token; + const refreshToken = parsedCookies.refreshToken; + + user.federatedTokens = { + access_token: accessToken || rawToken, + id_token: rawToken, + refresh_token: refreshToken, + expires_at: payload.exp, + }; + done(null, user); } else { logger.warn( diff --git a/api/strategies/openidStrategy.js b/api/strategies/openidStrategy.js index 26143b226a..455ff1bd11 100644 --- a/api/strategies/openidStrategy.js +++ b/api/strategies/openidStrategy.js @@ -543,7 +543,15 @@ async function setupOpenId() { }, ); - done(null, { ...user, tokenset }); + done(null, { + ...user, + tokenset, + federatedTokens: { + access_token: tokenset.access_token, + refresh_token: tokenset.refresh_token, + expires_at: tokenset.expires_at, + }, + }); } catch (err) { logger.error('[openidStrategy] login failed', err); done(err); diff --git a/api/strategies/openidStrategy.spec.js b/api/strategies/openidStrategy.spec.js index fa6af7f40f..9ac22ff42f 100644 --- a/api/strategies/openidStrategy.spec.js +++ b/api/strategies/openidStrategy.spec.js @@ -18,6 +18,8 @@ jest.mock('~/server/services/Config', () => ({ jest.mock('@librechat/api', () => ({ ...jest.requireActual('@librechat/api'), isEnabled: jest.fn(() => false), + isEmailDomainAllowed: jest.fn(() => true), + findOpenIDUser: jest.requireActual('@librechat/api').findOpenIDUser, getBalanceConfig: jest.fn(() => ({ enabled: false, })), @@ -446,6 +448,46 @@ describe('setupOpenId', () => { expect(callOptions.params?.code_challenge_method).toBeUndefined(); }); + it('should attach federatedTokens to user object for token propagation', async () => { + // Arrange - setup tokenset with access token, refresh token, and expiration + const tokensetWithTokens = { + ...tokenset, + access_token: 'mock_access_token_abc123', + refresh_token: 'mock_refresh_token_xyz789', + expires_at: 1234567890, + }; + + // Act - validate with the tokenset containing tokens + const { user } = await validate(tokensetWithTokens); + + // Assert - verify federatedTokens object is attached with correct values + expect(user.federatedTokens).toBeDefined(); + expect(user.federatedTokens).toEqual({ + access_token: 'mock_access_token_abc123', + refresh_token: 'mock_refresh_token_xyz789', + expires_at: 1234567890, + }); + }); + + it('should include tokenset along with federatedTokens', async () => { + // Arrange + const tokensetWithTokens = { + ...tokenset, + access_token: 'test_access_token', + refresh_token: 'test_refresh_token', + expires_at: 9999999999, + }; + + // Act + const { user } = await validate(tokensetWithTokens); + + // Assert - both tokenset and federatedTokens should be present + expect(user.tokenset).toBeDefined(); + expect(user.federatedTokens).toBeDefined(); + expect(user.tokenset.access_token).toBe('test_access_token'); + expect(user.federatedTokens.access_token).toBe('test_access_token'); + }); + it('should set role to "ADMIN" if OPENID_ADMIN_ROLE is set and user has that role', async () => { // Act const { user } = await validate(tokenset); diff --git a/api/test/__mocks__/openid-client.js b/api/test/__mocks__/openid-client.js index 4848a4799f..766d8a305d 100644 --- a/api/test/__mocks__/openid-client.js +++ b/api/test/__mocks__/openid-client.js @@ -40,6 +40,10 @@ module.exports = { clientId: 'fake_client_id', clientSecret: 'fake_client_secret', issuer: 'https://fake-issuer.com', + serverMetadata: jest.fn().mockReturnValue({ + jwks_uri: 'https://fake-issuer.com/.well-known/jwks.json', + end_session_endpoint: 'https://fake-issuer.com/logout', + }), Client: jest.fn().mockImplementation(() => ({ authorizationUrl: jest.fn().mockReturnValue('mock_auth_url'), callback: jest.fn().mockResolvedValue({ diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index 63e9111a0b..5df80c5f39 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -11,7 +11,7 @@ import type { } from '@librechat/agents'; import type { Agent } from 'librechat-data-provider'; import type * as t from '~/types'; -import { resolveHeaders } from '~/utils/env'; +import { resolveHeaders, createSafeUser } from '~/utils/env'; const customProviders = new Set([ Providers.XAI, @@ -66,6 +66,7 @@ export async function createRun({ signal, agents, requestBody, + user, tokenCounter, customHandlers, indexTokenCountMap, @@ -78,6 +79,7 @@ export async function createRun({ streaming?: boolean; streamUsage?: boolean; requestBody?: t.RequestBody; + user?: t.TUser; } & Pick): Promise< Run > { @@ -118,6 +120,7 @@ export async function createRun({ if (llmConfig?.configuration?.defaultHeaders != null) { llmConfig.configuration.defaultHeaders = resolveHeaders({ headers: llmConfig.configuration.defaultHeaders as Record, + user: createSafeUser(user), body: requestBody, }); } diff --git a/packages/api/src/mcp/oauth/handler.ts b/packages/api/src/mcp/oauth/handler.ts index 4c20f2ccdd..2357e0c606 100644 --- a/packages/api/src/mcp/oauth/handler.ts +++ b/packages/api/src/mcp/oauth/handler.ts @@ -223,6 +223,23 @@ export class MCPOAuthHandler { // Check if we have pre-configured OAuth settings if (config?.authorization_url && config?.token_url && config?.client_id) { logger.debug(`[MCPOAuth] Using pre-configured OAuth settings for ${serverName}`); + + const skipCodeChallengeCheck = + config?.skip_code_challenge_check === true || + process.env.MCP_SKIP_CODE_CHALLENGE_CHECK === 'true'; + let codeChallengeMethodsSupported: string[]; + + if (config?.code_challenge_methods_supported !== undefined) { + codeChallengeMethodsSupported = config.code_challenge_methods_supported; + } else if (skipCodeChallengeCheck) { + codeChallengeMethodsSupported = ['S256', 'plain']; + logger.debug( + `[MCPOAuth] Code challenge check skip enabled, forcing S256 support for ${serverName}`, + ); + } else { + codeChallengeMethodsSupported = ['S256', 'plain']; + } + /** Metadata based on pre-configured settings */ const metadata: OAuthMetadata = { authorization_endpoint: config.authorization_url, @@ -238,10 +255,7 @@ export class MCPOAuthHandler { 'client_secret_post', ], response_types_supported: config?.response_types_supported ?? ['code'], - code_challenge_methods_supported: config?.code_challenge_methods_supported ?? [ - 'S256', - 'plain', - ], + code_challenge_methods_supported: codeChallengeMethodsSupported, }; logger.debug(`[MCPOAuth] metadata for "${serverName}": ${JSON.stringify(metadata)}`); const clientInfo: OAuthClientInformation = { diff --git a/packages/api/src/utils/env.ts b/packages/api/src/utils/env.ts index 43429ef5a6..81c7f3923b 100644 --- a/packages/api/src/utils/env.ts +++ b/packages/api/src/utils/env.ts @@ -2,6 +2,7 @@ import { extractEnvVariable } from 'librechat-data-provider'; import type { TUser, MCPOptions } from 'librechat-data-provider'; import type { IUser } from '@librechat/data-schemas'; import type { RequestBody } from '~/types'; +import { extractOpenIDTokenInfo, processOpenIDPlaceholders, isOpenIDTokenValid } from './oidc'; /** * List of allowed user fields that can be used in MCP environment variables. @@ -32,23 +33,29 @@ type SafeUser = Pick; /** * Creates a safe user object containing only allowed fields. - * Optimized for performance while maintaining type safety. + * Preserves federatedTokens for OpenID token template variable resolution. * * @param user - The user object to extract safe fields from - * @returns A new object containing only allowed fields + * @returns A new object containing only allowed fields plus federatedTokens if present */ -export function createSafeUser(user: IUser | null | undefined): Partial { +export function createSafeUser( + user: IUser | null | undefined, +): Partial & { federatedTokens?: unknown } { if (!user) { return {}; } - const safeUser: Partial = {}; + const safeUser: Partial & { federatedTokens?: unknown } = {}; for (const field of ALLOWED_USER_FIELDS) { if (field in user) { safeUser[field] = user[field]; } } + if ('federatedTokens' in user) { + safeUser.federatedTokens = user.federatedTokens; + } + return safeUser; } @@ -139,7 +146,6 @@ function processSingleValue({ }): string { let value = originalValue; - // 1. Replace custom user variables if (customUserVars) { for (const [varName, varVal] of Object.entries(customUserVars)) { /** Escaped varName for use in regex to avoid issues with special characters */ @@ -149,15 +155,17 @@ function processSingleValue({ } } - // 2. Replace user field placeholders (e.g., {{LIBRECHAT_USER_EMAIL}}, {{LIBRECHAT_USER_ID}}) value = processUserPlaceholders(value, user); - // 3. Replace body field placeholders (e.g., {{LIBRECHAT_BODY_CONVERSATIONID}}, {{LIBRECHAT_BODY_PARENTMESSAGEID}}) + const openidTokenInfo = extractOpenIDTokenInfo(user); + if (openidTokenInfo && isOpenIDTokenValid(openidTokenInfo)) { + value = processOpenIDPlaceholders(value, openidTokenInfo); + } + if (body) { value = processBodyPlaceholders(value, body); } - // 4. Replace system environment variables value = extractEnvVariable(value); return value; diff --git a/packages/api/src/utils/oidc.spec.ts b/packages/api/src/utils/oidc.spec.ts new file mode 100644 index 0000000000..a5312e9c69 --- /dev/null +++ b/packages/api/src/utils/oidc.spec.ts @@ -0,0 +1,482 @@ +import { extractOpenIDTokenInfo, isOpenIDTokenValid, processOpenIDPlaceholders } from './oidc'; +import type { TUser } from 'librechat-data-provider'; + +describe('OpenID Token Utilities', () => { + describe('extractOpenIDTokenInfo', () => { + it('should extract token info from user with federatedTokens', () => { + const user: Partial = { + id: 'user-123', + provider: 'openid', + openidId: 'oidc-sub-456', + email: 'test@example.com', + name: 'Test User', + federatedTokens: { + access_token: 'access-token-value', + id_token: 'id-token-value', + refresh_token: 'refresh-token-value', + expires_at: Math.floor(Date.now() / 1000) + 3600, + }, + }; + + const result = extractOpenIDTokenInfo(user); + + expect(result).toMatchObject({ + accessToken: 'access-token-value', + idToken: 'id-token-value', + userId: expect.any(String), + userEmail: 'test@example.com', + userName: 'Test User', + }); + expect(result?.expiresAt).toBeDefined(); + }); + + it('should return null when user is undefined', () => { + const result = extractOpenIDTokenInfo(undefined); + expect(result).toBeNull(); + }); + + it('should return null when user is not OpenID provider', () => { + const user: Partial = { + id: 'user-123', + provider: 'email', + }; + + const result = extractOpenIDTokenInfo(user); + expect(result).toBeNull(); + }); + + it('should return token info when user has no federatedTokens but is OpenID provider', () => { + const user: Partial = { + id: 'user-123', + provider: 'openid', + openidId: 'oidc-sub-456', + email: 'test@example.com', + name: 'Test User', + }; + + const result = extractOpenIDTokenInfo(user); + + expect(result).toMatchObject({ + userId: 'oidc-sub-456', + userEmail: 'test@example.com', + userName: 'Test User', + }); + expect(result?.accessToken).toBeUndefined(); + expect(result?.idToken).toBeUndefined(); + }); + + it('should extract partial token info when some tokens are missing', () => { + const user: Partial = { + id: 'user-123', + provider: 'openid', + openidId: 'oidc-sub-456', + email: 'test@example.com', + federatedTokens: { + access_token: 'access-token-value', + id_token: undefined, + refresh_token: undefined, + expires_at: undefined, + }, + }; + + const result = extractOpenIDTokenInfo(user); + + expect(result).toMatchObject({ + accessToken: 'access-token-value', + userId: 'oidc-sub-456', + userEmail: 'test@example.com', + }); + }); + + it('should prioritize openidId over regular id', () => { + const user: Partial = { + id: 'user-123', + provider: 'openid', + openidId: 'oidc-sub-456', + federatedTokens: { + access_token: 'access-token-value', + }, + }; + + const result = extractOpenIDTokenInfo(user); + + expect(result?.userId).toBe('oidc-sub-456'); + }); + + it('should fall back to regular id when openidId is not available', () => { + const user: Partial = { + id: 'user-123', + provider: 'openid', + federatedTokens: { + access_token: 'access-token-value', + }, + }; + + const result = extractOpenIDTokenInfo(user); + + expect(result?.userId).toBe('user-123'); + }); + }); + + describe('isOpenIDTokenValid', () => { + it('should return false when tokenInfo is null', () => { + expect(isOpenIDTokenValid(null)).toBe(false); + }); + + it('should return false when tokenInfo has no accessToken', () => { + const tokenInfo = { + userId: 'oidc-sub-456', + }; + + expect(isOpenIDTokenValid(tokenInfo)).toBe(false); + }); + + it('should return true when token has access token and no expiresAt', () => { + const tokenInfo = { + accessToken: 'access-token-value', + userId: 'oidc-sub-456', + }; + + expect(isOpenIDTokenValid(tokenInfo)).toBe(true); + }); + + it('should return true when token has not expired', () => { + const futureTimestamp = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now + const tokenInfo = { + accessToken: 'access-token-value', + expiresAt: futureTimestamp, + userId: 'oidc-sub-456', + }; + + expect(isOpenIDTokenValid(tokenInfo)).toBe(true); + }); + + it('should return false when token has expired', () => { + const pastTimestamp = Math.floor(Date.now() / 1000) - 3600; // 1 hour ago + const tokenInfo = { + accessToken: 'access-token-value', + expiresAt: pastTimestamp, + userId: 'oidc-sub-456', + }; + + expect(isOpenIDTokenValid(tokenInfo)).toBe(false); + }); + + it('should return false when token expires exactly now', () => { + const nowTimestamp = Math.floor(Date.now() / 1000); + const tokenInfo = { + accessToken: 'access-token-value', + expiresAt: nowTimestamp, + userId: 'oidc-sub-456', + }; + + expect(isOpenIDTokenValid(tokenInfo)).toBe(false); + }); + + it('should return true when token is just about to expire (within 1 second)', () => { + const almostExpiredTimestamp = Math.floor(Date.now() / 1000) + 1; + const tokenInfo = { + accessToken: 'access-token-value', + expiresAt: almostExpiredTimestamp, + userId: 'oidc-sub-456', + }; + + expect(isOpenIDTokenValid(tokenInfo)).toBe(true); + }); + }); + + describe('processOpenIDPlaceholders', () => { + it('should replace LIBRECHAT_OPENID_TOKEN with access token', () => { + const tokenInfo = { + accessToken: 'access-token-value', + idToken: 'id-token-value', + userId: 'oidc-sub-456', + }; + + const input = 'Authorization: Bearer {{LIBRECHAT_OPENID_TOKEN}}'; + const result = processOpenIDPlaceholders(input, tokenInfo); + + expect(result).toBe('Authorization: Bearer access-token-value'); + }); + + it('should replace LIBRECHAT_OPENID_ACCESS_TOKEN with access token', () => { + const tokenInfo = { + accessToken: 'access-token-value', + userId: 'oidc-sub-456', + }; + + const input = 'Token: {{LIBRECHAT_OPENID_ACCESS_TOKEN}}'; + const result = processOpenIDPlaceholders(input, tokenInfo); + + expect(result).toBe('Token: access-token-value'); + }); + + it('should replace LIBRECHAT_OPENID_ID_TOKEN with id token', () => { + const tokenInfo = { + idToken: 'id-token-value', + userId: 'oidc-sub-456', + }; + + const input = 'ID Token: {{LIBRECHAT_OPENID_ID_TOKEN}}'; + const result = processOpenIDPlaceholders(input, tokenInfo); + + expect(result).toBe('ID Token: id-token-value'); + }); + + it('should replace LIBRECHAT_OPENID_USER_ID with user id', () => { + const tokenInfo = { + userId: 'oidc-sub-456', + }; + + const input = 'User: {{LIBRECHAT_OPENID_USER_ID}}'; + const result = processOpenIDPlaceholders(input, tokenInfo); + + expect(result).toBe('User: oidc-sub-456'); + }); + + it('should replace LIBRECHAT_OPENID_USER_EMAIL with user email', () => { + const tokenInfo = { + userEmail: 'test@example.com', + userId: 'oidc-sub-456', + }; + + const input = 'Email: {{LIBRECHAT_OPENID_USER_EMAIL}}'; + const result = processOpenIDPlaceholders(input, tokenInfo); + + expect(result).toBe('Email: test@example.com'); + }); + + it('should replace LIBRECHAT_OPENID_USER_NAME with user name', () => { + const tokenInfo = { + userName: 'Test User', + userId: 'oidc-sub-456', + }; + + const input = 'Name: {{LIBRECHAT_OPENID_USER_NAME}}'; + const result = processOpenIDPlaceholders(input, tokenInfo); + + expect(result).toBe('Name: Test User'); + }); + + it('should replace multiple placeholders in a single string', () => { + const tokenInfo = { + accessToken: 'access-token-value', + idToken: 'id-token-value', + userId: 'oidc-sub-456', + userEmail: 'test@example.com', + }; + + const input = + 'Authorization: Bearer {{LIBRECHAT_OPENID_TOKEN}}, ID: {{LIBRECHAT_OPENID_ID_TOKEN}}, User: {{LIBRECHAT_OPENID_USER_ID}}'; + const result = processOpenIDPlaceholders(input, tokenInfo); + + expect(result).toBe( + 'Authorization: Bearer access-token-value, ID: id-token-value, User: oidc-sub-456', + ); + }); + + it('should replace empty string when token field is undefined', () => { + const tokenInfo = { + accessToken: undefined, + idToken: undefined, + userId: 'oidc-sub-456', + }; + + const input = + 'Access: {{LIBRECHAT_OPENID_TOKEN}}, ID: {{LIBRECHAT_OPENID_ID_TOKEN}}, User: {{LIBRECHAT_OPENID_USER_ID}}'; + const result = processOpenIDPlaceholders(input, tokenInfo); + + expect(result).toBe('Access: , ID: , User: oidc-sub-456'); + }); + + it('should handle all placeholder types in one value', () => { + const tokenInfo = { + accessToken: 'access-token-value', + idToken: 'id-token-value', + userId: 'oidc-sub-456', + userEmail: 'test@example.com', + userName: 'Test User', + expiresAt: 1234567890, + }; + + const input = ` + Authorization: Bearer {{LIBRECHAT_OPENID_TOKEN}} + ID Token: {{LIBRECHAT_OPENID_ID_TOKEN}} + Access Token (alt): {{LIBRECHAT_OPENID_ACCESS_TOKEN}} + User ID: {{LIBRECHAT_OPENID_USER_ID}} + User Email: {{LIBRECHAT_OPENID_USER_EMAIL}} + User Name: {{LIBRECHAT_OPENID_USER_NAME}} + Expires: {{LIBRECHAT_OPENID_EXPIRES_AT}} + `; + + const result = processOpenIDPlaceholders(input, tokenInfo); + + expect(result).toContain('Bearer access-token-value'); + expect(result).toContain('ID Token: id-token-value'); + expect(result).toContain('Access Token (alt): access-token-value'); + expect(result).toContain('User ID: oidc-sub-456'); + expect(result).toContain('User Email: test@example.com'); + expect(result).toContain('User Name: Test User'); + expect(result).toContain('Expires: 1234567890'); + }); + + it('should not modify string when no placeholders are present', () => { + const tokenInfo = { + accessToken: 'access-token-value', + userId: 'oidc-sub-456', + }; + + const input = 'Authorization: Bearer static-token'; + const result = processOpenIDPlaceholders(input, tokenInfo); + + expect(result).toBe('Authorization: Bearer static-token'); + }); + + it('should handle case-sensitive placeholders', () => { + const tokenInfo = { + accessToken: 'access-token-value', + userId: 'oidc-sub-456', + }; + + // Wrong case should NOT be replaced + const input = 'Token: {{librechat_openid_token}}'; + const result = processOpenIDPlaceholders(input, tokenInfo); + + expect(result).toBe('Token: {{librechat_openid_token}}'); + }); + + it('should handle multiple occurrences of the same placeholder', () => { + const tokenInfo = { + accessToken: 'access-token-value', + userId: 'oidc-sub-456', + }; + + const input = + 'Primary: {{LIBRECHAT_OPENID_TOKEN}}, Secondary: {{LIBRECHAT_OPENID_TOKEN}}, Backup: {{LIBRECHAT_OPENID_TOKEN}}'; + const result = processOpenIDPlaceholders(input, tokenInfo); + + expect(result).toBe( + 'Primary: access-token-value, Secondary: access-token-value, Backup: access-token-value', + ); + }); + + it('should handle token info with all fields undefined except userId', () => { + const tokenInfo = { + accessToken: undefined, + idToken: undefined, + userId: 'oidc-sub-456', + userEmail: undefined, + userName: undefined, + }; + + const input = + 'Access: {{LIBRECHAT_OPENID_TOKEN}}, ID: {{LIBRECHAT_OPENID_ID_TOKEN}}, User: {{LIBRECHAT_OPENID_USER_ID}}'; + const result = processOpenIDPlaceholders(input, tokenInfo); + + expect(result).toBe('Access: , ID: , User: oidc-sub-456'); + }); + + it('should return original value when tokenInfo is null', () => { + const input = 'Authorization: Bearer {{LIBRECHAT_OPENID_TOKEN}}'; + const result = processOpenIDPlaceholders(input, null); + + expect(result).toBe('Authorization: Bearer {{LIBRECHAT_OPENID_TOKEN}}'); + }); + + it('should return original value when value is not a string', () => { + const tokenInfo = { + accessToken: 'access-token-value', + userId: 'oidc-sub-456', + }; + + const result = processOpenIDPlaceholders(123 as unknown as string, tokenInfo); + + expect(result).toBe(123); + }); + }); + + describe('Integration: Full OpenID Token Flow', () => { + it('should extract, validate, and process tokens correctly', () => { + const user: Partial = { + id: 'user-123', + provider: 'openid', + openidId: 'oidc-sub-456', + email: 'test@example.com', + name: 'Test User', + federatedTokens: { + access_token: 'access-token-value', + id_token: 'id-token-value', + refresh_token: 'refresh-token-value', + expires_at: Math.floor(Date.now() / 1000) + 3600, + }, + }; + + // Step 1: Extract token info + const tokenInfo = extractOpenIDTokenInfo(user); + expect(tokenInfo).not.toBeNull(); + + // Step 2: Validate token + const isValid = isOpenIDTokenValid(tokenInfo!); + expect(isValid).toBe(true); + + // Step 3: Process placeholders + const input = + 'Authorization: Bearer {{LIBRECHAT_OPENID_TOKEN}}, User: {{LIBRECHAT_OPENID_USER_ID}}'; + const result = processOpenIDPlaceholders(input, tokenInfo!); + expect(result).toContain('Authorization: Bearer access-token-value'); + expect(result).toContain('User:'); + }); + + it('should handle expired tokens correctly', () => { + const user: Partial = { + id: 'user-123', + provider: 'openid', + openidId: 'oidc-sub-456', + federatedTokens: { + access_token: 'access-token-value', + expires_at: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago + }, + }; + + const tokenInfo = extractOpenIDTokenInfo(user); + expect(tokenInfo).not.toBeNull(); + + const isValid = isOpenIDTokenValid(tokenInfo!); + expect(isValid).toBe(false); // Token is expired + + // Even if expired, processOpenIDPlaceholders should still work + // (validation is checked separately by the caller) + const input = 'Authorization: Bearer {{LIBRECHAT_OPENID_TOKEN}}'; + const result = processOpenIDPlaceholders(input, tokenInfo!); + expect(result).toBe('Authorization: Bearer access-token-value'); + }); + + it('should handle user with no federatedTokens but still has OpenID provider', () => { + const user: Partial = { + id: 'user-123', + provider: 'openid', + openidId: 'oidc-sub-456', + }; + + const tokenInfo = extractOpenIDTokenInfo(user); + expect(tokenInfo).not.toBeNull(); + expect(tokenInfo?.userId).toBe('oidc-sub-456'); + expect(tokenInfo?.accessToken).toBeUndefined(); + }); + + it('should handle missing user', () => { + const tokenInfo = extractOpenIDTokenInfo(undefined); + expect(tokenInfo).toBeNull(); + }); + + it('should handle non-OpenID users', () => { + const user: Partial = { + id: 'user-123', + provider: 'email', + }; + + const tokenInfo = extractOpenIDTokenInfo(user); + expect(tokenInfo).toBeNull(); + }); + }); +}); diff --git a/packages/api/src/utils/oidc.ts b/packages/api/src/utils/oidc.ts new file mode 100644 index 0000000000..fb16cdf16f --- /dev/null +++ b/packages/api/src/utils/oidc.ts @@ -0,0 +1,197 @@ +import { logger } from '@librechat/data-schemas'; +import type { TUser } from 'librechat-data-provider'; +import type { IUser } from '@librechat/data-schemas'; + +export interface OpenIDTokenInfo { + accessToken?: string; + idToken?: string; + expiresAt?: number; + userId?: string; + userEmail?: string; + userName?: string; + claims?: Record; +} + +interface FederatedTokens { + access_token?: string; + id_token?: string; + refresh_token?: string; + expires_at?: number; +} + +function isFederatedTokens(obj: unknown): obj is FederatedTokens { + if (!obj || typeof obj !== 'object') { + return false; + } + return 'access_token' in obj || 'id_token' in obj || 'expires_at' in obj; +} + +const OPENID_TOKEN_FIELDS = [ + 'ACCESS_TOKEN', + 'ID_TOKEN', + 'USER_ID', + 'USER_EMAIL', + 'USER_NAME', + 'EXPIRES_AT', +] as const; + +export function extractOpenIDTokenInfo( + user: IUser | TUser | null | undefined, +): OpenIDTokenInfo | null { + if (!user) { + logger.debug('[extractOpenIDTokenInfo] No user provided'); + return null; + } + + try { + logger.debug( + '[extractOpenIDTokenInfo] User provider:', + user.provider, + 'openidId:', + user.openidId, + ); + + if (user.provider !== 'openid' && !user.openidId) { + logger.debug('[extractOpenIDTokenInfo] User not authenticated via OpenID'); + return null; + } + + const tokenInfo: OpenIDTokenInfo = {}; + + logger.debug( + '[extractOpenIDTokenInfo] Checking for federatedTokens in user object:', + 'federatedTokens' in user, + ); + + if ('federatedTokens' in user && isFederatedTokens(user.federatedTokens)) { + const tokens = user.federatedTokens; + logger.debug('[extractOpenIDTokenInfo] Found federatedTokens:', { + has_access_token: !!tokens.access_token, + has_id_token: !!tokens.id_token, + has_refresh_token: !!tokens.refresh_token, + expires_at: tokens.expires_at, + }); + tokenInfo.accessToken = tokens.access_token; + tokenInfo.idToken = tokens.id_token; + tokenInfo.expiresAt = tokens.expires_at; + } else if ('openidTokens' in user && isFederatedTokens(user.openidTokens)) { + const tokens = user.openidTokens; + logger.debug('[extractOpenIDTokenInfo] Found openidTokens'); + tokenInfo.accessToken = tokens.access_token; + tokenInfo.idToken = tokens.id_token; + tokenInfo.expiresAt = tokens.expires_at; + } else { + logger.warn( + '[extractOpenIDTokenInfo] No federatedTokens or openidTokens found in user object', + ); + } + + tokenInfo.userId = user.openidId || user.id; + tokenInfo.userEmail = user.email; + tokenInfo.userName = user.name || user.username; + + if (tokenInfo.idToken) { + try { + const payload = JSON.parse( + Buffer.from(tokenInfo.idToken.split('.')[1], 'base64').toString(), + ); + tokenInfo.claims = payload; + + if (payload.sub) tokenInfo.userId = payload.sub; + if (payload.email) tokenInfo.userEmail = payload.email; + if (payload.name) tokenInfo.userName = payload.name; + if (payload.exp) tokenInfo.expiresAt = payload.exp; + } catch (jwtError) { + logger.warn('Could not parse ID token claims:', jwtError); + } + } + + return tokenInfo; + } catch (error) { + logger.error('Error extracting OpenID token info:', error); + return null; + } +} + +export function isOpenIDTokenValid(tokenInfo: OpenIDTokenInfo | null): boolean { + if (!tokenInfo || !tokenInfo.accessToken) { + return false; + } + + if (tokenInfo.expiresAt) { + const now = Math.floor(Date.now() / 1000); + if (now >= tokenInfo.expiresAt) { + logger.warn('OpenID token has expired'); + return false; + } + } + + return true; +} + +export function processOpenIDPlaceholders( + value: string, + tokenInfo: OpenIDTokenInfo | null, +): string { + if (!tokenInfo || typeof value !== 'string') { + return value; + } + + let processedValue = value; + + for (const field of OPENID_TOKEN_FIELDS) { + const placeholder = `{{LIBRECHAT_OPENID_${field}}}`; + if (!processedValue.includes(placeholder)) { + continue; + } + + let replacementValue = ''; + + switch (field) { + case 'ACCESS_TOKEN': + replacementValue = tokenInfo.accessToken || ''; + break; + case 'ID_TOKEN': + replacementValue = tokenInfo.idToken || ''; + break; + case 'USER_ID': + replacementValue = tokenInfo.userId || ''; + break; + case 'USER_EMAIL': + replacementValue = tokenInfo.userEmail || ''; + break; + case 'USER_NAME': + replacementValue = tokenInfo.userName || ''; + break; + case 'EXPIRES_AT': + replacementValue = tokenInfo.expiresAt ? String(tokenInfo.expiresAt) : ''; + break; + } + + processedValue = processedValue.replace(new RegExp(placeholder, 'g'), replacementValue); + } + + const genericPlaceholder = '{{LIBRECHAT_OPENID_TOKEN}}'; + if (processedValue.includes(genericPlaceholder)) { + const replacementValue = tokenInfo.accessToken || ''; + processedValue = processedValue.replace(new RegExp(genericPlaceholder, 'g'), replacementValue); + } + + return processedValue; +} + +export function createBearerAuthHeader(tokenInfo: OpenIDTokenInfo | null): string { + if (!tokenInfo || !tokenInfo.accessToken) { + return ''; + } + + return `Bearer ${tokenInfo.accessToken}`; +} + +export function isOpenIDAvailable(): boolean { + const openidClientId = process.env.OPENID_CLIENT_ID; + const openidClientSecret = process.env.OPENID_CLIENT_SECRET; + const openidIssuer = process.env.OPENID_ISSUER; + + return !!(openidClientId && openidClientSecret && openidIssuer); +} diff --git a/packages/data-provider/src/mcp.ts b/packages/data-provider/src/mcp.ts index 72299e96a5..f22a412f8c 100644 --- a/packages/data-provider/src/mcp.ts +++ b/packages/data-provider/src/mcp.ts @@ -56,6 +56,8 @@ const BaseOptionsSchema = z.object({ response_types_supported: z.array(z.string()).optional(), /** Supported code challenge methods (defaults to ['S256', 'plain']) */ code_challenge_methods_supported: z.array(z.string()).optional(), + /** Skip code challenge validation and force S256 (useful for providers like AWS Cognito that support S256 but don't advertise it) */ + skip_code_challenge_check: z.boolean().optional(), /** OAuth revocation endpoint (optional - can be auto-discovered) */ revocation_endpoint: z.string().url().optional(), /** OAuth revocation endpoint authentication methods supported (optional - can be auto-discovered) */ diff --git a/src/tests/oidc-integration.test.ts b/src/tests/oidc-integration.test.ts new file mode 100644 index 0000000000..2bbff4fd37 --- /dev/null +++ b/src/tests/oidc-integration.test.ts @@ -0,0 +1,473 @@ +import { describe, it, expect, jest, beforeEach } from '@jest/globals'; +import { + extractOpenIDTokenInfo, + processOpenIDPlaceholders, + isOpenIDTokenValid, + createBearerAuthHeader, + isOpenIDAvailable, + type OpenIDTokenInfo, +} from '../packages/api/src/utils/oidc'; +import { processMCPEnv, resolveHeaders } from '../packages/api/src/utils/env'; +import type { TUser } from 'librechat-data-provider'; +import type { IUser } from '@librechat/data-schemas'; + +// Mock logger to avoid console output during tests +jest.mock('@librechat/data-schemas', () => ({ + logger: { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + }, +})); + +describe('OpenID Connect Federated Provider Token Integration', () => { + // Mock user with Cognito tokens + const mockCognitoUser: Partial = { + id: 'user-123', + email: 'test@example.com', + name: 'Test User', + provider: 'openid', + openidId: 'cognito-user-123', + federatedTokens: { + access_token: 'cognito-access-token-123', + id_token: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJjb2duaXRvLXVzZXItMTIzIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmFtZSI6IlRlc3QgVXNlciIsImV4cCI6MTcwMDAwMDAwMH0.fake-signature', + expires_at: Math.floor(Date.now() / 1000) + 3600, // Expires in 1 hour + }, + }; + + const mockExpiredCognitoUser: Partial = { + ...mockCognitoUser, + federatedTokens: { + access_token: 'expired-cognito-token', + id_token: 'expired-cognito-id-token', + expires_at: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago + }, + }; + + // Mock user with tokens in alternative location + const mockOpenIDTokensUser: Partial = { + id: 'user-456', + email: 'alt@example.com', + name: 'Alt User', + provider: 'openid', + openidId: 'alt-user-456', + openidTokens: { + access_token: 'alt-access-token-456', + id_token: 'alt-id-token-789', + expires_at: Math.floor(Date.now() / 1000) + 3600, + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('extractOpenIDTokenInfo', () => { + it('should extract federated provider token info from Cognito user', () => { + const tokenInfo = extractOpenIDTokenInfo(mockCognitoUser as IUser); + + expect(tokenInfo).toEqual({ + accessToken: 'cognito-access-token-123', + idToken: expect.stringContaining('eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9'), + expiresAt: expect.any(Number), + userId: 'cognito-user-123', + userEmail: 'test@example.com', + userName: 'Test User', + claims: expect.objectContaining({ + sub: 'cognito-user-123', + email: 'test@example.com', + name: 'Test User', + }), + }); + }); + + it('should extract tokens from alternative storage location', () => { + const tokenInfo = extractOpenIDTokenInfo(mockOpenIDTokensUser as IUser); + + expect(tokenInfo).toEqual({ + accessToken: 'alt-access-token-456', + idToken: 'alt-id-token-789', + expiresAt: expect.any(Number), + userId: 'alt-user-456', + userEmail: 'alt@example.com', + userName: 'Alt User', + }); + }); + + it('should return null for non-OpenID user', () => { + const nonOpenIDUser: Partial = { + id: 'user-123', + provider: 'google', + email: 'test@example.com', + }; + + const tokenInfo = extractOpenIDTokenInfo(nonOpenIDUser as IUser); + expect(tokenInfo).toBeNull(); + }); + + it('should return null for null/undefined user', () => { + expect(extractOpenIDTokenInfo(null)).toBeNull(); + expect(extractOpenIDTokenInfo(undefined)).toBeNull(); + }); + + it('should handle JWT parsing errors gracefully', () => { + const userWithMalformedJWT: Partial = { + ...mockCognitoUser, + federatedTokens: { + access_token: 'valid-access-token', + id_token: 'malformed.jwt.token', + expires_at: Math.floor(Date.now() / 1000) + 3600, + }, + }; + + const tokenInfo = extractOpenIDTokenInfo(userWithMalformedJWT as IUser); + + expect(tokenInfo).toBeDefined(); + expect(tokenInfo?.accessToken).toBe('valid-access-token'); + expect(tokenInfo?.claims).toBeUndefined(); + }); + }); + + describe('isOpenIDTokenValid', () => { + it('should return true for valid Cognito token', () => { + const tokenInfo = extractOpenIDTokenInfo(mockCognitoUser as IUser); + expect(isOpenIDTokenValid(tokenInfo)).toBe(true); + }); + + it('should return false for expired Cognito token', () => { + const tokenInfo = extractOpenIDTokenInfo(mockExpiredCognitoUser as IUser); + expect(isOpenIDTokenValid(tokenInfo)).toBe(false); + }); + + it('should return false for null token info', () => { + expect(isOpenIDTokenValid(null)).toBe(false); + }); + + it('should return false for token info without access token', () => { + const tokenInfo: OpenIDTokenInfo = { + userId: 'user-123', + userEmail: 'test@example.com', + }; + expect(isOpenIDTokenValid(tokenInfo)).toBe(false); + }); + }); + + describe('processOpenIDPlaceholders', () => { + const tokenInfo: OpenIDTokenInfo = { + accessToken: 'cognito-access-token-123', + idToken: 'cognito-id-token-456', + userId: 'cognito-user-789', + userEmail: 'cognito@example.com', + userName: 'Cognito User', + expiresAt: 1700000000, + }; + + it('should replace OpenID Connect token placeholders', () => { + const template = 'Bearer {{LIBRECHAT_OPENID_TOKEN}}'; + const result = processOpenIDPlaceholders(template, tokenInfo); + expect(result).toBe('Bearer cognito-access-token-123'); + }); + + it('should replace specific OpenID Connect placeholders', () => { + const template = ` + Access: {{LIBRECHAT_OPENID_ACCESS_TOKEN}} + ID: {{LIBRECHAT_OPENID_ID_TOKEN}} + User: {{LIBRECHAT_OPENID_USER_ID}} + Email: {{LIBRECHAT_OPENID_USER_EMAIL}} + Name: {{LIBRECHAT_OPENID_USER_NAME}} + Expires: {{LIBRECHAT_OPENID_EXPIRES_AT}} + `; + + const result = processOpenIDPlaceholders(template, tokenInfo); + + expect(result).toContain('Access: cognito-access-token-123'); + expect(result).toContain('ID: cognito-id-token-456'); + expect(result).toContain('User: cognito-user-789'); + expect(result).toContain('Email: cognito@example.com'); + expect(result).toContain('Name: Cognito User'); + expect(result).toContain('Expires: 1700000000'); + }); + + it('should handle missing token fields gracefully', () => { + const partialTokenInfo: OpenIDTokenInfo = { + accessToken: 'partial-cognito-token', + userId: 'user-123', + }; + + const template = 'Token: {{LIBRECHAT_OPENID_TOKEN}}, Email: {{LIBRECHAT_OPENID_USER_EMAIL}}'; + const result = processOpenIDPlaceholders(template, partialTokenInfo); + + expect(result).toBe('Token: partial-cognito-token, Email: '); + }); + + it('should return original value for null token info', () => { + const template = 'Bearer {{LIBRECHAT_OPENID_TOKEN}}'; + const result = processOpenIDPlaceholders(template, null); + expect(result).toBe(template); + }); + }); + + describe('createBearerAuthHeader', () => { + it('should create proper Bearer header with Cognito token', () => { + const tokenInfo: OpenIDTokenInfo = { + accessToken: 'cognito-test-token-123', + }; + + const header = createBearerAuthHeader(tokenInfo); + expect(header).toBe('Bearer cognito-test-token-123'); + }); + + it('should return empty string for null token info', () => { + const header = createBearerAuthHeader(null); + expect(header).toBe(''); + }); + + it('should return empty string for token info without access token', () => { + const tokenInfo: OpenIDTokenInfo = { + userId: 'user-123', + }; + + const header = createBearerAuthHeader(tokenInfo); + expect(header).toBe(''); + }); + }); + + describe('isOpenIDAvailable', () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.resetModules(); + process.env = { ...originalEnv }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it('should return true when OpenID Connect is properly configured for Cognito', () => { + process.env.OPENID_CLIENT_ID = 'cognito-client-id'; + process.env.OPENID_CLIENT_SECRET = 'cognito-client-secret'; + process.env.OPENID_ISSUER = 'https://cognito-idp.us-east-1.amazonaws.com/us-east-1_ABC123'; + + expect(isOpenIDAvailable()).toBe(true); + }); + + it('should return false when OpenID Connect is not configured', () => { + delete process.env.OPENID_CLIENT_ID; + delete process.env.OPENID_CLIENT_SECRET; + delete process.env.OPENID_ISSUER; + + expect(isOpenIDAvailable()).toBe(false); + }); + + it('should return false when OpenID Connect is partially configured', () => { + process.env.OPENID_CLIENT_ID = 'cognito-client-id'; + delete process.env.OPENID_CLIENT_SECRET; + delete process.env.OPENID_ISSUER; + + expect(isOpenIDAvailable()).toBe(false); + }); + }); + + describe('Integration with resolveHeaders', () => { + it('should resolve OpenID Connect placeholders in headers for Cognito', () => { + const headers = { + 'Authorization': '{{LIBRECHAT_OPENID_TOKEN}}', + 'X-User-ID': '{{LIBRECHAT_OPENID_USER_ID}}', + 'X-User-Email': '{{LIBRECHAT_OPENID_USER_EMAIL}}', + }; + + const resolvedHeaders = resolveHeaders({ + headers, + user: mockCognitoUser as TUser, + }); + + expect(resolvedHeaders['Authorization']).toBe('cognito-access-token-123'); + expect(resolvedHeaders['X-User-ID']).toBe('cognito-user-123'); + expect(resolvedHeaders['X-User-Email']).toBe('test@example.com'); + }); + + it('should work with Bearer token format for Cognito', () => { + const headers = { + 'Authorization': 'Bearer {{LIBRECHAT_OPENID_TOKEN}}', + }; + + const resolvedHeaders = resolveHeaders({ + headers, + user: mockCognitoUser as TUser, + }); + + expect(resolvedHeaders['Authorization']).toBe('Bearer cognito-access-token-123'); + }); + + it('should work with specific access token placeholder', () => { + const headers = { + 'Authorization': 'Bearer {{LIBRECHAT_OPENID_ACCESS_TOKEN}}', + 'X-Cognito-ID-Token': '{{LIBRECHAT_OPENID_ID_TOKEN}}', + }; + + const resolvedHeaders = resolveHeaders({ + headers, + user: mockCognitoUser as TUser, + }); + + expect(resolvedHeaders['Authorization']).toBe('Bearer cognito-access-token-123'); + expect(resolvedHeaders['X-Cognito-ID-Token']).toContain('eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9'); + }); + }); + + describe('Integration with processMCPEnv', () => { + it('should process OpenID Connect placeholders in MCP environment variables for Cognito', () => { + const mcpOptions = { + command: 'node', + args: ['server.js'], + env: { + 'COGNITO_ACCESS_TOKEN': '{{LIBRECHAT_OPENID_TOKEN}}', + 'USER_ID': '{{LIBRECHAT_OPENID_USER_ID}}', + 'USER_EMAIL': '{{LIBRECHAT_OPENID_USER_EMAIL}}', + }, + }; + + const processedOptions = processMCPEnv({ + options: mcpOptions, + user: mockCognitoUser as TUser, + }); + + expect(processedOptions.env?.['COGNITO_ACCESS_TOKEN']).toBe('cognito-access-token-123'); + expect(processedOptions.env?.['USER_ID']).toBe('cognito-user-123'); + expect(processedOptions.env?.['USER_EMAIL']).toBe('test@example.com'); + }); + + it('should process OpenID Connect placeholders in MCP headers for HTTP transport', () => { + const mcpOptions = { + type: 'sse' as const, + url: 'https://api.example.com/mcp', + headers: { + 'Authorization': 'Bearer {{LIBRECHAT_OPENID_ACCESS_TOKEN}}', + 'X-Cognito-User-Info': '{{LIBRECHAT_OPENID_USER_EMAIL}}', + 'X-Cognito-ID-Token': '{{LIBRECHAT_OPENID_ID_TOKEN}}', + }, + }; + + const processedOptions = processMCPEnv({ + options: mcpOptions, + user: mockCognitoUser as TUser, + }); + + expect(processedOptions.headers?.['Authorization']).toBe('Bearer cognito-access-token-123'); + expect(processedOptions.headers?.['X-Cognito-User-Info']).toBe('test@example.com'); + expect(processedOptions.headers?.['X-Cognito-ID-Token']).toContain('eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9'); + }); + + it('should handle AWS-specific MCP server configuration', () => { + const awsMcpOptions = { + command: 'node', + args: ['aws-mcp-server.js'], + env: { + 'AWS_COGNITO_TOKEN': '{{LIBRECHAT_OPENID_ACCESS_TOKEN}}', + 'AWS_COGNITO_ID_TOKEN': '{{LIBRECHAT_OPENID_ID_TOKEN}}', + 'COGNITO_USER_SUB': '{{LIBRECHAT_OPENID_USER_ID}}', + }, + }; + + const processedOptions = processMCPEnv({ + options: awsMcpOptions, + user: mockCognitoUser as TUser, + }); + + expect(processedOptions.env?.['AWS_COGNITO_TOKEN']).toBe('cognito-access-token-123'); + expect(processedOptions.env?.['AWS_COGNITO_ID_TOKEN']).toContain('eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9'); + expect(processedOptions.env?.['COGNITO_USER_SUB']).toBe('cognito-user-123'); + }); + }); + + describe('Security and Edge Cases', () => { + it('should not process OpenID Connect placeholders for expired tokens', () => { + const headers = { + 'Authorization': 'Bearer {{LIBRECHAT_OPENID_TOKEN}}', + }; + + const resolvedHeaders = resolveHeaders({ + headers, + user: mockExpiredCognitoUser as TUser, + }); + + // Should not replace placeholder if token is expired + expect(resolvedHeaders['Authorization']).toBe('Bearer {{LIBRECHAT_OPENID_TOKEN}}'); + }); + + it('should handle malformed federated token data gracefully', () => { + const malformedUser: Partial = { + id: 'user-123', + provider: 'openid', + openidId: 'cognito-user', + federatedTokens: null, // Malformed tokens + }; + + const headers = { + 'Authorization': 'Bearer {{LIBRECHAT_OPENID_TOKEN}}', + }; + + const resolvedHeaders = resolveHeaders({ + headers, + user: malformedUser as TUser, + }); + + // Should not replace placeholder if token extraction fails + expect(resolvedHeaders['Authorization']).toBe('Bearer {{LIBRECHAT_OPENID_TOKEN}}'); + }); + + it('should handle multiple placeholder instances in same string', () => { + const template = '{{LIBRECHAT_OPENID_TOKEN}}-{{LIBRECHAT_OPENID_TOKEN}}-{{LIBRECHAT_OPENID_USER_ID}}'; + + const tokenInfo: OpenIDTokenInfo = { + accessToken: 'cognito-token123', + userId: 'cognito-user456', + }; + + const result = processOpenIDPlaceholders(template, tokenInfo); + expect(result).toBe('cognito-token123-cognito-token123-cognito-user456'); + }); + + it('should handle users without federated tokens storage', () => { + const userWithoutTokens: Partial = { + id: 'user-789', + provider: 'openid', + openidId: 'user-without-tokens', + email: 'no-tokens@example.com', + // No federatedTokens or openidTokens + }; + + const headers = { + 'Authorization': 'Bearer {{LIBRECHAT_OPENID_TOKEN}}', + }; + + const resolvedHeaders = resolveHeaders({ + headers, + user: userWithoutTokens as TUser, + }); + + // Should not replace placeholder if no tokens available + expect(resolvedHeaders['Authorization']).toBe('Bearer {{LIBRECHAT_OPENID_TOKEN}}'); + }); + + it('should prioritize federatedTokens over openidTokens', () => { + const userWithBothTokens: Partial = { + id: 'user-priority', + provider: 'openid', + openidId: 'priority-user', + federatedTokens: { + access_token: 'federated-priority-token', + expires_at: Math.floor(Date.now() / 1000) + 3600, + }, + openidTokens: { + access_token: 'openid-fallback-token', + expires_at: Math.floor(Date.now() / 1000) + 3600, + }, + }; + + const tokenInfo = extractOpenIDTokenInfo(userWithBothTokens as IUser); + expect(tokenInfo?.accessToken).toBe('federated-priority-token'); + }); + }); +}); \ No newline at end of file