mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-04-07 00:15:23 +02:00
Merge branch 'main' into style/clean-copied-text
This commit is contained in:
commit
3ca62c4d55
72 changed files with 3464 additions and 1873 deletions
|
|
@ -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
|
||||
|
|
|
|||
16
.github/workflows/eslint-ci.yml
vendored
16
.github/workflows/eslint-ci.yml
vendored
|
|
@ -35,8 +35,6 @@ jobs:
|
|||
|
||||
# Run ESLint on changed files within the api/ and client/ directories.
|
||||
- name: Run ESLint on changed files
|
||||
env:
|
||||
SARIF_ESLINT_IGNORE_SUPPRESSED: "true"
|
||||
run: |
|
||||
# Extract the base commit SHA from the pull_request event payload.
|
||||
BASE_SHA=$(jq --raw-output .pull_request.base.sha "$GITHUB_EVENT_PATH")
|
||||
|
|
@ -52,22 +50,10 @@ jobs:
|
|||
# Ensure there are files to lint before running ESLint
|
||||
if [[ -z "$CHANGED_FILES" ]]; then
|
||||
echo "No matching files changed. Skipping ESLint."
|
||||
echo "UPLOAD_SARIF=false" >> $GITHUB_ENV
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Set variable to allow SARIF upload
|
||||
echo "UPLOAD_SARIF=true" >> $GITHUB_ENV
|
||||
|
||||
# Run ESLint
|
||||
npx eslint --no-error-on-unmatched-pattern \
|
||||
--config eslint.config.mjs \
|
||||
--format @microsoft/eslint-formatter-sarif \
|
||||
--output-file eslint-results.sarif $CHANGED_FILES || true
|
||||
|
||||
- name: Upload analysis results to GitHub
|
||||
if: env.UPLOAD_SARIF == 'true'
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
with:
|
||||
sarif_file: eslint-results.sarif
|
||||
wait-for-processing: true
|
||||
$CHANGED_FILES
|
||||
31
.gitignore
vendored
31
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
const { getBasePath } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
|
||||
/**
|
||||
|
|
@ -32,6 +33,8 @@ function addImages(intermediateSteps, responseMessage) {
|
|||
return;
|
||||
}
|
||||
|
||||
const basePath = getBasePath();
|
||||
|
||||
// Correct any erroneous URLs in the responseMessage.text first
|
||||
intermediateSteps.forEach((step) => {
|
||||
const { observation } = step;
|
||||
|
|
@ -44,12 +47,14 @@ function addImages(intermediateSteps, responseMessage) {
|
|||
return;
|
||||
}
|
||||
const essentialImagePath = match[0];
|
||||
const fullImagePath = `${basePath}${essentialImagePath}`;
|
||||
|
||||
const regex = /!\[.*?\]\((.*?)\)/g;
|
||||
let matchErroneous;
|
||||
while ((matchErroneous = regex.exec(responseMessage.text)) !== null) {
|
||||
if (matchErroneous[1] && !matchErroneous[1].startsWith('/images/')) {
|
||||
responseMessage.text = responseMessage.text.replace(matchErroneous[1], essentialImagePath);
|
||||
if (matchErroneous[1] && !matchErroneous[1].startsWith(`${basePath}/images/`)) {
|
||||
// Replace with the full path including base path
|
||||
responseMessage.text = responseMessage.text.replace(matchErroneous[1], fullImagePath);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -61,9 +66,23 @@ function addImages(intermediateSteps, responseMessage) {
|
|||
return;
|
||||
}
|
||||
const observedImagePath = observation.match(/!\[[^(]*\]\([^)]*\)/g);
|
||||
if (observedImagePath && !responseMessage.text.includes(observedImagePath[0])) {
|
||||
responseMessage.text += '\n' + observedImagePath[0];
|
||||
logger.debug('[addImages] added image from intermediateSteps:', observedImagePath[0]);
|
||||
if (observedImagePath) {
|
||||
// Fix the image path to include base path if it doesn't already
|
||||
let imageMarkdown = observedImagePath[0];
|
||||
const urlMatch = imageMarkdown.match(/\(([^)]+)\)/);
|
||||
if (
|
||||
urlMatch &&
|
||||
urlMatch[1] &&
|
||||
!urlMatch[1].startsWith(`${basePath}/images/`) &&
|
||||
urlMatch[1].startsWith('/images/')
|
||||
) {
|
||||
imageMarkdown = imageMarkdown.replace(urlMatch[1], `${basePath}${urlMatch[1]}`);
|
||||
}
|
||||
|
||||
if (!responseMessage.text.includes(imageMarkdown)) {
|
||||
responseMessage.text += '\n' + imageMarkdown;
|
||||
logger.debug('[addImages] added image from intermediateSteps:', imageMarkdown);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ describe('addImages', () => {
|
|||
|
||||
it('should append correctly from a real scenario', () => {
|
||||
responseMessage.text =
|
||||
'Here is the generated image based on your request. It depicts a surreal landscape filled with floating musical notes. The style is impressionistic, with vibrant sunset hues dominating the scene. At the center, there\'s a silhouette of a grand piano, adding a dreamy emotion to the overall image. This could serve as a unique and creative music album cover. Would you like to make any changes or generate another image?';
|
||||
"Here is the generated image based on your request. It depicts a surreal landscape filled with floating musical notes. The style is impressionistic, with vibrant sunset hues dominating the scene. At the center, there's a silhouette of a grand piano, adding a dreamy emotion to the overall image. This could serve as a unique and creative music album cover. Would you like to make any changes or generate another image?";
|
||||
const originalText = responseMessage.text;
|
||||
const imageMarkdown = '';
|
||||
intermediateSteps.push({ observation: imageMarkdown });
|
||||
|
|
@ -139,4 +139,108 @@ describe('addImages', () => {
|
|||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe('\n');
|
||||
});
|
||||
|
||||
describe('basePath functionality', () => {
|
||||
let originalDomainClient;
|
||||
|
||||
beforeEach(() => {
|
||||
originalDomainClient = process.env.DOMAIN_CLIENT;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.DOMAIN_CLIENT = originalDomainClient;
|
||||
});
|
||||
|
||||
it('should prepend base path to image URLs when DOMAIN_CLIENT is set', () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
||||
intermediateSteps.push({ observation: '' });
|
||||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe('\n');
|
||||
});
|
||||
|
||||
it('should not prepend base path when image URL already has base path', () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
||||
intermediateSteps.push({ observation: '' });
|
||||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe('\n');
|
||||
});
|
||||
|
||||
it('should correct erroneous URLs with base path', () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
||||
responseMessage.text = '';
|
||||
intermediateSteps.push({ observation: '' });
|
||||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe('');
|
||||
});
|
||||
|
||||
it('should handle empty base path (root deployment)', () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/';
|
||||
intermediateSteps.push({ observation: '' });
|
||||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe('\n');
|
||||
});
|
||||
|
||||
it('should handle missing DOMAIN_CLIENT', () => {
|
||||
delete process.env.DOMAIN_CLIENT;
|
||||
intermediateSteps.push({ observation: '' });
|
||||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe('\n');
|
||||
});
|
||||
|
||||
it('should handle observation without image path match', () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
||||
intermediateSteps.push({ observation: '' });
|
||||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe('\n');
|
||||
});
|
||||
|
||||
it('should handle nested subdirectories in base path', () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/apps/librechat';
|
||||
intermediateSteps.push({ observation: '' });
|
||||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe('\n');
|
||||
});
|
||||
|
||||
it('should handle multiple observations with mixed base path scenarios', () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
||||
intermediateSteps.push({ observation: '' });
|
||||
intermediateSteps.push({ observation: '' });
|
||||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe(
|
||||
'\n\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle complex markdown with base path', () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
||||
const complexMarkdown = `
|
||||
# Document Title
|
||||

|
||||
Some text between images
|
||||

|
||||
`;
|
||||
intermediateSteps.push({ observation: complexMarkdown });
|
||||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe('\n');
|
||||
});
|
||||
|
||||
it('should handle URLs that are already absolute', () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
||||
intermediateSteps.push({ observation: '' });
|
||||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe('\n');
|
||||
});
|
||||
|
||||
it('should handle data URLs', () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
||||
intermediateSteps.push({
|
||||
observation:
|
||||
'',
|
||||
});
|
||||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe(
|
||||
'\n',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ const { v4: uuidv4 } = require('uuid');
|
|||
const { Tool } = require('@langchain/core/tools');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { FileContext, ContentTypes } = require('librechat-data-provider');
|
||||
const { getBasePath } = require('@librechat/api');
|
||||
const paths = require('~/config/paths');
|
||||
|
||||
const displayMessage =
|
||||
|
|
@ -36,7 +37,7 @@ class StableDiffusionAPI extends Tool {
|
|||
this.description_for_model = `// Generate images and visuals using text.
|
||||
// Guidelines:
|
||||
// - ALWAYS use {{"prompt": "7+ detailed keywords", "negative_prompt": "7+ detailed keywords"}} structure for queries.
|
||||
// - ALWAYS include the markdown url in your final response to show the user: 
|
||||
// - ALWAYS include the markdown url in your final response to show the user: }/images/id.png)
|
||||
// - Visually describe the moods, details, structures, styles, and/or proportions of the image. Remember, the focus is on visual attributes.
|
||||
// - Craft your input by "showing" and not "telling" the imagery. Think in terms of what you'd want to see in a photograph or a painting.
|
||||
// - Here's an example for generating a realistic portrait photo of a man:
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ const {
|
|||
} = require('./Project');
|
||||
const { removeAllPermissions } = require('~/server/services/PermissionService');
|
||||
const { getMCPServerTools } = require('~/server/services/Config');
|
||||
const { Agent, AclEntry } = require('~/db/models');
|
||||
const { getActions } = require('./Action');
|
||||
const { Agent } = require('~/db/models');
|
||||
|
||||
/**
|
||||
* Create an agent with the provided data.
|
||||
|
|
@ -539,6 +539,37 @@ const deleteAgent = async (searchParameter) => {
|
|||
return agent;
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes all agents created by a specific user.
|
||||
* @param {string} userId - The ID of the user whose agents should be deleted.
|
||||
* @returns {Promise<void>} A promise that resolves when all user agents have been deleted.
|
||||
*/
|
||||
const deleteUserAgents = async (userId) => {
|
||||
try {
|
||||
const userAgents = await getAgents({ author: userId });
|
||||
|
||||
if (userAgents.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const agentIds = userAgents.map((agent) => agent.id);
|
||||
const agentObjectIds = userAgents.map((agent) => agent._id);
|
||||
|
||||
for (const agentId of agentIds) {
|
||||
await removeAgentFromAllProjects(agentId);
|
||||
}
|
||||
|
||||
await AclEntry.deleteMany({
|
||||
resourceType: ResourceType.AGENT,
|
||||
resourceId: { $in: agentObjectIds },
|
||||
});
|
||||
|
||||
await Agent.deleteMany({ author: userId });
|
||||
} catch (error) {
|
||||
logger.error('[deleteUserAgents] General error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get agents by accessible IDs with optional cursor-based pagination.
|
||||
* @param {Object} params - The parameters for getting accessible agents.
|
||||
|
|
@ -856,6 +887,7 @@ module.exports = {
|
|||
createAgent,
|
||||
updateAgent,
|
||||
deleteAgent,
|
||||
deleteUserAgents,
|
||||
getListAgents,
|
||||
revertAgentVersion,
|
||||
updateAgentProjects,
|
||||
|
|
|
|||
|
|
@ -346,8 +346,8 @@ async function getMessage({ user, messageId }) {
|
|||
*
|
||||
* @async
|
||||
* @function deleteMessages
|
||||
* @param {Object} filter - The filter criteria to find messages to delete.
|
||||
* @returns {Promise<Object>} The metadata with count of deleted messages.
|
||||
* @param {import('mongoose').FilterQuery<import('mongoose').Document>} filter - The filter criteria to find messages to delete.
|
||||
* @returns {Promise<import('mongoose').DeleteResult>} The metadata with count of deleted messages.
|
||||
* @throws {Error} If there is an error in deleting messages.
|
||||
*/
|
||||
async function deleteMessages(filter) {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ const {
|
|||
getProjectByName,
|
||||
} = require('./Project');
|
||||
const { removeAllPermissions } = require('~/server/services/PermissionService');
|
||||
const { PromptGroup, Prompt } = require('~/db/models');
|
||||
const { PromptGroup, Prompt, AclEntry } = require('~/db/models');
|
||||
const { escapeRegExp } = require('~/server/utils');
|
||||
|
||||
/**
|
||||
|
|
@ -591,6 +591,36 @@ module.exports = {
|
|||
return { prompt: 'Prompt deleted successfully' };
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Delete all prompts and prompt groups created by a specific user.
|
||||
* @param {ServerRequest} req - The server request object.
|
||||
* @param {string} userId - The ID of the user whose prompts and prompt groups are to be deleted.
|
||||
*/
|
||||
deleteUserPrompts: async (req, userId) => {
|
||||
try {
|
||||
const promptGroups = await getAllPromptGroups(req, { author: new ObjectId(userId) });
|
||||
|
||||
if (promptGroups.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const groupIds = promptGroups.map((group) => group._id);
|
||||
|
||||
for (const groupId of groupIds) {
|
||||
await removeGroupFromAllProjects(groupId);
|
||||
}
|
||||
|
||||
await AclEntry.deleteMany({
|
||||
resourceType: ResourceType.PROMPTGROUP,
|
||||
resourceId: { $in: groupIds },
|
||||
});
|
||||
|
||||
await PromptGroup.deleteMany({ author: new ObjectId(userId) });
|
||||
await Prompt.deleteMany({ author: new ObjectId(userId) });
|
||||
} catch (error) {
|
||||
logger.error('[deleteUserPrompts] General error:', error);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Update prompt group
|
||||
* @param {Partial<MongoPromptGroup>} filter - Filter to find prompt group
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@
|
|||
"@langchain/google-genai": "^0.2.13",
|
||||
"@langchain/google-vertexai": "^0.2.13",
|
||||
"@langchain/textsplitters": "^0.1.0",
|
||||
"@librechat/agents": "^3.0.27",
|
||||
"@librechat/agents": "^3.0.30",
|
||||
"@librechat/api": "*",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -3,32 +3,45 @@ const { Tools, CacheKeys, Constants, FileSources } = require('librechat-data-pro
|
|||
const {
|
||||
MCPOAuthHandler,
|
||||
MCPTokenStorage,
|
||||
mcpServersRegistry,
|
||||
normalizeHttpError,
|
||||
extractWebSearchEnvVars,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
getFiles,
|
||||
findToken,
|
||||
updateUser,
|
||||
deleteFiles,
|
||||
deleteConvos,
|
||||
deletePresets,
|
||||
deleteMessages,
|
||||
deleteUserById,
|
||||
deleteAllSharedLinks,
|
||||
deleteAllUserSessions,
|
||||
deleteAllSharedLinks,
|
||||
deleteUserById,
|
||||
deleteMessages,
|
||||
deletePresets,
|
||||
deleteConvos,
|
||||
deleteFiles,
|
||||
updateUser,
|
||||
findToken,
|
||||
getFiles,
|
||||
} = require('~/models');
|
||||
const {
|
||||
ConversationTag,
|
||||
Transaction,
|
||||
MemoryEntry,
|
||||
Assistant,
|
||||
AclEntry,
|
||||
Balance,
|
||||
Action,
|
||||
Group,
|
||||
Token,
|
||||
User,
|
||||
} = require('~/db/models');
|
||||
const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/services/PluginService');
|
||||
const { updateUserPluginsService, deleteUserKey } = require('~/server/services/UserService');
|
||||
const { verifyEmail, resendVerificationEmail } = require('~/server/services/AuthService');
|
||||
const { needsRefresh, getNewS3URL } = require('~/server/services/Files/S3/crud');
|
||||
const { processDeleteRequest } = require('~/server/services/Files/process');
|
||||
const { Transaction, Balance, User, Token } = require('~/db/models');
|
||||
const { getMCPManager, getFlowStateManager } = require('~/config');
|
||||
const { getAppConfig } = require('~/server/services/Config');
|
||||
const { deleteToolCalls } = require('~/models/ToolCall');
|
||||
const { deleteUserPrompts } = require('~/models/Prompt');
|
||||
const { deleteUserAgents } = require('~/models/Agent');
|
||||
const { getLogStores } = require('~/cache');
|
||||
const { mcpServersRegistry } = require('@librechat/api');
|
||||
|
||||
const getUserController = async (req, res) => {
|
||||
const appConfig = await getAppConfig({ role: req.user?.role });
|
||||
|
|
@ -237,7 +250,6 @@ const deleteUserController = async (req, res) => {
|
|||
await deleteUserKey({ userId: user.id, all: true }); // delete user keys
|
||||
await Balance.deleteMany({ user: user._id }); // delete user balances
|
||||
await deletePresets(user.id); // delete user presets
|
||||
/* TODO: Delete Assistant Threads */
|
||||
try {
|
||||
await deleteConvos(user.id); // delete user convos
|
||||
} catch (error) {
|
||||
|
|
@ -249,7 +261,19 @@ const deleteUserController = async (req, res) => {
|
|||
await deleteUserFiles(req); // delete user files
|
||||
await deleteFiles(null, user.id); // delete database files in case of orphaned files from previous steps
|
||||
await deleteToolCalls(user.id); // delete user tool calls
|
||||
/* TODO: queue job for cleaning actions and assistants of non-existant users */
|
||||
await deleteUserAgents(user.id); // delete user agents
|
||||
await Assistant.deleteMany({ user: user.id }); // delete user assistants
|
||||
await ConversationTag.deleteMany({ user: user.id }); // delete user conversation tags
|
||||
await MemoryEntry.deleteMany({ userId: user.id }); // delete user memory entries
|
||||
await deleteUserPrompts(req, user.id); // delete user prompts
|
||||
await Action.deleteMany({ user: user.id }); // delete user actions
|
||||
await Token.deleteMany({ userId: user.id }); // delete user OAuth tokens
|
||||
await Group.updateMany(
|
||||
// remove user from all groups
|
||||
{ memberIds: user.id },
|
||||
{ $pull: { memberIds: user.id } },
|
||||
);
|
||||
await AclEntry.deleteMany({ principalId: user._id }); // delete user ACL entries
|
||||
logger.info(`User deleted account. Email: ${user.email} ID: ${user.id}`);
|
||||
res.status(200).send({ message: 'User deleted' });
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
const jwt = require('jsonwebtoken');
|
||||
const { isEnabled } = require('@librechat/api');
|
||||
const createValidateImageRequest = require('~/server/middleware/validateImageRequest');
|
||||
|
||||
// Mock only isEnabled, keep getBasePath real so it reads process.env.DOMAIN_CLIENT
|
||||
jest.mock('@librechat/api', () => ({
|
||||
...jest.requireActual('@librechat/api'),
|
||||
isEnabled: jest.fn(),
|
||||
}));
|
||||
|
||||
const { isEnabled } = require('@librechat/api');
|
||||
|
||||
describe('validateImageRequest middleware', () => {
|
||||
let req, res, next, validateImageRequest;
|
||||
const validObjectId = '65cfb246f7ecadb8b1e8036b';
|
||||
|
|
@ -23,6 +26,7 @@ describe('validateImageRequest middleware', () => {
|
|||
next = jest.fn();
|
||||
process.env.JWT_REFRESH_SECRET = 'test-secret';
|
||||
process.env.OPENID_REUSE_TOKENS = 'false';
|
||||
delete process.env.DOMAIN_CLIENT; // Clear for tests without basePath
|
||||
|
||||
// Default: OpenID token reuse disabled
|
||||
isEnabled.mockReturnValue(false);
|
||||
|
|
@ -296,4 +300,175 @@ describe('validateImageRequest middleware', () => {
|
|||
expect(res.send).toHaveBeenCalledWith('Access Denied');
|
||||
});
|
||||
});
|
||||
|
||||
describe('basePath functionality', () => {
|
||||
let originalDomainClient;
|
||||
|
||||
beforeEach(() => {
|
||||
originalDomainClient = process.env.DOMAIN_CLIENT;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.DOMAIN_CLIENT = originalDomainClient;
|
||||
});
|
||||
|
||||
test('should validate image paths with base path', async () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
||||
const validToken = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
req.originalUrl = `/librechat/images/${validObjectId}/test.jpg`;
|
||||
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should validate agent avatar paths with base path', async () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
||||
const validToken = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
req.originalUrl = `/librechat/images/${validObjectId}/agent-avatar.png`;
|
||||
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should reject image paths without base path when DOMAIN_CLIENT is set', async () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
||||
const validToken = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
req.originalUrl = `/images/${validObjectId}/test.jpg`;
|
||||
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.send).toHaveBeenCalledWith('Access Denied');
|
||||
});
|
||||
|
||||
test('should handle empty base path (root deployment)', async () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/';
|
||||
const validToken = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
req.originalUrl = `/images/${validObjectId}/test.jpg`;
|
||||
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should handle missing DOMAIN_CLIENT', async () => {
|
||||
delete process.env.DOMAIN_CLIENT;
|
||||
const validToken = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
req.originalUrl = `/images/${validObjectId}/test.jpg`;
|
||||
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should handle nested subdirectories in base path', async () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/apps/librechat';
|
||||
const validToken = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
req.originalUrl = `/apps/librechat/images/${validObjectId}/test.jpg`;
|
||||
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should prevent path traversal with base path', async () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
||||
const validToken = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
req.originalUrl = `/librechat/images/${validObjectId}/../../../etc/passwd`;
|
||||
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.send).toHaveBeenCalledWith('Access Denied');
|
||||
});
|
||||
|
||||
test('should handle URLs with query parameters and base path', async () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
||||
const validToken = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
req.originalUrl = `/librechat/images/${validObjectId}/test.jpg?version=1`;
|
||||
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should handle URLs with fragments and base path', async () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
||||
const validToken = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
req.originalUrl = `/librechat/images/${validObjectId}/test.jpg#section`;
|
||||
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should handle HTTPS URLs with base path', async () => {
|
||||
process.env.DOMAIN_CLIENT = 'https://example.com/librechat';
|
||||
const validToken = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
req.originalUrl = `/librechat/images/${validObjectId}/test.jpg`;
|
||||
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should handle invalid DOMAIN_CLIENT gracefully', async () => {
|
||||
process.env.DOMAIN_CLIENT = 'not-a-valid-url';
|
||||
const validToken = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
req.originalUrl = `/images/${validObjectId}/test.jpg`;
|
||||
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should handle OpenID flow with base path', async () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
||||
process.env.OPENID_REUSE_TOKENS = 'true';
|
||||
const validToken = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=${validToken}; token_provider=openid; openid_user_id=${validToken}`;
|
||||
req.originalUrl = `/librechat/images/${validObjectId}/test.jpg`;
|
||||
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
const cookies = require('cookie');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { isEnabled } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { isEnabled, getBasePath } = require('@librechat/api');
|
||||
|
||||
const OBJECT_ID_LENGTH = 24;
|
||||
const OBJECT_ID_PATTERN = /^[0-9a-f]{24}$/i;
|
||||
|
|
@ -124,14 +124,21 @@ function createValidateImageRequest(secureImageLinks) {
|
|||
return res.status(403).send('Access Denied');
|
||||
}
|
||||
|
||||
const agentAvatarPattern = /^\/images\/[a-f0-9]{24}\/agent-[^/]*$/;
|
||||
const basePath = getBasePath();
|
||||
const imagesPath = `${basePath}/images`;
|
||||
|
||||
const agentAvatarPattern = new RegExp(
|
||||
`^${imagesPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/[a-f0-9]{24}/agent-[^/]*$`,
|
||||
);
|
||||
if (agentAvatarPattern.test(fullPath)) {
|
||||
logger.debug('[validateImageRequest] Image request validated');
|
||||
return next();
|
||||
}
|
||||
|
||||
const escapedUserId = userIdForPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const pathPattern = new RegExp(`^/images/${escapedUserId}/[^/]+$`);
|
||||
const pathPattern = new RegExp(
|
||||
`^${imagesPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/${escapedUserId}/[^/]+$`,
|
||||
);
|
||||
|
||||
if (pathPattern.test(fullPath)) {
|
||||
logger.debug('[validateImageRequest] Image request validated');
|
||||
|
|
|
|||
|
|
@ -30,11 +30,46 @@ const publicSharedLinksEnabled =
|
|||
const sharePointFilePickerEnabled = isEnabled(process.env.ENABLE_SHAREPOINT_FILEPICKER);
|
||||
const openidReuseTokens = isEnabled(process.env.OPENID_REUSE_TOKENS);
|
||||
|
||||
/**
|
||||
* Fetches MCP servers from registry and adds them to the payload.
|
||||
* Registry now includes all configured servers (from YAML) plus inspection data when available.
|
||||
* Always fetches fresh to avoid caching incomplete initialization state.
|
||||
*/
|
||||
const getMCPServers = async (payload, appConfig) => {
|
||||
try {
|
||||
if (appConfig?.mcpConfig == null) {
|
||||
return;
|
||||
}
|
||||
const mcpManager = getMCPManager();
|
||||
if (!mcpManager) {
|
||||
return;
|
||||
}
|
||||
const mcpServers = await mcpServersRegistry.getAllServerConfigs();
|
||||
if (!mcpServers) return;
|
||||
for (const serverName in mcpServers) {
|
||||
if (!payload.mcpServers) {
|
||||
payload.mcpServers = {};
|
||||
}
|
||||
const serverConfig = mcpServers[serverName];
|
||||
payload.mcpServers[serverName] = removeNullishValues({
|
||||
startup: serverConfig?.startup,
|
||||
chatMenu: serverConfig?.chatMenu,
|
||||
isOAuth: serverConfig.requiresOAuth,
|
||||
customUserVars: serverConfig?.customUserVars,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error loading MCP servers', error);
|
||||
}
|
||||
};
|
||||
|
||||
router.get('/', async function (req, res) {
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
|
||||
const cachedStartupConfig = await cache.get(CacheKeys.STARTUP_CONFIG);
|
||||
if (cachedStartupConfig) {
|
||||
const appConfig = await getAppConfig({ role: req.user?.role });
|
||||
await getMCPServers(cachedStartupConfig, appConfig);
|
||||
res.send(cachedStartupConfig);
|
||||
return;
|
||||
}
|
||||
|
|
@ -126,35 +161,6 @@ router.get('/', async function (req, res) {
|
|||
payload.minPasswordLength = minPasswordLength;
|
||||
}
|
||||
|
||||
const getMCPServers = async () => {
|
||||
try {
|
||||
if (appConfig?.mcpConfig == null) {
|
||||
return;
|
||||
}
|
||||
const mcpManager = getMCPManager();
|
||||
if (!mcpManager) {
|
||||
return;
|
||||
}
|
||||
const mcpServers = await mcpServersRegistry.getAllServerConfigs();
|
||||
if (!mcpServers) return;
|
||||
for (const serverName in mcpServers) {
|
||||
if (!payload.mcpServers) {
|
||||
payload.mcpServers = {};
|
||||
}
|
||||
const serverConfig = mcpServers[serverName];
|
||||
payload.mcpServers[serverName] = removeNullishValues({
|
||||
startup: serverConfig?.startup,
|
||||
chatMenu: serverConfig?.chatMenu,
|
||||
isOAuth: serverConfig.requiresOAuth,
|
||||
customUserVars: serverConfig?.customUserVars,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error loading MCP servers', error);
|
||||
}
|
||||
};
|
||||
|
||||
await getMCPServers();
|
||||
const webSearchConfig = appConfig?.webSearch;
|
||||
if (
|
||||
webSearchConfig != null &&
|
||||
|
|
@ -184,6 +190,7 @@ router.get('/', async function (req, res) {
|
|||
}
|
||||
|
||||
await cache.set(CacheKeys.STARTUP_CONFIG, payload);
|
||||
await getMCPServers(payload, appConfig);
|
||||
return res.status(200).send(payload);
|
||||
} catch (err) {
|
||||
logger.error('Error in startup config', err);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,12 @@ const {
|
|||
deleteUserController,
|
||||
getUserController,
|
||||
} = require('~/server/controllers/UserController');
|
||||
const { requireJwtAuth, canDeleteAccount, verifyEmailLimiter } = require('~/server/middleware');
|
||||
const {
|
||||
verifyEmailLimiter,
|
||||
configMiddleware,
|
||||
canDeleteAccount,
|
||||
requireJwtAuth,
|
||||
} = require('~/server/middleware');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
|
|
@ -16,7 +21,7 @@ router.get('/', requireJwtAuth, getUserController);
|
|||
router.get('/terms', requireJwtAuth, getTermsStatusController);
|
||||
router.post('/terms/accept', requireJwtAuth, acceptTermsController);
|
||||
router.post('/plugins', requireJwtAuth, updateUserPluginsController);
|
||||
router.delete('/delete', requireJwtAuth, canDeleteAccount, deleteUserController);
|
||||
router.delete('/delete', requireJwtAuth, canDeleteAccount, configMiddleware, deleteUserController);
|
||||
router.post('/verify', verifyEmailController);
|
||||
router.post('/verify/resend', verifyEmailLimiter, resendVerificationController);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
const path = require('path');
|
||||
const { v4 } = require('uuid');
|
||||
const axios = require('axios');
|
||||
const { logAxiosError } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { getCodeBaseURL } = require('@librechat/agents');
|
||||
const { logAxiosError, getBasePath } = require('@librechat/api');
|
||||
const {
|
||||
Tools,
|
||||
FileContext,
|
||||
|
|
@ -41,11 +41,12 @@ const processCodeOutput = async ({
|
|||
const appConfig = req.config;
|
||||
const currentDate = new Date();
|
||||
const baseURL = getCodeBaseURL();
|
||||
const basePath = getBasePath();
|
||||
const fileExt = path.extname(name);
|
||||
if (!fileExt || !imageExtRegex.test(name)) {
|
||||
return {
|
||||
filename: name,
|
||||
filepath: `/api/files/code/download/${session_id}/${id}`,
|
||||
filepath: `${basePath}/api/files/code/download/${session_id}/${id}`,
|
||||
/** Note: expires 24 hours after creation */
|
||||
expiresAt: currentDate.getTime() + 86400000,
|
||||
conversationId,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ module.exports = {
|
|||
'jest-file-loader',
|
||||
},
|
||||
transformIgnorePatterns: ['node_modules/?!@zattoo/use-double-click'],
|
||||
preset: 'ts-jest',
|
||||
setupFilesAfterEnv: ['@testing-library/jest-dom/extend-expect', '<rootDir>/test/setupTests.js'],
|
||||
clearMocks: true,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -147,7 +147,6 @@
|
|||
"postcss-loader": "^7.1.0",
|
||||
"postcss-preset-env": "^8.2.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"ts-jest": "^29.4.5",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^6.4.1",
|
||||
"vite-plugin-compression2": "^2.2.1",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
|||
import { Toast, ThemeProvider, ToastProvider } from '@librechat/client';
|
||||
import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query';
|
||||
import { ScreenshotProvider, useApiErrorBoundary } from './hooks';
|
||||
import WakeLockManager from '~/components/System/WakeLockManager';
|
||||
import { getThemeFromEnv } from './utils/getThemeFromEnv';
|
||||
import { initializeFontSize } from '~/store/fontSize';
|
||||
import { LiveAnnouncer } from '~/a11y';
|
||||
|
|
@ -51,6 +52,7 @@ const App = () => {
|
|||
<ToastProvider>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<RouterProvider router={router} />
|
||||
<WakeLockManager />
|
||||
<ReactQueryDevtools initialIsOpen={false} position="top-right" />
|
||||
<Toast />
|
||||
<RadixToast.Viewport className="pointer-events-none fixed inset-0 z-[1000] mx-auto my-2 flex max-w-[560px] flex-col items-stretch justify-start md:pb-5" />
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ function AuthLayout({
|
|||
|
||||
<div className="flex flex-grow items-center justify-center">
|
||||
<div className="w-authPageWidth overflow-hidden bg-white px-6 py-4 dark:bg-gray-900 sm:max-w-md sm:rounded-lg">
|
||||
{!hasStartupConfigError && !isFetching && (
|
||||
{!hasStartupConfigError && !isFetching && header && (
|
||||
<h1
|
||||
className="mb-4 text-center text-3xl font-semibold text-black dark:text-white"
|
||||
style={{ userSelect: 'none' }}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { ErrorTypes } from 'librechat-data-provider';
|
||||
import { ErrorTypes, registerPage } from 'librechat-data-provider';
|
||||
import { OpenIDIcon, useToastContext } from '@librechat/client';
|
||||
import { useOutletContext, useSearchParams } from 'react-router-dom';
|
||||
import type { TLoginLayoutContext } from '~/common';
|
||||
|
|
@ -104,7 +104,7 @@ function Login() {
|
|||
{' '}
|
||||
{localize('com_auth_no_account')}{' '}
|
||||
<a
|
||||
href="/register"
|
||||
href={registerPage()}
|
||||
className="inline-flex p-1 text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
|
||||
>
|
||||
{localize('com_auth_sign_up')}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Turnstile } from '@marsidev/react-turnstile';
|
|||
import { ThemeContext, Spinner, Button, isDark } from '@librechat/client';
|
||||
import { useNavigate, useOutletContext, useLocation } from 'react-router-dom';
|
||||
import { useRegisterUserMutation } from 'librechat-data-provider/react-query';
|
||||
import { loginPage } from 'librechat-data-provider';
|
||||
import type { TRegisterUser, TError } from 'librechat-data-provider';
|
||||
import type { TLoginLayoutContext } from '~/common';
|
||||
import { useLocalize, TranslationKeys } from '~/hooks';
|
||||
|
|
@ -213,7 +214,7 @@ const Registration: React.FC = () => {
|
|||
<p className="my-4 text-center text-sm font-light text-gray-700 dark:text-white">
|
||||
{localize('com_auth_already_have_account')}{' '}
|
||||
<a
|
||||
href="/login"
|
||||
href={loginPage()}
|
||||
aria-label="Login"
|
||||
className="inline-flex p-1 text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useState, ReactNode } from 'react';
|
|||
import { Spinner, Button } from '@librechat/client';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { useRequestPasswordResetMutation } from 'librechat-data-provider/react-query';
|
||||
import { loginPage } from 'librechat-data-provider';
|
||||
import type { TRequestPasswordReset, TRequestPasswordResetResponse } from 'librechat-data-provider';
|
||||
import type { TLoginLayoutContext } from '~/common';
|
||||
import type { FC } from 'react';
|
||||
|
|
@ -26,7 +27,7 @@ const ResetPasswordBodyText = () => {
|
|||
<p>{localize('com_auth_reset_password_if_email_exists')}</p>
|
||||
<a
|
||||
className="inline-flex text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
|
||||
href="/login"
|
||||
href={loginPage()}
|
||||
>
|
||||
{localize('com_auth_back_to_login')}
|
||||
</a>
|
||||
|
|
@ -134,7 +135,7 @@ function RequestPasswordReset() {
|
|||
{isLoading ? <Spinner /> : localize('com_auth_continue')}
|
||||
</Button>
|
||||
<a
|
||||
href="/login"
|
||||
href={loginPage()}
|
||||
className="block text-center text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
|
||||
>
|
||||
{localize('com_auth_back_to_login')}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useState, useRef, useMemo } from 'react';
|
||||
import { Skeleton } from '@librechat/client';
|
||||
import { LazyLoadImage } from 'react-lazy-load-image-component';
|
||||
import { apiBaseUrl } from 'librechat-data-provider';
|
||||
import { cn, scaleImage } from '~/utils';
|
||||
import DialogImage from './DialogImage';
|
||||
|
||||
|
|
@ -36,6 +37,24 @@ const Image = ({
|
|||
|
||||
const handleImageLoad = () => setIsLoaded(true);
|
||||
|
||||
// Fix image path to include base path for subdirectory deployments
|
||||
const absoluteImageUrl = useMemo(() => {
|
||||
if (!imagePath) return imagePath;
|
||||
|
||||
// If it's already an absolute URL or doesn't start with /images/, return as is
|
||||
if (
|
||||
imagePath.startsWith('http') ||
|
||||
imagePath.startsWith('data:') ||
|
||||
!imagePath.startsWith('/images/')
|
||||
) {
|
||||
return imagePath;
|
||||
}
|
||||
|
||||
// Get the base URL and prepend it to the image path
|
||||
const baseURL = apiBaseUrl();
|
||||
return `${baseURL}${imagePath}`;
|
||||
}, [imagePath]);
|
||||
|
||||
const { width: scaledWidth, height: scaledHeight } = useMemo(
|
||||
() =>
|
||||
scaleImage({
|
||||
|
|
@ -48,7 +67,7 @@ const Image = ({
|
|||
|
||||
const downloadImage = async () => {
|
||||
try {
|
||||
const response = await fetch(imagePath);
|
||||
const response = await fetch(absoluteImageUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch image: ${response.status}`);
|
||||
}
|
||||
|
|
@ -67,7 +86,7 @@ const Image = ({
|
|||
} catch (error) {
|
||||
console.error('Download failed:', error);
|
||||
const link = document.createElement('a');
|
||||
link.href = imagePath;
|
||||
link.href = absoluteImageUrl;
|
||||
link.download = altText || 'image.png';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
|
@ -97,7 +116,7 @@ const Image = ({
|
|||
'opacity-100 transition-opacity duration-100',
|
||||
isLoaded ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
src={imagePath}
|
||||
src={absoluteImageUrl}
|
||||
style={{
|
||||
width: `${scaledWidth}`,
|
||||
height: 'auto',
|
||||
|
|
@ -117,7 +136,7 @@ const Image = ({
|
|||
<DialogImage
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
src={imagePath}
|
||||
src={absoluteImageUrl}
|
||||
downloadImage={downloadImage}
|
||||
args={args}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { ArtifactProvider, CodeBlockProvider } from '~/Providers';
|
|||
import MarkdownErrorBoundary from './MarkdownErrorBoundary';
|
||||
import { langSubset, preprocessLaTeX } from '~/utils';
|
||||
import { unicodeCitation } from '~/components/Web';
|
||||
import { code, a, p } from './MarkdownComponents';
|
||||
import { code, a, p, img } from './MarkdownComponents';
|
||||
import store from '~/store';
|
||||
|
||||
type TContentProps = {
|
||||
|
|
@ -81,6 +81,7 @@ const Markdown = memo(({ content = '', isLatestMessage }: TContentProps) => {
|
|||
code,
|
||||
a,
|
||||
p,
|
||||
img,
|
||||
artifact: Artifact,
|
||||
citation: Citation,
|
||||
'highlighted-text': HighlightedText,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { memo, useMemo, useRef, useEffect } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { useToastContext } from '@librechat/client';
|
||||
import { PermissionTypes, Permissions, dataService } from 'librechat-data-provider';
|
||||
import { PermissionTypes, Permissions, apiBaseUrl } from 'librechat-data-provider';
|
||||
import CodeBlock from '~/components/Messages/Content/CodeBlock';
|
||||
import useHasAccess from '~/hooks/Roles/useHasAccess';
|
||||
import { useFileDownload } from '~/data-provider';
|
||||
|
|
@ -135,7 +135,7 @@ export const a: React.ElementType = memo(({ href, children }: TAnchorProps) => {
|
|||
props.onClick = handleDownload;
|
||||
props.target = '_blank';
|
||||
|
||||
const domainServerBaseUrl = dataService.getDomainServerBaseUrl();
|
||||
const domainServerBaseUrl = `${apiBaseUrl()}/api`;
|
||||
|
||||
return (
|
||||
<a
|
||||
|
|
@ -158,3 +158,31 @@ type TParagraphProps = {
|
|||
export const p: React.ElementType = memo(({ children }: TParagraphProps) => {
|
||||
return <p className="mb-2 whitespace-pre-wrap">{children}</p>;
|
||||
});
|
||||
|
||||
type TImageProps = {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
title?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
export const img: React.ElementType = memo(({ src, alt, title, className, style }: TImageProps) => {
|
||||
// Get the base URL from the API endpoints
|
||||
const baseURL = apiBaseUrl();
|
||||
|
||||
// If src starts with /images/, prepend the base URL
|
||||
const fixedSrc = useMemo(() => {
|
||||
if (!src) return src;
|
||||
|
||||
// If it's already an absolute URL or doesn't start with /images/, return as is
|
||||
if (src.startsWith('http') || src.startsWith('data:') || !src.startsWith('/images/')) {
|
||||
return src;
|
||||
}
|
||||
|
||||
// Prepend base URL to the image path
|
||||
return `${baseURL}${src}`;
|
||||
}, [src, baseURL]);
|
||||
|
||||
return <img src={fixedSrc} alt={alt} title={title} className={className} style={style} />;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import supersub from 'remark-supersub';
|
|||
import ReactMarkdown from 'react-markdown';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import type { PluggableList } from 'unified';
|
||||
import { code, codeNoExecution, a, p } from './MarkdownComponents';
|
||||
import { code, codeNoExecution, a, p, img } from './MarkdownComponents';
|
||||
import { CodeBlockProvider, ArtifactProvider } from '~/Providers';
|
||||
import MarkdownErrorBoundary from './MarkdownErrorBoundary';
|
||||
import { langSubset } from '~/utils';
|
||||
|
|
@ -44,6 +44,7 @@ const MarkdownLite = memo(
|
|||
code: codeExecution ? code : codeNoExecution,
|
||||
a,
|
||||
p,
|
||||
img,
|
||||
} as {
|
||||
[nodeType: string]: React.ElementType;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,13 @@ const toggleSwitchConfigs = [
|
|||
hoverCardText: undefined,
|
||||
key: 'hideSidePanel',
|
||||
},
|
||||
{
|
||||
stateAtom: store.keepScreenAwake,
|
||||
localizationKey: 'com_nav_keep_screen_awake',
|
||||
switchId: 'keepScreenAwake',
|
||||
hoverCardText: undefined,
|
||||
key: 'keepScreenAwake',
|
||||
},
|
||||
];
|
||||
|
||||
export const ThemeSelector = ({
|
||||
|
|
|
|||
31
client/src/components/System/WakeLockManager.tsx
Normal file
31
client/src/components/System/WakeLockManager.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { useRecoilValue } from 'recoil';
|
||||
import useWakeLock from '~/hooks/useWakeLock';
|
||||
import store from '~/store';
|
||||
|
||||
/**
|
||||
* WakeLockManager Component
|
||||
*
|
||||
* Manages the Screen Wake Lock during AI response generation to prevent
|
||||
* device screens from sleeping or dimming during long-running operations.
|
||||
*
|
||||
* The wake lock is only active when:
|
||||
* 1. Any conversation is currently generating a response (anySubmittingSelector)
|
||||
* 2. User has not disabled the feature in settings (keepScreenAwake preference)
|
||||
*
|
||||
* This component is rendered at the root level of the application
|
||||
* to ensure wake lock state persists across all conversations and routes.
|
||||
*
|
||||
* @see useWakeLock - The hook that manages the actual wake lock implementation
|
||||
* @see anySubmittingSelector - Recoil selector tracking if any conversation is generating
|
||||
*/
|
||||
const WakeLockManager = () => {
|
||||
const isSubmitting = useRecoilValue(store.anySubmittingSelector);
|
||||
const keepScreenAwake = useRecoilValue(store.keepScreenAwake);
|
||||
|
||||
const shouldPreventSleep = isSubmitting && keepScreenAwake;
|
||||
useWakeLock(shouldPreventSleep);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default WakeLockManager;
|
||||
211
client/src/hooks/useWakeLock.ts
Normal file
211
client/src/hooks/useWakeLock.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* Extended Navigator type that includes the Screen Wake Lock API
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/Screen_Wake_Lock_API
|
||||
*/
|
||||
type WakeLockCapableNavigator = Navigator & {
|
||||
wakeLock?: {
|
||||
request: (type: WakeLockType) => Promise<WakeLockSentinel>;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if we're in a client-side environment (browser)
|
||||
* Prevents SSR issues by verifying window, navigator, and document exist
|
||||
*/
|
||||
const isClientEnvironment =
|
||||
typeof window !== 'undefined' &&
|
||||
typeof navigator !== 'undefined' &&
|
||||
typeof document !== 'undefined';
|
||||
|
||||
const getNavigator = () => navigator as WakeLockCapableNavigator;
|
||||
|
||||
/**
|
||||
* Determines if the browser supports the Screen Wake Lock API
|
||||
* Checking outside component scope for better performance
|
||||
*/
|
||||
const supportsWakeLock = isClientEnvironment && 'wakeLock' in navigator;
|
||||
|
||||
/**
|
||||
* Enable/disable debug logging for wake lock operations
|
||||
* Set to true during development to see wake lock lifecycle events
|
||||
*/
|
||||
const DEBUG_WAKE_LOCK = false;
|
||||
|
||||
/**
|
||||
* Custom hook to prevent screen from sleeping during critical operations
|
||||
* Uses the Screen Wake Lock API to keep the device screen active
|
||||
*
|
||||
* @param shouldHold - Boolean flag indicating whether to acquire/hold the wake lock
|
||||
* @returns void - This hook manages wake lock state internally
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const isGeneratingResponse = useRecoilValue(anySubmittingSelector);
|
||||
* useWakeLock(isGeneratingResponse);
|
||||
* ```
|
||||
*
|
||||
* @remarks
|
||||
* - Automatically handles page visibility changes (reacquires lock when tab becomes visible)
|
||||
* - Properly cleans up lock on unmount or when shouldHold becomes false
|
||||
* - Gracefully degrades on browsers without Wake Lock API support
|
||||
* - Wake locks are automatically released when user switches tabs
|
||||
*/
|
||||
export const useWakeLock = (shouldHold: boolean) => {
|
||||
const wakeLockRef = useRef<WakeLockSentinel | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!supportsWakeLock) {
|
||||
if (DEBUG_WAKE_LOCK) {
|
||||
console.log('[WakeLock] API not supported in this browser');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flag to prevent operations after effect cleanup
|
||||
* Essential for avoiding race conditions when:
|
||||
* - Component unmounts while lock is being acquired
|
||||
* - shouldHold changes while async operations are in flight
|
||||
* - Multiple visibility change events fire in quick succession
|
||||
*/
|
||||
let cancelled = false;
|
||||
const { wakeLock } = getNavigator();
|
||||
|
||||
if (!wakeLock) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases the currently held wake lock
|
||||
* Called when: shouldHold becomes false, component unmounts, or before acquiring new lock
|
||||
*/
|
||||
const releaseLock = async () => {
|
||||
if (!wakeLockRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await wakeLockRef.current.release();
|
||||
if (DEBUG_WAKE_LOCK) {
|
||||
console.log('[WakeLock] Lock released successfully');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[WakeLock] release failed', error);
|
||||
} finally {
|
||||
wakeLockRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Requests a new wake lock from the browser
|
||||
* Checks multiple conditions before requesting to avoid unnecessary API calls:
|
||||
* - shouldHold must be true (user wants lock)
|
||||
* - cancelled must be false (effect still active)
|
||||
* - document must be visible (API requirement - locks only work in visible tabs)
|
||||
* - no existing lock (prevent duplicate locks)
|
||||
*/
|
||||
const requestLock = async () => {
|
||||
if (
|
||||
!shouldHold ||
|
||||
cancelled ||
|
||||
document.visibilityState !== 'visible' ||
|
||||
wakeLockRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sentinel = await wakeLock.request('screen');
|
||||
wakeLockRef.current = sentinel;
|
||||
|
||||
if (DEBUG_WAKE_LOCK) {
|
||||
console.log('[WakeLock] Lock acquired successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* CRITICAL: Recursive re-acquire logic for automatic lock restoration
|
||||
*
|
||||
* The browser automatically releases wake locks when:
|
||||
* - User switches to a different tab
|
||||
* - User minimizes the browser window
|
||||
* - Device goes to sleep
|
||||
* - User navigates to a different page
|
||||
*
|
||||
* This handler automatically re-acquires the lock when:
|
||||
* 1. The lock is released by the browser
|
||||
* 2. The effect is still active (not cancelled)
|
||||
* 3. The component still wants to hold the lock (shouldHold is true)
|
||||
* 4. The tab is visible again (document.visibilityState === 'visible')
|
||||
*
|
||||
* This ensures users don't need to manually restart their work after
|
||||
* switching tabs during long-running operations like AI response generation.
|
||||
*/
|
||||
const handleRelease = () => {
|
||||
wakeLockRef.current = null;
|
||||
sentinel.removeEventListener('release', handleRelease);
|
||||
|
||||
if (DEBUG_WAKE_LOCK) {
|
||||
console.log('[WakeLock] Lock released, checking if re-acquire needed');
|
||||
}
|
||||
|
||||
if (!cancelled && shouldHold && document.visibilityState === 'visible') {
|
||||
if (DEBUG_WAKE_LOCK) {
|
||||
console.log('[WakeLock] Re-acquiring lock');
|
||||
}
|
||||
void requestLock();
|
||||
}
|
||||
};
|
||||
|
||||
sentinel.addEventListener('release', handleRelease);
|
||||
} catch (error) {
|
||||
console.warn('[WakeLock] request failed', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles browser tab visibility changes
|
||||
* When user returns to the tab, re-acquire the lock if it's still needed
|
||||
* This is necessary because wake locks are automatically released when tab becomes hidden
|
||||
*/
|
||||
const handleVisibilityChange = () => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (DEBUG_WAKE_LOCK) {
|
||||
console.log('[WakeLock] Visibility changed:', document.visibilityState);
|
||||
}
|
||||
|
||||
if (document.visibilityState === 'visible' && shouldHold) {
|
||||
void requestLock();
|
||||
}
|
||||
};
|
||||
|
||||
if (shouldHold) {
|
||||
void requestLock();
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
} else {
|
||||
void releaseLock();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup function runs when:
|
||||
* - Component unmounts
|
||||
* - shouldHold changes
|
||||
* - Effect dependencies change
|
||||
*
|
||||
* Sets cancelled flag first to prevent any in-flight operations from completing
|
||||
* Removes event listeners to prevent memory leaks
|
||||
* Releases any held wake lock
|
||||
*/
|
||||
return () => {
|
||||
cancelled = true;
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
void releaseLock();
|
||||
};
|
||||
}, [shouldHold]);
|
||||
};
|
||||
|
||||
export default useWakeLock;
|
||||
|
|
@ -493,6 +493,7 @@
|
|||
"com_nav_info_save_draft": "When enabled, the text and attachments you enter in the chat form will be automatically saved locally as drafts. These drafts will be available even if you reload the page or switch to a different conversation. Drafts are stored locally on your device and are deleted once the message is sent.",
|
||||
"com_nav_info_show_thinking": "When enabled, the chat will display the thinking dropdowns open by default, allowing you to view the AI's reasoning in real-time. When disabled, the thinking dropdowns will remain closed by default for a cleaner and more streamlined interface",
|
||||
"com_nav_info_user_name_display": "When enabled, the username of the sender will be shown above each message you send. When disabled, you will only see \"You\" above your messages.",
|
||||
"com_nav_keep_screen_awake": "Keep screen awake during response generation",
|
||||
"com_nav_lang_arabic": "العربية",
|
||||
"com_nav_lang_armenian": "Հայերեն",
|
||||
"com_nav_lang_auto": "Auto detect",
|
||||
|
|
|
|||
202
client/src/routes/__tests__/useAuthRedirect.spec.tsx
Normal file
202
client/src/routes/__tests__/useAuthRedirect.spec.tsx
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
/* eslint-disable i18next/no-literal-string */
|
||||
import React from 'react';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
|
||||
import useAuthRedirect from '../useAuthRedirect';
|
||||
import { useAuthContext } from '~/hooks';
|
||||
|
||||
// Polyfill Request for React Router in test environment
|
||||
if (typeof Request === 'undefined') {
|
||||
global.Request = class Request {
|
||||
constructor(
|
||||
public url: string,
|
||||
public init?: RequestInit,
|
||||
) {}
|
||||
} as any;
|
||||
}
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useAuthContext: jest.fn(),
|
||||
}));
|
||||
|
||||
/**
|
||||
* TestComponent that uses the useAuthRedirect hook and exposes its return value
|
||||
*/
|
||||
function TestComponent() {
|
||||
const result = useAuthRedirect();
|
||||
// Expose result for assertions
|
||||
(window as any).__testResult = result;
|
||||
return <div data-testid="test-component">Test Component</div>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a test router with optional basename to verify navigation works correctly
|
||||
* with subdirectory deployments (e.g., /librechat)
|
||||
*/
|
||||
const createTestRouter = (basename = '/') => {
|
||||
// When using basename, initialEntries must include the basename
|
||||
const initialEntry = basename === '/' ? '/' : `${basename}/`;
|
||||
|
||||
return createMemoryRouter(
|
||||
[
|
||||
{
|
||||
path: '/',
|
||||
element: <TestComponent />,
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
element: <div data-testid="login-page">Login Page</div>,
|
||||
},
|
||||
],
|
||||
{
|
||||
basename,
|
||||
initialEntries: [initialEntry],
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
describe('useAuthRedirect', () => {
|
||||
beforeEach(() => {
|
||||
(window as any).__testResult = undefined;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(window as any).__testResult = undefined;
|
||||
});
|
||||
|
||||
it('should not redirect when user is authenticated', async () => {
|
||||
(useAuthContext as jest.Mock).mockReturnValue({
|
||||
user: { id: '123', email: 'test@example.com' },
|
||||
isAuthenticated: true,
|
||||
});
|
||||
|
||||
const router = createTestRouter();
|
||||
const { getByTestId } = render(<RouterProvider router={router} />);
|
||||
|
||||
expect(router.state.location.pathname).toBe('/');
|
||||
expect(getByTestId('test-component')).toBeInTheDocument();
|
||||
|
||||
// Wait for the timeout (300ms) plus a buffer
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
|
||||
// Should still be on home page, not redirected
|
||||
expect(router.state.location.pathname).toBe('/');
|
||||
expect(getByTestId('test-component')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should redirect to /login when user is not authenticated', async () => {
|
||||
(useAuthContext as jest.Mock).mockReturnValue({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
|
||||
const router = createTestRouter();
|
||||
const { getByTestId, queryByTestId } = render(<RouterProvider router={router} />);
|
||||
|
||||
expect(router.state.location.pathname).toBe('/');
|
||||
expect(getByTestId('test-component')).toBeInTheDocument();
|
||||
|
||||
// Wait for the redirect to happen (300ms timeout + navigation)
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(router.state.location.pathname).toBe('/login');
|
||||
expect(getByTestId('login-page')).toBeInTheDocument();
|
||||
expect(queryByTestId('test-component')).not.toBeInTheDocument();
|
||||
},
|
||||
{ timeout: 1000 },
|
||||
);
|
||||
|
||||
// Verify navigation used replace (history has only 1 entry)
|
||||
// This prevents users from hitting back to return to protected pages
|
||||
expect(router.state.historyAction).toBe('REPLACE');
|
||||
});
|
||||
|
||||
it('should respect router basename when redirecting (subdirectory deployment)', async () => {
|
||||
(useAuthContext as jest.Mock).mockReturnValue({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
|
||||
// Test with basename="/librechat" (simulates subdirectory deployment)
|
||||
const router = createTestRouter('/librechat');
|
||||
const { getByTestId } = render(<RouterProvider router={router} />);
|
||||
|
||||
// Full pathname includes basename
|
||||
expect(router.state.location.pathname).toBe('/librechat/');
|
||||
|
||||
// Wait for the redirect - router handles basename internally
|
||||
await waitFor(
|
||||
() => {
|
||||
// Router state pathname includes the full path with basename
|
||||
expect(router.state.location.pathname).toBe('/librechat/login');
|
||||
expect(getByTestId('login-page')).toBeInTheDocument();
|
||||
},
|
||||
{ timeout: 1000 },
|
||||
);
|
||||
|
||||
// The key point: navigate('/login', { replace: true }) works correctly with basename
|
||||
// The router automatically prepends the basename to create the full URL
|
||||
expect(router.state.historyAction).toBe('REPLACE');
|
||||
});
|
||||
|
||||
it('should use React Router navigate (not window.location) for SPA experience', async () => {
|
||||
(useAuthContext as jest.Mock).mockReturnValue({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
|
||||
const router = createTestRouter('/librechat');
|
||||
const { getByTestId } = render(<RouterProvider router={router} />);
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(router.state.location.pathname).toBe('/librechat/login');
|
||||
expect(getByTestId('login-page')).toBeInTheDocument();
|
||||
},
|
||||
{ timeout: 1000 },
|
||||
);
|
||||
|
||||
// The fact that navigation worked within the router proves we're using
|
||||
// navigate() and not window.location.href (which would cause a full reload
|
||||
// and break the test entirely). This maintains the SPA experience.
|
||||
expect(router.state.location.pathname).toBe('/librechat/login');
|
||||
});
|
||||
|
||||
it('should clear timeout on unmount', async () => {
|
||||
(useAuthContext as jest.Mock).mockReturnValue({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
|
||||
const router = createTestRouter();
|
||||
const { unmount } = render(<RouterProvider router={router} />);
|
||||
|
||||
// Unmount immediately before timeout fires
|
||||
unmount();
|
||||
|
||||
// Wait past the timeout period
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
|
||||
// Should still be at home, not redirected (timeout was cleared)
|
||||
expect(router.state.location.pathname).toBe('/');
|
||||
});
|
||||
|
||||
it('should return user and isAuthenticated values', async () => {
|
||||
const mockUser = { id: '123', email: 'test@example.com' };
|
||||
(useAuthContext as jest.Mock).mockReturnValue({
|
||||
user: mockUser,
|
||||
isAuthenticated: true,
|
||||
});
|
||||
|
||||
const router = createTestRouter();
|
||||
render(<RouterProvider router={router} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const testResult = (window as any).__testResult;
|
||||
expect(testResult).toBeDefined();
|
||||
expect(testResult.user).toEqual(mockUser);
|
||||
expect(testResult.isAuthenticated).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -190,6 +190,14 @@ const isSubmittingFamily = atomFamily({
|
|||
],
|
||||
});
|
||||
|
||||
const anySubmittingSelector = selector<boolean>({
|
||||
key: 'anySubmittingSelector',
|
||||
get: ({ get }) => {
|
||||
const keys = get(conversationKeysAtom);
|
||||
return keys.some((key) => get(isSubmittingFamily(key)) === true);
|
||||
},
|
||||
});
|
||||
|
||||
const optionSettingsFamily = atomFamily<TOptionSettings, string | number>({
|
||||
key: 'optionSettingsByIndex',
|
||||
default: {},
|
||||
|
|
@ -399,6 +407,7 @@ export default {
|
|||
showPopoverFamily,
|
||||
latestMessageFamily,
|
||||
messagesSiblingIdxFamily,
|
||||
anySubmittingSelector,
|
||||
allConversationsSelector,
|
||||
conversationByKeySelector,
|
||||
useClearConvoState,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ const localStorageAtoms = {
|
|||
LocalStorageKeys.ENABLE_USER_MSG_MARKDOWN,
|
||||
true,
|
||||
),
|
||||
keepScreenAwake: atomWithLocalStorage('keepScreenAwake', true),
|
||||
|
||||
// Chat settings
|
||||
enterToSend: atomWithLocalStorage('enterToSend', true),
|
||||
|
|
|
|||
|
|
@ -1,26 +1,31 @@
|
|||
#!/usr/bin/env node
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-nocheck
|
||||
const path = require('path');
|
||||
const mongoose = require('mongoose');
|
||||
const {
|
||||
User,
|
||||
Agent,
|
||||
Assistant,
|
||||
Balance,
|
||||
Transaction,
|
||||
ConversationTag,
|
||||
Conversation,
|
||||
Message,
|
||||
File,
|
||||
Key,
|
||||
MemoryEntry,
|
||||
PluginAuth,
|
||||
Prompt,
|
||||
PromptGroup,
|
||||
Preset,
|
||||
Session,
|
||||
SharedLink,
|
||||
ToolCall,
|
||||
User,
|
||||
File,
|
||||
Agent,
|
||||
Token,
|
||||
Group,
|
||||
Action,
|
||||
Preset,
|
||||
Prompt,
|
||||
Balance,
|
||||
Message,
|
||||
Session,
|
||||
AclEntry,
|
||||
ToolCall,
|
||||
Assistant,
|
||||
SharedLink,
|
||||
PluginAuth,
|
||||
MemoryEntry,
|
||||
PromptGroup,
|
||||
Transaction,
|
||||
Conversation,
|
||||
ConversationTag,
|
||||
} = require('@librechat/data-schemas').createModels(mongoose);
|
||||
require('module-alias')({ base: path.resolve(__dirname, '..', 'api') });
|
||||
const { askQuestion, silentExit } = require('./helpers');
|
||||
|
|
@ -72,6 +77,7 @@ async function gracefulExit(code = 0) {
|
|||
|
||||
// 5) Build and run deletion tasks
|
||||
const tasks = [
|
||||
Action.deleteMany({ user: uid }),
|
||||
Agent.deleteMany({ author: uid }),
|
||||
Assistant.deleteMany({ user: uid }),
|
||||
Balance.deleteMany({ user: uid }),
|
||||
|
|
@ -89,6 +95,7 @@ async function gracefulExit(code = 0) {
|
|||
SharedLink.deleteMany({ user: uid }),
|
||||
ToolCall.deleteMany({ user: uid }),
|
||||
Token.deleteMany({ userId: uid }),
|
||||
AclEntry.deleteMany({ principalId: user._id }),
|
||||
];
|
||||
|
||||
if (deleteTx) {
|
||||
|
|
@ -97,7 +104,10 @@ async function gracefulExit(code = 0) {
|
|||
|
||||
await Promise.all(tasks);
|
||||
|
||||
// 6) Finally delete the user document itself
|
||||
// 6) Remove user from all groups
|
||||
await Group.updateMany({ memberIds: user._id }, { $pull: { memberIds: user._id } });
|
||||
|
||||
// 7) Finally delete the user document itself
|
||||
await User.deleteOne({ _id: uid });
|
||||
|
||||
console.green(`✔ Successfully deleted user ${email} and all associated data.`);
|
||||
|
|
|
|||
|
|
@ -158,12 +158,22 @@ async function flushRedisCache(dryRun = false, verbose = false) {
|
|||
if (dryRun) {
|
||||
console.log('🔍 [DRY RUN] Would flush Redis cache');
|
||||
try {
|
||||
const keys = await redis.keys('*');
|
||||
console.log(` Would delete ${keys.length} keys`);
|
||||
if (verbose && keys.length > 0) {
|
||||
let allKeys = [];
|
||||
if (useCluster) {
|
||||
const nodes = redis.nodes('master');
|
||||
console.log(` Cluster detected: ${nodes.length} master nodes`);
|
||||
for (const node of nodes) {
|
||||
const keys = await node.keys('*');
|
||||
allKeys = allKeys.concat(keys);
|
||||
}
|
||||
} else {
|
||||
allKeys = await redis.keys('*');
|
||||
}
|
||||
console.log(` Would delete ${allKeys.length} keys`);
|
||||
if (verbose && allKeys.length > 0) {
|
||||
console.log(
|
||||
' Sample keys:',
|
||||
keys.slice(0, 10).join(', ') + (keys.length > 10 ? '...' : ''),
|
||||
allKeys.slice(0, 10).join(', ') + (allKeys.length > 10 ? '...' : ''),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -176,15 +186,29 @@ async function flushRedisCache(dryRun = false, verbose = false) {
|
|||
// Get key count before flushing
|
||||
let keyCount = 0;
|
||||
try {
|
||||
const keys = await redis.keys('*');
|
||||
keyCount = keys.length;
|
||||
if (useCluster) {
|
||||
const nodes = redis.nodes('master');
|
||||
for (const node of nodes) {
|
||||
const keys = await node.keys('*');
|
||||
keyCount += keys.length;
|
||||
}
|
||||
} else {
|
||||
const keys = await redis.keys('*');
|
||||
keyCount = keys.length;
|
||||
}
|
||||
} catch (_error) {
|
||||
// Continue with flush even if we can't count keys
|
||||
}
|
||||
|
||||
// Flush the Redis cache
|
||||
await redis.flushdb();
|
||||
console.log('✅ Redis cache flushed successfully');
|
||||
if (useCluster) {
|
||||
const nodes = redis.nodes('master');
|
||||
await Promise.all(nodes.map((node) => node.flushdb()));
|
||||
console.log(`✅ Redis cluster cache flushed successfully (${nodes.length} master nodes)`);
|
||||
} else {
|
||||
await redis.flushdb();
|
||||
console.log('✅ Redis cache flushed successfully');
|
||||
}
|
||||
|
||||
if (keyCount > 0) {
|
||||
console.log(` Deleted ${keyCount} keys`);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
deleteMessages,
|
||||
deleteAllUserSessions,
|
||||
} from '@librechat/backend/models';
|
||||
import { User, Balance, Transaction, AclEntry, Token, Group } from '@librechat/backend/db/models';
|
||||
|
||||
type TUser = { email: string; password: string };
|
||||
|
||||
|
|
@ -40,13 +41,13 @@ export default async function cleanupUser(user: TUser) {
|
|||
// Delete all user sessions
|
||||
await deleteAllUserSessions(userId.toString());
|
||||
|
||||
// Get models from the registered models
|
||||
const { User, Balance, Transaction } = getModels();
|
||||
|
||||
// Delete user, balance, and transactions using the registered models
|
||||
await User.deleteMany({ _id: userId });
|
||||
// Delete user, balance, transactions, tokens, ACL entries, and remove from groups
|
||||
await Balance.deleteMany({ user: userId });
|
||||
await Transaction.deleteMany({ user: userId });
|
||||
await Token.deleteMany({ userId: userId });
|
||||
await AclEntry.deleteMany({ principalId: userId });
|
||||
await Group.updateMany({ memberIds: userId }, { $pull: { memberIds: userId } });
|
||||
await User.deleteMany({ _id: userId });
|
||||
|
||||
console.log('🤖: ✅ Deleted user from Database');
|
||||
|
||||
|
|
|
|||
1836
package-lock.json
generated
1836
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -100,7 +100,6 @@
|
|||
"@eslint/compat": "^1.2.6",
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@eslint/js": "^9.20.0",
|
||||
"@microsoft/eslint-formatter-sarif": "^3.1.0",
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@types/react-virtualized": "^9.22.0",
|
||||
"caniuse-lite": "^1.0.30001741",
|
||||
|
|
@ -122,7 +121,6 @@
|
|||
"jest": "^30.2.0",
|
||||
"lint-staged": "^15.4.3",
|
||||
"prettier": "^3.5.0",
|
||||
"prettier-eslint": "^16.4.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"typescript-eslint": "^8.24.0"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@
|
|||
"@babel/preset-react": "^7.18.6",
|
||||
"@babel/preset-typescript": "^7.21.0",
|
||||
"@rollup/plugin-alias": "^5.1.0",
|
||||
"@rollup/plugin-commonjs": "^25.0.2",
|
||||
"@rollup/plugin-commonjs": "^29.0.0",
|
||||
"@rollup/plugin-json": "^6.1.0",
|
||||
"@rollup/plugin-node-resolve": "^15.1.0",
|
||||
"@rollup/plugin-replace": "^5.0.5",
|
||||
|
|
@ -68,7 +68,7 @@
|
|||
"jest-junit": "^16.0.0",
|
||||
"librechat-data-provider": "*",
|
||||
"mongodb": "^6.14.2",
|
||||
"rimraf": "^5.0.1",
|
||||
"rimraf": "^6.1.2",
|
||||
"rollup": "^4.22.4",
|
||||
"rollup-plugin-peer-deps-external": "^2.2.4",
|
||||
"ts-node": "^10.9.2",
|
||||
|
|
@ -84,7 +84,7 @@
|
|||
"@azure/storage-blob": "^12.27.0",
|
||||
"@keyv/redis": "^4.3.3",
|
||||
"@langchain/core": "^0.3.79",
|
||||
"@librechat/agents": "^3.0.27",
|
||||
"@librechat/agents": "^3.0.30",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@modelcontextprotocol/sdk": "^1.21.0",
|
||||
"axios": "^1.12.1",
|
||||
|
|
|
|||
|
|
@ -9,9 +9,10 @@ import type {
|
|||
RunConfig,
|
||||
IState,
|
||||
} from '@librechat/agents';
|
||||
import type { IUser } from '@librechat/data-schemas';
|
||||
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 +67,7 @@ export async function createRun({
|
|||
signal,
|
||||
agents,
|
||||
requestBody,
|
||||
user,
|
||||
tokenCounter,
|
||||
customHandlers,
|
||||
indexTokenCountMap,
|
||||
|
|
@ -78,6 +80,7 @@ export async function createRun({
|
|||
streaming?: boolean;
|
||||
streamUsage?: boolean;
|
||||
requestBody?: t.RequestBody;
|
||||
user?: IUser;
|
||||
} & Pick<RunConfig, 'tokenCounter' | 'customHandlers' | 'indexTokenCountMap'>): Promise<
|
||||
Run<IState>
|
||||
> {
|
||||
|
|
@ -118,6 +121,7 @@ export async function createRun({
|
|||
if (llmConfig?.configuration?.defaultHeaders != null) {
|
||||
llmConfig.configuration.defaultHeaders = resolveHeaders({
|
||||
headers: llmConfig.configuration.defaultHeaders as Record<string, string>,
|
||||
user: createSafeUser(user),
|
||||
body: requestBody,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,8 @@ import pick from 'lodash/pick';
|
|||
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 } from '@librechat/data-schemas';
|
||||
import type { TokenMethods, IUser } from '@librechat/data-schemas';
|
||||
import type { FlowStateManager } from '~/flow/manager';
|
||||
import type { TUser } from 'librechat-data-provider';
|
||||
import type { MCPOAuthTokens } from './oauth';
|
||||
import type { RequestBody } from '~/types';
|
||||
import type * as t from './types';
|
||||
|
|
@ -49,7 +48,7 @@ export class MCPManager extends UserConnectionManager {
|
|||
public async getConnection(
|
||||
args: {
|
||||
serverName: string;
|
||||
user?: TUser;
|
||||
user?: IUser;
|
||||
forceNew?: boolean;
|
||||
flowManager?: FlowStateManager<MCPOAuthTokens | null>;
|
||||
} & Omit<t.OAuthConnectionOptions, 'useOAuth' | 'user' | 'flowManager'>,
|
||||
|
|
@ -176,7 +175,7 @@ Please follow these instructions when using tools from the respective MCP server
|
|||
oauthEnd,
|
||||
customUserVars,
|
||||
}: {
|
||||
user?: TUser;
|
||||
user?: IUser;
|
||||
serverName: string;
|
||||
toolName: string;
|
||||
provider: t.Provider;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { logger } from '@librechat/data-schemas';
|
||||
import type { TokenMethods } from '@librechat/data-schemas';
|
||||
import type { TUser } from 'librechat-data-provider';
|
||||
import type { TokenMethods, IUser } from '@librechat/data-schemas';
|
||||
import type { MCPOAuthTokens } from './types';
|
||||
import { OAuthReconnectionTracker } from './OAuthReconnectionTracker';
|
||||
import { FlowStateManager } from '~/flow/manager';
|
||||
|
|
@ -117,7 +116,7 @@ export class OAuthReconnectionManager {
|
|||
// attempt to get connection (this will use existing tokens and refresh if needed)
|
||||
const connection = await this.mcpManager.getUserConnection({
|
||||
serverName,
|
||||
user: { id: userId } as TUser,
|
||||
user: { id: userId } as IUser,
|
||||
flowManager: this.flowManager,
|
||||
tokenMethods: this.tokenMethods,
|
||||
// don't force new connection, let it reuse existing or create new as needed
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -34,6 +34,9 @@ export class MCPServersInitializer {
|
|||
public static async initialize(rawConfigs: t.MCPServers): Promise<void> {
|
||||
if (await statusCache.isInitialized()) return;
|
||||
|
||||
/** Store raw configs immediately so they're available even if initialization fails/is slow */
|
||||
registry.setRawConfigs(rawConfigs);
|
||||
|
||||
if (await isLeader()) {
|
||||
// Leader performs initialization
|
||||
await statusCache.reset();
|
||||
|
|
|
|||
|
|
@ -13,12 +13,22 @@ import {
|
|||
*
|
||||
* Provides a unified interface for retrieving server configs with proper fallback hierarchy:
|
||||
* checks shared app servers first, then shared user servers, then private user servers.
|
||||
* Falls back to raw config when servers haven't been initialized yet or failed to initialize.
|
||||
* Handles server lifecycle operations including adding, removing, and querying configurations.
|
||||
*/
|
||||
class MCPServersRegistry {
|
||||
public readonly sharedAppServers = ServerConfigsCacheFactory.create('App', true);
|
||||
public readonly sharedUserServers = ServerConfigsCacheFactory.create('User', true);
|
||||
public readonly sharedAppServers = ServerConfigsCacheFactory.create('App', false);
|
||||
public readonly sharedUserServers = ServerConfigsCacheFactory.create('User', false);
|
||||
private readonly privateUserServers: Map<string | undefined, ServerConfigsCache> = new Map();
|
||||
private rawConfigs: t.MCPServers = {};
|
||||
|
||||
/**
|
||||
* Stores the raw MCP configuration as a fallback when servers haven't been initialized yet.
|
||||
* Should be called during initialization before inspecting servers.
|
||||
*/
|
||||
public setRawConfigs(configs: t.MCPServers): void {
|
||||
this.rawConfigs = configs;
|
||||
}
|
||||
|
||||
public async addPrivateUserServer(
|
||||
userId: string,
|
||||
|
|
@ -59,15 +69,32 @@ class MCPServersRegistry {
|
|||
const privateUserServer = await this.privateUserServers.get(userId)?.get(serverName);
|
||||
if (privateUserServer) return privateUserServer;
|
||||
|
||||
/** Fallback to raw config if server hasn't been initialized yet */
|
||||
const rawConfig = this.rawConfigs[serverName];
|
||||
if (rawConfig) return rawConfig as t.ParsedServerConfig;
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public async getAllServerConfigs(userId?: string): Promise<Record<string, t.ParsedServerConfig>> {
|
||||
return {
|
||||
const registryConfigs = {
|
||||
...(await this.sharedAppServers.getAll()),
|
||||
...(await this.sharedUserServers.getAll()),
|
||||
...((await this.privateUserServers.get(userId)?.getAll()) ?? {}),
|
||||
};
|
||||
|
||||
/** Include all raw configs, but registry configs take precedence (they have inspection data) */
|
||||
const allConfigs: Record<string, t.ParsedServerConfig> = {};
|
||||
for (const serverName in this.rawConfigs) {
|
||||
allConfigs[serverName] = this.rawConfigs[serverName] as t.ParsedServerConfig;
|
||||
}
|
||||
|
||||
/** Override with registry configs where available (they have richer data) */
|
||||
for (const serverName in registryConfigs) {
|
||||
allConfigs[serverName] = registryConfigs[serverName];
|
||||
}
|
||||
|
||||
return allConfigs;
|
||||
}
|
||||
|
||||
// TODO: This is currently used to determine if a server requires OAuth. However, this info can
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
import { z } from 'zod';
|
||||
import {
|
||||
Tools,
|
||||
SSEOptionsSchema,
|
||||
MCPOptionsSchema,
|
||||
MCPServersSchema,
|
||||
StdioOptionsSchema,
|
||||
WebSocketOptionsSchema,
|
||||
StreamableHTTPOptionsSchema,
|
||||
Tools,
|
||||
} from 'librechat-data-provider';
|
||||
import type { SearchResultData, UIResource, TPlugin, TUser } from 'librechat-data-provider';
|
||||
import type { SearchResultData, UIResource, TPlugin } from 'librechat-data-provider';
|
||||
import type { TokenMethods, JsonSchemaType, IUser } from '@librechat/data-schemas';
|
||||
import type * as t from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { TokenMethods, JsonSchemaType } from '@librechat/data-schemas';
|
||||
import type { FlowStateManager } from '~/flow/manager';
|
||||
import type { RequestBody } from '~/types/http';
|
||||
import type * as o from '~/mcp/oauth/types';
|
||||
|
|
@ -161,7 +161,7 @@ export interface BasicConnectionOptions {
|
|||
}
|
||||
|
||||
export interface OAuthConnectionOptions {
|
||||
user: TUser;
|
||||
user: IUser;
|
||||
useOAuth: true;
|
||||
requestBody?: RequestBody;
|
||||
customUserVars?: Record<string, string>;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { resolveHeaders, processMCPEnv } from './env';
|
||||
import { TokenExchangeMethodEnum } from 'librechat-data-provider';
|
||||
import type { TUser, MCPOptions } from 'librechat-data-provider';
|
||||
import { resolveHeaders, resolveNestedObject, processMCPEnv } from './env';
|
||||
import type { MCPOptions } from 'librechat-data-provider';
|
||||
import type { IUser } from '@librechat/data-schemas';
|
||||
import { Types } from 'mongoose';
|
||||
|
||||
function isStdioOptions(options: MCPOptions): options is Extract<MCPOptions, { type?: 'stdio' }> {
|
||||
return !options.type || options.type === 'stdio';
|
||||
|
|
@ -13,19 +15,21 @@ function isStreamableHTTPOptions(
|
|||
}
|
||||
|
||||
/** Helper function to create test user objects */
|
||||
function createTestUser(overrides: Partial<TUser> = {}): TUser {
|
||||
function createTestUser(overrides: Partial<IUser> = {}): IUser {
|
||||
return {
|
||||
id: 'test-user-id',
|
||||
_id: new Types.ObjectId(),
|
||||
id: new Types.ObjectId().toString(),
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
avatar: 'https://example.com/avatar.png',
|
||||
provider: 'email',
|
||||
role: 'user',
|
||||
createdAt: new Date('2021-01-01').toISOString(),
|
||||
updatedAt: new Date('2021-01-01').toISOString(),
|
||||
createdAt: new Date('2021-01-01'),
|
||||
updatedAt: new Date('2021-01-01'),
|
||||
emailVerified: true,
|
||||
...overrides,
|
||||
};
|
||||
} as IUser;
|
||||
}
|
||||
|
||||
describe('resolveHeaders', () => {
|
||||
|
|
@ -445,6 +449,428 @@ describe('resolveHeaders', () => {
|
|||
const result = resolveHeaders({ headers, body });
|
||||
expect(result['X-Conversation']).toBe('conv-123');
|
||||
});
|
||||
|
||||
describe('non-string header values (type guard tests)', () => {
|
||||
it('should handle numeric header values without crashing', () => {
|
||||
const headers = {
|
||||
'X-Number': 12345 as unknown as string,
|
||||
'X-String': 'normal-string',
|
||||
};
|
||||
const result = resolveHeaders({ headers });
|
||||
expect(result['X-Number']).toBe('12345');
|
||||
expect(result['X-String']).toBe('normal-string');
|
||||
});
|
||||
|
||||
it('should handle boolean header values without crashing', () => {
|
||||
const headers = {
|
||||
'X-Boolean-True': true as unknown as string,
|
||||
'X-Boolean-False': false as unknown as string,
|
||||
'X-String': 'normal-string',
|
||||
};
|
||||
const result = resolveHeaders({ headers });
|
||||
expect(result['X-Boolean-True']).toBe('true');
|
||||
expect(result['X-Boolean-False']).toBe('false');
|
||||
expect(result['X-String']).toBe('normal-string');
|
||||
});
|
||||
|
||||
it('should handle null and undefined header values', () => {
|
||||
const headers = {
|
||||
'X-Null': null as unknown as string,
|
||||
'X-Undefined': undefined as unknown as string,
|
||||
'X-String': 'normal-string',
|
||||
};
|
||||
const result = resolveHeaders({ headers });
|
||||
expect(result['X-Null']).toBe('null');
|
||||
expect(result['X-Undefined']).toBe('undefined');
|
||||
expect(result['X-String']).toBe('normal-string');
|
||||
});
|
||||
|
||||
it('should handle numeric values with placeholders', () => {
|
||||
const user = { id: 'user-123' };
|
||||
const headers = {
|
||||
'X-Number': 42 as unknown as string,
|
||||
'X-String-With-Placeholder': '{{LIBRECHAT_USER_ID}}',
|
||||
};
|
||||
const result = resolveHeaders({ headers, user });
|
||||
expect(result['X-Number']).toBe('42');
|
||||
expect(result['X-String-With-Placeholder']).toBe('user-123');
|
||||
});
|
||||
|
||||
it('should handle objects in header values', () => {
|
||||
const headers = {
|
||||
'X-Object': { nested: 'value' } as unknown as string,
|
||||
'X-String': 'normal-string',
|
||||
};
|
||||
const result = resolveHeaders({ headers });
|
||||
expect(result['X-Object']).toBe('[object Object]');
|
||||
expect(result['X-String']).toBe('normal-string');
|
||||
});
|
||||
|
||||
it('should handle arrays in header values', () => {
|
||||
const headers = {
|
||||
'X-Array': ['value1', 'value2'] as unknown as string,
|
||||
'X-String': 'normal-string',
|
||||
};
|
||||
const result = resolveHeaders({ headers });
|
||||
expect(result['X-Array']).toBe('value1,value2');
|
||||
expect(result['X-String']).toBe('normal-string');
|
||||
});
|
||||
|
||||
it('should handle numeric values with env variables', () => {
|
||||
process.env.TEST_API_KEY = 'test-api-key-value';
|
||||
const headers = {
|
||||
'X-Number': 12345 as unknown as string,
|
||||
'X-Env': '${TEST_API_KEY}',
|
||||
};
|
||||
const result = resolveHeaders({ headers });
|
||||
expect(result['X-Number']).toBe('12345');
|
||||
expect(result['X-Env']).toBe('test-api-key-value');
|
||||
delete process.env.TEST_API_KEY;
|
||||
});
|
||||
|
||||
it('should handle numeric values with body placeholders', () => {
|
||||
const body = {
|
||||
conversationId: 'conv-123',
|
||||
parentMessageId: 'parent-456',
|
||||
messageId: 'msg-789',
|
||||
};
|
||||
const headers = {
|
||||
'X-Number': 999 as unknown as string,
|
||||
'X-Conv': '{{LIBRECHAT_BODY_CONVERSATIONID}}',
|
||||
};
|
||||
const result = resolveHeaders({ headers, body });
|
||||
expect(result['X-Number']).toBe('999');
|
||||
expect(result['X-Conv']).toBe('conv-123');
|
||||
});
|
||||
|
||||
it('should handle mixed type headers with user and custom vars', () => {
|
||||
const user = { id: 'user-123', email: 'test@example.com' };
|
||||
const customUserVars = { CUSTOM_TOKEN: 'secret-token' };
|
||||
const headers = {
|
||||
'X-Number': 42 as unknown as string,
|
||||
'X-Boolean': true as unknown as string,
|
||||
'X-User-Id': '{{LIBRECHAT_USER_ID}}',
|
||||
'X-Custom': '{{CUSTOM_TOKEN}}',
|
||||
'X-String': 'normal',
|
||||
};
|
||||
const result = resolveHeaders({ headers, user, customUserVars });
|
||||
expect(result['X-Number']).toBe('42');
|
||||
expect(result['X-Boolean']).toBe('true');
|
||||
expect(result['X-User-Id']).toBe('user-123');
|
||||
expect(result['X-Custom']).toBe('secret-token');
|
||||
expect(result['X-String']).toBe('normal');
|
||||
});
|
||||
|
||||
it('should not crash when calling includes on non-string body field values', () => {
|
||||
const body = {
|
||||
conversationId: 12345 as unknown as string,
|
||||
parentMessageId: 'parent-456',
|
||||
messageId: 'msg-789',
|
||||
};
|
||||
const headers = {
|
||||
'X-Conv-Id': '{{LIBRECHAT_BODY_CONVERSATIONID}}',
|
||||
'X-Number': 999 as unknown as string,
|
||||
};
|
||||
expect(() => resolveHeaders({ headers, body })).not.toThrow();
|
||||
const result = resolveHeaders({ headers, body });
|
||||
expect(result['X-Number']).toBe('999');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveNestedObject', () => {
|
||||
beforeEach(() => {
|
||||
process.env.TEST_API_KEY = 'test-api-key-value';
|
||||
process.env.ANOTHER_SECRET = 'another-secret-value';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.TEST_API_KEY;
|
||||
delete process.env.ANOTHER_SECRET;
|
||||
});
|
||||
|
||||
it('should preserve nested object structure', () => {
|
||||
const obj = {
|
||||
thinking: {
|
||||
type: 'enabled',
|
||||
budget_tokens: 2000,
|
||||
},
|
||||
anthropic_beta: ['output-128k-2025-02-19'],
|
||||
max_tokens: 4096,
|
||||
temperature: 0.7,
|
||||
};
|
||||
|
||||
const result = resolveNestedObject({ obj });
|
||||
|
||||
expect(result).toEqual({
|
||||
thinking: {
|
||||
type: 'enabled',
|
||||
budget_tokens: 2000,
|
||||
},
|
||||
anthropic_beta: ['output-128k-2025-02-19'],
|
||||
max_tokens: 4096,
|
||||
temperature: 0.7,
|
||||
});
|
||||
});
|
||||
|
||||
it('should process placeholders in string values while preserving structure', () => {
|
||||
const user = { id: 'user-123', email: 'test@example.com' };
|
||||
const obj = {
|
||||
thinking: {
|
||||
type: 'enabled',
|
||||
budget_tokens: 2000,
|
||||
user_context: '{{LIBRECHAT_USER_ID}}',
|
||||
},
|
||||
anthropic_beta: ['output-128k-2025-02-19'],
|
||||
api_key: '${TEST_API_KEY}',
|
||||
max_tokens: 4096,
|
||||
};
|
||||
|
||||
const result = resolveNestedObject({ obj, user });
|
||||
|
||||
expect(result).toEqual({
|
||||
thinking: {
|
||||
type: 'enabled',
|
||||
budget_tokens: 2000,
|
||||
user_context: 'user-123',
|
||||
},
|
||||
anthropic_beta: ['output-128k-2025-02-19'],
|
||||
api_key: 'test-api-key-value',
|
||||
max_tokens: 4096,
|
||||
});
|
||||
});
|
||||
|
||||
it('should process strings in arrays', () => {
|
||||
const user = { id: 'user-123' };
|
||||
const obj = {
|
||||
headers: ['Authorization: Bearer ${TEST_API_KEY}', 'X-User-Id: {{LIBRECHAT_USER_ID}}'],
|
||||
values: [1, 2, 3],
|
||||
mixed: ['string', 42, true, '{{LIBRECHAT_USER_ID}}'],
|
||||
};
|
||||
|
||||
const result = resolveNestedObject({ obj, user });
|
||||
|
||||
expect(result).toEqual({
|
||||
headers: ['Authorization: Bearer test-api-key-value', 'X-User-Id: user-123'],
|
||||
values: [1, 2, 3],
|
||||
mixed: ['string', 42, true, 'user-123'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle deeply nested structures', () => {
|
||||
const user = { id: 'user-123' };
|
||||
const obj = {
|
||||
level1: {
|
||||
level2: {
|
||||
level3: {
|
||||
user_id: '{{LIBRECHAT_USER_ID}}',
|
||||
settings: {
|
||||
api_key: '${TEST_API_KEY}',
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = resolveNestedObject({ obj, user });
|
||||
|
||||
expect(result).toEqual({
|
||||
level1: {
|
||||
level2: {
|
||||
level3: {
|
||||
user_id: 'user-123',
|
||||
settings: {
|
||||
api_key: 'test-api-key-value',
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve all primitive types', () => {
|
||||
const obj = {
|
||||
string: 'text',
|
||||
number: 42,
|
||||
float: 3.14,
|
||||
boolean_true: true,
|
||||
boolean_false: false,
|
||||
null_value: null,
|
||||
undefined_value: undefined,
|
||||
};
|
||||
|
||||
const result = resolveNestedObject({ obj });
|
||||
|
||||
expect(result).toEqual(obj);
|
||||
});
|
||||
|
||||
it('should handle empty objects and arrays', () => {
|
||||
const obj = {
|
||||
empty_object: {},
|
||||
empty_array: [],
|
||||
nested: {
|
||||
also_empty: {},
|
||||
},
|
||||
};
|
||||
|
||||
const result = resolveNestedObject({ obj });
|
||||
|
||||
expect(result).toEqual(obj);
|
||||
});
|
||||
|
||||
it('should handle body placeholders in nested objects', () => {
|
||||
const body = {
|
||||
conversationId: 'conv-123',
|
||||
parentMessageId: 'parent-456',
|
||||
messageId: 'msg-789',
|
||||
};
|
||||
const obj = {
|
||||
metadata: {
|
||||
conversation: '{{LIBRECHAT_BODY_CONVERSATIONID}}',
|
||||
parent: '{{LIBRECHAT_BODY_PARENTMESSAGEID}}',
|
||||
count: 5,
|
||||
},
|
||||
};
|
||||
|
||||
const result = resolveNestedObject({ obj, body });
|
||||
|
||||
expect(result).toEqual({
|
||||
metadata: {
|
||||
conversation: 'conv-123',
|
||||
parent: 'parent-456',
|
||||
count: 5,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle custom user variables in nested objects', () => {
|
||||
const customUserVars = {
|
||||
CUSTOM_TOKEN: 'secret-token',
|
||||
REGION: 'us-west-1',
|
||||
};
|
||||
const obj = {
|
||||
auth: {
|
||||
token: '{{CUSTOM_TOKEN}}',
|
||||
region: '{{REGION}}',
|
||||
timeout: 3000,
|
||||
},
|
||||
};
|
||||
|
||||
const result = resolveNestedObject({ obj, customUserVars });
|
||||
|
||||
expect(result).toEqual({
|
||||
auth: {
|
||||
token: 'secret-token',
|
||||
region: 'us-west-1',
|
||||
timeout: 3000,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle mixed placeholders in nested objects', () => {
|
||||
const user = { id: 'user-123', email: 'test@example.com' };
|
||||
const customUserVars = { CUSTOM_VAR: 'custom-value' };
|
||||
const body = { conversationId: 'conv-456' };
|
||||
|
||||
const obj = {
|
||||
config: {
|
||||
user_id: '{{LIBRECHAT_USER_ID}}',
|
||||
custom: '{{CUSTOM_VAR}}',
|
||||
api_key: '${TEST_API_KEY}',
|
||||
conversation: '{{LIBRECHAT_BODY_CONVERSATIONID}}',
|
||||
nested: {
|
||||
email: '{{LIBRECHAT_USER_EMAIL}}',
|
||||
port: 8080,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = resolveNestedObject({ obj, user, customUserVars, body });
|
||||
|
||||
expect(result).toEqual({
|
||||
config: {
|
||||
user_id: 'user-123',
|
||||
custom: 'custom-value',
|
||||
api_key: 'test-api-key-value',
|
||||
conversation: 'conv-456',
|
||||
nested: {
|
||||
email: 'test@example.com',
|
||||
port: 8080,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle Bedrock additionalModelRequestFields example', () => {
|
||||
const obj = {
|
||||
thinking: {
|
||||
type: 'enabled',
|
||||
budget_tokens: 2000,
|
||||
},
|
||||
anthropic_beta: ['output-128k-2025-02-19'],
|
||||
};
|
||||
|
||||
const result = resolveNestedObject({ obj });
|
||||
|
||||
expect(result).toEqual({
|
||||
thinking: {
|
||||
type: 'enabled',
|
||||
budget_tokens: 2000,
|
||||
},
|
||||
anthropic_beta: ['output-128k-2025-02-19'],
|
||||
});
|
||||
|
||||
expect(typeof result.thinking).toBe('object');
|
||||
expect(Array.isArray(result.anthropic_beta)).toBe(true);
|
||||
expect(result.thinking).not.toBe('[object Object]');
|
||||
});
|
||||
|
||||
it('should return undefined when obj is undefined', () => {
|
||||
const result = resolveNestedObject({ obj: undefined });
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return null when obj is null', () => {
|
||||
const result = resolveNestedObject({ obj: null });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle arrays of objects', () => {
|
||||
const user = { id: 'user-123' };
|
||||
const obj = {
|
||||
items: [
|
||||
{ name: 'item1', user: '{{LIBRECHAT_USER_ID}}', count: 1 },
|
||||
{ name: 'item2', user: '{{LIBRECHAT_USER_ID}}', count: 2 },
|
||||
],
|
||||
};
|
||||
|
||||
const result = resolveNestedObject({ obj, user });
|
||||
|
||||
expect(result).toEqual({
|
||||
items: [
|
||||
{ name: 'item1', user: 'user-123', count: 1 },
|
||||
{ name: 'item2', user: 'user-123', count: 2 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should not modify the original object', () => {
|
||||
const user = { id: 'user-123' };
|
||||
const originalObj = {
|
||||
thinking: {
|
||||
type: 'enabled',
|
||||
budget_tokens: 2000,
|
||||
user_id: '{{LIBRECHAT_USER_ID}}',
|
||||
},
|
||||
};
|
||||
|
||||
const result = resolveNestedObject({ obj: originalObj, user });
|
||||
|
||||
expect(result.thinking.user_id).toBe('user-123');
|
||||
expect(originalObj.thinking.user_id).toBe('{{LIBRECHAT_USER_ID}}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('processMCPEnv', () => {
|
||||
|
|
@ -774,4 +1200,181 @@ describe('processMCPEnv', () => {
|
|||
throw new Error('Expected stdio options');
|
||||
}
|
||||
});
|
||||
|
||||
describe('non-string values (type guard tests)', () => {
|
||||
it('should handle numeric values in env without crashing', () => {
|
||||
const options: MCPOptions = {
|
||||
type: 'stdio',
|
||||
command: 'mcp-server',
|
||||
args: [],
|
||||
env: {
|
||||
PORT: 8080 as unknown as string,
|
||||
TIMEOUT: 30000 as unknown as string,
|
||||
API_KEY: '${TEST_API_KEY}',
|
||||
},
|
||||
};
|
||||
|
||||
const result = processMCPEnv({ options });
|
||||
|
||||
if (isStdioOptions(result)) {
|
||||
expect(result.env?.PORT).toBe('8080');
|
||||
expect(result.env?.TIMEOUT).toBe('30000');
|
||||
expect(result.env?.API_KEY).toBe('test-api-key-value');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle boolean values in env without crashing', () => {
|
||||
const options: MCPOptions = {
|
||||
type: 'stdio',
|
||||
command: 'mcp-server',
|
||||
args: [],
|
||||
env: {
|
||||
DEBUG: true as unknown as string,
|
||||
PRODUCTION: false as unknown as string,
|
||||
API_KEY: '${TEST_API_KEY}',
|
||||
},
|
||||
};
|
||||
|
||||
const result = processMCPEnv({ options });
|
||||
|
||||
if (isStdioOptions(result)) {
|
||||
expect(result.env?.DEBUG).toBe('true');
|
||||
expect(result.env?.PRODUCTION).toBe('false');
|
||||
expect(result.env?.API_KEY).toBe('test-api-key-value');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle numeric values in args without crashing', () => {
|
||||
const options: MCPOptions = {
|
||||
type: 'stdio',
|
||||
command: 'mcp-server',
|
||||
args: ['--port', 8080 as unknown as string, '--timeout', 30000 as unknown as string],
|
||||
};
|
||||
|
||||
const result = processMCPEnv({ options });
|
||||
|
||||
if (isStdioOptions(result)) {
|
||||
expect(result.args).toEqual(['--port', '8080', '--timeout', '30000']);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle null and undefined values in env', () => {
|
||||
const options: MCPOptions = {
|
||||
type: 'stdio',
|
||||
command: 'mcp-server',
|
||||
args: [],
|
||||
env: {
|
||||
NULL_VALUE: null as unknown as string,
|
||||
UNDEFINED_VALUE: undefined as unknown as string,
|
||||
NORMAL_VALUE: 'normal',
|
||||
},
|
||||
};
|
||||
|
||||
const result = processMCPEnv({ options });
|
||||
|
||||
if (isStdioOptions(result)) {
|
||||
expect(result.env?.NULL_VALUE).toBe('null');
|
||||
expect(result.env?.UNDEFINED_VALUE).toBe('undefined');
|
||||
expect(result.env?.NORMAL_VALUE).toBe('normal');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle numeric values in headers without crashing', () => {
|
||||
const options: MCPOptions = {
|
||||
type: 'streamable-http',
|
||||
url: 'https://api.example.com',
|
||||
headers: {
|
||||
'X-Timeout': 5000 as unknown as string,
|
||||
'X-Retry-Count': 3 as unknown as string,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
|
||||
const result = processMCPEnv({ options });
|
||||
|
||||
if (isStreamableHTTPOptions(result)) {
|
||||
expect(result.headers?.['X-Timeout']).toBe('5000');
|
||||
expect(result.headers?.['X-Retry-Count']).toBe('3');
|
||||
expect(result.headers?.['Content-Type']).toBe('application/json');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle numeric URL values', () => {
|
||||
const options: MCPOptions = {
|
||||
type: 'websocket',
|
||||
url: 12345 as unknown as string,
|
||||
};
|
||||
|
||||
const result = processMCPEnv({ options });
|
||||
|
||||
expect((result as unknown as { url?: string }).url).toBe('12345');
|
||||
});
|
||||
|
||||
it('should handle mixed numeric and placeholder values', () => {
|
||||
const user = createTestUser({ id: 'user-123' });
|
||||
const options: MCPOptions = {
|
||||
type: 'stdio',
|
||||
command: 'mcp-server',
|
||||
args: [],
|
||||
env: {
|
||||
PORT: 8080 as unknown as string,
|
||||
USER_ID: '{{LIBRECHAT_USER_ID}}',
|
||||
API_KEY: '${TEST_API_KEY}',
|
||||
},
|
||||
};
|
||||
|
||||
const result = processMCPEnv({ options, user });
|
||||
|
||||
if (isStdioOptions(result)) {
|
||||
expect(result.env?.PORT).toBe('8080');
|
||||
expect(result.env?.USER_ID).toBe('user-123');
|
||||
expect(result.env?.API_KEY).toBe('test-api-key-value');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle objects and arrays in env values', () => {
|
||||
const options: MCPOptions = {
|
||||
type: 'stdio',
|
||||
command: 'mcp-server',
|
||||
args: [],
|
||||
env: {
|
||||
OBJECT_VALUE: { nested: 'value' } as unknown as string,
|
||||
ARRAY_VALUE: ['item1', 'item2'] as unknown as string,
|
||||
STRING_VALUE: 'normal',
|
||||
},
|
||||
};
|
||||
|
||||
const result = processMCPEnv({ options });
|
||||
|
||||
if (isStdioOptions(result)) {
|
||||
expect(result.env?.OBJECT_VALUE).toBe('[object Object]');
|
||||
expect(result.env?.ARRAY_VALUE).toBe('item1,item2');
|
||||
expect(result.env?.STRING_VALUE).toBe('normal');
|
||||
}
|
||||
});
|
||||
|
||||
it('should not crash with numeric body field values', () => {
|
||||
const body = {
|
||||
conversationId: 12345 as unknown as string,
|
||||
parentMessageId: 'parent-456',
|
||||
messageId: 'msg-789',
|
||||
};
|
||||
const options: MCPOptions = {
|
||||
type: 'stdio',
|
||||
command: 'mcp-server',
|
||||
args: [],
|
||||
env: {
|
||||
CONV_ID: '{{LIBRECHAT_BODY_CONVERSATIONID}}',
|
||||
PORT: 8080 as unknown as string,
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => processMCPEnv({ options, body })).not.toThrow();
|
||||
const result = processMCPEnv({ options, body });
|
||||
|
||||
if (isStdioOptions(result)) {
|
||||
expect(result.env?.PORT).toBe('8080');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { extractEnvVariable } from 'librechat-data-provider';
|
||||
import type { TUser, MCPOptions } from 'librechat-data-provider';
|
||||
import type { 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<IUser, AllowedUserField>;
|
|||
|
||||
/**
|
||||
* 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<SafeUser> {
|
||||
export function createSafeUser(
|
||||
user: IUser | null | undefined,
|
||||
): Partial<SafeUser> & { federatedTokens?: unknown } {
|
||||
if (!user) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const safeUser: Partial<SafeUser> = {};
|
||||
const safeUser: Partial<SafeUser> & { 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;
|
||||
}
|
||||
|
||||
|
|
@ -64,18 +71,19 @@ const ALLOWED_BODY_FIELDS = ['conversationId', 'parentMessageId', 'messageId'] a
|
|||
* @param user - The user object
|
||||
* @returns The processed string with placeholders replaced
|
||||
*/
|
||||
function processUserPlaceholders(value: string, user?: TUser): string {
|
||||
function processUserPlaceholders(value: string, user?: IUser): string {
|
||||
if (!user || typeof value !== 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
for (const field of ALLOWED_USER_FIELDS) {
|
||||
const placeholder = `{{LIBRECHAT_USER_${field.toUpperCase()}}}`;
|
||||
if (!value.includes(placeholder)) {
|
||||
|
||||
if (typeof value !== 'string' || !value.includes(placeholder)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fieldValue = user[field as keyof TUser];
|
||||
const fieldValue = user[field as keyof IUser];
|
||||
|
||||
// Skip replacement if field doesn't exist in user object
|
||||
if (!(field in user)) {
|
||||
|
|
@ -104,6 +112,11 @@ function processUserPlaceholders(value: string, user?: TUser): string {
|
|||
* @returns The processed string with placeholders replaced
|
||||
*/
|
||||
function processBodyPlaceholders(value: string, body: RequestBody): string {
|
||||
// Type guard: ensure value is a string
|
||||
if (typeof value !== 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
for (const field of ALLOWED_BODY_FIELDS) {
|
||||
const placeholder = `{{LIBRECHAT_BODY_${field.toUpperCase()}}}`;
|
||||
if (!value.includes(placeholder)) {
|
||||
|
|
@ -134,12 +147,16 @@ function processSingleValue({
|
|||
}: {
|
||||
originalValue: string;
|
||||
customUserVars?: Record<string, string>;
|
||||
user?: TUser;
|
||||
user?: IUser;
|
||||
body?: RequestBody;
|
||||
}): string {
|
||||
// Type guard: ensure we're working with a string
|
||||
if (typeof originalValue !== 'string') {
|
||||
return String(originalValue);
|
||||
}
|
||||
|
||||
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 +166,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;
|
||||
|
|
@ -174,7 +193,7 @@ function processSingleValue({
|
|||
*/
|
||||
export function processMCPEnv(params: {
|
||||
options: Readonly<MCPOptions>;
|
||||
user?: TUser;
|
||||
user?: IUser;
|
||||
customUserVars?: Record<string, string>;
|
||||
body?: RequestBody;
|
||||
}): MCPOptions {
|
||||
|
|
@ -219,7 +238,7 @@ export function processMCPEnv(params: {
|
|||
|
||||
// Process OAuth configuration if it exists (for all transport types)
|
||||
if ('oauth' in newObj && newObj.oauth) {
|
||||
const processedOAuth: Record<string, string | string[] | undefined> = {};
|
||||
const processedOAuth: Record<string, boolean | string | string[] | undefined> = {};
|
||||
for (const [key, originalValue] of Object.entries(newObj.oauth)) {
|
||||
// Only process string values for environment variables
|
||||
// token_exchange_method is an enum and shouldn't be processed
|
||||
|
|
@ -235,6 +254,74 @@ export function processMCPEnv(params: {
|
|||
return newObj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively processes a value, replacing placeholders in strings while preserving structure
|
||||
* @param value - The value to process (can be string, number, boolean, array, object, etc.)
|
||||
* @param options - Processing options
|
||||
* @returns The processed value with the same structure
|
||||
*/
|
||||
function processValue(
|
||||
value: unknown,
|
||||
options: {
|
||||
customUserVars?: Record<string, string>;
|
||||
user?: IUser;
|
||||
body?: RequestBody;
|
||||
},
|
||||
): unknown {
|
||||
if (typeof value === 'string') {
|
||||
return processSingleValue({
|
||||
originalValue: value,
|
||||
customUserVars: options.customUserVars,
|
||||
user: options.user,
|
||||
body: options.body,
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => processValue(item, options));
|
||||
}
|
||||
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const processed: Record<string, unknown> = {};
|
||||
for (const [key, val] of Object.entries(value)) {
|
||||
processed[key] = processValue(val, options);
|
||||
}
|
||||
return processed;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively resolves placeholders in a nested object structure while preserving types.
|
||||
* Only processes string values - leaves numbers, booleans, arrays, and nested objects intact.
|
||||
*
|
||||
* @param options - Configuration object
|
||||
* @param options.obj - The object to process
|
||||
* @param options.user - Optional user object for replacing user field placeholders
|
||||
* @param options.body - Optional request body object for replacing body field placeholders
|
||||
* @param options.customUserVars - Optional custom user variables to replace placeholders
|
||||
* @returns The processed object with placeholders replaced in string values
|
||||
*/
|
||||
export function resolveNestedObject<T = unknown>(options?: {
|
||||
obj: T | undefined;
|
||||
user?: Partial<IUser> | { id: string };
|
||||
body?: RequestBody;
|
||||
customUserVars?: Record<string, string>;
|
||||
}): T {
|
||||
const { obj, user, body, customUserVars } = options ?? {};
|
||||
|
||||
if (!obj) {
|
||||
return obj as T;
|
||||
}
|
||||
|
||||
return processValue(obj, {
|
||||
customUserVars,
|
||||
user: user as IUser,
|
||||
body,
|
||||
}) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves header values by replacing user placeholders, body variables, custom variables, and environment variables.
|
||||
*
|
||||
|
|
@ -247,7 +334,7 @@ export function processMCPEnv(params: {
|
|||
*/
|
||||
export function resolveHeaders(options?: {
|
||||
headers: Record<string, string> | undefined;
|
||||
user?: Partial<TUser> | { id: string };
|
||||
user?: Partial<IUser> | { id: string };
|
||||
body?: RequestBody;
|
||||
customUserVars?: Record<string, string>;
|
||||
}) {
|
||||
|
|
@ -261,7 +348,7 @@ export function resolveHeaders(options?: {
|
|||
resolvedHeaders[key] = processSingleValue({
|
||||
originalValue: inputHeaders[key],
|
||||
customUserVars,
|
||||
user: user as TUser,
|
||||
user: user as IUser,
|
||||
body,
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export * from './env';
|
|||
export * from './events';
|
||||
export * from './files';
|
||||
export * from './generators';
|
||||
export * from './path';
|
||||
export * from './key';
|
||||
export * from './latex';
|
||||
export * from './llm';
|
||||
|
|
|
|||
482
packages/api/src/utils/oidc.spec.ts
Normal file
482
packages/api/src/utils/oidc.spec.ts
Normal file
|
|
@ -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<TUser> = {
|
||||
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<TUser> = {
|
||||
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<TUser> = {
|
||||
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<TUser> = {
|
||||
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<TUser> = {
|
||||
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<TUser> = {
|
||||
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<TUser> = {
|
||||
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<TUser> = {
|
||||
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<TUser> = {
|
||||
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<TUser> = {
|
||||
id: 'user-123',
|
||||
provider: 'email',
|
||||
};
|
||||
|
||||
const tokenInfo = extractOpenIDTokenInfo(user);
|
||||
expect(tokenInfo).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
190
packages/api/src/utils/oidc.ts
Normal file
190
packages/api/src/utils/oidc.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import { logger } from '@librechat/data-schemas';
|
||||
import type { IUser } from '@librechat/data-schemas';
|
||||
|
||||
export interface OpenIDTokenInfo {
|
||||
accessToken?: string;
|
||||
idToken?: string;
|
||||
expiresAt?: number;
|
||||
userId?: string;
|
||||
userEmail?: string;
|
||||
userName?: string;
|
||||
claims?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
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 | 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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
97
packages/api/src/utils/path.spec.ts
Normal file
97
packages/api/src/utils/path.spec.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { logger } from '@librechat/data-schemas';
|
||||
import type { Logger } from '@librechat/agents';
|
||||
import { getBasePath } from './path';
|
||||
|
||||
describe('getBasePath', () => {
|
||||
let originalDomainClient: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalDomainClient = process.env.DOMAIN_CLIENT;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.DOMAIN_CLIENT = originalDomainClient;
|
||||
});
|
||||
|
||||
it('should return empty string when DOMAIN_CLIENT is not set', () => {
|
||||
delete process.env.DOMAIN_CLIENT;
|
||||
expect(getBasePath()).toBe('');
|
||||
});
|
||||
|
||||
it('should return empty string when DOMAIN_CLIENT is root path', () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/';
|
||||
expect(getBasePath()).toBe('');
|
||||
});
|
||||
|
||||
it('should return base path for subdirectory deployment', () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
||||
expect(getBasePath()).toBe('/librechat');
|
||||
});
|
||||
|
||||
it('should return base path without trailing slash', () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat/';
|
||||
expect(getBasePath()).toBe('/librechat');
|
||||
});
|
||||
|
||||
it('should handle nested subdirectories', () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/apps/librechat';
|
||||
expect(getBasePath()).toBe('/apps/librechat');
|
||||
});
|
||||
|
||||
it('should handle HTTPS URLs', () => {
|
||||
process.env.DOMAIN_CLIENT = 'https://example.com/librechat';
|
||||
expect(getBasePath()).toBe('/librechat');
|
||||
});
|
||||
|
||||
it('should handle URLs with query parameters', () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat?param=value';
|
||||
expect(getBasePath()).toBe('/librechat');
|
||||
});
|
||||
|
||||
it('should handle URLs with fragments', () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat#section';
|
||||
expect(getBasePath()).toBe('/librechat');
|
||||
});
|
||||
|
||||
it('should return empty string for invalid URL', () => {
|
||||
process.env.DOMAIN_CLIENT = 'not-a-valid-url';
|
||||
// Accepts (infoObject: object), return value is not used
|
||||
const loggerSpy = jest.spyOn(logger, 'warn').mockImplementation(() => {
|
||||
return logger as unknown as Logger;
|
||||
});
|
||||
expect(getBasePath()).toBe('');
|
||||
expect(loggerSpy).toHaveBeenCalledWith(
|
||||
'Error parsing DOMAIN_CLIENT for base path:',
|
||||
expect.objectContaining({
|
||||
message: 'Invalid URL',
|
||||
}),
|
||||
);
|
||||
loggerSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle empty string DOMAIN_CLIENT', () => {
|
||||
process.env.DOMAIN_CLIENT = '';
|
||||
expect(getBasePath()).toBe('');
|
||||
});
|
||||
|
||||
it('should handle undefined DOMAIN_CLIENT', () => {
|
||||
process.env.DOMAIN_CLIENT = undefined;
|
||||
expect(getBasePath()).toBe('');
|
||||
});
|
||||
|
||||
it('should handle null DOMAIN_CLIENT', () => {
|
||||
// @ts-expect-error Testing null case
|
||||
process.env.DOMAIN_CLIENT = null;
|
||||
expect(getBasePath()).toBe('');
|
||||
});
|
||||
|
||||
it('should handle URLs with ports', () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:8080/librechat';
|
||||
expect(getBasePath()).toBe('/librechat');
|
||||
});
|
||||
|
||||
it('should handle URLs with subdomains', () => {
|
||||
process.env.DOMAIN_CLIENT = 'https://app.example.com/librechat';
|
||||
expect(getBasePath()).toBe('/librechat');
|
||||
});
|
||||
});
|
||||
25
packages/api/src/utils/path.ts
Normal file
25
packages/api/src/utils/path.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { logger } from '@librechat/data-schemas';
|
||||
|
||||
/**
|
||||
* Gets the base path from the DOMAIN_CLIENT environment variable.
|
||||
* This is useful for constructing URLs when LibreChat is served from a subdirectory.
|
||||
* @returns {string} The base path (e.g., '/librechat' or '')
|
||||
*/
|
||||
export function getBasePath(): string {
|
||||
if (!process.env.DOMAIN_CLIENT) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
const clientUrl = new URL(process.env.DOMAIN_CLIENT);
|
||||
// Keep consistent with the logic in api/server/index.js
|
||||
const baseHref = clientUrl.pathname.endsWith('/')
|
||||
? clientUrl.pathname.slice(0, -1) // Remove trailing slash for path construction
|
||||
: clientUrl.pathname;
|
||||
|
||||
return baseHref === '/' ? '' : baseHref;
|
||||
} catch (error) {
|
||||
logger.warn('Error parsing DOMAIN_CLIENT for base path:', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
|
@ -60,8 +60,7 @@ describe('sanitizeTitle', () => {
|
|||
});
|
||||
|
||||
it('should handle multiple attributes', () => {
|
||||
const input =
|
||||
'<think reason="test" type="deep" id="1">reasoning</think> Title';
|
||||
const input = '<think reason="test" type="deep" id="1">reasoning</think> Title';
|
||||
expect(sanitizeTitle(input)).toBe('Title');
|
||||
});
|
||||
|
||||
|
|
@ -170,8 +169,7 @@ describe('sanitizeTitle', () => {
|
|||
});
|
||||
|
||||
it('should handle real-world with attributes', () => {
|
||||
const input =
|
||||
'<think reasoning="multi-step">\nStep 1\nStep 2\n</think> Project Status';
|
||||
const input = '<think reasoning="multi-step">\nStep 1\nStep 2\n</think> Project Status';
|
||||
expect(sanitizeTitle(input)).toBe('Project Status');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-alias": "^5.1.0",
|
||||
"@rollup/plugin-commonjs": "^25.0.2",
|
||||
"@rollup/plugin-commonjs": "^29.0.0",
|
||||
"@rollup/plugin-node-resolve": "^15.0.0",
|
||||
"@rollup/plugin-replace": "^5.0.5",
|
||||
"@rollup/plugin-terser": "^0.4.4",
|
||||
|
|
@ -85,7 +85,7 @@
|
|||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-i18next": "^15.4.0",
|
||||
"rimraf": "^5.0.1",
|
||||
"rimraf": "^6.1.2",
|
||||
"rollup": "^4.0.0",
|
||||
"rollup-plugin-peer-deps-external": "^2.2.4",
|
||||
"rollup-plugin-postcss": "^4.0.2",
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@
|
|||
"@babel/preset-typescript": "^7.21.0",
|
||||
"@langchain/core": "^0.3.62",
|
||||
"@rollup/plugin-alias": "^5.1.0",
|
||||
"@rollup/plugin-commonjs": "^25.0.2",
|
||||
"@rollup/plugin-commonjs": "^29.0.0",
|
||||
"@rollup/plugin-json": "^6.1.0",
|
||||
"@rollup/plugin-node-resolve": "^15.1.0",
|
||||
"@rollup/plugin-replace": "^5.0.5",
|
||||
|
|
@ -63,7 +63,7 @@
|
|||
"jest": "^30.2.0",
|
||||
"jest-junit": "^16.0.0",
|
||||
"openapi-types": "^12.1.3",
|
||||
"rimraf": "^5.0.1",
|
||||
"rimraf": "^6.1.2",
|
||||
"rollup": "^4.22.4",
|
||||
"rollup-plugin-peer-deps-external": "^2.2.4",
|
||||
"rollup-plugin-typescript2": "^0.35.0",
|
||||
|
|
|
|||
|
|
@ -149,6 +149,10 @@ export const resetPassword = () => `${BASE_URL}/api/auth/resetPassword`;
|
|||
|
||||
export const verifyEmail = () => `${BASE_URL}/api/user/verify`;
|
||||
|
||||
// Auth page URLs (for client-side navigation and redirects)
|
||||
export const loginPage = () => `${BASE_URL}/login`;
|
||||
export const registerPage = () => `${BASE_URL}/register`;
|
||||
|
||||
export const resendVerificationEmail = () => `${BASE_URL}/api/user/verify/resend`;
|
||||
|
||||
export const plugins = () => `${BASE_URL}/api/plugins`;
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ export * from './accessPermissions';
|
|||
export * from './keys';
|
||||
/* api call helpers */
|
||||
export * from './headers-helpers';
|
||||
export { loginPage, registerPage, apiBaseUrl } from './api-endpoints';
|
||||
export { default as request } from './request';
|
||||
export { dataService };
|
||||
import * as dataService from './data-service';
|
||||
|
|
|
|||
|
|
@ -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) */
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ if (typeof window !== 'undefined') {
|
|||
`Refresh token failed from shared link, attempting request to ${originalRequest.url}`,
|
||||
);
|
||||
} else {
|
||||
window.location.href = '/login';
|
||||
window.location.href = endpoints.loginPage();
|
||||
}
|
||||
} catch (err) {
|
||||
processQueue(err as AxiosError, null);
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@
|
|||
"homepage": "https://librechat.ai",
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-alias": "^5.1.0",
|
||||
"@rollup/plugin-commonjs": "^25.0.2",
|
||||
"@rollup/plugin-commonjs": "^29.0.0",
|
||||
"@rollup/plugin-json": "^6.1.0",
|
||||
"@rollup/plugin-node-resolve": "^15.1.0",
|
||||
"@rollup/plugin-replace": "^5.0.5",
|
||||
|
|
@ -51,7 +51,7 @@
|
|||
"jest": "^30.2.0",
|
||||
"jest-junit": "^16.0.0",
|
||||
"mongodb-memory-server": "^10.1.4",
|
||||
"rimraf": "^5.0.1",
|
||||
"rimraf": "^6.1.2",
|
||||
"rollup": "^4.22.4",
|
||||
"rollup-plugin-peer-deps-external": "^2.2.4",
|
||||
"rollup-plugin-typescript2": "^0.35.0",
|
||||
|
|
|
|||
473
src/tests/oidc-integration.test.ts
Normal file
473
src/tests/oidc-integration.test.ts
Normal file
|
|
@ -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<IUser> = {
|
||||
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<IUser> = {
|
||||
...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<IUser> = {
|
||||
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<IUser> = {
|
||||
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<IUser> = {
|
||||
...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<IUser> = {
|
||||
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<IUser> = {
|
||||
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<IUser> = {
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue