Compare commits

..

40 Commits

Author SHA1 Message Date
Danny Avila
95214c43c2 chore: revert removal of unused vars for versioning 2025-06-20 16:22:45 -04:00
Danny Avila
f8c8b89f4d chore: address ESLint errors 2025-06-20 16:22:45 -04:00
Danny Avila
5512c55d71 chore: linting, remove unused vars, and remove project-related parameters from updateAgentHandler 2025-06-20 16:22:45 -04:00
Danny Avila
e07067c86d chore: linting in api/models/Agent.js and removed unused variables 2025-06-20 16:22:44 -04:00
Danny Avila
2bd1eb3cca refactor: remove German locale (must be added via i18n) 2025-06-20 16:22:44 -04:00
Atef Bellaaj
57084f2caa feat: enforce at least one owner requirement for permission updates and add corresponding localization messages 2025-06-20 16:22:44 -04:00
Atef Bellaaj
669af746ed feat: add support for including Entra ID group owners as members in permissions management + fix Group members paging 2025-06-20 16:22:43 -04:00
Atef Bellaaj
f9994d1547 feat: enhance agent permissions migration with DocumentDB compatibility and add dry-run script 2025-06-20 16:22:43 -04:00
Atef Bellaaj
c62a23fafc refactor: remove problematic projection pipelines in getResourcePermissions for document db aws compatibility 2025-06-20 16:22:42 -04:00
Atef Bellaaj
6bbefcd16e fix: add roleId parameter to grantPermission and update tests for GraphApiService 2025-06-20 16:22:42 -04:00
Atef Bellaaj
1016a33b89 feat: add check for People.Read scope in searchContacts 2025-06-20 16:22:42 -04:00
Atef Bellaaj
c9aa10d3d5 - Fix ObjectId comparison in getListAgentsHandler using .equals() method instead of strict equality
- Add findPubliclyAccessibleResources function to PermissionService for bulk public resource queries
  - Add hasPublicPermission function to PermissionService for individual resource public permission checks
  - Update getAgentHandler to use hasPublicPermission for accurate individual agent public status
  - Replace instanceProjectId-based global checks with isPublic property from backend in client code
  - Add isPublic property to Agent type definition
  - Add NODE_TLS_REJECT_UNAUTHORIZED debug setting to VS Code launch config
2025-06-20 16:22:41 -04:00
Atef Bellaaj
5979efd607 refactor permission service with reuse of model methods from data-schema package 2025-06-20 16:22:41 -04:00
Atef Bellaaj
25b97ba388 - add cursor-based pagination to getListAgentsByAccess and update handler
- add index on updatedAt and _id in agent schema for improved query performance
2025-06-20 16:22:41 -04:00
Danny Avila
e97444a863 chore: address ESLint Warnings 2025-06-20 16:22:40 -04:00
Danny Avila
717f61d878 ci: enhance GraphApiService tests with additional logger mocks
- Added mock implementation for logger methods in GraphApiService tests to improve error and debug logging during test execution.
- Ensured existing mocks remain intact while enhancing test coverage and clarity.
2025-06-20 16:22:40 -04:00
Danny Avila
dbb234d3bf ci: enhance GraphApiService tests with MongoDB in-memory server
- Updated test setup to use MongoDB in-memory server for isolated testing.
- Refactored beforeEach to beforeAll for database connection management.
- Cleared database before each test to ensure a clean state.
- Retained existing mocks while improving test structure for better clarity.
2025-06-20 16:22:39 -04:00
Danny Avila
718af1ada1 ci: enhance AgentFooter tests with improved mocks and permission handling
- Updated mocks for `useWatch`, `useAuthContext`, `useHasAccess`, and `useResourcePermissions` to streamline test setup.
- Adjusted assertions to reflect changes in UI based on agent ID and user roles.
- Replaced `share-agent` component with `grant-access-dialog` in tests to align with recent UI updates.
- Added tests for handling null agent data and permissions loading scenarios.
2025-06-20 16:22:39 -04:00
Danny Avila
ee4cdaed20 refactor: remove unused mocks from GraphApiService tests 2025-06-20 16:22:39 -04:00
Danny Avila
f897420586 ci: add unit tests for access control middleware
- Introduced tests for the `canAccessAgentResource` middleware to validate permission checks for agent resources.
- Implemented tests for various scenarios including user roles, ACL entries, and permission levels.
- Added tests for the `checkAccess` function to ensure proper permission handling based on user roles and permissions.
- Utilized MongoDB in-memory server for isolated test environments.
2025-06-20 16:22:38 -04:00
Danny Avila
004f128aec ci: fix existing tests based off new permission handling
- Renamed test cases to reflect changes in permission checks being handled at the route level.
- Updated assertions to verify that agents are returned regardless of user permissions due to the new permission system.
- Adjusted mocks in AppService and PermissionService tests to ensure proper functionality without relying on actual implementations.
2025-06-20 16:22:38 -04:00
Danny Avila
c07a342c97 chore: remove unused translation keys from localization file 2025-06-20 16:22:38 -04:00
Danny Avila
fd64e2380e chore(data-schemas): add comprehensive README for data schemas package
- Introduced a detailed README.md file outlining the structure, architecture patterns, and best practices for the LibreChat Data Schemas package.
- Included guidelines for creating new entities, type definitions, schema files, model factory functions, and database methods.
- Added examples and common patterns to enhance understanding and usage of the package.
2025-06-20 16:22:37 -04:00
Danny Avila
3f82aed9eb fix: imports in migrate-agent-permissions.js 2025-06-20 16:22:37 -04:00
Danny Avila
0143ae5728 fix(data-schemas): use partial index for group idOnTheSource uniqueness
Replace sparse index with partial filter expression to allow multiple local groups
while maintaining unique constraint for external source IDs. The sparse option
on compound indexes doesn't work as expected when one field is always present.
2025-06-20 16:22:37 -04:00
Danny Avila
eed43e6662 feat: Add granular role-based permissions system with Entra ID integration
- Implement RBAC with viewer/editor/owner roles using bitwise permissions
      - Add AccessRole, AclEntry, and Group models for permission management
      - Create PermissionService for core permission logic and validation
      - Integrate Microsoft Graph API for Entra ID user/group search
      - Add middleware for resource access validation with custom ID resolvers
      - Implement bulk permission updates with transaction support
      - Create permission management UI with people picker and role selection
      - Add public sharing capabilities for resources
      - Include database migration for existing agent ownership
      - Support hybrid local/Entra ID identity management
      - Add comprehensive test coverage for all new services

chore: Update @librechat/data-schemas to version 0.0.9 and export common module in index.ts

fix: Update userGroup tests to mock logger correctly and change principalId expectation from null to undefined
2025-06-20 16:22:36 -04:00
Danny Avila
fa54c9ae90 📦 chore: Bump Agents Packages (#7992)
* chore: update peer dependency for @librechat/agents to version 2.4.41

* 🔧 chore: proxy handling in OpenAI endpoint to use undici

* 🔧 chore: update @anthropic-ai/sdk to version 0.52.0 and refactor proxy handling to use undici

* 🔧 chore: update globIgnores in vite.config.ts to exclude index.html from caching

* 🔧 ci: update proxy handling in getLLMConfig to use fetchOptions and ProxyAgent

* 🔧 chore: refactor proxy handling in Anthropic and OpenAI clients to use fetchOptions

* refactor: agent initialization to streamline model parameters and resendFiles handling

* chore: update @google/generative-ai to version 0.24.0
2025-06-20 15:49:24 -04:00
Danny Avila
97085073d2 🔧 fix: Potential Null Values for Custom Config 2025-06-19 22:54:49 -04:00
github-actions[bot]
299c484c7a 🌍 i18n: Update translation.json with latest translations (#7893)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-06-19 20:45:05 -04:00
Danny Avila
d53cd1f391 🔧 fix: Immutability Issue in useChatFunctions (#7983)
* fix: Use mutable version of conversation in useChatFunctions

* fix: Use cloneDeep for conversation in useChatFunctions to ensure immutability

---------

Co-authored-by: lucioperca <lucioperca@users.noreply.github.com>
2025-06-19 19:21:38 -04:00
Danny Avila
a5e8d009a1 🔧 fix: Logger Paths and Exclude index.html from Service Worker Caching (#7982) 2025-06-19 19:00:45 -04:00
Danny Avila
3e4b01de82 🗝️ feat: User Provided Credentials for MCP Servers (#7980)
* 🗝️ feat: Per-User Credentials for MCP Servers

chore: add aider to gitignore

feat: fill custom variables to MCP server

feat: replace placeholders with custom user MCP variables

feat: handle MCP install/uninstall (uses pluginauths)

feat: add MCP custom variables dialog to MCPSelect

feat: add MCP custom variables dialog to the side panel

feat: do not require to fill MCP credentials for in tools dialog

feat: add translations keys (en+cs) for custom MCP variables

fix: handle LIBRECHAT_USER_ID correctly during MCP var replacement

style: remove unused MCP translation keys

style: fix eslint for MCP custom vars

chore: move aider gitignore to AI section

* feat: Add Plugin Authentication Methods to data-schemas

* refactor: Replace PluginAuth model methods with new utility functions for improved code organization and maintainability

* refactor: Move IPluginAuth interface to types directory for better organization and update pluginAuth schema to use the new import

* refactor: Remove unused getUsersPluginsAuthValuesMap function and streamline PluginService.js; add new getPluginAuthMap function for improved plugin authentication handling

* chore: fix typing for optional tools property with GenericTool[] type

* chore: update librechat-data-provider version to 0.7.88

* refactor: optimize getUserMCPAuthMap function by reducing variable usage and improving server key collection logic

* refactor: streamline MCP tool creation by removing customUserVars parameter and enhancing user-specific authentication handling to avoid closure encapsulation

* refactor: extract processSingleValue function to streamline MCP environment variable processing and enhance readability

* refactor: enhance MCP tool processing logic by simplifying conditions and improving authentication handling for custom user variables

* ci: fix action tests

* chore: fix imports, remove comments

* chore: remove non-english translations

* fix: remove newline at end of translation.json file

---------

Co-authored-by: Aleš Kůtek <kutekales@gmail.com>
2025-06-19 18:27:55 -04:00
Dustin Healy
8b15bb2ed6 feat: Enhance Agent Panel with Tool Grouping (#7951)
*  feat: Enhance Agent Panel with Tool Grouping

* 🧰 feat: Added support for grouping tools in the Agent Panel, allowing for better organization and management of related tools.
* 💡 feat: Added hovercards for tools belonging to a group which display their tool descriptions when their help icon is hovered over.
* 🧹 chore: Updated the AgentPanelContext to include grouped tools and their metadata.
* 🔨 refactor: Refactored AgentConfig and AgentTool components to utilize the new tool structure and enhance rendering logic.
* 🔍 feat: Improved the ToolSelectDialog to filter and display tools based on user input, including searching for tools within a group, and limits viewport height to prevent overflowing vertically on smaller screens.

This update enhances the overall functionality and usability of the Agent Panel, making it easier for users to interact with tools.

* Potential fix for code scanning alert no. 6217: Disallow unused variables

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* fix: Agent tool type mismatches

* fix: accessibility issues and mcp tool overflow issue

* fix: enhance keyboard accessibility and prevent event propagation in AgentTool

* chore: WIP types

* chore: address comments and fix accordian collapse bug

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
2025-06-19 10:01:50 -04:00
Danny Avila
c7e4523d7c 🎯 refactor: LaTeX and Math Rendering (#7952)
* refactor: Markdown LaTeX processing

- Added micromark-extension-llm-math as a dependency in package.json and package-lock.json.
- Updated Vite configuration to alias micromark-extension-math.
- Modified Markdown components to use singleDollarTextMath: false for improved LaTeX rendering.
- Refactored latex utility functions to enhance LaTeX processing and escaping mechanisms.

* chore: linting of `EditTextPart`

* fix: handle key up to initiate edit of latest user message by adding id prop to Edit Message HoverButton

* chore: linting in Artifact component

* refactor: enhance LaTeX preprocessing functionality

- Updated `preprocessLaTeX` to improve handling of currency and LaTeX expressions.
- Introduced optimized regex patterns for better performance.
- Added support for escaping mhchem commands and handling code blocks.
- Enhanced tests for various LaTeX scenarios, including currency and special characters.
- Refactored existing tests to align with new preprocessing logic.

* chore: filter out false positives in unused packages workflow

- Added a grep command to exclude the micromark-extension-llm-math package from the list of unused dependencies in the GitHub Actions workflow.
2025-06-18 00:58:51 -04:00
Robin Anderson
dba0ec4320 🔧 chore: update pricing for OpenAI o3 (#7948)
`o3` is now 80% cheaper, at $2/Mt input and $8/Mt output.
https://openai.com/api/pricing/
2025-06-17 21:27:31 -04:00
Rakshit Tiwari
3c9357580e 🖼️ feat: Add support for HEIC image format (#7914)
* feat: Add HEIC image format support with client-side conversion - Add HEIC/HEIF mime types to supported image formats - Install heic-to library for client-side HEIC to JPEG conversion - Create heicConverter utility with detection and conversion functions - Integrate HEIC processing into file upload flow - Add error handling and localization for HEIC conversion failures - Maintain backward compatibility with existing image formats - Resolves #5570

* feat: Add UI feedback during HEIC conversion - Show file thumbnail

* Addressing eslint errors

* Addressing the vite bundler issue
2025-06-17 21:12:15 -04:00
Danny Avila
10c0d7d474 ⬇️ fix: Image Download Browser Compatibility (#7950)
* fix: Add null check for SelectedTTS in MessageAudio component to prevent rendering issues

* fix: image download browser compatibility with error handling and fallback mechanism

- Updated the downloadImage function to use fetch for improved reliability and added error handling.
- Implemented a fallback to the original download method in case of fetch failure.
- Ensured the download link uses a blob URL for better compatibility with various image types.
2025-06-17 21:11:39 -04:00
Danny Avila
ec7370dfe9 🪐 feat: MCP OAuth 2.0 Discovery Support (#7924)
* chore: Update @modelcontextprotocol/sdk to version 1.12.3 in package.json and package-lock.json

- Bump version of @modelcontextprotocol/sdk to 1.12.3 to incorporate recent updates.
- Update dependencies for ajv and cross-spawn to their latest versions.
- Add ajv as a new dependency in the sdk module.
- Include json-schema-traverse as a new dependency in the sdk module.

* feat: @librechat/auth

* feat: Add crypto module exports to auth package

- Introduced a new crypto module by creating index.ts in the crypto directory.
- Updated the main index.ts of the auth package to export from the new crypto module.

* feat: Update package dependencies and build scripts for auth package

- Added @librechat/auth as a dependency in package.json and package-lock.json.
- Updated build scripts to include the auth package in both frontend and bun build processes.
- Removed unused mongoose and openid-client dependencies from package-lock.json for cleaner dependency management.

* refactor: Migrate crypto utility functions to @librechat/auth

- Replaced local crypto utility imports with the new @librechat/auth package across multiple files.
- Removed the obsolete crypto.js file and its exports.
- Updated relevant services and models to utilize the new encryption and decryption methods from @librechat/auth.

* feat: Enhance OAuth token handling and update dependencies in auth package

* chore: Remove Token model and TokenService due to restructuring of OAuth handling

- Deleted the Token.js model and TokenService.js, which were responsible for managing OAuth tokens.
- This change is part of a broader refactor to streamline OAuth token management and improve code organization.

* refactor: imports from '@librechat/auth' to '@librechat/api' and add OAuth token handling functionality

* refactor: Simplify logger usage in MCP and FlowStateManager classes

* chore: fix imports

* feat: Add OAuth configuration schema to MCP with token exchange method support

* feat: FIRST PASS Implement MCP OAuth flow with token management and error handling

- Added a new route for handling OAuth callbacks and token retrieval.
- Integrated OAuth token storage and retrieval mechanisms.
- Enhanced MCP connection to support automatic OAuth flow initiation on 401 errors.
- Implemented dynamic client registration and metadata discovery for OAuth.
- Updated MCPManager to manage OAuth tokens and handle authentication requirements.
- Introduced comprehensive logging for OAuth processes and error handling.

* refactor: Update MCPConnection and MCPManager to utilize new URL handling

- Added a `url` property to MCPConnection for better URL management.
- Refactored MCPManager to use the new `url` property instead of a deprecated method for OAuth handling.
- Changed logging from info to debug level for flow manager and token methods initialization.
- Improved comments for clarity on existing tokens and OAuth event listener setup.

* refactor: Improve connection timeout error messages in MCPConnection and MCPManager and use initTimeout for connection

- Updated the connection timeout error messages to include the duration of the timeout.
- Introduced a configurable `connectTimeout` variable in both MCPConnection and MCPManager for better flexibility.

* chore: cleanup MCP OAuth Token exchange handling; fix: erroneous use of flowsCache and remove verbose logs

* refactor: Update MCPManager and MCPTokenStorage to use TokenMethods for token management

- Removed direct token storage handling in MCPManager and replaced it with TokenMethods for better abstraction.
- Refactored MCPTokenStorage methods to accept parameters for token operations, enhancing flexibility and readability.
- Improved logging messages related to token persistence and retrieval processes.

* refactor: Update MCP OAuth handling to use static methods and improve flow management

- Refactored MCPOAuthHandler to utilize static methods for initiating and completing OAuth flows, enhancing clarity and reducing instance dependencies.
- Updated MCPManager to pass flowManager explicitly to OAuth handling methods, improving flexibility in flow state management.
- Enhanced comments and logging for better understanding of OAuth processes and flow state retrieval.

* refactor: Integrate token methods into createMCPTool for enhanced token management

* refactor: Change logging from info to debug level in MCPOAuthHandler for improved log management

* chore: clean up logging

* feat: first pass, auth URL from MCP OAuth flow

* chore: Improve logging format for OAuth authentication URL display

* chore: cleanup mcp manager comments

* feat: add connection reconnection logic in MCPManager

* refactor: reorganize token storage handling in MCP

- Moved token storage logic from MCPManager to a new MCPTokenStorage class for better separation of concerns.
- Updated imports to reflect the new token storage structure.
- Enhanced methods for storing, retrieving, updating, and deleting OAuth tokens, improving overall token management.

* chore: update comment for SYSTEM_USER_ID in MCPManager for clarity

* feat: implement refresh token functionality in MCP

- Added refresh token handling in MCPManager to support token renewal for both app-level and user-specific connections.
- Introduced a refreshTokens function to facilitate token refresh logic.
- Enhanced MCPTokenStorage to manage client information and refresh token processes.
- Updated logging for better traceability during token operations.

* chore: cleanup @librechat/auth

* feat: implement MCP server initialization in a separate service

- Added a new service to handle the initialization of MCP servers, improving code organization and readability.
- Refactored the server startup logic to utilize the new initializeMCP function.
- Removed redundant MCP initialization code from the main server file.

* fix: don't log auth url for user connections

* feat: enhance OAuth flow with success and error handling components

- Updated OAuth callback routes to redirect to new success and error pages instead of sending status messages.
- Introduced `OAuthSuccess` and `OAuthError` components to provide user feedback during authentication.
- Added localization support for success and error messages in the translation files.
- Implemented countdown functionality in the success component for a better user experience.

* fix: refresh token handling for user connections, add missing URL and methods

- add standard enum for system user id and helper for determining app-lvel vs. user-level connections

* refactor: update token handling in MCPManager and MCPTokenStorage

* fix: improve error logging in OAuth authentication handler

* fix: concurrency issues for both login url emission and concurrency of oauth flows for shared flows (same user, same server, multiple calls for same server)

* fix: properly fail shared flows for concurrent server calls and prevent duplication of tokens

* chore: remove unused auth package directory from update configuration

* ci: fix mocks in samlStrategy tests

* ci: add mcpConfig to AppService test setup

* chore: remove obsolete MCP OAuth implementation documentation

* fix: update build script for API to use correct command

* chore: bump version of @librechat/api to 1.2.4

* fix: update abort signal handling in createMCPTool function

* fix: add optional clientInfo parameter to refreshTokensFunction metadata

* refactor: replace app.locals.availableTools with getCachedTools in multiple services and controllers for improved tool management

* fix: concurrent refresh token handling issue

* refactor: add signal parameter to getUserConnection method for improved abort handling

* chore: JSDoc typing for `loadEphemeralAgent`

* refactor: update isConnectionActive method to use destructured parameters for improved readability

* feat: implement caching for MCP tools to handle app-level disconnects for loading list of tools

* ci: fix agent test
2025-06-17 13:50:33 -04:00
Ruben Talstra
b412455e9d 🚮 feat: Enhance "Delete User" Script (#7899)
* 🔧 fix: Enhance user deletion script to allow deep deletion of related data

* 🔧 fix: Update user deletion script to confirm deep deletion of transaction history

* 🔧 fix: Refactor user deletion script to use graceful exit and ensure deep deletion of related data

* Update config/delete-user.js

is a good idea

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-06-15 15:08:31 -04:00
Danny Avila
3af2666890 🪐 refactor: Migrate Share Functionality to Type-Safe Methods (#7903)
* chore: Update import for isEnabled utility in convoAccess middleware

* refactor: Migrate Share functionality to new methods structure in `@librechat/data-schemas`

- Deleted the old Share.js model and moved its functionality to a new share.ts file within the data-schemas package.
- Updated imports across the codebase to reflect the new structure.
- Enhanced error handling and logging in shared link operations.
- Introduced TypeScript types for shared links and related operations to improve type safety and maintainability.

* chore: Update promptGroupSchema validation with typing

* fix: error handling and logging in createSharedLink

* fix: don't allow empty shared link or shared link without messages

* ci: add tests for shared link methods

* chore: Bump version of @librechat/data-schemas to 0.0.9 in package.json and package-lock.json

* chore: Add nanoid as peer dependency

- Introduced `nanoid` as a dependency in `package.json` and `package-lock.json`.
- Replaced UUID generation with `nanoid` for creating unique conversation and message IDs in share methods tests.
2025-06-14 11:24:30 -04:00
280 changed files with 29233 additions and 8479 deletions

View File

@@ -485,6 +485,21 @@ SAML_IMAGE_URL=
# SAML_USE_AUTHN_RESPONSE_SIGNED=
#===============================================#
# Microsoft Graph API / Entra ID Integration #
#===============================================#
# Enable Entra ID people search integration in permissions/sharing system
# When enabled, the people picker will search both local database and Entra ID
USE_ENTRA_ID_FOR_PEOPLE_SEARCH=false
# When enabled, entra id groups owners will be considered as members of the group
ENTRA_ID_INCLUDE_OWNERS_AS_MEMBERS=false
# Microsoft Graph API scopes needed for people/group search
# Default scopes provide access to user profiles and group memberships
OPENID_GRAPH_SCOPES=User.Read,People.Read,GroupMember.Read.All
# LDAP
LDAP_URL=
LDAP_BIND_DN=

View File

@@ -98,6 +98,8 @@ jobs:
cd client
UNUSED=$(depcheck --json | jq -r '.dependencies | join("\n")' || echo "")
UNUSED=$(comm -23 <(echo "$UNUSED" | sort) <(cat ../client_used_deps.txt ../client_used_code.txt | sort) || echo "")
# Filter out false positives
UNUSED=$(echo "$UNUSED" | grep -v "^micromark-extension-llm-math$" || echo "")
echo "CLIENT_UNUSED<<EOF" >> $GITHUB_ENV
echo "$UNUSED" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV

1
.gitignore vendored
View File

@@ -55,6 +55,7 @@ bower_components/
# AI
.clineignore
.cursor
.aider*
# Floobits
.floo

3
.vscode/launch.json vendored
View File

@@ -8,7 +8,8 @@
"skipFiles": ["<node_internals>/**"],
"program": "${workspaceFolder}/api/server/index.js",
"env": {
"NODE_ENV": "production"
"NODE_ENV": "production",
"NODE_TLS_REJECT_UNAUTHORIZED": "0"
},
"console": "integratedTerminal",
"envFile": "${workspaceFolder}/.env"

View File

@@ -190,10 +190,11 @@ class AnthropicClient extends BaseClient {
reverseProxyUrl: this.options.reverseProxyUrl,
}),
apiKey: this.apiKey,
fetchOptions: {},
};
if (this.options.proxy) {
options.httpAgent = new HttpsProxyAgent(this.options.proxy);
options.fetchOptions.agent = new HttpsProxyAgent(this.options.proxy);
}
if (this.options.reverseProxyUrl) {

View File

@@ -1159,6 +1159,7 @@ ${convo}
logger.debug('[OpenAIClient] chatCompletion', { baseURL, modelOptions });
const opts = {
baseURL,
fetchOptions: {},
};
if (this.useOpenRouter) {
@@ -1177,7 +1178,7 @@ ${convo}
}
if (this.options.proxy) {
opts.httpAgent = new HttpsProxyAgent(this.options.proxy);
opts.fetchOptions.agent = new HttpsProxyAgent(this.options.proxy);
}
/** @type {TAzureConfig | undefined} */
@@ -1395,7 +1396,7 @@ ${convo}
...modelOptions,
stream: true,
};
const stream = await openai.beta.chat.completions
const stream = await openai.chat.completions
.stream(params)
.on('abort', () => {
/* Do nothing here */

View File

@@ -309,7 +309,7 @@ describe('AnthropicClient', () => {
};
client.setOptions({ modelOptions, promptCache: true });
const anthropicClient = client.getClient(modelOptions);
expect(anthropicClient.defaultHeaders).not.toHaveProperty('anthropic-beta');
expect(anthropicClient._options.defaultHeaders).toBeUndefined();
});
it('should not add beta header for other models', () => {
@@ -320,7 +320,7 @@ describe('AnthropicClient', () => {
},
});
const anthropicClient = client.getClient();
expect(anthropicClient.defaultHeaders).not.toHaveProperty('anthropic-beta');
expect(anthropicClient._options.defaultHeaders).toBeUndefined();
});
});

View File

@@ -1,14 +1,14 @@
const { mcpToolPattern } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { SerpAPI } = require('@langchain/community/tools/serpapi');
const { Calculator } = require('@langchain/community/tools/calculator');
const { EnvVar, createCodeExecutionTool, createSearchTool } = require('@librechat/agents');
const {
Tools,
Constants,
EToolResources,
loadWebSearchAuth,
replaceSpecialVars,
} = require('librechat-data-provider');
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
const {
availableTools,
manifestToolMap,
@@ -28,11 +28,10 @@ const {
} = require('../');
const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/process');
const { createFileSearchTool, primeFiles: primeSearchFiles } = require('./fileSearch');
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
const { loadAuthValues } = require('~/server/services/Tools/credentials');
const { getCachedTools } = require('~/server/services/Config');
const { createMCPTool } = require('~/server/services/MCP');
const { logger } = require('~/config');
const mcpToolPattern = new RegExp(`^.+${Constants.mcp_delimiter}.+$`);
/**
* Validates the availability and authentication of tools for a user based on environment variables or user-specific plugin authentication values.
@@ -93,7 +92,7 @@ const validateTools = async (user, tools = []) => {
return Array.from(validToolsSet.values());
} catch (err) {
logger.error('[validateTools] There was a problem validating tools', err);
throw new Error('There was a problem validating tools');
throw new Error(err);
}
};
@@ -236,7 +235,7 @@ const loadTools = async ({
/** @type {Record<string, string>} */
const toolContextMap = {};
const appTools = options.req?.app?.locals?.availableTools ?? {};
const appTools = (await getCachedTools({ includeGlobal: true })) ?? {};
for (const tool of tools) {
if (tool === Tools.execute_code) {
@@ -299,6 +298,7 @@ Current Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}' })}
requestedTools[tool] = async () =>
createMCPTool({
req: options.req,
res: options.res,
toolKey: tool,
model: agent?.model ?? model,
provider: agent?.provider ?? endpoint,

View File

@@ -29,6 +29,10 @@ const roles = isRedisEnabled
? new Keyv({ store: keyvRedis })
: new Keyv({ namespace: CacheKeys.ROLES });
const mcpTools = isRedisEnabled
? new Keyv({ store: keyvRedis })
: new Keyv({ namespace: CacheKeys.MCP_TOOLS });
const audioRuns = isRedisEnabled
? new Keyv({ store: keyvRedis, ttl: Time.TEN_MINUTES })
: new Keyv({ namespace: CacheKeys.AUDIO_RUNS, ttl: Time.TEN_MINUTES });
@@ -67,6 +71,7 @@ const openIdExchangedTokensCache = isRedisEnabled
const namespaces = {
[CacheKeys.ROLES]: roles,
[CacheKeys.MCP_TOOLS]: mcpTools,
[CacheKeys.CONFIG_STORE]: config,
[CacheKeys.PENDING_REQ]: pending_req,
[ViolationTypes.BAN]: new Keyv({ store: keyvMongo, namespace: CacheKeys.BANS, ttl: duration }),

View File

@@ -15,7 +15,7 @@ let flowManager = null;
*/
function getMCPManager(userId) {
if (!mcpManager) {
mcpManager = MCPManager.getInstance(logger);
mcpManager = MCPManager.getInstance();
} else {
mcpManager.checkIdleConnections(userId);
}
@@ -30,7 +30,6 @@ function getFlowStateManager(flowsCache) {
if (!flowManager) {
flowManager = new FlowStateManager(flowsCache, {
ttl: Time.ONE_MINUTE * 3,
logger,
});
}
return flowManager;

View File

@@ -1,6 +1,4 @@
/** @type {import('jest').Config} */
module.exports = {
displayName: 'default',
testEnvironment: 'node',
clearMocks: true,
roots: ['<rootDir>'],
@@ -13,7 +11,7 @@ module.exports = {
moduleNameMapper: {
'~/(.*)': '<rootDir>/$1',
'~/data/auth.json': '<rootDir>/__mocks__/auth.mock.json',
'^openid-client/passport$': '<rootDir>/test/__mocks__/openid-client-passport.js',
'^openid-client/passport$': '<rootDir>/test/__mocks__/openid-client-passport.js', // Mock for the passport strategy part
'^openid-client$': '<rootDir>/test/__mocks__/openid-client.js',
},
transformIgnorePatterns: ['/node_modules/(?!(openid-client|oauth4webapi|jose)/).*/'],

View File

@@ -4,14 +4,13 @@ const { logger } = require('@librechat/data-schemas');
const { SystemRoles, Tools, actionDelimiter } = require('librechat-data-provider');
const { GLOBAL_PROJECT_NAME, EPHEMERAL_AGENT_ID, mcp_delimiter } =
require('librechat-data-provider').Constants;
const { CONFIG_STORE, STARTUP_CONFIG } = require('librechat-data-provider').CacheKeys;
const {
getProjectByName,
addAgentIdsToProject,
removeAgentIdsFromProject,
removeAgentFromAllProjects,
} = require('./Project');
const getLogStores = require('~/cache/getLogStores');
const { getCachedTools } = require('~/server/services/Config');
const { getActions } = require('./Action');
const { Agent } = require('~/db/models');
@@ -22,7 +21,7 @@ const { Agent } = require('~/db/models');
* @throws {Error} If the agent creation fails.
*/
const createAgent = async (agentData) => {
const { author, ...versionData } = agentData;
const { author: _author, ...versionData } = agentData;
const timestamp = new Date();
const initialAgentData = {
...agentData,
@@ -55,12 +54,12 @@ const getAgent = async (searchParameter) => await Agent.findOne(searchParameter)
* @param {string} params.agent_id
* @param {string} params.endpoint
* @param {import('@librechat/agents').ClientOptions} [params.model_parameters]
* @returns {Agent|null} The agent document as a plain object, or null if not found.
* @returns {Promise<Agent|null>} The agent document as a plain object, or null if not found.
*/
const loadEphemeralAgent = ({ req, agent_id, endpoint, model_parameters: _m }) => {
const loadEphemeralAgent = async ({ req, agent_id, endpoint, model_parameters: _m }) => {
const { model, ...model_parameters } = _m;
/** @type {Record<string, FunctionTool>} */
const availableTools = req.app.locals.availableTools;
const availableTools = await getCachedTools({ includeGlobal: true });
/** @type {TEphemeralAgent | null} */
const ephemeralAgent = req.body.ephemeralAgent;
const mcpServers = new Set(ephemeralAgent?.mcp);
@@ -111,7 +110,7 @@ const loadAgent = async ({ req, agent_id, endpoint, model_parameters }) => {
return null;
}
if (agent_id === EPHEMERAL_AGENT_ID) {
return loadEphemeralAgent({ req, agent_id, endpoint, model_parameters });
return await loadEphemeralAgent({ req, agent_id, endpoint, model_parameters });
}
const agent = await getAgent({
id: agent_id,
@@ -122,29 +121,7 @@ const loadAgent = async ({ req, agent_id, endpoint, model_parameters }) => {
}
agent.version = agent.versions ? agent.versions.length : 0;
if (agent.author.toString() === req.user.id) {
return agent;
}
if (!agent.projectIds) {
return null;
}
const cache = getLogStores(CONFIG_STORE);
/** @type {TStartupConfig} */
const cachedStartupConfig = await cache.get(STARTUP_CONFIG);
let { instanceProjectId } = cachedStartupConfig ?? {};
if (!instanceProjectId) {
instanceProjectId = (await getProjectByName(GLOBAL_PROJECT_NAME, '_id'))._id.toString();
}
for (const projectObjectId of agent.projectIds) {
const projectId = projectObjectId.toString();
if (projectId === instanceProjectId) {
return agent;
}
}
return agent;
};
/**
@@ -174,7 +151,7 @@ const isDuplicateVersion = (updateData, currentData, versions, actionsHash = nul
'actionsHash', // Exclude actionsHash from direct comparison
];
const { $push, $pull, $addToSet, ...directUpdates } = updateData;
const { $push: _$push, $pull: _$pull, $addToSet: _$addToSet, ...directUpdates } = updateData;
if (Object.keys(directUpdates).length === 0 && !actionsHash) {
return null;
@@ -269,7 +246,14 @@ const updateAgent = async (searchParameter, updateData, options = {}) => {
const currentAgent = await Agent.findOne(searchParameter);
if (currentAgent) {
const { __v, _id, id, versions, author, ...versionData } = currentAgent.toObject();
const {
__v,
_id,
id: __id,
versions,
author: _author,
...versionData
} = currentAgent.toObject();
const { $push, $pull, $addToSet, ...directUpdates } = updateData;
let actionsHash = null;
@@ -460,8 +444,110 @@ const deleteAgent = async (searchParameter) => {
return agent;
};
/**
* Get agents by accessible IDs with optional cursor-based pagination.
* @param {Object} params - The parameters for getting accessible agents.
* @param {Array} [params.accessibleIds] - Array of agent ObjectIds the user has ACL access to.
* @param {Object} [params.otherParams] - Additional query parameters (including author filter).
* @param {number} [params.limit] - Number of agents to return (max 100). If not provided, returns all agents.
* @param {string} [params.after] - Cursor for pagination - get agents after this cursor. // base64 encoded JSON string with updatedAt and _id.
* @returns {Promise<Object>} A promise that resolves to an object containing the agents data and pagination info.
*/
const getListAgentsByAccess = async ({
accessibleIds = [],
otherParams = {},
limit = null,
after = null,
}) => {
const isPaginated = limit !== null && limit !== undefined;
const normalizedLimit = isPaginated ? Math.min(Math.max(1, parseInt(limit) || 20), 100) : null;
// Build base query combining ACL accessible agents with other filters
const baseQuery = { ...otherParams };
if (accessibleIds.length > 0) {
baseQuery._id = { $in: accessibleIds };
}
// Add cursor condition
if (after) {
try {
const cursor = JSON.parse(Buffer.from(after, 'base64').toString('utf8'));
const { updatedAt, _id } = cursor;
const cursorCondition = {
$or: [
{ updatedAt: { $lt: new Date(updatedAt) } },
{ updatedAt: new Date(updatedAt), _id: { $gt: mongoose.Types.ObjectId(_id) } },
],
};
// Merge cursor condition with base query
if (Object.keys(baseQuery).length > 0) {
baseQuery.$and = [{ ...baseQuery }, cursorCondition];
// Remove the original conditions from baseQuery to avoid duplication
Object.keys(baseQuery).forEach((key) => {
if (key !== '$and') delete baseQuery[key];
});
} else {
Object.assign(baseQuery, cursorCondition);
}
} catch (error) {
logger.warn('Invalid cursor:', error.message);
}
}
let query = Agent.find(baseQuery, {
id: 1,
_id: 1,
name: 1,
avatar: 1,
author: 1,
projectIds: 1,
description: 1,
updatedAt: 1,
}).sort({ updatedAt: -1, _id: 1 });
// Only apply limit if pagination is requested
if (isPaginated) {
query = query.limit(normalizedLimit + 1);
}
const agents = await query.lean();
const hasMore = isPaginated ? agents.length > normalizedLimit : false;
const data = (isPaginated ? agents.slice(0, normalizedLimit) : agents).map((agent) => {
if (agent.author) {
agent.author = agent.author.toString();
}
return agent;
});
// Generate next cursor only if paginated
let nextCursor = null;
if (isPaginated && hasMore && data.length > 0) {
const lastAgent = agents[normalizedLimit - 1];
nextCursor = Buffer.from(
JSON.stringify({
updatedAt: lastAgent.updatedAt.toISOString(),
_id: lastAgent._id.toString(),
}),
).toString('base64');
}
return {
object: 'list',
data,
first_id: data.length > 0 ? data[0].id : null,
last_id: data.length > 0 ? data[data.length - 1].id : null,
has_more: hasMore,
after: nextCursor,
};
};
/**
* Get all agents.
* @deprecated Use getListAgentsByAccess for ACL-aware agent listing
* @param {Object} searchParameter - The search parameters to find matching agents.
* @param {string} searchParameter.author - The user ID of the agent's author.
* @returns {Promise<Object>} A promise that resolves to an object containing the agents data and pagination info.
@@ -480,12 +566,13 @@ const getListAgents = async (searchParameter) => {
const agents = (
await Agent.find(query, {
id: 1,
_id: 0,
_id: 1,
name: 1,
avatar: 1,
author: 1,
projectIds: 1,
description: 1,
// @deprecated - isCollaborative replaced by ACL permissions
isCollaborative: 1,
}).lean()
).map((agent) => {
@@ -669,6 +756,7 @@ module.exports = {
revertAgentVersion,
updateAgentProjects,
addAgentResourceFile,
getListAgentsByAccess,
removeAgentResourceFiles,
generateActionMetadataHash,
};

View File

@@ -6,6 +6,10 @@ const originalEnv = {
process.env.CREDS_KEY = '0123456789abcdef0123456789abcdef';
process.env.CREDS_IV = '0123456789abcdef';
jest.mock('~/server/services/Config', () => ({
getCachedTools: jest.fn(),
}));
const mongoose = require('mongoose');
const { v4: uuidv4 } = require('uuid');
const { agentSchema } = require('@librechat/data-schemas');
@@ -23,6 +27,7 @@ const {
generateActionMetadataHash,
revertAgentVersion,
} = require('./Agent');
const { getCachedTools } = require('~/server/services/Config');
/**
* @type {import('mongoose').Model<import('@librechat/data-schemas').IAgent>}
@@ -406,6 +411,7 @@ describe('models/Agent', () => {
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
await mongoose.connect(mongoUri);
});
@@ -1546,6 +1552,12 @@ describe('models/Agent', () => {
test('should test ephemeral agent loading logic', async () => {
const { EPHEMERAL_AGENT_ID } = require('librechat-data-provider').Constants;
getCachedTools.mockResolvedValue({
tool1_mcp_server1: {},
tool2_mcp_server2: {},
another_tool: {},
});
const mockReq = {
user: { id: 'user123' },
body: {
@@ -1556,15 +1568,6 @@ describe('models/Agent', () => {
mcp: ['server1', 'server2'],
},
},
app: {
locals: {
availableTools: {
tool1_mcp_server1: {},
tool2_mcp_server2: {},
another_tool: {},
},
},
},
};
const result = await loadAgent({
@@ -1630,7 +1633,7 @@ describe('models/Agent', () => {
expect(result.version).toBe(1);
});
test('should return null when user is not author and agent has no projectIds', async () => {
test('should return agent even when user is not author (permissions checked at route level)', async () => {
const authorId = new mongoose.Types.ObjectId();
const userId = new mongoose.Types.ObjectId();
const agentId = `agent_${uuidv4()}`;
@@ -1651,12 +1654,18 @@ describe('models/Agent', () => {
model_parameters: { model: 'gpt-4' },
});
expect(result).toBeFalsy();
// With the new permission system, loadAgent returns the agent regardless of permissions
// Permission checks are handled at the route level via middleware
expect(result).toBeTruthy();
expect(result.id).toBe(agentId);
expect(result.name).toBe('Test Agent');
});
test('should handle ephemeral agent with no MCP servers', async () => {
const { EPHEMERAL_AGENT_ID } = require('librechat-data-provider').Constants;
getCachedTools.mockResolvedValue({});
const mockReq = {
user: { id: 'user123' },
body: {
@@ -1667,11 +1676,6 @@ describe('models/Agent', () => {
mcp: [],
},
},
app: {
locals: {
availableTools: {},
},
},
};
const result = await loadAgent({
@@ -1692,16 +1696,13 @@ describe('models/Agent', () => {
test('should handle ephemeral agent with undefined ephemeralAgent in body', async () => {
const { EPHEMERAL_AGENT_ID } = require('librechat-data-provider').Constants;
getCachedTools.mockResolvedValue({});
const mockReq = {
user: { id: 'user123' },
body: {
promptPrefix: 'Basic instructions',
},
app: {
locals: {
availableTools: {},
},
},
};
const result = await loadAgent({
@@ -1734,6 +1735,13 @@ describe('models/Agent', () => {
const { EPHEMERAL_AGENT_ID } = require('librechat-data-provider').Constants;
const largeToolList = Array.from({ length: 100 }, (_, i) => `tool_${i}_mcp_server1`);
const availableTools = largeToolList.reduce((acc, tool) => {
acc[tool] = {};
return acc;
}, {});
getCachedTools.mockResolvedValue(availableTools);
const mockReq = {
user: { id: 'user123' },
body: {
@@ -1744,14 +1752,6 @@ describe('models/Agent', () => {
mcp: ['server1'],
},
},
app: {
locals: {
availableTools: largeToolList.reduce((acc, tool) => {
acc[tool] = {};
return acc;
}, {}),
},
},
};
const result = await loadAgent({
@@ -1766,7 +1766,7 @@ describe('models/Agent', () => {
}
});
test('should handle loadAgent with agent from different project', async () => {
test('should return agent from different project (permissions checked at route level)', async () => {
const authorId = new mongoose.Types.ObjectId();
const userId = new mongoose.Types.ObjectId();
const agentId = `agent_${uuidv4()}`;
@@ -1789,7 +1789,11 @@ describe('models/Agent', () => {
model_parameters: { model: 'gpt-4' },
});
expect(result).toBeFalsy();
// With the new permission system, loadAgent returns the agent regardless of permissions
// Permission checks are handled at the route level via middleware
expect(result).toBeTruthy();
expect(result.id).toBe(agentId);
expect(result.name).toBe('Project Agent');
});
});
});
@@ -2272,6 +2276,13 @@ describe('models/Agent', () => {
test('should handle loadEphemeralAgent with malformed MCP tool names', async () => {
const { EPHEMERAL_AGENT_ID } = require('librechat-data-provider').Constants;
getCachedTools.mockResolvedValue({
malformed_tool_name: {}, // No mcp delimiter
tool__server1: {}, // Wrong delimiter
tool_mcp_server1: {}, // Correct format
tool_mcp_server2: {}, // Different server
});
const mockReq = {
user: { id: 'user123' },
body: {
@@ -2282,16 +2293,6 @@ describe('models/Agent', () => {
mcp: ['server1'],
},
},
app: {
locals: {
availableTools: {
malformed_tool_name: {}, // No mcp delimiter
tool__server1: {}, // Wrong delimiter
tool_mcp_server1: {}, // Correct format
tool_mcp_server2: {}, // Different server
},
},
},
};
const result = await loadAgent({

View File

@@ -1,346 +0,0 @@
const { nanoid } = require('nanoid');
const { logger } = require('@librechat/data-schemas');
const { Constants } = require('librechat-data-provider');
const { Conversation, SharedLink } = require('~/db/models');
const { getMessages } = require('./Message');
class ShareServiceError extends Error {
constructor(message, code) {
super(message);
this.name = 'ShareServiceError';
this.code = code;
}
}
const memoizedAnonymizeId = (prefix) => {
const memo = new Map();
return (id) => {
if (!memo.has(id)) {
memo.set(id, `${prefix}_${nanoid()}`);
}
return memo.get(id);
};
};
const anonymizeConvoId = memoizedAnonymizeId('convo');
const anonymizeAssistantId = memoizedAnonymizeId('a');
const anonymizeMessageId = (id) =>
id === Constants.NO_PARENT ? id : memoizedAnonymizeId('msg')(id);
function anonymizeConvo(conversation) {
if (!conversation) {
return null;
}
const newConvo = { ...conversation };
if (newConvo.assistant_id) {
newConvo.assistant_id = anonymizeAssistantId(newConvo.assistant_id);
}
return newConvo;
}
function anonymizeMessages(messages, newConvoId) {
if (!Array.isArray(messages)) {
return [];
}
const idMap = new Map();
return messages.map((message) => {
const newMessageId = anonymizeMessageId(message.messageId);
idMap.set(message.messageId, newMessageId);
const anonymizedAttachments = message.attachments?.map((attachment) => {
return {
...attachment,
messageId: newMessageId,
conversationId: newConvoId,
};
});
return {
...message,
messageId: newMessageId,
parentMessageId:
idMap.get(message.parentMessageId) || anonymizeMessageId(message.parentMessageId),
conversationId: newConvoId,
model: message.model?.startsWith('asst_')
? anonymizeAssistantId(message.model)
: message.model,
attachments: anonymizedAttachments,
};
});
}
async function getSharedMessages(shareId) {
try {
const share = await SharedLink.findOne({ shareId, isPublic: true })
.populate({
path: 'messages',
select: '-_id -__v -user',
})
.select('-_id -__v -user')
.lean();
if (!share?.conversationId || !share.isPublic) {
return null;
}
const newConvoId = anonymizeConvoId(share.conversationId);
const result = {
...share,
conversationId: newConvoId,
messages: anonymizeMessages(share.messages, newConvoId),
};
return result;
} catch (error) {
logger.error('[getShare] Error getting share link', {
error: error.message,
shareId,
});
throw new ShareServiceError('Error getting share link', 'SHARE_FETCH_ERROR');
}
}
async function getSharedLinks(user, pageParam, pageSize, isPublic, sortBy, sortDirection, search) {
try {
const query = { user, isPublic };
if (pageParam) {
if (sortDirection === 'desc') {
query[sortBy] = { $lt: pageParam };
} else {
query[sortBy] = { $gt: pageParam };
}
}
if (search && search.trim()) {
try {
const searchResults = await Conversation.meiliSearch(search);
if (!searchResults?.hits?.length) {
return {
links: [],
nextCursor: undefined,
hasNextPage: false,
};
}
const conversationIds = searchResults.hits.map((hit) => hit.conversationId);
query['conversationId'] = { $in: conversationIds };
} catch (searchError) {
logger.error('[getSharedLinks] Meilisearch error', {
error: searchError.message,
user,
});
return {
links: [],
nextCursor: undefined,
hasNextPage: false,
};
}
}
const sort = {};
sort[sortBy] = sortDirection === 'desc' ? -1 : 1;
if (Array.isArray(query.conversationId)) {
query.conversationId = { $in: query.conversationId };
}
const sharedLinks = await SharedLink.find(query)
.sort(sort)
.limit(pageSize + 1)
.select('-__v -user')
.lean();
const hasNextPage = sharedLinks.length > pageSize;
const links = sharedLinks.slice(0, pageSize);
const nextCursor = hasNextPage ? links[links.length - 1][sortBy] : undefined;
return {
links: links.map((link) => ({
shareId: link.shareId,
title: link?.title || 'Untitled',
isPublic: link.isPublic,
createdAt: link.createdAt,
conversationId: link.conversationId,
})),
nextCursor,
hasNextPage,
};
} catch (error) {
logger.error('[getSharedLinks] Error getting shares', {
error: error.message,
user,
});
throw new ShareServiceError('Error getting shares', 'SHARES_FETCH_ERROR');
}
}
async function deleteAllSharedLinks(user) {
try {
const result = await SharedLink.deleteMany({ user });
return {
message: 'All shared links deleted successfully',
deletedCount: result.deletedCount,
};
} catch (error) {
logger.error('[deleteAllSharedLinks] Error deleting shared links', {
error: error.message,
user,
});
throw new ShareServiceError('Error deleting shared links', 'BULK_DELETE_ERROR');
}
}
async function createSharedLink(user, conversationId) {
if (!user || !conversationId) {
throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS');
}
try {
const [existingShare, conversationMessages] = await Promise.all([
SharedLink.findOne({ conversationId, isPublic: true }).select('-_id -__v -user').lean(),
getMessages({ conversationId }),
]);
if (existingShare && existingShare.isPublic) {
throw new ShareServiceError('Share already exists', 'SHARE_EXISTS');
} else if (existingShare) {
await SharedLink.deleteOne({ conversationId });
}
const conversation = await Conversation.findOne({ conversationId }).lean();
const title = conversation?.title || 'Untitled';
const shareId = nanoid();
await SharedLink.create({
shareId,
conversationId,
messages: conversationMessages,
title,
user,
});
return { shareId, conversationId };
} catch (error) {
logger.error('[createSharedLink] Error creating shared link', {
error: error.message,
user,
conversationId,
});
throw new ShareServiceError('Error creating shared link', 'SHARE_CREATE_ERROR');
}
}
async function getSharedLink(user, conversationId) {
if (!user || !conversationId) {
throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS');
}
try {
const share = await SharedLink.findOne({ conversationId, user, isPublic: true })
.select('shareId -_id')
.lean();
if (!share) {
return { shareId: null, success: false };
}
return { shareId: share.shareId, success: true };
} catch (error) {
logger.error('[getSharedLink] Error getting shared link', {
error: error.message,
user,
conversationId,
});
throw new ShareServiceError('Error getting shared link', 'SHARE_FETCH_ERROR');
}
}
async function updateSharedLink(user, shareId) {
if (!user || !shareId) {
throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS');
}
try {
const share = await SharedLink.findOne({ shareId }).select('-_id -__v -user').lean();
if (!share) {
throw new ShareServiceError('Share not found', 'SHARE_NOT_FOUND');
}
const [updatedMessages] = await Promise.all([
getMessages({ conversationId: share.conversationId }),
]);
const newShareId = nanoid();
const update = {
messages: updatedMessages,
user,
shareId: newShareId,
};
const updatedShare = await SharedLink.findOneAndUpdate({ shareId, user }, update, {
new: true,
upsert: false,
runValidators: true,
}).lean();
if (!updatedShare) {
throw new ShareServiceError('Share update failed', 'SHARE_UPDATE_ERROR');
}
anonymizeConvo(updatedShare);
return { shareId: newShareId, conversationId: updatedShare.conversationId };
} catch (error) {
logger.error('[updateSharedLink] Error updating shared link', {
error: error.message,
user,
shareId,
});
throw new ShareServiceError(
error.code === 'SHARE_UPDATE_ERROR' ? error.message : 'Error updating shared link',
error.code || 'SHARE_UPDATE_ERROR',
);
}
}
async function deleteSharedLink(user, shareId) {
if (!user || !shareId) {
throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS');
}
try {
const result = await SharedLink.findOneAndDelete({ shareId, user }).lean();
if (!result) {
return null;
}
return {
success: true,
shareId,
message: 'Share deleted successfully',
};
} catch (error) {
logger.error('[deleteSharedLink] Error deleting shared link', {
error: error.message,
user,
shareId,
});
throw new ShareServiceError('Error deleting shared link', 'SHARE_DELETE_ERROR');
}
}
module.exports = {
getSharedLink,
getSharedLinks,
createSharedLink,
updateSharedLink,
deleteSharedLink,
getSharedMessages,
deleteAllSharedLinks,
};

View File

@@ -1,42 +0,0 @@
const { findToken, updateToken, createToken } = require('~/models');
const { encryptV2 } = require('~/server/utils/crypto');
/**
* Handles the OAuth token by creating or updating the token.
* @param {object} fields
* @param {string} fields.userId - The user's ID.
* @param {string} fields.token - The full token to store.
* @param {string} fields.identifier - Unique, alternative identifier for the token.
* @param {number} fields.expiresIn - The number of seconds until the token expires.
* @param {object} fields.metadata - Additional metadata to store with the token.
* @param {string} [fields.type="oauth"] - The type of token. Default is 'oauth'.
*/
async function handleOAuthToken({
token,
userId,
identifier,
expiresIn,
metadata,
type = 'oauth',
}) {
const encrypedToken = await encryptV2(token);
const tokenData = {
type,
userId,
metadata,
identifier,
token: encrypedToken,
expiresIn: parseInt(expiresIn, 10) || 3600,
};
const existingToken = await findToken({ userId, identifier });
if (existingToken) {
return await updateToken({ identifier }, tokenData);
} else {
return await createToken(tokenData);
}
}
module.exports = {
handleOAuthToken,
};

View File

@@ -1,6 +1,6 @@
const mongoose = require('mongoose');
const { getRandomValues } = require('@librechat/api');
const { logger, hashToken } = require('@librechat/data-schemas');
const { getRandomValues } = require('~/server/utils/crypto');
const { createToken, findToken } = require('~/models');
/**

View File

@@ -78,7 +78,7 @@ const tokenValues = Object.assign(
'gpt-3.5-turbo-1106': { prompt: 1, completion: 2 },
'o4-mini': { prompt: 1.1, completion: 4.4 },
'o3-mini': { prompt: 1.1, completion: 4.4 },
o3: { prompt: 10, completion: 40 },
o3: { prompt: 2, completion: 8 },
'o1-mini': { prompt: 1.1, completion: 4.4 },
'o1-preview': { prompt: 15, completion: 60 },
o1: { prompt: 15, completion: 60 },

View File

@@ -34,25 +34,25 @@
},
"homepage": "https://librechat.ai",
"dependencies": {
"@anthropic-ai/sdk": "^0.37.0",
"@anthropic-ai/sdk": "^0.52.0",
"@aws-sdk/client-s3": "^3.758.0",
"@aws-sdk/s3-request-presigner": "^3.758.0",
"@azure/identity": "^4.7.0",
"@azure/search-documents": "^12.0.0",
"@azure/storage-blob": "^12.27.0",
"@google/generative-ai": "^0.23.0",
"@google/generative-ai": "^0.24.0",
"@googleapis/youtube": "^20.0.0",
"@keyv/redis": "^4.3.3",
"@langchain/community": "^0.3.44",
"@langchain/core": "^0.3.57",
"@langchain/google-genai": "^0.2.9",
"@langchain/google-vertexai": "^0.2.9",
"@langchain/community": "^0.3.47",
"@langchain/core": "^0.3.60",
"@langchain/google-genai": "^0.2.13",
"@langchain/google-vertexai": "^0.2.13",
"@langchain/textsplitters": "^0.1.0",
"@librechat/agents": "^2.4.38",
"@librechat/auth": "*",
"@librechat/agents": "^2.4.41",
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@node-saml/passport-saml": "^5.0.0",
"@microsoft/microsoft-graph-client": "^3.0.7",
"@waylaidwanderer/fetch-event-source": "^3.0.1",
"axios": "^1.8.2",
"bcryptjs": "^2.4.3",
@@ -121,7 +121,6 @@
"jest": "^29.7.0",
"mongodb-memory-server": "^10.1.3",
"nodemon": "^3.0.3",
"supertest": "^7.1.0",
"ts-jest": "^29.4.0"
"supertest": "^7.1.0"
}
}
}

View File

@@ -1,24 +1,21 @@
const cookies = require('cookie');
const jwt = require('jsonwebtoken');
const openIdClient = require('openid-client');
const { logger } = require('@librechat/data-schemas');
const {
registerUser,
requestPasswordReset,
resetPassword,
setAuthTokens,
requestPasswordReset,
setOpenIDAuthTokens,
} = require('@librechat/auth');
} = require('~/server/services/AuthService');
const { findUser, getUserById, deleteAllUserSessions, findSession } = require('~/models');
const { getOpenIdConfig } = require('@librechat/auth');
const { getOpenIdConfig } = require('~/strategies');
const { isEnabled } = require('~/server/utils');
const { isEmailDomainAllowed } = require('~/server/services/domains');
const { getBalanceConfig } = require('~/server/services/Config');
const registrationController = async (req, res) => {
try {
const isEmailDomAllowed = await isEmailDomainAllowed(req.body.email);
const balanceConfig = await getBalanceConfig();
const response = await registerUser(req.body, {}, isEmailDomAllowed, balanceConfig);
const response = await registerUser(req.body);
const { status, message } = response;
res.status(status).send({ message });
} catch (err) {
@@ -68,11 +65,9 @@ const refreshController = async (req, res) => {
if (!refreshToken) {
return res.status(200).send('Refresh token not provided');
}
if (token_provider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS) === true) {
try {
const openIdConfig = getOpenIdConfig();
const openIdClient = await import('openid-client');
const tokenset = await openIdClient.refreshTokenGrant(openIdConfig, refreshToken);
const claims = tokenset.claims();
const user = await findUser({ email: claims.email });

View File

@@ -0,0 +1,437 @@
/**
* @import { TUpdateResourcePermissionsRequest, TUpdateResourcePermissionsResponse } from 'librechat-data-provider'
*/
const mongoose = require('mongoose');
const { logger } = require('@librechat/data-schemas');
const {
getAvailableRoles,
ensurePrincipalExists,
getEffectivePermissions,
ensureGroupPrincipalExists,
bulkUpdateResourcePermissions,
} = require('~/server/services/PermissionService');
const { AclEntry } = require('~/db/models');
const {
searchPrincipals: searchLocalPrincipals,
sortPrincipalsByRelevance,
calculateRelevanceScore,
} = require('~/models');
const {
searchEntraIdPrincipals,
entraIdPrincipalFeatureEnabled,
} = require('~/server/services/GraphApiService');
/**
* Generic controller for resource permission endpoints
* Delegates validation and logic to PermissionService
*/
/**
* Bulk update permissions for a resource (grant, update, remove)
* @route PUT /api/{resourceType}/{resourceId}/permissions
* @param {Object} req - Express request object
* @param {Object} req.params - Route parameters
* @param {string} req.params.resourceType - Resource type (e.g., 'agent')
* @param {string} req.params.resourceId - Resource ID
* @param {TUpdateResourcePermissionsRequest} req.body - Request body
* @param {Object} res - Express response object
* @returns {Promise<TUpdateResourcePermissionsResponse>} Updated permissions response
*/
const updateResourcePermissions = async (req, res) => {
try {
const { resourceType, resourceId } = req.params;
/** @type {TUpdateResourcePermissionsRequest} */
const { updated, removed, public: isPublic, publicAccessRoleId } = req.body;
const { id: userId } = req.user;
// Prepare principals for the service call
const updatedPrincipals = [];
const revokedPrincipals = [];
// Add updated principals
if (updated && Array.isArray(updated)) {
updatedPrincipals.push(...updated);
}
// Add public permission if enabled
if (isPublic && publicAccessRoleId) {
updatedPrincipals.push({
type: 'public',
id: null,
accessRoleId: publicAccessRoleId,
});
}
// Prepare authentication context for enhanced group member fetching
const useEntraId = entraIdPrincipalFeatureEnabled(req.user);
const authHeader = req.headers.authorization;
const accessToken =
authHeader && authHeader.startsWith('Bearer ') ? authHeader.substring(7) : null;
const authContext =
useEntraId && accessToken
? {
accessToken,
sub: req.user.openidId,
}
: null;
// Ensure updated principals exist in the database before processing permissions
const validatedPrincipals = [];
for (const principal of updatedPrincipals) {
try {
let principalId;
if (principal.type === 'public') {
principalId = null; // Public principals don't need database records
} else if (principal.type === 'user') {
principalId = await ensurePrincipalExists(principal);
} else if (principal.type === 'group') {
// Pass authContext to enable member fetching for Entra ID groups when available
principalId = await ensureGroupPrincipalExists(principal, authContext);
} else {
logger.error(`Unsupported principal type: ${principal.type}`);
continue; // Skip invalid principal types
}
// Update the principal with the validated ID for ACL operations
validatedPrincipals.push({
...principal,
id: principalId,
});
} catch (error) {
logger.error('Error ensuring principal exists:', {
principal: {
type: principal.type,
id: principal.id,
name: principal.name,
source: principal.source,
},
error: error.message,
});
// Continue with other principals instead of failing the entire operation
continue;
}
}
// Add removed principals
if (removed && Array.isArray(removed)) {
revokedPrincipals.push(...removed);
}
// If public is disabled, add public to revoked list
if (!isPublic) {
revokedPrincipals.push({
type: 'public',
id: null,
});
}
const results = await bulkUpdateResourcePermissions({
resourceType,
resourceId,
updatedPrincipals: validatedPrincipals,
revokedPrincipals,
grantedBy: userId,
});
/** @type {TUpdateResourcePermissionsResponse} */
const response = {
message: 'Permissions updated successfully',
results: {
principals: results.granted,
public: isPublic || false,
publicAccessRoleId: isPublic ? publicAccessRoleId : undefined,
},
};
res.status(200).json(response);
} catch (error) {
logger.error('Error updating resource permissions:', error);
res.status(400).json({
error: 'Failed to update permissions',
details: error.message,
});
}
};
/**
* Get principals with their permission roles for a resource (UI-friendly format)
* Uses efficient aggregation pipeline to join User/Group data in single query
* @route GET /api/permissions/{resourceType}/{resourceId}
*/
const getResourcePermissions = async (req, res) => {
try {
const { resourceType, resourceId } = req.params;
// Use aggregation pipeline for efficient single-query data retrieval
const results = await AclEntry.aggregate([
// Match ACL entries for this resource
{
$match: {
resourceType,
resourceId: mongoose.Types.ObjectId.isValid(resourceId)
? mongoose.Types.ObjectId.createFromHexString(resourceId)
: resourceId,
},
},
// Lookup AccessRole information
{
$lookup: {
from: 'accessroles',
localField: 'roleId',
foreignField: '_id',
as: 'role',
},
},
// Lookup User information (for user principals)
{
$lookup: {
from: 'users',
localField: 'principalId',
foreignField: '_id',
as: 'userInfo',
},
},
// Lookup Group information (for group principals)
{
$lookup: {
from: 'groups',
localField: 'principalId',
foreignField: '_id',
as: 'groupInfo',
},
},
// Project final structure
{
$project: {
principalType: 1,
principalId: 1,
accessRoleId: { $arrayElemAt: ['$role.accessRoleId', 0] },
userInfo: { $arrayElemAt: ['$userInfo', 0] },
groupInfo: { $arrayElemAt: ['$groupInfo', 0] },
},
},
]);
const principals = [];
let publicPermission = null;
// Process aggregation results
for (const result of results) {
if (result.principalType === 'public') {
publicPermission = {
public: true,
publicAccessRoleId: result.accessRoleId,
};
} else if (result.principalType === 'user' && result.userInfo) {
principals.push({
type: 'user',
id: result.userInfo._id.toString(),
name: result.userInfo.name || result.userInfo.username,
email: result.userInfo.email,
avatar: result.userInfo.avatar,
source: !result.userInfo._id ? 'entra' : 'local',
idOnTheSource: result.userInfo.idOnTheSource || result.userInfo._id.toString(),
accessRoleId: result.accessRoleId,
});
} else if (result.principalType === 'group' && result.groupInfo) {
principals.push({
type: 'group',
id: result.groupInfo._id.toString(),
name: result.groupInfo.name,
email: result.groupInfo.email,
description: result.groupInfo.description,
avatar: result.groupInfo.avatar,
source: result.groupInfo.source || 'local',
idOnTheSource: result.groupInfo.idOnTheSource || result.groupInfo._id.toString(),
accessRoleId: result.accessRoleId,
});
}
}
// Return response in format expected by frontend
const response = {
resourceType,
resourceId,
principals,
public: publicPermission?.public || false,
...(publicPermission?.publicAccessRoleId && {
publicAccessRoleId: publicPermission.publicAccessRoleId,
}),
};
res.status(200).json(response);
} catch (error) {
logger.error('Error getting resource permissions principals:', error);
res.status(500).json({
error: 'Failed to get permissions principals',
details: error.message,
});
}
};
/**
* Get available roles for a resource type
* @route GET /api/{resourceType}/roles
*/
const getResourceRoles = async (req, res) => {
try {
const { resourceType } = req.params;
const roles = await getAvailableRoles({ resourceType });
res.status(200).json(
roles.map((role) => ({
accessRoleId: role.accessRoleId,
name: role.name,
description: role.description,
permBits: role.permBits,
})),
);
} catch (error) {
logger.error('Error getting resource roles:', error);
res.status(500).json({
error: 'Failed to get roles',
details: error.message,
});
}
};
/**
* Get user's effective permission bitmask for a resource
* @route GET /api/{resourceType}/{resourceId}/effective
*/
const getUserEffectivePermissions = async (req, res) => {
try {
const { resourceType, resourceId } = req.params;
const { id: userId } = req.user;
const permissionBits = await getEffectivePermissions({
userId,
resourceType,
resourceId,
});
res.status(200).json({
permissionBits,
});
} catch (error) {
logger.error('Error getting user effective permissions:', error);
res.status(500).json({
error: 'Failed to get effective permissions',
details: error.message,
});
}
};
/**
* Search for users and groups to grant permissions
* Supports hybrid local database + Entra ID search when configured
* @route GET /api/permissions/search-principals
*/
const searchPrincipals = async (req, res) => {
try {
const { q: query, limit = 20, type } = req.query;
if (!query || query.trim().length === 0) {
return res.status(400).json({
error: 'Query parameter "q" is required and must not be empty',
});
}
if (query.trim().length < 2) {
return res.status(400).json({
error: 'Query must be at least 2 characters long',
});
}
const searchLimit = Math.min(Math.max(1, parseInt(limit) || 10), 50);
const typeFilter = ['user', 'group'].includes(type) ? type : null;
const localResults = await searchLocalPrincipals(query.trim(), searchLimit, typeFilter);
let allPrincipals = [...localResults];
const useEntraId = entraIdPrincipalFeatureEnabled(req.user);
if (useEntraId && localResults.length < searchLimit) {
try {
const graphTypeMap = {
user: 'users',
group: 'groups',
null: 'all',
};
const authHeader = req.headers.authorization;
const accessToken =
authHeader && authHeader.startsWith('Bearer ') ? authHeader.substring(7) : null;
if (accessToken) {
const graphResults = await searchEntraIdPrincipals(
accessToken,
req.user.openidId,
query.trim(),
graphTypeMap[typeFilter],
searchLimit - localResults.length,
);
const localEmails = new Set(
localResults.map((p) => p.email?.toLowerCase()).filter(Boolean),
);
const localGroupSourceIds = new Set(
localResults.map((p) => p.idOnTheSource).filter(Boolean),
);
for (const principal of graphResults) {
const isDuplicateByEmail =
principal.email && localEmails.has(principal.email.toLowerCase());
const isDuplicateBySourceId =
principal.idOnTheSource && localGroupSourceIds.has(principal.idOnTheSource);
if (!isDuplicateByEmail && !isDuplicateBySourceId) {
allPrincipals.push(principal);
}
}
}
} catch (graphError) {
logger.warn('Graph API search failed, falling back to local results:', graphError.message);
}
}
const scoredResults = allPrincipals.map((item) => ({
...item,
_searchScore: calculateRelevanceScore(item, query.trim()),
}));
allPrincipals = sortPrincipalsByRelevance(scoredResults)
.slice(0, searchLimit)
.map((result) => {
const { _searchScore, ...resultWithoutScore } = result;
return resultWithoutScore;
});
res.status(200).json({
query: query.trim(),
limit: searchLimit,
type: typeFilter,
results: allPrincipals,
count: allPrincipals.length,
sources: {
local: allPrincipals.filter((r) => r.source === 'local').length,
entra: allPrincipals.filter((r) => r.source === 'entra').length,
},
});
} catch (error) {
logger.error('Error searching principals:', error);
res.status(500).json({
error: 'Failed to search principals',
details: error.message,
});
}
};
module.exports = {
updateResourcePermissions,
getResourcePermissions,
getResourceRoles,
getUserEffectivePermissions,
searchPrincipals,
};

View File

@@ -1,9 +1,11 @@
const { logger } = require('@librechat/data-schemas');
const { CacheKeys, AuthType } = require('librechat-data-provider');
const { getCustomConfig, getCachedTools } = require('~/server/services/Config');
const { getToolkitKey } = require('~/server/services/ToolService');
const { getCustomConfig } = require('~/server/services/Config');
const { getMCPManager, getFlowStateManager } = require('~/config');
const { availableTools } = require('~/app/clients/tools');
const { getMCPManager } = require('~/config');
const { getLogStores } = require('~/cache');
const { Constants } = require('librechat-data-provider');
/**
* Filters out duplicate plugins from the list of plugins.
@@ -84,6 +86,45 @@ const getAvailablePluginsController = async (req, res) => {
}
};
function createServerToolsCallback() {
/**
* @param {string} serverName
* @param {TPlugin[] | null} serverTools
*/
return async function (serverName, serverTools) {
try {
const mcpToolsCache = getLogStores(CacheKeys.MCP_TOOLS);
if (!serverName || !mcpToolsCache) {
return;
}
await mcpToolsCache.set(serverName, serverTools);
logger.debug(`MCP tools for ${serverName} added to cache.`);
} catch (error) {
logger.error('Error retrieving MCP tools from cache:', error);
}
};
}
function createGetServerTools() {
/**
* Retrieves cached server tools
* @param {string} serverName
* @returns {Promise<TPlugin[] | null>}
*/
return async function (serverName) {
try {
const mcpToolsCache = getLogStores(CacheKeys.MCP_TOOLS);
if (!mcpToolsCache) {
return null;
}
return await mcpToolsCache.get(serverName);
} catch (error) {
logger.error('Error retrieving MCP tools from cache:', error);
return null;
}
};
}
/**
* Retrieves and returns a list of available tools, either from a cache or by reading a plugin manifest file.
*
@@ -109,7 +150,16 @@ const getAvailableTools = async (req, res) => {
const customConfig = await getCustomConfig();
if (customConfig?.mcpServers != null) {
const mcpManager = getMCPManager();
pluginManifest = await mcpManager.loadManifestTools(pluginManifest);
const flowsCache = getLogStores(CacheKeys.FLOWS);
const flowManager = flowsCache ? getFlowStateManager(flowsCache) : null;
const serverToolsCallback = createServerToolsCallback();
const getServerTools = createGetServerTools();
const mcpTools = await mcpManager.loadManifestTools({
flowManager,
serverToolsCallback,
getServerTools,
});
pluginManifest = [...mcpTools, ...pluginManifest];
}
/** @type {TPlugin[]} */
@@ -123,17 +173,57 @@ const getAvailableTools = async (req, res) => {
}
});
const toolDefinitions = req.app.locals.availableTools;
const tools = authenticatedPlugins.filter(
(plugin) =>
toolDefinitions[plugin.pluginKey] !== undefined ||
(plugin.toolkit === true &&
Object.keys(toolDefinitions).some((key) => getToolkitKey(key) === plugin.pluginKey)),
);
const toolDefinitions = await getCachedTools({ includeGlobal: true });
await cache.set(CacheKeys.TOOLS, tools);
res.status(200).json(tools);
const toolsOutput = [];
for (const plugin of authenticatedPlugins) {
const isToolDefined = toolDefinitions[plugin.pluginKey] !== undefined;
const isToolkit =
plugin.toolkit === true &&
Object.keys(toolDefinitions).some((key) => getToolkitKey(key) === plugin.pluginKey);
if (!isToolDefined && !isToolkit) {
continue;
}
const toolToAdd = { ...plugin };
if (!plugin.pluginKey.includes(Constants.mcp_delimiter)) {
toolsOutput.push(toolToAdd);
continue;
}
const parts = plugin.pluginKey.split(Constants.mcp_delimiter);
const serverName = parts[parts.length - 1];
const serverConfig = customConfig?.mcpServers?.[serverName];
if (!serverConfig?.customUserVars) {
toolsOutput.push(toolToAdd);
continue;
}
const customVarKeys = Object.keys(serverConfig.customUserVars);
if (customVarKeys.length === 0) {
toolToAdd.authConfig = [];
toolToAdd.authenticated = true;
} else {
toolToAdd.authConfig = Object.entries(serverConfig.customUserVars).map(([key, value]) => ({
authField: key,
label: value.title || key,
description: value.description || '',
}));
toolToAdd.authenticated = false;
}
toolsOutput.push(toolToAdd);
}
const finalTools = filterUniquePlugins(toolsOutput);
await cache.set(CacheKeys.TOOLS, finalTools);
res.status(200).json(finalTools);
} catch (error) {
logger.error('[getAvailableTools]', error);
res.status(500).json({ message: error.message });
}
};

View File

@@ -1,3 +1,4 @@
const { encryptV3 } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const {
verifyTOTP,
@@ -7,7 +8,6 @@ const {
generateBackupCodes,
} = require('~/server/services/twoFactorService');
const { getUserById, updateUser } = require('~/models');
const { encryptV3 } = require('~/server/utils/crypto');
const safeAppTitle = (process.env.APP_TITLE || 'LibreChat').replace(/\s+/g, '');

View File

@@ -1,5 +1,6 @@
const {
Tools,
Constants,
FileSources,
webSearchKeys,
extractWebSearchEnvVars,
@@ -17,12 +18,13 @@ const {
} = require('~/models');
const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/services/PluginService');
const { updateUserPluginsService, deleteUserKey } = require('~/server/services/UserService');
const { verifyEmail, resendVerificationEmail } = require('@librechat/auth');
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 } = require('~/db/models');
const { deleteAllSharedLinks } = require('~/models/Share');
const { deleteToolCalls } = require('~/models/ToolCall');
const { deleteAllSharedLinks } = require('~/models');
const { getMCPManager } = require('~/config');
const getUserController = async (req, res) => {
/** @type {MongoUser} */
@@ -102,10 +104,22 @@ const updateUserPluginsController = async (req, res) => {
}
let keys = Object.keys(auth);
if (keys.length === 0 && pluginKey !== Tools.web_search) {
const values = Object.values(auth); // Used in 'install' block
const isMCPTool = pluginKey.startsWith('mcp_') || pluginKey.includes(Constants.mcp_delimiter);
// Early exit condition:
// If keys are empty (meaning auth: {} was likely sent for uninstall, or auth was empty for install)
// AND it's not web_search (which has special key handling to populate `keys` for uninstall)
// AND it's NOT (an uninstall action FOR an MCP tool - we need to proceed for this case to clear all its auth)
// THEN return.
if (
keys.length === 0 &&
pluginKey !== Tools.web_search &&
!(action === 'uninstall' && isMCPTool)
) {
return res.status(200).send();
}
const values = Object.values(auth);
/** @type {number} */
let status = 200;
@@ -132,16 +146,53 @@ const updateUserPluginsController = async (req, res) => {
}
}
} else if (action === 'uninstall') {
for (let i = 0; i < keys.length; i++) {
authService = await deleteUserPluginAuth(user.id, keys[i]);
// const isMCPTool was defined earlier
if (isMCPTool && keys.length === 0) {
// This handles the case where auth: {} is sent for an MCP tool uninstall.
// It means "delete all credentials associated with this MCP pluginKey".
authService = await deleteUserPluginAuth(user.id, null, true, pluginKey);
if (authService instanceof Error) {
logger.error('[authService]', authService);
logger.error(
`[authService] Error deleting all auth for MCP tool ${pluginKey}:`,
authService,
);
({ status, message } = authService);
}
} else {
// This handles:
// 1. Web_search uninstall (keys will be populated with all webSearchKeys if auth was {}).
// 2. Other tools uninstall (if keys were provided).
// 3. MCP tool uninstall if specific keys were provided in `auth` (not current frontend behavior).
// If keys is empty for non-MCP tools (and not web_search), this loop won't run, and nothing is deleted.
for (let i = 0; i < keys.length; i++) {
authService = await deleteUserPluginAuth(user.id, keys[i]); // Deletes by authField name
if (authService instanceof Error) {
logger.error('[authService] Error deleting specific auth key:', authService);
({ status, message } = authService);
}
}
}
}
if (status === 200) {
// If auth was updated successfully, disconnect MCP sessions as they might use these credentials
if (pluginKey.startsWith(Constants.mcp_prefix)) {
try {
const mcpManager = getMCPManager(user.id);
if (mcpManager) {
logger.info(
`[updateUserPluginsController] Disconnecting MCP connections for user ${user.id} after plugin auth update for ${pluginKey}.`,
);
await mcpManager.disconnectUserConnections(user.id);
}
} catch (disconnectError) {
logger.error(
`[updateUserPluginsController] Error disconnecting MCP connections for user ${user.id} after plugin auth update:`,
disconnectError,
);
// Do not fail the request for this, but log it.
}
}
return res.status(status).send();
}

View File

@@ -31,11 +31,15 @@ const {
} = require('librechat-data-provider');
const { DynamicStructuredTool } = require('@langchain/core/tools');
const { getBufferString, HumanMessage } = require('@langchain/core/messages');
const { getCustomEndpointConfig, checkCapability } = require('~/server/services/Config');
const {
getCustomEndpointConfig,
createGetMCPAuthMap,
checkCapability,
} = require('~/server/services/Config');
const { addCacheControl, createContextHandlers } = require('~/app/clients/prompts');
const { initializeAgent } = require('~/server/services/Endpoints/agents/agent');
const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens');
const { setMemory, deleteMemory, getFormattedMemories } = require('~/models');
const { getFormattedMemories, deleteMemory, setMemory } = require('~/models');
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
const initOpenAI = require('~/server/services/Endpoints/openAI/initialize');
const { checkAccess } = require('~/server/middleware/roles/access');
@@ -679,6 +683,8 @@ class AgentClient extends BaseClient {
version: 'v2',
};
const getUserMCPAuthMap = await createGetMCPAuthMap();
const toolSet = new Set((this.options.agent.tools ?? []).map((tool) => tool && tool.name));
let { messages: initialMessages, indexTokenCountMap } = formatAgentMessages(
payload,
@@ -798,6 +804,20 @@ class AgentClient extends BaseClient {
run.Graph.contentData = contentData;
}
try {
if (getUserMCPAuthMap) {
config.configurable.userMCPAuthMap = await getUserMCPAuthMap({
tools: agent.tools,
userId: this.options.req.user.id,
});
}
} catch (err) {
logger.error(
`[api/server/controllers/agents/client.js #chatCompletion] Error getting custom user vars for agent ${agent.id}`,
err,
);
}
await run.processStream({ messages }, config, {
keepContent: i !== 0,
tokenCounter: createTokenCounter(this.getEncoding()),

View File

@@ -1,11 +1,10 @@
const fs = require('fs').promises;
const { nanoid } = require('nanoid');
const { logger, PermissionBits } = require('@librechat/data-schemas');
const {
Tools,
Constants,
FileContext,
FileSources,
SystemRoles,
FileSources,
EToolResources,
actionDelimiter,
} = require('librechat-data-provider');
@@ -14,18 +13,22 @@ const {
createAgent,
updateAgent,
deleteAgent,
getListAgents,
getListAgentsByAccess,
} = require('~/models/Agent');
const { uploadImageBuffer, filterFile } = require('~/server/services/Files/process');
const {
grantPermission,
findAccessibleResources,
findPubliclyAccessibleResources,
hasPublicPermission,
} = require('~/server/services/PermissionService');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { resizeAvatar } = require('@librechat/auth');
const { resizeAvatar } = require('~/server/services/Files/images/avatar');
const { refreshS3Url } = require('~/server/services/Files/S3/crud');
const { filterFile } = require('~/server/services/Files/process');
const { updateAction, getActions } = require('~/models/Action');
const { updateAgentProjects } = require('~/models/Agent');
const { getProjectByName } = require('~/models/Project');
const { deleteFileByFilter } = require('~/models/File');
const { getCachedTools } = require('~/server/services/Config');
const { revertAgentVersion } = require('~/models/Agent');
const { logger } = require('~/config');
const { deleteFileByFilter } = require('~/models/File');
const systemTools = {
[Tools.execute_code]: true,
@@ -47,8 +50,9 @@ const createAgentHandler = async (req, res) => {
agentData.tools = [];
const availableTools = await getCachedTools({ includeGlobal: true });
for (const tool of tools) {
if (req.app.locals.availableTools[tool]) {
if (availableTools[tool]) {
agentData.tools.push(tool);
}
@@ -68,6 +72,27 @@ const createAgentHandler = async (req, res) => {
agentData.id = `agent_${nanoid()}`;
const agent = await createAgent(agentData);
// Automatically grant owner permissions to the creator
try {
await grantPermission({
principalType: 'user',
principalId: userId,
resourceType: 'agent',
resourceId: agent._id,
accessRoleId: 'agent_owner',
grantedBy: userId,
});
logger.debug(
`[createAgent] Granted owner permissions to user ${userId} for agent ${agent.id}`,
);
} catch (permissionError) {
logger.error(
`[createAgent] Failed to grant owner permissions for agent ${agent.id}:`,
permissionError,
);
}
res.status(201).json(agent);
} catch (error) {
logger.error('[/Agents] Error creating agent', error);
@@ -86,21 +111,14 @@ const createAgentHandler = async (req, res) => {
* @returns {Promise<Agent>} 200 - success response - application/json
* @returns {Error} 404 - Agent not found
*/
const getAgentHandler = async (req, res) => {
const getAgentHandler = async (req, res, expandProperties = false) => {
try {
const id = req.params.id;
const author = req.user.id;
let query = { id, author };
const globalProject = await getProjectByName(Constants.GLOBAL_PROJECT_NAME, ['agentIds']);
if (globalProject && (globalProject.agentIds?.length ?? 0) > 0) {
query = {
$or: [{ id, $in: globalProject.agentIds }, query],
};
}
const agent = await getAgent(query);
// Permissions are validated by middleware before calling this function
// Simply load the agent by ID
const agent = await getAgent({ id });
if (!agent) {
return res.status(404).json({ error: 'Agent not found' });
@@ -117,23 +135,45 @@ const getAgentHandler = async (req, res) => {
}
agent.author = agent.author.toString();
// @deprecated - isCollaborative replaced by ACL permissions
agent.isCollaborative = !!agent.isCollaborative;
// Check if agent is public
const isPublic = await hasPublicPermission({
resourceType: 'agent',
resourceId: agent._id,
requiredPermissions: PermissionBits.VIEW,
});
agent.isPublic = isPublic;
if (agent.author !== author) {
delete agent.author;
}
if (!agent.isCollaborative && agent.author !== author && req.user.role !== SystemRoles.ADMIN) {
if (!expandProperties) {
// VIEW permission: Basic agent info only
return res.status(200).json({
_id: agent._id,
id: agent.id,
name: agent.name,
description: agent.description,
avatar: agent.avatar,
author: agent.author,
provider: agent.provider,
model: agent.model,
projectIds: agent.projectIds,
// @deprecated - isCollaborative replaced by ACL permissions
isCollaborative: agent.isCollaborative,
isPublic: agent.isPublic,
version: agent.version,
// Safe metadata
createdAt: agent.createdAt,
updatedAt: agent.updatedAt,
});
}
// EDIT permission: Full agent details including sensitive configuration
return res.status(200).json(agent);
} catch (error) {
logger.error('[/Agents/:id] Error retrieving agent', error);
@@ -153,42 +193,20 @@ const getAgentHandler = async (req, res) => {
const updateAgentHandler = async (req, res) => {
try {
const id = req.params.id;
const { projectIds, removeProjectIds, ...updateData } = req.body;
const isAdmin = req.user.role === SystemRoles.ADMIN;
const { _id, ...updateData } = req.body;
const existingAgent = await getAgent({ id });
const isAuthor = existingAgent.author.toString() === req.user.id;
if (!existingAgent) {
return res.status(404).json({ error: 'Agent not found' });
}
const hasEditPermission = existingAgent.isCollaborative || isAdmin || isAuthor;
if (!hasEditPermission) {
return res.status(403).json({
error: 'You do not have permission to modify this non-collaborative agent',
});
}
/** @type {boolean} */
const isProjectUpdate = (projectIds?.length ?? 0) > 0 || (removeProjectIds?.length ?? 0) > 0;
let updatedAgent =
Object.keys(updateData).length > 0
? await updateAgent({ id }, updateData, {
updatingUserId: req.user.id,
skipVersioning: isProjectUpdate,
})
: existingAgent;
if (isProjectUpdate) {
updatedAgent = await updateAgentProjects({
user: req.user,
agentId: id,
projectIds,
removeProjectIds,
});
}
if (updatedAgent.author) {
updatedAgent.author = updatedAgent.author.toString();
}
@@ -306,6 +324,26 @@ const duplicateAgentHandler = async (req, res) => {
newAgentData.actions = agentActions;
const newAgent = await createAgent(newAgentData);
// Automatically grant owner permissions to the duplicator
try {
await grantPermission({
principalType: 'user',
principalId: userId,
resourceType: 'agent',
resourceId: newAgent._id,
accessRoleId: 'agent_owner',
grantedBy: userId,
});
logger.debug(
`[duplicateAgent] Granted owner permissions to user ${userId} for duplicated agent ${newAgent.id}`,
);
} catch (permissionError) {
logger.error(
`[duplicateAgent] Failed to grant owner permissions for duplicated agent ${newAgent.id}:`,
permissionError,
);
}
return res.status(201).json({
agent: newAgent,
actions: newActionsList,
@@ -332,7 +370,7 @@ const deleteAgentHandler = async (req, res) => {
if (!agent) {
return res.status(404).json({ error: 'Agent not found' });
}
await deleteAgent({ id, author: req.user.id });
await deleteAgent({ id });
return res.json({ message: 'Agent deleted' });
} catch (error) {
logger.error('[/Agents/:id] Error deleting Agent', error);
@@ -341,7 +379,7 @@ const deleteAgentHandler = async (req, res) => {
};
/**
*
* Lists agents using ACL-aware permissions (ownership + explicit shares).
* @route GET /Agents
* @param {object} req - Express Request
* @param {object} req.query - Request query
@@ -350,9 +388,31 @@ const deleteAgentHandler = async (req, res) => {
*/
const getListAgentsHandler = async (req, res) => {
try {
const data = await getListAgents({
author: req.user.id,
const userId = req.user.id;
// Get agent IDs the user has VIEW access to via ACL
const accessibleIds = await findAccessibleResources({
userId,
resourceType: 'agent',
requiredPermissions: PermissionBits.VIEW,
});
const publiclyAccessibleIds = await findPubliclyAccessibleResources({
resourceType: 'agent',
requiredPermissions: PermissionBits.VIEW,
});
// Use the new ACL-aware function
const data = await getListAgentsByAccess({
accessibleIds,
otherParams: {}, // Can add query params here if needed
});
if (data?.data?.length) {
data.data = data.data.map((agent) => {
if (publiclyAccessibleIds.some((id) => id.equals(agent._id))) {
agent.isPublic = true;
}
return agent;
});
}
return res.json(data);
} catch (error) {
logger.error('[/Agents] Error listing Agents', error);
@@ -430,7 +490,7 @@ const uploadAgentAvatarHandler = async (req, res) => {
};
promises.push(
await updateAgent({ id: agent_id, author: req.user.id }, data, {
await updateAgent({ id: agent_id }, data, {
updatingUserId: req.user.id,
}),
);
@@ -445,7 +505,7 @@ const uploadAgentAvatarHandler = async (req, res) => {
try {
await fs.unlink(req.file.path);
logger.debug('[/:agent_id/avatar] Temp. image upload file deleted');
} catch (error) {
} catch {
logger.debug('[/:agent_id/avatar] Temp. image upload file already deleted');
}
}

View File

@@ -1,4 +1,5 @@
const fs = require('fs').promises;
const { logger } = require('@librechat/data-schemas');
const { FileContext } = require('librechat-data-provider');
const { uploadImageBuffer, filterFile } = require('~/server/services/Files/process');
const validateAuthor = require('~/server/middleware/assistants/validateAuthor');
@@ -6,9 +7,9 @@ const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { deleteAssistantActions } = require('~/server/services/ActionService');
const { updateAssistantDoc, getAssistants } = require('~/models/Assistant');
const { getOpenAIClient, fetchAssistants } = require('./helpers');
const { getCachedTools } = require('~/server/services/Config');
const { manifestToolMap } = require('~/app/clients/tools');
const { deleteFileByFilter } = require('~/models/File');
const { logger } = require('~/config');
/**
* Create an assistant.
@@ -30,21 +31,20 @@ const createAssistant = async (req, res) => {
delete assistantData.conversation_starters;
delete assistantData.append_current_datetime;
const toolDefinitions = await getCachedTools({ includeGlobal: true });
assistantData.tools = tools
.map((tool) => {
if (typeof tool !== 'string') {
return tool;
}
const toolDefinitions = req.app.locals.availableTools;
const toolDef = toolDefinitions[tool];
if (!toolDef && manifestToolMap[tool] && manifestToolMap[tool].toolkit === true) {
return (
Object.entries(toolDefinitions)
.filter(([key]) => key.startsWith(`${tool}_`))
// eslint-disable-next-line no-unused-vars
.map(([_, val]) => val)
);
return Object.entries(toolDefinitions)
.filter(([key]) => key.startsWith(`${tool}_`))
.map(([_, val]) => val);
}
return toolDef;
@@ -135,21 +135,21 @@ const patchAssistant = async (req, res) => {
append_current_datetime,
...updateData
} = req.body;
const toolDefinitions = await getCachedTools({ includeGlobal: true });
updateData.tools = (updateData.tools ?? [])
.map((tool) => {
if (typeof tool !== 'string') {
return tool;
}
const toolDefinitions = req.app.locals.availableTools;
const toolDef = toolDefinitions[tool];
if (!toolDef && manifestToolMap[tool] && manifestToolMap[tool].toolkit === true) {
return (
Object.entries(toolDefinitions)
.filter(([key]) => key.startsWith(`${tool}_`))
// eslint-disable-next-line no-unused-vars
.map(([_, val]) => val)
);
return Object.entries(toolDefinitions)
.filter(([key]) => key.startsWith(`${tool}_`))
.map(([_, val]) => val);
}
return toolDef;

View File

@@ -1,10 +1,11 @@
const { logger } = require('@librechat/data-schemas');
const { ToolCallTypes } = require('librechat-data-provider');
const validateAuthor = require('~/server/middleware/assistants/validateAuthor');
const { validateAndUpdateTool } = require('~/server/services/ActionService');
const { getCachedTools } = require('~/server/services/Config');
const { updateAssistantDoc } = require('~/models/Assistant');
const { manifestToolMap } = require('~/app/clients/tools');
const { getOpenAIClient } = require('./helpers');
const { logger } = require('~/config');
/**
* Create an assistant.
@@ -27,21 +28,20 @@ const createAssistant = async (req, res) => {
delete assistantData.conversation_starters;
delete assistantData.append_current_datetime;
const toolDefinitions = await getCachedTools({ includeGlobal: true });
assistantData.tools = tools
.map((tool) => {
if (typeof tool !== 'string') {
return tool;
}
const toolDefinitions = req.app.locals.availableTools;
const toolDef = toolDefinitions[tool];
if (!toolDef && manifestToolMap[tool] && manifestToolMap[tool].toolkit === true) {
return (
Object.entries(toolDefinitions)
.filter(([key]) => key.startsWith(`${tool}_`))
// eslint-disable-next-line no-unused-vars
.map(([_, val]) => val)
);
return Object.entries(toolDefinitions)
.filter(([key]) => key.startsWith(`${tool}_`))
.map(([_, val]) => val);
}
return toolDef;
@@ -125,13 +125,13 @@ const updateAssistant = async ({ req, openai, assistant_id, updateData }) => {
let hasFileSearch = false;
for (const tool of updateData.tools ?? []) {
const toolDefinitions = req.app.locals.availableTools;
const toolDefinitions = await getCachedTools({ includeGlobal: true });
let actualTool = typeof tool === 'string' ? toolDefinitions[tool] : tool;
if (!actualTool && manifestToolMap[tool] && manifestToolMap[tool].toolkit === true) {
actualTool = Object.entries(toolDefinitions)
.filter(([key]) => key.startsWith(`${tool}_`))
// eslint-disable-next-line no-unused-vars
.map(([_, val]) => val);
} else if (!actualTool) {
continue;

View File

@@ -1,5 +1,5 @@
const { generate2FATempToken } = require('~/server/services/twoFactorService');
const { setAuthTokens } = require('@librechat/auth');
const { setAuthTokens } = require('~/server/services/AuthService');
const { logger } = require('~/config');
const loginController = async (req, res) => {

View File

@@ -1,6 +1,6 @@
const cookies = require('cookie');
const { getOpenIdConfig } = require('@librechat/auth');
const { logoutUser } = require('@librechat/auth');
const { getOpenIdConfig } = require('~/strategies');
const { logoutUser } = require('~/server/services/AuthService');
const { isEnabled } = require('~/server/utils');
const { logger } = require('~/config');

View File

@@ -5,7 +5,7 @@ const {
getTOTPSecret,
verifyBackupCode,
} = require('~/server/services/twoFactorService');
const { setAuthTokens } = require('@librechat/auth');
const { setAuthTokens } = require('~/server/services/AuthService');
const { getUserById } = require('~/models');
/**

View File

@@ -1,29 +1,27 @@
require('dotenv').config();
const fs = require('fs');
const path = require('path');
require('module-alias')({ base: path.resolve(__dirname, '..') });
const cors = require('cors');
const axios = require('axios');
const express = require('express');
const compression = require('compression');
const passport = require('passport');
const mongoSanitize = require('express-mongo-sanitize');
const fs = require('fs');
const compression = require('compression');
const cookieParser = require('cookie-parser');
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const mongoSanitize = require('express-mongo-sanitize');
const { connectDb, indexSync } = require('~/db');
const { initAuth, passportLogin, ldapLogin, jwtLogin } = require('@librechat/auth');
const { isEnabled } = require('~/server/utils');
const { logger } = require('~/config');
const validateImageRequest = require('./middleware/validateImageRequest');
const { jwtLogin, ldapLogin, passportLogin } = require('~/strategies');
const errorController = require('./controllers/ErrorController');
const initializeMCP = require('./services/initializeMCP');
const configureSocialLogins = require('./socialLogins');
const AppService = require('./services/AppService');
const staticCache = require('./utils/staticCache');
const noIndex = require('./middleware/noIndex');
const routes = require('./routes');
const { getBalanceConfig } = require('./services/Config');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { FileSources } = require('librechat-data-provider');
const { PORT, HOST, ALLOW_SOCIAL_LOGIN, DISABLE_COMPRESSION, TRUST_PROXY } = process.env ?? {};
@@ -38,12 +36,7 @@ const startServer = async () => {
if (typeof Bun !== 'undefined') {
axios.defaults.headers.common['Accept-Encoding'] = 'gzip';
}
const mongooseInstance = await connectDb();
const balanceConfig = await getBalanceConfig();
const { saveBuffer } = getStrategyFunctions(process.env.CDN_PROVIDER ?? FileSources.local);
// initialize the auth package
initAuth(mongooseInstance, balanceConfig, saveBuffer);
await connectDb();
logger.info('Connected to MongoDB');
await indexSync();
@@ -125,7 +118,10 @@ const startServer = async () => {
app.use('/api/banner', routes.banner);
app.use('/api/bedrock', routes.bedrock);
app.use('/api/memories', routes.memories);
app.use('/api/permissions', routes.accessPermissions);
app.use('/api/tags', routes.tags);
app.use('/api/mcp', routes.mcp);
app.use((req, res) => {
res.set({
@@ -149,6 +145,8 @@ const startServer = async () => {
} else {
logger.info(`Server listening at http://${host == '0.0.0.0' ? 'localhost' : host}:${port}`);
}
initializeMCP(app);
});
};
@@ -191,5 +189,5 @@ process.on('uncaughtException', (err) => {
process.exit(1);
});
// export app for easier testing purposes
/** Export app for easier testing purposes */
module.exports = app;

View File

@@ -0,0 +1,97 @@
const { logger } = require('@librechat/data-schemas');
const { Constants, isAgentsEndpoint } = require('librechat-data-provider');
const { canAccessResource } = require('./canAccessResource');
const { getAgent } = require('~/models/Agent');
/**
* Agent ID resolver function for agent_id from request body
* Resolves custom agent ID (e.g., "agent_abc123") to MongoDB ObjectId
* This is used specifically for chat routes where agent_id comes from request body
*
* @param {string} agentCustomId - Custom agent ID from request body
* @returns {Promise<Object|null>} Agent document with _id field, or null if not found
*/
const resolveAgentIdFromBody = async (agentCustomId) => {
// Handle ephemeral agents - they don't need permission checks
if (agentCustomId === Constants.EPHEMERAL_AGENT_ID) {
return null; // No permission check needed for ephemeral agents
}
return await getAgent({ id: agentCustomId });
};
/**
* Middleware factory that creates middleware to check agent access permissions from request body.
* This middleware is specifically designed for chat routes where the agent_id comes from req.body
* instead of route parameters.
*
* @param {Object} options - Configuration options
* @param {number} options.requiredPermission - The permission bit required (1=view, 2=edit, 4=delete, 8=share)
* @returns {Function} Express middleware function
*
* @example
* // Basic usage for agent chat (requires VIEW permission)
* router.post('/chat',
* canAccessAgentFromBody({ requiredPermission: PermissionBits.VIEW }),
* buildEndpointOption,
* chatController
* );
*/
const canAccessAgentFromBody = (options) => {
const { requiredPermission } = options;
// Validate required options
if (!requiredPermission || typeof requiredPermission !== 'number') {
throw new Error('canAccessAgentFromBody: requiredPermission is required and must be a number');
}
return async (req, res, next) => {
try {
const { endpoint, agent_id } = req.body;
let agentId = agent_id;
if (!isAgentsEndpoint(endpoint)) {
agentId = Constants.EPHEMERAL_AGENT_ID;
}
if (!agentId) {
return res.status(400).json({
error: 'Bad Request',
message: 'agent_id is required in request body',
});
}
// Skip permission checks for ephemeral agents
if (agentId === Constants.EPHEMERAL_AGENT_ID) {
return next();
}
const agentAccessMiddleware = canAccessResource({
resourceType: 'agent',
requiredPermission,
resourceIdParam: 'agent_id', // This will be ignored since we use custom resolver
idResolver: () => resolveAgentIdFromBody(agentId),
});
const tempReq = {
...req,
params: {
...req.params,
agent_id: agentId,
},
};
return agentAccessMiddleware(tempReq, res, next);
} catch (error) {
logger.error('Failed to validate agent access permissions', error);
return res.status(500).json({
error: 'Internal Server Error',
message: 'Failed to validate agent access permissions',
});
}
};
};
module.exports = {
canAccessAgentFromBody,
};

View File

@@ -0,0 +1,58 @@
const { getAgent } = require('~/models/Agent');
const { canAccessResource } = require('./canAccessResource');
/**
* Agent ID resolver function
* Resolves custom agent ID (e.g., "agent_abc123") to MongoDB ObjectId
*
* @param {string} agentCustomId - Custom agent ID from route parameter
* @returns {Promise<Object|null>} Agent document with _id field, or null if not found
*/
const resolveAgentId = async (agentCustomId) => {
return await getAgent({ id: agentCustomId });
};
/**
* Agent-specific middleware factory that creates middleware to check agent access permissions.
* This middleware extends the generic canAccessResource to handle agent custom ID resolution.
*
* @param {Object} options - Configuration options
* @param {number} options.requiredPermission - The permission bit required (1=view, 2=edit, 4=delete, 8=share)
* @param {string} [options.resourceIdParam='id'] - The name of the route parameter containing the agent custom ID
* @returns {Function} Express middleware function
*
* @example
* // Basic usage for viewing agents
* router.get('/agents/:id',
* canAccessAgentResource({ requiredPermission: 1 }),
* getAgent
* );
*
* @example
* // Custom resource ID parameter and edit permission
* router.patch('/agents/:agent_id',
* canAccessAgentResource({
* requiredPermission: 2,
* resourceIdParam: 'agent_id'
* }),
* updateAgent
* );
*/
const canAccessAgentResource = (options) => {
const { requiredPermission, resourceIdParam = 'id' } = options;
if (!requiredPermission || typeof requiredPermission !== 'number') {
throw new Error('canAccessAgentResource: requiredPermission is required and must be a number');
}
return canAccessResource({
resourceType: 'agent',
requiredPermission,
resourceIdParam,
idResolver: resolveAgentId,
});
};
module.exports = {
canAccessAgentResource,
};

View File

@@ -0,0 +1,384 @@
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { canAccessAgentResource } = require('./canAccessAgentResource');
const { User, Role, AclEntry } = require('~/db/models');
const { createAgent } = require('~/models/Agent');
describe('canAccessAgentResource middleware', () => {
let mongoServer;
let req, res, next;
let testUser;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await mongoose.connection.dropDatabase();
await Role.create({
name: 'test-role',
permissions: {
AGENTS: {
USE: true,
CREATE: true,
SHARED_GLOBAL: false,
},
},
});
// Create a test user
testUser = await User.create({
email: 'test@example.com',
name: 'Test User',
username: 'testuser',
role: 'test-role',
});
req = {
user: { id: testUser._id.toString(), role: 'test-role' },
params: {},
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
next = jest.fn();
jest.clearAllMocks();
});
describe('middleware factory', () => {
test('should throw error if requiredPermission is not provided', () => {
expect(() => canAccessAgentResource({})).toThrow(
'canAccessAgentResource: requiredPermission is required and must be a number',
);
});
test('should throw error if requiredPermission is not a number', () => {
expect(() => canAccessAgentResource({ requiredPermission: '1' })).toThrow(
'canAccessAgentResource: requiredPermission is required and must be a number',
);
});
test('should create middleware with default resourceIdParam', () => {
const middleware = canAccessAgentResource({ requiredPermission: 1 });
expect(typeof middleware).toBe('function');
expect(middleware.length).toBe(3); // Express middleware signature
});
test('should create middleware with custom resourceIdParam', () => {
const middleware = canAccessAgentResource({
requiredPermission: 2,
resourceIdParam: 'agent_id',
});
expect(typeof middleware).toBe('function');
expect(middleware.length).toBe(3);
});
});
describe('permission checking with real agents', () => {
test('should allow access when user is the agent author', async () => {
// Create an agent owned by the test user
const agent = await createAgent({
id: `agent_${Date.now()}`,
name: 'Test Agent',
provider: 'openai',
model: 'gpt-4',
author: testUser._id,
});
// Create ACL entry for the author (owner permissions)
await AclEntry.create({
principalType: 'user',
principalId: testUser._id,
principalModel: 'User',
resourceType: 'agent',
resourceId: agent._id,
permBits: 15, // All permissions (1+2+4+8)
grantedBy: testUser._id,
});
req.params.id = agent.id;
const middleware = canAccessAgentResource({ requiredPermission: 1 }); // VIEW permission
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
test('should deny access when user is not the author and has no ACL entry', async () => {
// Create an agent owned by a different user
const otherUser = await User.create({
email: 'other@example.com',
name: 'Other User',
username: 'otheruser',
role: 'test-role',
});
const agent = await createAgent({
id: `agent_${Date.now()}`,
name: 'Other User Agent',
provider: 'openai',
model: 'gpt-4',
author: otherUser._id,
});
// Create ACL entry for the other user (owner)
await AclEntry.create({
principalType: 'user',
principalId: otherUser._id,
principalModel: 'User',
resourceType: 'agent',
resourceId: agent._id,
permBits: 15, // All permissions
grantedBy: otherUser._id,
});
req.params.id = agent.id;
const middleware = canAccessAgentResource({ requiredPermission: 1 }); // VIEW permission
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Forbidden',
message: 'Insufficient permissions to access this agent',
});
});
test('should allow access when user has ACL entry with sufficient permissions', async () => {
// Create an agent owned by a different user
const otherUser = await User.create({
email: 'other2@example.com',
name: 'Other User 2',
username: 'otheruser2',
role: 'test-role',
});
const agent = await createAgent({
id: `agent_${Date.now()}`,
name: 'Shared Agent',
provider: 'openai',
model: 'gpt-4',
author: otherUser._id,
});
// Create ACL entry granting view permission to test user
await AclEntry.create({
principalType: 'user',
principalId: testUser._id,
principalModel: 'User',
resourceType: 'agent',
resourceId: agent._id,
permBits: 1, // VIEW permission
grantedBy: otherUser._id,
});
req.params.id = agent.id;
const middleware = canAccessAgentResource({ requiredPermission: 1 }); // VIEW permission
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
test('should deny access when ACL permissions are insufficient', async () => {
// Create an agent owned by a different user
const otherUser = await User.create({
email: 'other3@example.com',
name: 'Other User 3',
username: 'otheruser3',
role: 'test-role',
});
const agent = await createAgent({
id: `agent_${Date.now()}`,
name: 'Limited Access Agent',
provider: 'openai',
model: 'gpt-4',
author: otherUser._id,
});
// Create ACL entry granting only view permission
await AclEntry.create({
principalType: 'user',
principalId: testUser._id,
principalModel: 'User',
resourceType: 'agent',
resourceId: agent._id,
permBits: 1, // VIEW permission only
grantedBy: otherUser._id,
});
req.params.id = agent.id;
const middleware = canAccessAgentResource({ requiredPermission: 2 }); // EDIT permission required
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Forbidden',
message: 'Insufficient permissions to access this agent',
});
});
test('should handle non-existent agent', async () => {
req.params.id = 'agent_nonexistent';
const middleware = canAccessAgentResource({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({
error: 'Not Found',
message: 'agent not found',
});
});
test('should use custom resourceIdParam', async () => {
const agent = await createAgent({
id: `agent_${Date.now()}`,
name: 'Custom Param Agent',
provider: 'openai',
model: 'gpt-4',
author: testUser._id,
});
// Create ACL entry for the author
await AclEntry.create({
principalType: 'user',
principalId: testUser._id,
principalModel: 'User',
resourceType: 'agent',
resourceId: agent._id,
permBits: 15, // All permissions
grantedBy: testUser._id,
});
req.params.agent_id = agent.id; // Using custom param name
const middleware = canAccessAgentResource({
requiredPermission: 1,
resourceIdParam: 'agent_id',
});
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
});
describe('permission levels', () => {
let agent;
beforeEach(async () => {
agent = await createAgent({
id: `agent_${Date.now()}`,
name: 'Permission Test Agent',
provider: 'openai',
model: 'gpt-4',
author: testUser._id,
});
// Create ACL entry with all permissions for the owner
await AclEntry.create({
principalType: 'user',
principalId: testUser._id,
principalModel: 'User',
resourceType: 'agent',
resourceId: agent._id,
permBits: 15, // All permissions (1+2+4+8)
grantedBy: testUser._id,
});
req.params.id = agent.id;
});
test('should support view permission (1)', async () => {
const middleware = canAccessAgentResource({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should support edit permission (2)', async () => {
const middleware = canAccessAgentResource({ requiredPermission: 2 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should support delete permission (4)', async () => {
const middleware = canAccessAgentResource({ requiredPermission: 4 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should support share permission (8)', async () => {
const middleware = canAccessAgentResource({ requiredPermission: 8 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should support combined permissions', async () => {
const viewAndEdit = 1 | 2; // 3
const middleware = canAccessAgentResource({ requiredPermission: viewAndEdit });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
});
describe('integration with agent operations', () => {
test('should work with agent CRUD operations', async () => {
const agentId = `agent_${Date.now()}`;
// Create agent
const agent = await createAgent({
id: agentId,
name: 'Integration Test Agent',
provider: 'openai',
model: 'gpt-4',
author: testUser._id,
description: 'Testing integration',
});
// Create ACL entry for the author
await AclEntry.create({
principalType: 'user',
principalId: testUser._id,
principalModel: 'User',
resourceType: 'agent',
resourceId: agent._id,
permBits: 15, // All permissions
grantedBy: testUser._id,
});
req.params.id = agentId;
// Test view access
const viewMiddleware = canAccessAgentResource({ requiredPermission: 1 });
await viewMiddleware(req, res, next);
expect(next).toHaveBeenCalled();
jest.clearAllMocks();
// Update the agent
const { updateAgent } = require('~/models/Agent');
await updateAgent({ id: agentId }, { description: 'Updated description' });
// Test edit access
const editMiddleware = canAccessAgentResource({ requiredPermission: 2 });
await editMiddleware(req, res, next);
expect(next).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,157 @@
const { logger } = require('@librechat/data-schemas');
const { SystemRoles } = require('librechat-data-provider');
const { checkPermission } = require('~/server/services/PermissionService');
/**
* Generic base middleware factory that creates middleware to check resource access permissions.
* This middleware expects MongoDB ObjectIds as resource identifiers for ACL permission checks.
*
* @param {Object} options - Configuration options
* @param {string} options.resourceType - The type of resource (e.g., 'agent', 'file', 'project')
* @param {number} options.requiredPermission - The permission bit required (1=view, 2=edit, 4=delete, 8=share)
* @param {string} [options.resourceIdParam='resourceId'] - The name of the route parameter containing the resource ID
* @param {Function} [options.idResolver] - Optional function to resolve custom IDs to ObjectIds
* @returns {Function} Express middleware function
*
* @example
* // Direct usage with ObjectId (for resources that use MongoDB ObjectId in routes)
* router.get('/prompts/:promptId',
* canAccessResource({ resourceType: 'prompt', requiredPermission: 1 }),
* getPrompt
* );
*
* @example
* // Usage with custom ID resolver (for resources that use custom string IDs)
* router.get('/agents/:id',
* canAccessResource({
* resourceType: 'agent',
* requiredPermission: 1,
* resourceIdParam: 'id',
* idResolver: (customId) => resolveAgentId(customId)
* }),
* getAgent
* );
*/
const canAccessResource = (options) => {
const {
resourceType,
requiredPermission,
resourceIdParam = 'resourceId',
idResolver = null,
} = options;
if (!resourceType || typeof resourceType !== 'string') {
throw new Error('canAccessResource: resourceType is required and must be a string');
}
if (!requiredPermission || typeof requiredPermission !== 'number') {
throw new Error('canAccessResource: requiredPermission is required and must be a number');
}
return async (req, res, next) => {
try {
// Extract resource ID from route parameters
const rawResourceId = req.params[resourceIdParam];
if (!rawResourceId) {
logger.warn(`[canAccessResource] Missing ${resourceIdParam} in route parameters`);
return res.status(400).json({
error: 'Bad Request',
message: `${resourceIdParam} is required`,
});
}
// Check if user is authenticated
if (!req.user || !req.user.id) {
logger.warn(
`[canAccessResource] Unauthenticated request for ${resourceType} ${rawResourceId}`,
);
return res.status(401).json({
error: 'Unauthorized',
message: 'Authentication required',
});
}
// if system admin let through
if (req.user.role === SystemRoles.ADMIN) {
return next();
}
const userId = req.user.id;
let resourceId = rawResourceId;
let resourceInfo = null;
// Resolve custom ID to ObjectId if resolver is provided
if (idResolver) {
logger.debug(
`[canAccessResource] Resolving ${resourceType} custom ID ${rawResourceId} to ObjectId`,
);
const resolutionResult = await idResolver(rawResourceId);
if (!resolutionResult) {
logger.warn(`[canAccessResource] ${resourceType} not found: ${rawResourceId}`);
return res.status(404).json({
error: 'Not Found',
message: `${resourceType} not found`,
});
}
// Handle different resolver return formats
if (typeof resolutionResult === 'string' || resolutionResult._id) {
resourceId = resolutionResult._id || resolutionResult;
resourceInfo = typeof resolutionResult === 'object' ? resolutionResult : null;
} else {
resourceId = resolutionResult;
}
logger.debug(
`[canAccessResource] Resolved ${resourceType} ${rawResourceId} to ObjectId ${resourceId}`,
);
}
// Check permissions using PermissionService with ObjectId
const hasPermission = await checkPermission({
userId,
resourceType,
resourceId,
requiredPermission,
});
if (hasPermission) {
logger.debug(
`[canAccessResource] User ${userId} has permission ${requiredPermission} on ${resourceType} ${rawResourceId} (${resourceId})`,
);
req.resourceAccess = {
resourceType,
resourceId, // MongoDB ObjectId for ACL operations
customResourceId: rawResourceId, // Original ID from route params
permission: requiredPermission,
userId,
...(resourceInfo && { resourceInfo }),
};
return next();
}
logger.warn(
`[canAccessResource] User ${userId} denied access to ${resourceType} ${rawResourceId} ` +
`(required permission: ${requiredPermission})`,
);
return res.status(403).json({
error: 'Forbidden',
message: `Insufficient permissions to access this ${resourceType}`,
});
} catch (error) {
logger.error(`[canAccessResource] Error checking access for ${resourceType}:`, error);
return res.status(500).json({
error: 'Internal Server Error',
message: 'Failed to check resource access permissions',
});
}
};
};
module.exports = {
canAccessResource,
};

View File

@@ -0,0 +1,9 @@
const { canAccessResource } = require('./canAccessResource');
const { canAccessAgentResource } = require('./canAccessAgentResource');
const { canAccessAgentFromBody } = require('./canAccessAgentFromBody');
module.exports = {
canAccessResource,
canAccessAgentResource,
canAccessAgentFromBody,
};

View File

@@ -8,6 +8,7 @@ const concurrentLimiter = require('./concurrentLimiter');
const validateEndpoint = require('./validateEndpoint');
const requireLocalAuth = require('./requireLocalAuth');
const canDeleteAccount = require('./canDeleteAccount');
const accessResources = require('./accessResources');
const setBalanceConfig = require('./setBalanceConfig');
const requireLdapAuth = require('./requireLdapAuth');
const abortMiddleware = require('./abortMiddleware');
@@ -29,6 +30,7 @@ module.exports = {
...validate,
...limiters,
...roles,
...accessResources,
noIndex,
checkBan,
uaParser,

View File

@@ -0,0 +1,251 @@
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { checkAccess, generateCheckAccess } = require('./access');
const { PermissionTypes, Permissions } = require('librechat-data-provider');
const { Role } = require('~/db/models');
// Mock only the logger
jest.mock('~/config', () => ({
logger: {
warn: jest.fn(),
error: jest.fn(),
},
}));
describe('Access Middleware', () => {
let mongoServer;
let req, res, next;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await mongoose.connection.dropDatabase();
// Create test roles
await Role.create({
name: 'user',
permissions: {
[PermissionTypes.AGENTS]: {
[Permissions.USE]: true,
[Permissions.CREATE]: false,
[Permissions.SHARED_GLOBAL]: false,
},
},
});
await Role.create({
name: 'admin',
permissions: {
[PermissionTypes.AGENTS]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.SHARED_GLOBAL]: true,
},
},
});
req = {
user: { id: 'user123', role: 'user' },
body: {},
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
next = jest.fn();
jest.clearAllMocks();
});
describe('checkAccess', () => {
test('should return false if user is not provided', async () => {
const result = await checkAccess(null, PermissionTypes.AGENTS, [Permissions.USE]);
expect(result).toBe(false);
});
test('should return true if user has required permission', async () => {
const result = await checkAccess(req.user, PermissionTypes.AGENTS, [Permissions.USE]);
expect(result).toBe(true);
});
test('should return false if user lacks required permission', async () => {
const result = await checkAccess(req.user, PermissionTypes.AGENTS, [Permissions.CREATE]);
expect(result).toBe(false);
});
test('should return true if user has any of multiple permissions', async () => {
const result = await checkAccess(req.user, PermissionTypes.AGENTS, [
Permissions.USE,
Permissions.CREATE,
]);
expect(result).toBe(true);
});
test('should check body properties when permission is not directly granted', async () => {
// User role doesn't have CREATE permission, but bodyProps allows it
const bodyProps = {
[Permissions.CREATE]: ['agentId', 'name'],
};
const checkObject = { agentId: 'agent123' };
const result = await checkAccess(
req.user,
PermissionTypes.AGENTS,
[Permissions.CREATE],
bodyProps,
checkObject,
);
expect(result).toBe(true);
});
test('should return false if role is not found', async () => {
req.user.role = 'nonexistent';
const result = await checkAccess(req.user, PermissionTypes.AGENTS, [Permissions.USE]);
expect(result).toBe(false);
});
test('should return false if role has no permissions for the requested type', async () => {
await Role.create({
name: 'limited',
permissions: {
// Explicitly set AGENTS permissions to false
[PermissionTypes.AGENTS]: {
[Permissions.USE]: false,
[Permissions.CREATE]: false,
[Permissions.SHARED_GLOBAL]: false,
},
// Has permissions for other types
[PermissionTypes.PROMPTS]: {
[Permissions.USE]: true,
},
},
});
req.user.role = 'limited';
const result = await checkAccess(req.user, PermissionTypes.AGENTS, [Permissions.USE]);
expect(result).toBe(false);
});
test('should handle admin role with all permissions', async () => {
req.user.role = 'admin';
const createResult = await checkAccess(req.user, PermissionTypes.AGENTS, [
Permissions.CREATE,
]);
expect(createResult).toBe(true);
const shareResult = await checkAccess(req.user, PermissionTypes.AGENTS, [
Permissions.SHARED_GLOBAL,
]);
expect(shareResult).toBe(true);
});
});
describe('generateCheckAccess', () => {
test('should call next() when user has required permission', async () => {
const middleware = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]);
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
test('should return 403 when user lacks permission', async () => {
const middleware = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.CREATE]);
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({ message: 'Forbidden: Insufficient permissions' });
});
test('should check body properties when configured', async () => {
req.body = { agentId: 'agent123', description: 'test' };
const bodyProps = {
[Permissions.CREATE]: ['agentId'],
};
const middleware = generateCheckAccess(
PermissionTypes.AGENTS,
[Permissions.CREATE],
bodyProps,
);
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
test('should handle database errors gracefully', async () => {
// Create a user with an invalid role that will cause getRoleByName to fail
req.user.role = { invalid: 'object' }; // This will cause an error when querying
const middleware = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]);
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
message: expect.stringContaining('Server error:'),
});
});
test('should work with multiple permission types', async () => {
req.user.role = 'admin';
const middleware = generateCheckAccess(PermissionTypes.AGENTS, [
Permissions.USE,
Permissions.CREATE,
Permissions.SHARED_GLOBAL,
]);
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should handle missing user gracefully', async () => {
req.user = null;
const middleware = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]);
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
message: expect.stringContaining('Server error:'),
});
});
test('should handle role with no AGENTS permissions', async () => {
await Role.create({
name: 'noaccess',
permissions: {
// Explicitly set AGENTS with all permissions false
[PermissionTypes.AGENTS]: {
[Permissions.USE]: false,
[Permissions.CREATE]: false,
[Permissions.SHARED_GLOBAL]: false,
},
},
});
req.user.role = 'noaccess';
const middleware = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]);
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({ message: 'Forbidden: Insufficient permissions' });
});
});
});

View File

@@ -1,8 +1,8 @@
const { isEnabled } = require('@librechat/api');
const { Constants, ViolationTypes, Time } = require('librechat-data-provider');
const { searchConversation } = require('~/models/Conversation');
const denyRequest = require('~/server/middleware/denyRequest');
const { logViolation, getLogStores } = require('~/cache');
const { isEnabled } = require('~/server/utils');
const { USE_REDIS, CONVO_ACCESS_VIOLATION_SCORE: score = 0 } = process.env ?? {};

View File

@@ -0,0 +1,62 @@
const express = require('express');
const { PermissionBits } = require('@librechat/data-schemas');
const {
getUserEffectivePermissions,
updateResourcePermissions,
getResourcePermissions,
getResourceRoles,
searchPrincipals,
} = require('~/server/controllers/PermissionsController');
const { requireJwtAuth, checkBan, uaParser, canAccessResource } = require('~/server/middleware');
const router = express.Router();
// Apply common middleware
router.use(requireJwtAuth);
router.use(checkBan);
router.use(uaParser);
/**
* Generic routes for resource permissions
* Pattern: /api/permissions/{resourceType}/{resourceId}
*/
/**
* GET /api/permissions/search-principals
* Search for users and groups to grant permissions
*/
router.get('/search-principals', searchPrincipals);
/**
* GET /api/permissions/{resourceType}/roles
* Get available roles for a resource type
*/
router.get('/:resourceType/roles', getResourceRoles);
/**
* GET /api/permissions/{resourceType}/{resourceId}
* Get all permissions for a specific resource
*/
router.get('/:resourceType/:resourceId', getResourcePermissions);
/**
* PUT /api/permissions/{resourceType}/{resourceId}
* Bulk update permissions for a specific resource
*/
router.put(
'/:resourceType/:resourceId',
canAccessResource({
resourceType: 'agent',
requiredPermission: PermissionBits.SHARE,
resourceIdParam: 'resourceId',
}),
updateResourcePermissions,
);
/**
* GET /api/permissions/{resourceType}/{resourceId}/effective
* Get user's effective permissions for a specific resource
*/
router.get('/:resourceType/:resourceId/effective', getUserEffectivePermissions);
module.exports = router;

View File

@@ -1,8 +1,10 @@
const express = require('express');
const jwt = require('jsonwebtoken');
const { getAccessToken } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { CacheKeys } = require('librechat-data-provider');
const { getAccessToken } = require('~/server/services/TokenService');
const { logger, getFlowStateManager } = require('~/config');
const { findToken, updateToken, createToken } = require('~/models');
const { getFlowStateManager } = require('~/config');
const { getLogStores } = require('~/cache');
const router = express.Router();
@@ -28,18 +30,19 @@ router.get('/:action_id/oauth/callback', async (req, res) => {
try {
decodedState = jwt.verify(state, JWT_SECRET);
} catch (err) {
logger.error('Error verifying state parameter:', err);
await flowManager.failFlow(identifier, 'oauth', 'Invalid or expired state parameter');
return res.status(400).send('Invalid or expired state parameter');
return res.redirect('/oauth/error?error=invalid_state');
}
if (decodedState.action_id !== action_id) {
await flowManager.failFlow(identifier, 'oauth', 'Mismatched action ID in state parameter');
return res.status(400).send('Mismatched action ID in state parameter');
return res.redirect('/oauth/error?error=invalid_state');
}
if (!decodedState.user) {
await flowManager.failFlow(identifier, 'oauth', 'Invalid user ID in state parameter');
return res.status(400).send('Invalid user ID in state parameter');
return res.redirect('/oauth/error?error=invalid_state');
}
identifier = `${decodedState.user}:${action_id}`;
const flowState = await flowManager.getFlowState(identifier, 'oauth');
@@ -47,91 +50,34 @@ router.get('/:action_id/oauth/callback', async (req, res) => {
throw new Error('OAuth flow not found');
}
const tokenData = await getAccessToken({
code,
userId: decodedState.user,
identifier,
client_url: flowState.metadata.client_url,
redirect_uri: flowState.metadata.redirect_uri,
token_exchange_method: flowState.metadata.token_exchange_method,
/** Encrypted values */
encrypted_oauth_client_id: flowState.metadata.encrypted_oauth_client_id,
encrypted_oauth_client_secret: flowState.metadata.encrypted_oauth_client_secret,
});
const tokenData = await getAccessToken(
{
code,
userId: decodedState.user,
identifier,
client_url: flowState.metadata.client_url,
redirect_uri: flowState.metadata.redirect_uri,
token_exchange_method: flowState.metadata.token_exchange_method,
/** Encrypted values */
encrypted_oauth_client_id: flowState.metadata.encrypted_oauth_client_id,
encrypted_oauth_client_secret: flowState.metadata.encrypted_oauth_client_secret,
},
{
findToken,
updateToken,
createToken,
},
);
await flowManager.completeFlow(identifier, 'oauth', tokenData);
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>Authentication Successful</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<style>
body {
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont;
background-color: rgb(249, 250, 251);
margin: 0;
padding: 2rem;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.card {
background-color: white;
border-radius: 0.5rem;
padding: 2rem;
max-width: 28rem;
width: 100%;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
text-align: center;
}
.heading {
color: rgb(17, 24, 39);
font-size: 1.875rem;
font-weight: 700;
margin: 0 0 1rem;
}
.description {
color: rgb(75, 85, 99);
font-size: 0.875rem;
margin: 0.5rem 0;
}
.countdown {
color: rgb(99, 102, 241);
font-weight: 500;
}
</style>
</head>
<body>
<div class="card">
<h1 class="heading">Authentication Successful</h1>
<p class="description">
Your authentication was successful. This window will close in
<span class="countdown" id="countdown">3</span> seconds.
</p>
</div>
<script>
let secondsLeft = 3;
const countdownElement = document.getElementById('countdown');
const countdown = setInterval(() => {
secondsLeft--;
countdownElement.textContent = secondsLeft;
if (secondsLeft <= 0) {
clearInterval(countdown);
window.close();
}
}, 1000);
</script>
</body>
</html>
`);
/** Redirect to React success page */
const serverName = flowState.metadata?.action_name || `Action ${action_id}`;
const redirectUrl = `/oauth/success?serverName=${encodeURIComponent(serverName)}`;
res.redirect(redirectUrl);
} catch (error) {
logger.error('Error in OAuth callback:', error);
await flowManager.failFlow(identifier, 'oauth', error);
res.status(500).send('Authentication failed. Please try again.');
res.redirect('/oauth/error?error=callback_failed');
}
});

View File

@@ -1,20 +1,15 @@
const express = require('express');
const { nanoid } = require('nanoid');
const { actionDelimiter, SystemRoles, removeNullishValues } = require('librechat-data-provider');
const { logger, PermissionBits } = require('@librechat/data-schemas');
const { actionDelimiter, removeNullishValues } = require('librechat-data-provider');
const { encryptMetadata, domainParser } = require('~/server/services/ActionService');
const { updateAction, getActions, deleteAction } = require('~/models/Action');
const { isActionDomainAllowed } = require('~/server/services/domains');
const { canAccessAgentResource } = require('~/server/middleware');
const { getAgent, updateAgent } = require('~/models/Agent');
const { logger } = require('~/config');
const router = express.Router();
// If the user has ADMIN role
// then action edition is possible even if not owner of the assistant
const isAdmin = (req) => {
return req.user.role === SystemRoles.ADMIN;
};
/**
* Retrieves all user's actions
* @route GET /actions/
@@ -23,9 +18,8 @@ const isAdmin = (req) => {
*/
router.get('/', async (req, res) => {
try {
const admin = isAdmin(req);
// If admin, get all actions, otherwise only user's actions
const searchParams = admin ? {} : { user: req.user.id };
// Get all actions for the user (admin permissions handled by middleware if needed)
const searchParams = { user: req.user.id };
res.json(await getActions(searchParams));
} catch (error) {
res.status(500).json({ error: error.message });
@@ -41,106 +35,110 @@ router.get('/', async (req, res) => {
* @param {ActionMetadata} req.body.metadata - Metadata for the action.
* @returns {Object} 200 - success response - application/json
*/
router.post('/:agent_id', async (req, res) => {
try {
const { agent_id } = req.params;
router.post(
'/:agent_id',
canAccessAgentResource({
requiredPermission: PermissionBits.EDIT,
resourceIdParam: 'agent_id',
}),
async (req, res) => {
try {
const { agent_id } = req.params;
/** @type {{ functions: FunctionTool[], action_id: string, metadata: ActionMetadata }} */
const { functions, action_id: _action_id, metadata: _metadata } = req.body;
if (!functions.length) {
return res.status(400).json({ message: 'No functions provided' });
}
let metadata = await encryptMetadata(removeNullishValues(_metadata, true));
const isDomainAllowed = await isActionDomainAllowed(metadata.domain);
if (!isDomainAllowed) {
return res.status(400).json({ message: 'Domain not allowed' });
}
let { domain } = metadata;
domain = await domainParser(domain, true);
if (!domain) {
return res.status(400).json({ message: 'No domain provided' });
}
const action_id = _action_id ?? nanoid();
const initialPromises = [];
const admin = isAdmin(req);
// If admin, can edit any agent, otherwise only user's agents
const agentQuery = admin ? { id: agent_id } : { id: agent_id, author: req.user.id };
// TODO: share agents
initialPromises.push(getAgent(agentQuery));
if (_action_id) {
initialPromises.push(getActions({ action_id }, true));
}
/** @type {[Agent, [Action|undefined]]} */
const [agent, actions_result] = await Promise.all(initialPromises);
if (!agent) {
return res.status(404).json({ message: 'Agent not found for adding action' });
}
if (actions_result && actions_result.length) {
const action = actions_result[0];
metadata = { ...action.metadata, ...metadata };
}
const { actions: _actions = [], author: agent_author } = agent ?? {};
const actions = [];
for (const action of _actions) {
const [_action_domain, current_action_id] = action.split(actionDelimiter);
if (current_action_id === action_id) {
continue;
/** @type {{ functions: FunctionTool[], action_id: string, metadata: ActionMetadata }} */
const { functions, action_id: _action_id, metadata: _metadata } = req.body;
if (!functions.length) {
return res.status(400).json({ message: 'No functions provided' });
}
actions.push(action);
}
actions.push(`${domain}${actionDelimiter}${action_id}`);
/** @type {string[]}} */
const { tools: _tools = [] } = agent;
const tools = _tools
.filter((tool) => !(tool && (tool.includes(domain) || tool.includes(action_id))))
.concat(functions.map((tool) => `${tool.function.name}${actionDelimiter}${domain}`));
// Force version update since actions are changing
const updatedAgent = await updateAgent(
agentQuery,
{ tools, actions },
{
updatingUserId: req.user.id,
forceVersion: true,
},
);
// Only update user field for new actions
const actionUpdateData = { metadata, agent_id };
if (!actions_result || !actions_result.length) {
// For new actions, use the agent owner's user ID
actionUpdateData.user = agent_author || req.user.id;
}
/** @type {[Action]} */
const updatedAction = await updateAction({ action_id }, actionUpdateData);
const sensitiveFields = ['api_key', 'oauth_client_id', 'oauth_client_secret'];
for (let field of sensitiveFields) {
if (updatedAction.metadata[field]) {
delete updatedAction.metadata[field];
let metadata = await encryptMetadata(removeNullishValues(_metadata, true));
const isDomainAllowed = await isActionDomainAllowed(metadata.domain);
if (!isDomainAllowed) {
return res.status(400).json({ message: 'Domain not allowed' });
}
}
res.json([updatedAgent, updatedAction]);
} catch (error) {
const message = 'Trouble updating the Agent Action';
logger.error(message, error);
res.status(500).json({ message });
}
});
let { domain } = metadata;
domain = await domainParser(domain, true);
if (!domain) {
return res.status(400).json({ message: 'No domain provided' });
}
const action_id = _action_id ?? nanoid();
const initialPromises = [];
// Permissions already validated by middleware - load agent directly
initialPromises.push(getAgent({ id: agent_id }));
if (_action_id) {
initialPromises.push(getActions({ action_id }, true));
}
/** @type {[Agent, [Action|undefined]]} */
const [agent, actions_result] = await Promise.all(initialPromises);
if (!agent) {
return res.status(404).json({ message: 'Agent not found for adding action' });
}
if (actions_result && actions_result.length) {
const action = actions_result[0];
metadata = { ...action.metadata, ...metadata };
}
const { actions: _actions = [], author: agent_author } = agent ?? {};
const actions = [];
for (const action of _actions) {
const [_action_domain, current_action_id] = action.split(actionDelimiter);
if (current_action_id === action_id) {
continue;
}
actions.push(action);
}
actions.push(`${domain}${actionDelimiter}${action_id}`);
/** @type {string[]}} */
const { tools: _tools = [] } = agent;
const tools = _tools
.filter((tool) => !(tool && (tool.includes(domain) || tool.includes(action_id))))
.concat(functions.map((tool) => `${tool.function.name}${actionDelimiter}${domain}`));
// Force version update since actions are changing
const updatedAgent = await updateAgent(
{ id: agent_id },
{ tools, actions },
{
updatingUserId: req.user.id,
forceVersion: true,
},
);
// Only update user field for new actions
const actionUpdateData = { metadata, agent_id };
if (!actions_result || !actions_result.length) {
// For new actions, use the agent owner's user ID
actionUpdateData.user = agent_author || req.user.id;
}
/** @type {[Action]} */
const updatedAction = await updateAction({ action_id }, actionUpdateData);
const sensitiveFields = ['api_key', 'oauth_client_id', 'oauth_client_secret'];
for (let field of sensitiveFields) {
if (updatedAction.metadata[field]) {
delete updatedAction.metadata[field];
}
}
res.json([updatedAgent, updatedAction]);
} catch (error) {
const message = 'Trouble updating the Agent Action';
logger.error(message, error);
res.status(500).json({ message });
}
},
);
/**
* Deletes an action for a specific agent.
@@ -149,52 +147,55 @@ router.post('/:agent_id', async (req, res) => {
* @param {string} req.params.action_id - The ID of the action to delete.
* @returns {Object} 200 - success response - application/json
*/
router.delete('/:agent_id/:action_id', async (req, res) => {
try {
const { agent_id, action_id } = req.params;
const admin = isAdmin(req);
router.delete(
'/:agent_id/:action_id',
canAccessAgentResource({
requiredPermission: PermissionBits.EDIT,
resourceIdParam: 'agent_id',
}),
async (req, res) => {
try {
const { agent_id, action_id } = req.params;
// If admin, can delete any agent, otherwise only user's agents
const agentQuery = admin ? { id: agent_id } : { id: agent_id, author: req.user.id };
const agent = await getAgent(agentQuery);
if (!agent) {
return res.status(404).json({ message: 'Agent not found for deleting action' });
}
const { tools = [], actions = [] } = agent;
let domain = '';
const updatedActions = actions.filter((action) => {
if (action.includes(action_id)) {
[domain] = action.split(actionDelimiter);
return false;
// Permissions already validated by middleware - load agent directly
const agent = await getAgent({ id: agent_id });
if (!agent) {
return res.status(404).json({ message: 'Agent not found for deleting action' });
}
return true;
});
domain = await domainParser(domain, true);
const { tools = [], actions = [] } = agent;
if (!domain) {
return res.status(400).json({ message: 'No domain provided' });
let domain = '';
const updatedActions = actions.filter((action) => {
if (action.includes(action_id)) {
[domain] = action.split(actionDelimiter);
return false;
}
return true;
});
domain = await domainParser(domain, true);
if (!domain) {
return res.status(400).json({ message: 'No domain provided' });
}
const updatedTools = tools.filter((tool) => !(tool && tool.includes(domain)));
// Force version update since actions are being removed
await updateAgent(
{ id: agent_id },
{ tools: updatedTools, actions: updatedActions },
{ updatingUserId: req.user.id, forceVersion: true },
);
await deleteAction({ action_id });
res.status(200).json({ message: 'Action deleted successfully' });
} catch (error) {
const message = 'Trouble deleting the Agent Action';
logger.error(message, error);
res.status(500).json({ message });
}
const updatedTools = tools.filter((tool) => !(tool && tool.includes(domain)));
// Force version update since actions are being removed
await updateAgent(
agentQuery,
{ tools: updatedTools, actions: updatedActions },
{ updatingUserId: req.user.id, forceVersion: true },
);
// If admin, can delete any action, otherwise only user's actions
const actionQuery = admin ? { action_id } : { action_id, user: req.user.id };
await deleteAction(actionQuery);
res.status(200).json({ message: 'Action deleted successfully' });
} catch (error) {
const message = 'Trouble deleting the Agent Action';
logger.error(message, error);
res.status(500).json({ message });
}
});
},
);
module.exports = router;

View File

@@ -1,4 +1,5 @@
const express = require('express');
const { PermissionBits } = require('@librechat/data-schemas');
const { PermissionTypes, Permissions } = require('librechat-data-provider');
const {
setHeaders,
@@ -7,6 +8,7 @@ const {
generateCheckAccess,
validateConvoAccess,
buildEndpointOption,
canAccessAgentFromBody,
} = require('~/server/middleware');
const { initializeClient } = require('~/server/services/Endpoints/agents');
const AgentController = require('~/server/controllers/agents/request');
@@ -17,8 +19,12 @@ const router = express.Router();
router.use(moderateText);
const checkAgentAccess = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]);
const checkAgentResourceAccess = canAccessAgentFromBody({
requiredPermission: PermissionBits.VIEW,
});
router.use(checkAgentAccess);
router.use(checkAgentResourceAccess);
router.use(validateConvoAccess);
router.use(buildEndpointOption);
router.use(setHeaders);

View File

@@ -1,6 +1,11 @@
const express = require('express');
const { PermissionBits } = require('@librechat/data-schemas');
const { PermissionTypes, Permissions } = require('librechat-data-provider');
const { requireJwtAuth, generateCheckAccess } = require('~/server/middleware');
const {
requireJwtAuth,
generateCheckAccess,
canAccessAgentResource,
} = require('~/server/middleware');
const v1 = require('~/server/controllers/agents/v1');
const actions = require('./actions');
const tools = require('./tools');
@@ -46,13 +51,38 @@ router.use('/tools', tools);
router.post('/', checkAgentCreate, v1.createAgent);
/**
* Retrieves an agent.
* Retrieves basic agent information (VIEW permission required).
* Returns safe, non-sensitive agent data for viewing purposes.
* @route GET /agents/:id
* @param {string} req.params.id - Agent identifier.
* @returns {Agent} 200 - Success response - application/json
* @returns {Agent} 200 - Basic agent info - application/json
*/
router.get('/:id', checkAgentAccess, v1.getAgent);
router.get(
'/:id',
checkAgentAccess,
canAccessAgentResource({
requiredPermission: PermissionBits.VIEW,
resourceIdParam: 'id',
}),
v1.getAgent,
);
/**
* Retrieves full agent details including sensitive configuration (EDIT permission required).
* Returns complete agent data for editing/configuration purposes.
* @route GET /agents/:id/expanded
* @param {string} req.params.id - Agent identifier.
* @returns {Agent} 200 - Full agent details - application/json
*/
router.get(
'/:id/expanded',
checkAgentAccess,
canAccessAgentResource({
requiredPermission: PermissionBits.EDIT,
resourceIdParam: 'id',
}),
(req, res) => v1.getAgent(req, res, true), // Expanded version
);
/**
* Updates an agent.
* @route PATCH /agents/:id
@@ -60,7 +90,15 @@ router.get('/:id', checkAgentAccess, v1.getAgent);
* @param {AgentUpdateParams} req.body - The agent update parameters.
* @returns {Agent} 200 - Success response - application/json
*/
router.patch('/:id', checkGlobalAgentShare, v1.updateAgent);
router.patch(
'/:id',
checkGlobalAgentShare,
canAccessAgentResource({
requiredPermission: PermissionBits.EDIT,
resourceIdParam: 'id',
}),
v1.updateAgent,
);
/**
* Duplicates an agent.
@@ -68,7 +106,15 @@ router.patch('/:id', checkGlobalAgentShare, v1.updateAgent);
* @param {string} req.params.id - Agent identifier.
* @returns {Agent} 201 - Success response - application/json
*/
router.post('/:id/duplicate', checkAgentCreate, v1.duplicateAgent);
router.post(
'/:id/duplicate',
checkAgentCreate,
canAccessAgentResource({
requiredPermission: PermissionBits.VIEW,
resourceIdParam: 'id',
}),
v1.duplicateAgent,
);
/**
* Deletes an agent.
@@ -76,7 +122,15 @@ router.post('/:id/duplicate', checkAgentCreate, v1.duplicateAgent);
* @param {string} req.params.id - Agent identifier.
* @returns {Agent} 200 - success response - application/json
*/
router.delete('/:id', checkAgentCreate, v1.deleteAgent);
router.delete(
'/:id',
checkAgentCreate,
canAccessAgentResource({
requiredPermission: PermissionBits.DELETE,
resourceIdParam: 'id',
}),
v1.deleteAgent,
);
/**
* Reverts an agent to a previous version.
@@ -103,6 +157,14 @@ router.get('/', checkAgentAccess, v1.getListAgents);
* @param {string} [req.body.metadata] - Optional metadata for the agent's avatar.
* @returns {Object} 200 - success response - application/json
*/
avatar.post('/:agent_id/avatar/', checkAgentAccess, v1.uploadAgentAvatar);
avatar.post(
'/:agent_id/avatar/',
checkAgentAccess,
canAccessAgentResource({
requiredPermission: PermissionBits.EDIT,
resourceIdParam: 'agent_id',
}),
v1.uploadAgentAvatar,
);
module.exports = { v1: router, avatar };

View File

@@ -1,10 +1,11 @@
const express = require('express');
const { logger } = require('@librechat/data-schemas');
const { CacheKeys, defaultSocialLogins, Constants } = require('librechat-data-provider');
const { getCustomConfig } = require('~/server/services/Config/getCustomConfig');
const { getLdapConfig } = require('~/server/services/Config/ldap');
const { getProjectByName } = require('~/models/Project');
const { isEnabled } = require('~/server/utils');
const { getLogStores } = require('~/cache');
const { logger } = require('~/config');
const router = express.Router();
const emailLoginEnabled =
@@ -21,6 +22,7 @@ const publicSharedLinksEnabled =
router.get('/', async function (req, res) {
const cache = getLogStores(CacheKeys.CONFIG_STORE);
const cachedStartupConfig = await cache.get(CacheKeys.STARTUP_CONFIG);
if (cachedStartupConfig) {
res.send(cachedStartupConfig);
@@ -96,6 +98,18 @@ router.get('/', async function (req, res) {
bundlerURL: process.env.SANDPACK_BUNDLER_URL,
staticBundlerURL: process.env.SANDPACK_STATIC_BUNDLER_URL,
};
payload.mcpServers = {};
const config = await getCustomConfig();
if (config?.mcpServers != null) {
for (const serverName in config.mcpServers) {
const serverConfig = config.mcpServers[serverName];
payload.mcpServers[serverName] = {
customUserVars: serverConfig?.customUserVars || {},
};
}
}
/** @type {TCustomConfig['webSearch']} */
const webSearchConfig = req.app.locals.webSearch;
if (

View File

@@ -1,6 +1,7 @@
const fs = require('fs').promises;
const express = require('express');
const { getAvatarProcessFunction, resizeAvatar } = require('@librechat/auth');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { resizeAvatar } = require('~/server/services/Files/images/avatar');
const { filterFile } = require('~/server/services/Files/process');
const { logger } = require('~/config');
@@ -25,7 +26,7 @@ router.post('/', async (req, res) => {
desiredFormat,
});
const processAvatar = getAvatarProcessFunction(fileStrategy);
const { processAvatar } = getStrategyFunctions(fileStrategy);
const url = await processAvatar({ buffer: resizedBuffer, userId, manual });
res.json({ url });

View File

@@ -27,9 +27,12 @@ const edit = require('./edit');
const keys = require('./keys');
const user = require('./user');
const ask = require('./ask');
const accessPermissions = require('./accessPermissions');
const mcp = require('./mcp');
module.exports = {
ask,
mcp,
edit,
auth,
keys,
@@ -58,4 +61,5 @@ module.exports = {
assistants,
categories,
staticRoute,
accessPermissions,
};

205
api/server/routes/mcp.js Normal file
View File

@@ -0,0 +1,205 @@
const { Router } = require('express');
const { MCPOAuthHandler } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { CacheKeys } = require('librechat-data-provider');
const { requireJwtAuth } = require('~/server/middleware');
const { getFlowStateManager } = require('~/config');
const { getLogStores } = require('~/cache');
const router = Router();
/**
* Initiate OAuth flow
* This endpoint is called when the user clicks the auth link in the UI
*/
router.get('/:serverName/oauth/initiate', requireJwtAuth, async (req, res) => {
try {
const { serverName } = req.params;
const { userId, flowId } = req.query;
const user = req.user;
// Verify the userId matches the authenticated user
if (userId !== user.id) {
return res.status(403).json({ error: 'User mismatch' });
}
logger.debug('[MCP OAuth] Initiate request', { serverName, userId, flowId });
const flowsCache = getLogStores(CacheKeys.FLOWS);
const flowManager = getFlowStateManager(flowsCache);
/** Flow state to retrieve OAuth config */
const flowState = await flowManager.getFlowState(flowId, 'mcp_oauth');
if (!flowState) {
logger.error('[MCP OAuth] Flow state not found', { flowId });
return res.status(404).json({ error: 'Flow not found' });
}
const { serverUrl, oauth: oauthConfig } = flowState.metadata || {};
if (!serverUrl || !oauthConfig) {
logger.error('[MCP OAuth] Missing server URL or OAuth config in flow state');
return res.status(400).json({ error: 'Invalid flow state' });
}
const { authorizationUrl, flowId: oauthFlowId } = await MCPOAuthHandler.initiateOAuthFlow(
serverName,
serverUrl,
userId,
oauthConfig,
);
logger.debug('[MCP OAuth] OAuth flow initiated', { oauthFlowId, authorizationUrl });
// Redirect user to the authorization URL
res.redirect(authorizationUrl);
} catch (error) {
logger.error('[MCP OAuth] Failed to initiate OAuth', error);
res.status(500).json({ error: 'Failed to initiate OAuth' });
}
});
/**
* OAuth callback handler
* This handles the OAuth callback after the user has authorized the application
*/
router.get('/:serverName/oauth/callback', async (req, res) => {
try {
const { serverName } = req.params;
const { code, state, error: oauthError } = req.query;
logger.debug('[MCP OAuth] Callback received', {
serverName,
code: code ? 'present' : 'missing',
state,
error: oauthError,
});
if (oauthError) {
logger.error('[MCP OAuth] OAuth error received', { error: oauthError });
return res.redirect(`/oauth/error?error=${encodeURIComponent(String(oauthError))}`);
}
if (!code || typeof code !== 'string') {
logger.error('[MCP OAuth] Missing or invalid code');
return res.redirect('/oauth/error?error=missing_code');
}
if (!state || typeof state !== 'string') {
logger.error('[MCP OAuth] Missing or invalid state');
return res.redirect('/oauth/error?error=missing_state');
}
// Extract flow ID from state
const flowId = state;
logger.debug('[MCP OAuth] Using flow ID from state', { flowId });
const flowsCache = getLogStores(CacheKeys.FLOWS);
const flowManager = getFlowStateManager(flowsCache);
logger.debug('[MCP OAuth] Getting flow state for flowId: ' + flowId);
const flowState = await MCPOAuthHandler.getFlowState(flowId, flowManager);
if (!flowState) {
logger.error('[MCP OAuth] Flow state not found for flowId:', flowId);
return res.redirect('/oauth/error?error=invalid_state');
}
logger.debug('[MCP OAuth] Flow state details', {
serverName: flowState.serverName,
userId: flowState.userId,
hasMetadata: !!flowState.metadata,
hasClientInfo: !!flowState.clientInfo,
hasCodeVerifier: !!flowState.codeVerifier,
});
// Complete the OAuth flow
logger.debug('[MCP OAuth] Completing OAuth flow');
const tokens = await MCPOAuthHandler.completeOAuthFlow(flowId, code, flowManager);
logger.info('[MCP OAuth] OAuth flow completed, tokens received in callback route');
// For system-level OAuth, we need to store the tokens and retry the connection
if (flowState.userId === 'system') {
logger.debug(`[MCP OAuth] System-level OAuth completed for ${serverName}`);
}
/** ID of the flow that the tool/connection is waiting for */
const toolFlowId = flowState.metadata?.toolFlowId;
if (toolFlowId) {
logger.debug('[MCP OAuth] Completing tool flow', { toolFlowId });
await flowManager.completeFlow(toolFlowId, 'mcp_oauth', tokens);
}
/** Redirect to success page with flowId and serverName */
const redirectUrl = `/oauth/success?serverName=${encodeURIComponent(serverName)}`;
res.redirect(redirectUrl);
} catch (error) {
logger.error('[MCP OAuth] OAuth callback error', error);
res.redirect('/oauth/error?error=callback_failed');
}
});
/**
* Get OAuth tokens for a completed flow
* This is primarily for user-level OAuth flows
*/
router.get('/oauth/tokens/:flowId', requireJwtAuth, async (req, res) => {
try {
const { flowId } = req.params;
const user = req.user;
if (!user?.id) {
return res.status(401).json({ error: 'User not authenticated' });
}
// Allow system flows or user-owned flows
if (!flowId.startsWith(`${user.id}:`) && !flowId.startsWith('system:')) {
return res.status(403).json({ error: 'Access denied' });
}
const flowsCache = getLogStores(CacheKeys.FLOWS);
const flowManager = getFlowStateManager(flowsCache);
const flowState = await flowManager.getFlowState(flowId, 'mcp_oauth');
if (!flowState) {
return res.status(404).json({ error: 'Flow not found' });
}
if (flowState.status !== 'COMPLETED') {
return res.status(400).json({ error: 'Flow not completed' });
}
res.json({ tokens: flowState.result });
} catch (error) {
logger.error('[MCP OAuth] Failed to get tokens', error);
res.status(500).json({ error: 'Failed to get tokens' });
}
});
/**
* Check OAuth flow status
* This endpoint can be used to poll the status of an OAuth flow
*/
router.get('/oauth/status/:flowId', async (req, res) => {
try {
const { flowId } = req.params;
const flowsCache = getLogStores(CacheKeys.FLOWS);
const flowManager = getFlowStateManager(flowsCache);
const flowState = await flowManager.getFlowState(flowId, 'mcp_oauth');
if (!flowState) {
return res.status(404).json({ error: 'Flow not found' });
}
res.json({
status: flowState.status,
completed: flowState.status === 'COMPLETED',
failed: flowState.status === 'FAILED',
error: flowState.error,
});
} catch (error) {
logger.error('[MCP OAuth] Failed to get flow status', error);
res.status(500).json({ error: 'Failed to get flow status' });
}
});
module.exports = router;

View File

@@ -1,6 +1,7 @@
// file deepcode ignore NoRateLimitingForLogin: Rate limiting is handled by the `loginLimiter` middleware
const express = require('express');
const passport = require('passport');
const { randomState } = require('openid-client');
const {
checkBan,
logHeaders,
@@ -8,7 +9,8 @@ const {
setBalanceConfig,
checkDomainAllowed,
} = require('~/server/middleware');
const { setAuthTokens, setOpenIDAuthTokens } = require('@librechat/auth');
const { syncUserEntraGroupMemberships } = require('~/server/services/PermissionService');
const { setAuthTokens, setOpenIDAuthTokens } = require('~/server/services/AuthService');
const { isEnabled } = require('~/server/utils');
const { logger } = require('~/config');
@@ -34,6 +36,7 @@ const oauthHandler = async (req, res) => {
req.user.provider == 'openid' &&
isEnabled(process.env.OPENID_REUSE_TOKENS) === true
) {
await syncUserEntraGroupMemberships(req.user, req.user.tokenset.access_token);
setOpenIDAuthTokens(req.user.tokenset, res);
} else {
await setAuthTokens(req.user._id, res);
@@ -46,7 +49,9 @@ const oauthHandler = async (req, res) => {
router.get('/error', (req, res) => {
// A single error message is pushed by passport when authentication fails.
logger.error('Error in OAuth authentication:', { message: req.session.messages.pop() });
logger.error('Error in OAuth authentication:', {
message: req.session?.messages?.pop() || 'Unknown error',
});
// Redirect to login page with auth_failed parameter to prevent infinite redirect loops
res.redirect(`${domains.client}/login?redirect=false`);
@@ -103,8 +108,7 @@ router.get(
/**
* OpenID Routes
*/
router.get('/openid', async (req, res, next) => {
const { randomState } = await import('openid-client');
router.get('/openid', (req, res, next) => {
return passport.authenticate('openid', {
session: false,
state: randomState(),

View File

@@ -1,15 +1,15 @@
const express = require('express');
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const {
getSharedLink,
getSharedMessages,
createSharedLink,
updateSharedLink,
getSharedLinks,
deleteSharedLink,
} = require('~/models/Share');
getSharedLinks,
getSharedLink,
} = require('~/models');
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
const { isEnabled } = require('~/server/utils');
const router = express.Router();
/**
@@ -35,6 +35,7 @@ if (allowSharedLinks) {
res.status(404).end();
}
} catch (error) {
logger.error('Error getting shared messages:', error);
res.status(500).json({ message: 'Error getting shared messages' });
}
},
@@ -54,9 +55,7 @@ router.get('/', requireJwtAuth, async (req, res) => {
sortDirection: ['asc', 'desc'].includes(req.query.sortDirection)
? req.query.sortDirection
: 'desc',
search: req.query.search
? decodeURIComponent(req.query.search.trim())
: undefined,
search: req.query.search ? decodeURIComponent(req.query.search.trim()) : undefined,
};
const result = await getSharedLinks(
@@ -75,7 +74,7 @@ router.get('/', requireJwtAuth, async (req, res) => {
hasNextPage: result.hasNextPage,
});
} catch (error) {
console.error('Error getting shared links:', error);
logger.error('Error getting shared links:', error);
res.status(500).json({
message: 'Error getting shared links',
error: error.message,
@@ -93,6 +92,7 @@ router.get('/link/:conversationId', requireJwtAuth, async (req, res) => {
conversationId: req.params.conversationId,
});
} catch (error) {
logger.error('Error getting shared link:', error);
res.status(500).json({ message: 'Error getting shared link' });
}
});
@@ -106,6 +106,7 @@ router.post('/:conversationId', requireJwtAuth, async (req, res) => {
res.status(404).end();
}
} catch (error) {
logger.error('Error creating shared link:', error);
res.status(500).json({ message: 'Error creating shared link' });
}
});
@@ -119,6 +120,7 @@ router.patch('/:shareId', requireJwtAuth, async (req, res) => {
res.status(404).end();
}
} catch (error) {
logger.error('Error updating shared link:', error);
res.status(500).json({ message: 'Error updating shared link' });
}
});
@@ -133,7 +135,8 @@ router.delete('/:shareId', requireJwtAuth, async (req, res) => {
return res.status(200).json(result);
} catch (error) {
return res.status(400).json({ message: error.message });
logger.error('Error deleting shared link:', error);
return res.status(400).json({ message: 'Error deleting shared link' });
}
});

View File

@@ -3,7 +3,13 @@ const { nanoid } = require('nanoid');
const { tool } = require('@langchain/core/tools');
const { logger } = require('@librechat/data-schemas');
const { GraphEvents, sleep } = require('@librechat/agents');
const { sendEvent, logAxiosError } = require('@librechat/api');
const {
sendEvent,
encryptV2,
decryptV2,
logAxiosError,
refreshAccessToken,
} = require('@librechat/api');
const {
Time,
CacheKeys,
@@ -14,13 +20,11 @@ const {
isImageVisionTool,
actionDomainSeparator,
} = require('librechat-data-provider');
const { refreshAccessToken } = require('~/server/services/TokenService');
const { encryptV2, decryptV2 } = require('~/server/utils/crypto');
const { findToken, updateToken, createToken } = require('~/models');
const { getActions, deleteActions } = require('~/models/Action');
const { deleteAssistant } = require('~/models/Assistant');
const { getFlowStateManager } = require('~/config');
const { getLogStores } = require('~/cache');
const { findToken } = require('~/models');
const JWT_SECRET = process.env.JWT_SECRET;
const toolNameRegex = /^[a-zA-Z0-9_-]+$/;
@@ -258,15 +262,22 @@ async function createActionTool({
try {
const refresh_token = await decryptV2(refreshTokenData.token);
const refreshTokens = async () =>
await refreshAccessToken({
userId,
identifier,
refresh_token,
client_url: metadata.auth.client_url,
encrypted_oauth_client_id: encrypted.oauth_client_id,
token_exchange_method: metadata.auth.token_exchange_method,
encrypted_oauth_client_secret: encrypted.oauth_client_secret,
});
await refreshAccessToken(
{
userId,
identifier,
refresh_token,
client_url: metadata.auth.client_url,
encrypted_oauth_client_id: encrypted.oauth_client_id,
token_exchange_method: metadata.auth.token_exchange_method,
encrypted_oauth_client_secret: encrypted.oauth_client_secret,
},
{
findToken,
updateToken,
createToken,
},
);
const flowsCache = getLogStores(CacheKeys.FLOWS);
const flowManager = getFlowStateManager(flowsCache);
const refreshData = await flowManager.createFlowWithHandler(

View File

@@ -1,5 +1,6 @@
jest.mock('~/models', () => ({
initializeRoles: jest.fn(),
seedDefaultRoles: jest.fn(),
}));
jest.mock('~/models/Role', () => ({
updateAccessPermissions: jest.fn(),

View File

@@ -1,7 +1,6 @@
const {
FileSources,
loadOCRConfig,
processMCPEnv,
EModelEndpoint,
loadMemoryConfig,
getConfigDefaults,
@@ -18,6 +17,7 @@ const {
const { azureAssistantsDefaults, assistantsConfigSetup } = require('./start/assistants');
const { initializeAzureBlobService } = require('./Files/Azure/initialize');
const { initializeFirebase } = require('./Files/Firebase/initialize');
const { seedDefaultRoles, initializeRoles } = require('~/models');
const loadCustomConfig = require('./Config/loadCustomConfig');
const handleRateLimits = require('./Config/handleRateLimits');
const { loadDefaultInterface } = require('./start/interface');
@@ -27,8 +27,7 @@ const { processModelSpecs } = require('./start/modelSpecs');
const { initializeS3 } = require('./Files/S3/initialize');
const { loadAndFormatTools } = require('./ToolService');
const { isEnabled } = require('~/server/utils');
const { initializeRoles } = require('~/models');
const { getMCPManager } = require('~/config');
const { setCachedTools } = require('./Config');
const paths = require('~/config/paths');
/**
@@ -38,6 +37,7 @@ const paths = require('~/config/paths');
*/
const AppService = async (app) => {
await initializeRoles();
await seedDefaultRoles();
/** @type {TCustomConfig} */
const config = (await loadCustomConfig()) ?? {};
const configDefaults = getConfigDefaults();
@@ -76,11 +76,10 @@ const AppService = async (app) => {
directory: paths.structuredTools,
});
if (config.mcpServers != null) {
const mcpManager = getMCPManager();
await mcpManager.initializeMCP(config.mcpServers, processMCPEnv);
await mcpManager.mapAvailableTools(availableTools);
}
await setCachedTools(availableTools, { isGlobal: true });
// Store MCP config for later initialization
const mcpConfig = config.mcpServers || null;
const socialLogins =
config?.registration?.socialLogins ?? configDefaults?.registration?.socialLogins;
@@ -96,11 +95,11 @@ const AppService = async (app) => {
socialLogins,
filteredTools,
includedTools,
availableTools,
imageOutputType,
interfaceConfig,
turnstileConfig,
balance,
mcpConfig,
};
const agentsDefaults = agentsConfigSetup(config);

View File

@@ -28,10 +28,30 @@ jest.mock('./Files/Firebase/initialize', () => ({
}));
jest.mock('~/models', () => ({
initializeRoles: jest.fn(),
seedDefaultRoles: jest.fn(),
}));
jest.mock('~/models/Role', () => ({
updateAccessPermissions: jest.fn(),
}));
jest.mock('./Config', () => ({
setCachedTools: jest.fn(),
getCachedTools: jest.fn().mockResolvedValue({
ExampleTool: {
type: 'function',
function: {
description: 'Example tool function',
name: 'exampleFunction',
parameters: {
type: 'object',
properties: {
param1: { type: 'string', description: 'An example parameter' },
},
required: ['param1'],
},
},
},
}),
}));
jest.mock('./ToolService', () => ({
loadAndFormatTools: jest.fn().mockReturnValue({
ExampleTool: {
@@ -121,22 +141,9 @@ describe('AppService', () => {
sidePanel: true,
presets: true,
}),
mcpConfig: null,
turnstileConfig: mockedTurnstileConfig,
modelSpecs: undefined,
availableTools: {
ExampleTool: {
type: 'function',
function: expect.objectContaining({
description: 'Example tool function',
name: 'exampleFunction',
parameters: expect.objectContaining({
type: 'object',
properties: expect.any(Object),
required: expect.arrayContaining(['param1']),
}),
}),
},
},
paths: expect.anything(),
ocr: expect.anything(),
imageOutputType: expect.any(String),
@@ -223,14 +230,41 @@ describe('AppService', () => {
it('should load and format tools accurately with defined structure', async () => {
const { loadAndFormatTools } = require('./ToolService');
const { setCachedTools, getCachedTools } = require('./Config');
await AppService(app);
expect(loadAndFormatTools).toHaveBeenCalledWith({
adminFilter: undefined,
adminIncluded: undefined,
directory: expect.anything(),
});
expect(app.locals.availableTools.ExampleTool).toBeDefined();
expect(app.locals.availableTools.ExampleTool).toEqual({
// Verify setCachedTools was called with the tools
expect(setCachedTools).toHaveBeenCalledWith(
{
ExampleTool: {
type: 'function',
function: {
description: 'Example tool function',
name: 'exampleFunction',
parameters: {
type: 'object',
properties: {
param1: { type: 'string', description: 'An example parameter' },
},
required: ['param1'],
},
},
},
},
{ isGlobal: true },
);
// Verify we can retrieve the tools from cache
const cachedTools = await getCachedTools({ includeGlobal: true });
expect(cachedTools.ExampleTool).toBeDefined();
expect(cachedTools.ExampleTool).toEqual({
type: 'function',
function: {
description: 'Example tool function',
@@ -535,7 +569,6 @@ describe('AppService updating app.locals and issuing warnings', () => {
expect(app.locals).toBeDefined();
expect(app.locals.paths).toBeDefined();
expect(app.locals.availableTools).toBeDefined();
expect(app.locals.fileStrategy).toEqual(FileSources.local);
expect(app.locals.socialLogins).toEqual(defaultSocialLogins);
expect(app.locals.balance).toEqual(
@@ -568,7 +601,6 @@ describe('AppService updating app.locals and issuing warnings', () => {
expect(app.locals).toBeDefined();
expect(app.locals.paths).toBeDefined();
expect(app.locals.availableTools).toBeDefined();
expect(app.locals.fileStrategy).toEqual(customConfig.fileStrategy);
expect(app.locals.socialLogins).toEqual(customConfig.registration.socialLogins);
expect(app.locals.balance).toEqual(customConfig.balance);

View File

@@ -0,0 +1,512 @@
const bcrypt = require('bcryptjs');
const { webcrypto } = require('node:crypto');
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { SystemRoles, errorsToString } = require('librechat-data-provider');
const {
findUser,
createUser,
updateUser,
findToken,
countUsers,
getUserById,
findSession,
createToken,
deleteTokens,
deleteSession,
createSession,
generateToken,
deleteUserById,
generateRefreshToken,
} = require('~/models');
const { isEmailDomainAllowed } = require('~/server/services/domains');
const { checkEmailConfig, sendEmail } = require('~/server/utils');
const { getBalanceConfig } = require('~/server/services/Config');
const { registerSchema } = require('~/strategies/validators');
const domains = {
client: process.env.DOMAIN_CLIENT,
server: process.env.DOMAIN_SERVER,
};
const isProduction = process.env.NODE_ENV === 'production';
const genericVerificationMessage = 'Please check your email to verify your email address.';
/**
* Logout user
*
* @param {ServerRequest} req
* @param {string} refreshToken
* @returns
*/
const logoutUser = async (req, refreshToken) => {
try {
const userId = req.user._id;
const session = await findSession({ userId: userId, refreshToken });
if (session) {
try {
await deleteSession({ sessionId: session._id });
} catch (deleteErr) {
logger.error('[logoutUser] Failed to delete session.', deleteErr);
return { status: 500, message: 'Failed to delete session.' };
}
}
try {
req.session.destroy();
} catch (destroyErr) {
logger.debug('[logoutUser] Failed to destroy session.', destroyErr);
}
return { status: 200, message: 'Logout successful' };
} catch (err) {
return { status: 500, message: err.message };
}
};
/**
* Creates Token and corresponding Hash for verification
* @returns {[string, string]}
*/
const createTokenHash = () => {
const token = Buffer.from(webcrypto.getRandomValues(new Uint8Array(32))).toString('hex');
const hash = bcrypt.hashSync(token, 10);
return [token, hash];
};
/**
* Send Verification Email
* @param {Partial<MongoUser> & { _id: ObjectId, email: string, name: string}} user
* @returns {Promise<void>}
*/
const sendVerificationEmail = async (user) => {
const [verifyToken, hash] = createTokenHash();
const verificationLink = `${
domains.client
}/verify?token=${verifyToken}&email=${encodeURIComponent(user.email)}`;
await sendEmail({
email: user.email,
subject: 'Verify your email',
payload: {
appName: process.env.APP_TITLE || 'LibreChat',
name: user.name || user.username || user.email,
verificationLink: verificationLink,
year: new Date().getFullYear(),
},
template: 'verifyEmail.handlebars',
});
await createToken({
userId: user._id,
email: user.email,
token: hash,
createdAt: Date.now(),
expiresIn: 900,
});
logger.info(`[sendVerificationEmail] Verification link issued. [Email: ${user.email}]`);
};
/**
* Verify Email
* @param {Express.Request} req
*/
const verifyEmail = async (req) => {
const { email, token } = req.body;
const decodedEmail = decodeURIComponent(email);
const user = await findUser({ email: decodedEmail }, 'email _id emailVerified');
if (!user) {
logger.warn(`[verifyEmail] [User not found] [Email: ${decodedEmail}]`);
return new Error('User not found');
}
if (user.emailVerified) {
logger.info(`[verifyEmail] Email already verified [Email: ${decodedEmail}]`);
return { message: 'Email already verified', status: 'success' };
}
let emailVerificationData = await findToken({ email: decodedEmail });
if (!emailVerificationData) {
logger.warn(`[verifyEmail] [No email verification data found] [Email: ${decodedEmail}]`);
return new Error('Invalid or expired password reset token');
}
const isValid = bcrypt.compareSync(token, emailVerificationData.token);
if (!isValid) {
logger.warn(
`[verifyEmail] [Invalid or expired email verification token] [Email: ${decodedEmail}]`,
);
return new Error('Invalid or expired email verification token');
}
const updatedUser = await updateUser(emailVerificationData.userId, { emailVerified: true });
if (!updatedUser) {
logger.warn(`[verifyEmail] [User update failed] [Email: ${decodedEmail}]`);
return new Error('Failed to update user verification status');
}
await deleteTokens({ token: emailVerificationData.token });
logger.info(`[verifyEmail] Email verification successful [Email: ${decodedEmail}]`);
return { message: 'Email verification was successful', status: 'success' };
};
/**
* Register a new user.
* @param {MongoUser} user <email, password, name, username>
* @param {Partial<MongoUser>} [additionalData={}]
* @returns {Promise<{status: number, message: string, user?: MongoUser}>}
*/
const registerUser = async (user, additionalData = {}) => {
const { error } = registerSchema.safeParse(user);
if (error) {
const errorMessage = errorsToString(error.errors);
logger.info(
'Route: register - Validation Error',
{ name: 'Request params:', value: user },
{ name: 'Validation error:', value: errorMessage },
);
return { status: 404, message: errorMessage };
}
const { email, password, name, username } = user;
let newUserId;
try {
const existingUser = await findUser({ email }, 'email _id');
if (existingUser) {
logger.info(
'Register User - Email in use',
{ name: 'Request params:', value: user },
{ name: 'Existing user:', value: existingUser },
);
// Sleep for 1 second
await new Promise((resolve) => setTimeout(resolve, 1000));
return { status: 200, message: genericVerificationMessage };
}
if (!(await isEmailDomainAllowed(email))) {
const errorMessage =
'The email address provided cannot be used. Please use a different email address.';
logger.error(`[registerUser] [Registration not allowed] [Email: ${user.email}]`);
return { status: 403, message: errorMessage };
}
//determine if this is the first registered user (not counting anonymous_user)
const isFirstRegisteredUser = (await countUsers()) === 0;
const salt = bcrypt.genSaltSync(10);
const newUserData = {
provider: 'local',
email,
username,
name,
avatar: null,
role: isFirstRegisteredUser ? SystemRoles.ADMIN : SystemRoles.USER,
password: bcrypt.hashSync(password, salt),
...additionalData,
};
const emailEnabled = checkEmailConfig();
const disableTTL = isEnabled(process.env.ALLOW_UNVERIFIED_EMAIL_LOGIN);
const balanceConfig = await getBalanceConfig();
const newUser = await createUser(newUserData, balanceConfig, disableTTL, true);
newUserId = newUser._id;
if (emailEnabled && !newUser.emailVerified) {
await sendVerificationEmail({
_id: newUserId,
email,
name,
});
} else {
await updateUser(newUserId, { emailVerified: true });
}
return { status: 200, message: genericVerificationMessage };
} catch (err) {
logger.error('[registerUser] Error in registering user:', err);
if (newUserId) {
const result = await deleteUserById(newUserId);
logger.warn(
`[registerUser] [Email: ${email}] [Temporary User deleted: ${JSON.stringify(result)}]`,
);
}
return { status: 500, message: 'Something went wrong' };
}
};
/**
* Request password reset
* @param {Express.Request} req
*/
const requestPasswordReset = async (req) => {
const { email } = req.body;
const user = await findUser({ email }, 'email _id');
const emailEnabled = checkEmailConfig();
logger.warn(`[requestPasswordReset] [Password reset request initiated] [Email: ${email}]`);
if (!user) {
logger.warn(`[requestPasswordReset] [No user found] [Email: ${email}] [IP: ${req.ip}]`);
return {
message: 'If an account with that email exists, a password reset link has been sent to it.',
};
}
await deleteTokens({ userId: user._id });
const [resetToken, hash] = createTokenHash();
await createToken({
userId: user._id,
token: hash,
createdAt: Date.now(),
expiresIn: 900,
});
const link = `${domains.client}/reset-password?token=${resetToken}&userId=${user._id}`;
if (emailEnabled) {
await sendEmail({
email: user.email,
subject: 'Password Reset Request',
payload: {
appName: process.env.APP_TITLE || 'LibreChat',
name: user.name || user.username || user.email,
link: link,
year: new Date().getFullYear(),
},
template: 'requestPasswordReset.handlebars',
});
logger.info(
`[requestPasswordReset] Link emailed. [Email: ${email}] [ID: ${user._id}] [IP: ${req.ip}]`,
);
} else {
logger.info(
`[requestPasswordReset] Link issued. [Email: ${email}] [ID: ${user._id}] [IP: ${req.ip}]`,
);
return { link };
}
return {
message: 'If an account with that email exists, a password reset link has been sent to it.',
};
};
/**
* Reset Password
*
* @param {*} userId
* @param {String} token
* @param {String} password
* @returns
*/
const resetPassword = async (userId, token, password) => {
let passwordResetToken = await findToken({
userId,
});
if (!passwordResetToken) {
return new Error('Invalid or expired password reset token');
}
const isValid = bcrypt.compareSync(token, passwordResetToken.token);
if (!isValid) {
return new Error('Invalid or expired password reset token');
}
const hash = bcrypt.hashSync(password, 10);
const user = await updateUser(userId, { password: hash });
if (checkEmailConfig()) {
await sendEmail({
email: user.email,
subject: 'Password Reset Successfully',
payload: {
appName: process.env.APP_TITLE || 'LibreChat',
name: user.name || user.username || user.email,
year: new Date().getFullYear(),
},
template: 'passwordReset.handlebars',
});
}
await deleteTokens({ token: passwordResetToken.token });
logger.info(`[resetPassword] Password reset successful. [Email: ${user.email}]`);
return { message: 'Password reset was successful' };
};
/**
* Set Auth Tokens
*
* @param {String | ObjectId} userId
* @param {Object} res
* @param {String} sessionId
* @returns
*/
const setAuthTokens = async (userId, res, sessionId = null) => {
try {
const user = await getUserById(userId);
const token = await generateToken(user);
let session;
let refreshToken;
let refreshTokenExpires;
if (sessionId) {
session = await findSession({ sessionId: sessionId }, { lean: false });
refreshTokenExpires = session.expiration.getTime();
refreshToken = await generateRefreshToken(session);
} else {
const result = await createSession(userId);
session = result.session;
refreshToken = result.refreshToken;
refreshTokenExpires = session.expiration.getTime();
}
res.cookie('refreshToken', refreshToken, {
expires: new Date(refreshTokenExpires),
httpOnly: true,
secure: isProduction,
sameSite: 'strict',
});
res.cookie('token_provider', 'librechat', {
expires: new Date(refreshTokenExpires),
httpOnly: true,
secure: isProduction,
sameSite: 'strict',
});
return token;
} catch (error) {
logger.error('[setAuthTokens] Error in setting authentication tokens:', error);
throw error;
}
};
/**
* @function setOpenIDAuthTokens
* Set OpenID Authentication Tokens
* //type tokenset from openid-client
* @param {import('openid-client').TokenEndpointResponse & import('openid-client').TokenEndpointResponseHelpers} tokenset
* - The tokenset object containing access and refresh tokens
* @param {Object} res - response object
* @returns {String} - access token
*/
const setOpenIDAuthTokens = (tokenset, res) => {
try {
if (!tokenset) {
logger.error('[setOpenIDAuthTokens] No tokenset found in request');
return;
}
const { REFRESH_TOKEN_EXPIRY } = process.env ?? {};
const expiryInMilliseconds = REFRESH_TOKEN_EXPIRY
? eval(REFRESH_TOKEN_EXPIRY)
: 1000 * 60 * 60 * 24 * 7; // 7 days default
const expirationDate = new Date(Date.now() + expiryInMilliseconds);
if (tokenset == null) {
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');
return;
}
res.cookie('refreshToken', tokenset.refresh_token, {
expires: expirationDate,
httpOnly: true,
secure: isProduction,
sameSite: 'strict',
});
res.cookie('token_provider', 'openid', {
expires: expirationDate,
httpOnly: true,
secure: isProduction,
sameSite: 'strict',
});
return tokenset.access_token;
} catch (error) {
logger.error('[setOpenIDAuthTokens] Error in setting authentication tokens:', error);
throw error;
}
};
/**
* Resend Verification Email
* @param {Object} req
* @param {Object} req.body
* @param {String} req.body.email
* @returns {Promise<{status: number, message: string}>}
*/
const resendVerificationEmail = async (req) => {
try {
const { email } = req.body;
await deleteTokens(email);
const user = await findUser({ email }, 'email _id name');
if (!user) {
logger.warn(`[resendVerificationEmail] [No user found] [Email: ${email}]`);
return { status: 200, message: genericVerificationMessage };
}
const [verifyToken, hash] = createTokenHash();
const verificationLink = `${
domains.client
}/verify?token=${verifyToken}&email=${encodeURIComponent(user.email)}`;
await sendEmail({
email: user.email,
subject: 'Verify your email',
payload: {
appName: process.env.APP_TITLE || 'LibreChat',
name: user.name || user.username || user.email,
verificationLink: verificationLink,
year: new Date().getFullYear(),
},
template: 'verifyEmail.handlebars',
});
await createToken({
userId: user._id,
email: user.email,
token: hash,
createdAt: Date.now(),
expiresIn: 900,
});
logger.info(`[resendVerificationEmail] Verification link issued. [Email: ${user.email}]`);
return {
status: 200,
message: genericVerificationMessage,
};
} catch (error) {
logger.error(`[resendVerificationEmail] Error resending verification email: ${error.message}`);
return {
status: 500,
message: 'Something went wrong.',
};
}
};
module.exports = {
logoutUser,
verifyEmail,
registerUser,
setAuthTokens,
resetPassword,
requestPasswordReset,
resendVerificationEmail,
setOpenIDAuthTokens,
};

View File

@@ -0,0 +1,258 @@
const { CacheKeys } = require('librechat-data-provider');
const getLogStores = require('~/cache/getLogStores');
/**
* Cache key generators for different tool access patterns
* These will support future permission-based caching
*/
const ToolCacheKeys = {
/** Global tools available to all users */
GLOBAL: 'tools:global',
/** Tools available to a specific user */
USER: (userId) => `tools:user:${userId}`,
/** Tools available to a specific role */
ROLE: (roleId) => `tools:role:${roleId}`,
/** Tools available to a specific group */
GROUP: (groupId) => `tools:group:${groupId}`,
/** Combined effective tools for a user (computed from all sources) */
EFFECTIVE: (userId) => `tools:effective:${userId}`,
};
/**
* Retrieves available tools from cache
* @function getCachedTools
* @param {Object} options - Options for retrieving tools
* @param {string} [options.userId] - User ID for user-specific tools
* @param {string[]} [options.roleIds] - Role IDs for role-based tools
* @param {string[]} [options.groupIds] - Group IDs for group-based tools
* @param {boolean} [options.includeGlobal=true] - Whether to include global tools
* @returns {Promise<Object|null>} The available tools object or null if not cached
*/
async function getCachedTools(options = {}) {
const cache = getLogStores(CacheKeys.CONFIG_STORE);
const { userId, roleIds = [], groupIds = [], includeGlobal = true } = options;
// For now, return global tools (current behavior)
// This will be expanded to merge tools from different sources
if (!userId && includeGlobal) {
return await cache.get(ToolCacheKeys.GLOBAL);
}
// Future implementation will merge tools from multiple sources
// based on user permissions, roles, and groups
if (userId) {
// Check if we have pre-computed effective tools for this user
const effectiveTools = await cache.get(ToolCacheKeys.EFFECTIVE(userId));
if (effectiveTools) {
return effectiveTools;
}
// Otherwise, compute from individual sources
const toolSources = [];
if (includeGlobal) {
const globalTools = await cache.get(ToolCacheKeys.GLOBAL);
if (globalTools) {
toolSources.push(globalTools);
}
}
// User-specific tools
const userTools = await cache.get(ToolCacheKeys.USER(userId));
if (userTools) {
toolSources.push(userTools);
}
// Role-based tools
for (const roleId of roleIds) {
const roleTools = await cache.get(ToolCacheKeys.ROLE(roleId));
if (roleTools) {
toolSources.push(roleTools);
}
}
// Group-based tools
for (const groupId of groupIds) {
const groupTools = await cache.get(ToolCacheKeys.GROUP(groupId));
if (groupTools) {
toolSources.push(groupTools);
}
}
// Merge all tool sources (for now, simple merge - future will handle conflicts)
if (toolSources.length > 0) {
return mergeToolSources(toolSources);
}
}
return null;
}
/**
* Sets available tools in cache
* @function setCachedTools
* @param {Object} tools - The tools object to cache
* @param {Object} options - Options for caching tools
* @param {string} [options.userId] - User ID for user-specific tools
* @param {string} [options.roleId] - Role ID for role-based tools
* @param {string} [options.groupId] - Group ID for group-based tools
* @param {boolean} [options.isGlobal=false] - Whether these are global tools
* @param {number} [options.ttl] - Time to live in milliseconds
* @returns {Promise<boolean>} Whether the operation was successful
*/
async function setCachedTools(tools, options = {}) {
const cache = getLogStores(CacheKeys.CONFIG_STORE);
const { userId, roleId, groupId, isGlobal = false, ttl } = options;
let cacheKey;
if (isGlobal || (!userId && !roleId && !groupId)) {
cacheKey = ToolCacheKeys.GLOBAL;
} else if (userId) {
cacheKey = ToolCacheKeys.USER(userId);
} else if (roleId) {
cacheKey = ToolCacheKeys.ROLE(roleId);
} else if (groupId) {
cacheKey = ToolCacheKeys.GROUP(groupId);
}
if (!cacheKey) {
throw new Error('Invalid cache key options provided');
}
return await cache.set(cacheKey, tools, ttl);
}
/**
* Invalidates cached tools
* @function invalidateCachedTools
* @param {Object} options - Options for invalidating tools
* @param {string} [options.userId] - User ID to invalidate
* @param {string} [options.roleId] - Role ID to invalidate
* @param {string} [options.groupId] - Group ID to invalidate
* @param {boolean} [options.invalidateGlobal=false] - Whether to invalidate global tools
* @param {boolean} [options.invalidateEffective=true] - Whether to invalidate effective tools
* @returns {Promise<void>}
*/
async function invalidateCachedTools(options = {}) {
const cache = getLogStores(CacheKeys.CONFIG_STORE);
const { userId, roleId, groupId, invalidateGlobal = false, invalidateEffective = true } = options;
const keysToDelete = [];
if (invalidateGlobal) {
keysToDelete.push(ToolCacheKeys.GLOBAL);
}
if (userId) {
keysToDelete.push(ToolCacheKeys.USER(userId));
if (invalidateEffective) {
keysToDelete.push(ToolCacheKeys.EFFECTIVE(userId));
}
}
if (roleId) {
keysToDelete.push(ToolCacheKeys.ROLE(roleId));
// TODO: In future, invalidate all users with this role
}
if (groupId) {
keysToDelete.push(ToolCacheKeys.GROUP(groupId));
// TODO: In future, invalidate all users in this group
}
await Promise.all(keysToDelete.map((key) => cache.delete(key)));
}
/**
* Computes and caches effective tools for a user
* @function computeEffectiveTools
* @param {string} userId - The user ID
* @param {Object} context - Context containing user's roles and groups
* @param {string[]} [context.roleIds=[]] - User's role IDs
* @param {string[]} [context.groupIds=[]] - User's group IDs
* @param {number} [ttl] - Time to live for the computed result
* @returns {Promise<Object>} The computed effective tools
*/
async function computeEffectiveTools(userId, context = {}, ttl) {
const { roleIds = [], groupIds = [] } = context;
// Get all tool sources
const tools = await getCachedTools({
userId,
roleIds,
groupIds,
includeGlobal: true,
});
if (tools) {
// Cache the computed result
const cache = getLogStores(CacheKeys.CONFIG_STORE);
await cache.set(ToolCacheKeys.EFFECTIVE(userId), tools, ttl);
}
return tools;
}
/**
* Merges multiple tool sources into a single tools object
* @function mergeToolSources
* @param {Object[]} sources - Array of tool objects to merge
* @returns {Object} Merged tools object
*/
function mergeToolSources(sources) {
// For now, simple merge that combines all tools
// Future implementation will handle:
// - Permission precedence (deny > allow)
// - Tool property conflicts
// - Metadata merging
const merged = {};
for (const source of sources) {
if (!source || typeof source !== 'object') {
continue;
}
for (const [toolId, toolConfig] of Object.entries(source)) {
// Simple last-write-wins for now
// Future: merge based on permission levels
merged[toolId] = toolConfig;
}
}
return merged;
}
/**
* Middleware-friendly function to get tools for a request
* @function getToolsForRequest
* @param {Object} req - Express request object
* @returns {Promise<Object|null>} Available tools for the request
*/
async function getToolsForRequest(req) {
const userId = req.user?.id;
// For now, return global tools if no user
if (!userId) {
return getCachedTools({ includeGlobal: true });
}
// Future: Extract roles and groups from req.user
const roleIds = req.user?.roles || [];
const groupIds = req.user?.groups || [];
return getCachedTools({
userId,
roleIds,
groupIds,
includeGlobal: true,
});
}
module.exports = {
ToolCacheKeys,
getCachedTools,
setCachedTools,
getToolsForRequest,
invalidateCachedTools,
computeEffectiveTools,
};

View File

@@ -1,6 +1,10 @@
const { logger } = require('@librechat/data-schemas');
const { getUserMCPAuthMap } = require('@librechat/api');
const { CacheKeys, EModelEndpoint } = require('librechat-data-provider');
const { normalizeEndpointName, isEnabled } = require('~/server/utils');
const loadCustomConfig = require('./loadCustomConfig');
const { getCachedTools } = require('./getCachedTools');
const { findPluginAuthsByKeys } = require('~/models');
const getLogStores = require('~/cache/getLogStores');
/**
@@ -50,4 +54,46 @@ const getCustomEndpointConfig = async (endpoint) => {
);
};
module.exports = { getCustomConfig, getBalanceConfig, getCustomEndpointConfig };
async function createGetMCPAuthMap() {
const customConfig = await getCustomConfig();
const mcpServers = customConfig?.mcpServers;
const hasCustomUserVars = Object.values(mcpServers ?? {}).some((server) => server.customUserVars);
if (!hasCustomUserVars) {
return;
}
/**
* @param {Object} params
* @param {GenericTool[]} [params.tools]
* @param {string} params.userId
* @returns {Promise<Record<string, Record<string, string>> | undefined>}
*/
return async function ({ tools, userId }) {
try {
if (!tools || tools.length === 0) {
return;
}
const appTools = await getCachedTools({
userId,
});
return await getUserMCPAuthMap({
tools,
userId,
appTools,
findPluginAuthsByKeys,
});
} catch (err) {
logger.error(
`[api/server/controllers/agents/client.js #chatCompletion] Error getting custom user vars for agent`,
err,
);
}
};
}
module.exports = {
getCustomConfig,
getBalanceConfig,
createGetMCPAuthMap,
getCustomEndpointConfig,
};

View File

@@ -1,4 +1,5 @@
const { config } = require('./EndpointService');
const getCachedTools = require('./getCachedTools');
const getCustomConfig = require('./getCustomConfig');
const loadCustomConfig = require('./loadCustomConfig');
const loadConfigModels = require('./loadConfigModels');
@@ -14,6 +15,7 @@ module.exports = {
loadDefaultModels,
loadOverrideConfig,
loadAsyncEndpoints,
...getCachedTools,
...getCustomConfig,
...getEndpointsConfig,
};

View File

@@ -63,11 +63,17 @@ const initializeAgent = async ({
}
let currentFiles;
if (
isInitialAgent &&
conversationId != null &&
(agent.model_parameters?.resendFiles ?? true) === true
) {
const _modelOptions = structuredClone(
Object.assign(
{ model: agent.model },
agent.model_parameters ?? { model: agent.model },
isInitialAgent === true ? endpointOption?.model_parameters : {},
),
);
const { resendFiles = true, ...modelOptions } = _modelOptions;
if (isInitialAgent && conversationId != null && resendFiles) {
const fileIds = (await getConvoFiles(conversationId)) ?? [];
/** @type {Set<EToolResources>} */
const toolResourceSet = new Set();
@@ -117,15 +123,11 @@ const initializeAgent = async ({
getOptions = initCustom;
agent.provider = Providers.OPENAI;
}
const model_parameters = Object.assign(
{},
agent.model_parameters ?? { model: agent.model },
isInitialAgent === true ? endpointOption?.model_parameters : {},
);
const _endpointOption =
isInitialAgent === true
? Object.assign({}, endpointOption, { model_parameters })
: { model_parameters };
? Object.assign({}, endpointOption, { model_parameters: modelOptions })
: { model_parameters: modelOptions };
const options = await getOptions({
req,
@@ -136,6 +138,20 @@ const initializeAgent = async ({
endpointOption: _endpointOption,
});
const tokensModel =
agent.provider === EModelEndpoint.azureOpenAI ? agent.model : modelOptions.model;
const maxTokens = optionalChainWithEmptyCheck(
modelOptions.maxOutputTokens,
modelOptions.maxTokens,
0,
);
const maxContextTokens = optionalChainWithEmptyCheck(
modelOptions.maxContextTokens,
modelOptions.max_context_tokens,
getModelMaxTokens(tokensModel, providerEndpointMap[provider]),
4096,
);
if (
agent.endpoint === EModelEndpoint.azureOpenAI &&
options.llmConfig?.azureOpenAIApiInstanceName == null
@@ -148,15 +164,11 @@ const initializeAgent = async ({
}
/** @type {import('@librechat/agents').ClientOptions} */
agent.model_parameters = Object.assign(model_parameters, options.llmConfig);
agent.model_parameters = { ...options.llmConfig };
if (options.configOptions) {
agent.model_parameters.configuration = options.configOptions;
}
if (!agent.model_parameters.model) {
agent.model_parameters.model = agent.model;
}
if (agent.instructions && agent.instructions !== '') {
agent.instructions = replaceSpecialVars({
text: agent.instructions,
@@ -171,23 +183,11 @@ const initializeAgent = async ({
});
}
const tokensModel =
agent.provider === EModelEndpoint.azureOpenAI ? agent.model : agent.model_parameters.model;
const maxTokens = optionalChainWithEmptyCheck(
agent.model_parameters.maxOutputTokens,
agent.model_parameters.maxTokens,
0,
);
const maxContextTokens = optionalChainWithEmptyCheck(
agent.model_parameters.maxContextTokens,
agent.max_context_tokens,
getModelMaxTokens(tokensModel, providerEndpointMap[provider]),
4096,
);
return {
...agent,
tools,
attachments,
resendFiles,
toolContextMap,
maxContextTokens: (maxContextTokens - maxTokens) * 0.9,
};

View File

@@ -130,8 +130,8 @@ const initializeClient = async ({ req, res, endpointOption }) => {
iconURL: endpointOption.iconURL,
attachments: primaryConfig.attachments,
endpointType: endpointOption.endpointType,
resendFiles: primaryConfig.resendFiles ?? true,
maxContextTokens: primaryConfig.maxContextTokens,
resendFiles: primaryConfig.model_parameters?.resendFiles ?? true,
endpoint:
primaryConfig.id === Constants.EPHEMERAL_AGENT_ID
? primaryConfig.endpoint

View File

@@ -1,4 +1,4 @@
const { HttpsProxyAgent } = require('https-proxy-agent');
const { ProxyAgent } = require('undici');
const { anthropicSettings, removeNullishValues } = require('librechat-data-provider');
const { checkPromptCacheSupport, getClaudeHeaders, configureReasoning } = require('./helpers');
@@ -67,7 +67,10 @@ function getLLMConfig(apiKey, options = {}) {
}
if (options.proxy) {
requestOptions.clientOptions.httpAgent = new HttpsProxyAgent(options.proxy);
const proxyAgent = new ProxyAgent(options.proxy);
requestOptions.clientOptions.fetchOptions = {
dispatcher: proxyAgent,
};
}
if (options.reverseProxyUrl) {

View File

@@ -21,8 +21,12 @@ describe('getLLMConfig', () => {
proxy: 'http://proxy:8080',
});
expect(result.llmConfig.clientOptions).toHaveProperty('httpAgent');
expect(result.llmConfig.clientOptions.httpAgent).toHaveProperty('proxy', 'http://proxy:8080');
expect(result.llmConfig.clientOptions).toHaveProperty('fetchOptions');
expect(result.llmConfig.clientOptions.fetchOptions).toHaveProperty('dispatcher');
expect(result.llmConfig.clientOptions.fetchOptions.dispatcher).toBeDefined();
expect(result.llmConfig.clientOptions.fetchOptions.dispatcher.constructor.name).toBe(
'ProxyAgent',
);
});
it('should include reverse proxy URL when provided', () => {

View File

@@ -0,0 +1,86 @@
const sharp = require('sharp');
const fs = require('fs').promises;
const fetch = require('node-fetch');
const { EImageOutputType } = require('librechat-data-provider');
const { resizeAndConvert } = require('./resize');
const { logger } = require('~/config');
/**
* Uploads an avatar image for a user. This function can handle various types of input (URL, Buffer, or File object),
* processes the image to a square format, converts it to target format, and returns the resized buffer.
*
* @param {Object} params - The parameters object.
* @param {string} params.userId - The unique identifier of the user for whom the avatar is being uploaded.
* @param {string} options.desiredFormat - The desired output format of the image.
* @param {(string|Buffer|File)} params.input - The input representing the avatar image. Can be a URL (string),
* a Buffer, or a File object.
*
* @returns {Promise<any>}
* A promise that resolves to a resized buffer.
*
* @throws {Error} Throws an error if the user ID is undefined, the input type is invalid, the image fetching fails,
* or any other error occurs during the processing.
*/
async function resizeAvatar({ userId, input, desiredFormat = EImageOutputType.PNG }) {
try {
if (userId === undefined) {
throw new Error('User ID is undefined');
}
let imageBuffer;
if (typeof input === 'string') {
const response = await fetch(input);
if (!response.ok) {
throw new Error(`Failed to fetch image from URL. Status: ${response.status}`);
}
imageBuffer = await response.buffer();
} else if (input instanceof Buffer) {
imageBuffer = input;
} else if (typeof input === 'object' && input instanceof File) {
const fileContent = await fs.readFile(input.path);
imageBuffer = Buffer.from(fileContent);
} else {
throw new Error('Invalid input type. Expected URL, Buffer, or File.');
}
const metadata = await sharp(imageBuffer).metadata();
const { width, height } = metadata;
const minSize = Math.min(width, height);
if (metadata.format === 'gif') {
const resizedBuffer = await sharp(imageBuffer, { animated: true })
.extract({
left: Math.floor((width - minSize) / 2),
top: Math.floor((height - minSize) / 2),
width: minSize,
height: minSize,
})
.resize(250, 250)
.gif()
.toBuffer();
return resizedBuffer;
}
const squaredBuffer = await sharp(imageBuffer)
.extract({
left: Math.floor((width - minSize) / 2),
top: Math.floor((height - minSize) / 2),
width: minSize,
height: minSize,
})
.toBuffer();
const { buffer } = await resizeAndConvert({
inputBuffer: squaredBuffer,
desiredFormat,
});
return buffer;
} catch (error) {
logger.error('Error uploading the avatar:', error);
throw error;
}
}
module.exports = { resizeAvatar };

View File

@@ -1,3 +1,4 @@
const avatar = require('./avatar');
const convert = require('./convert');
const encode = require('./encode');
const parse = require('./parse');
@@ -8,4 +9,5 @@ module.exports = {
...encode,
...parse,
...resize,
avatar,
};

View File

@@ -89,4 +89,28 @@ async function resizeImageBuffer(inputBuffer, resolution, endpoint) {
};
}
module.exports = { resizeImageBuffer };
/**
* Resizes an image buffer to a specified format and width.
*
* @param {Object} options - The options for resizing and converting the image.
* @param {Buffer} options.inputBuffer - The buffer of the image to be resized.
* @param {string} options.desiredFormat - The desired output format of the image.
* @param {number} [options.width=150] - The desired width of the image. Defaults to 150 pixels.
* @returns {Promise<{ buffer: Buffer, width: number, height: number, bytes: number }>} An object containing the resized image buffer, its size, and dimensions.
* @throws Will throw an error if the resolution or format parameters are invalid.
*/
async function resizeAndConvert({ inputBuffer, desiredFormat, width = 150 }) {
const resizedBuffer = await sharp(inputBuffer)
.resize({ width })
.toFormat(desiredFormat)
.toBuffer();
const resizedMetadata = await sharp(resizedBuffer).metadata();
return {
buffer: resizedBuffer,
width: resizedMetadata.width,
height: resizedMetadata.height,
bytes: Buffer.byteLength(resizedBuffer),
};
}
module.exports = { resizeImageBuffer, resizeAndConvert };

View File

@@ -19,8 +19,11 @@ const {
isAssistantsEndpoint,
} = require('librechat-data-provider');
const { EnvVar } = require('@librechat/agents');
const { convertImage, resizeImageBuffer } = require('~/server/services/Files/images');
const { resizeAndConvert } = require('@librechat/auth');
const {
convertImage,
resizeAndConvert,
resizeImageBuffer,
} = require('~/server/services/Files/images');
const { addResourceFileId, deleteResourceFileId } = require('~/server/controllers/assistants/v2');
const { addAgentResourceFile, removeAgentResourceFiles } = require('~/models/Agent');
const { getOpenAIClient } = require('~/server/controllers/assistants/helpers');

View File

@@ -59,6 +59,7 @@ const firebaseStrategy = () => ({
deleteFile: deleteFirebaseFile,
saveBuffer: saveBufferToFirebase,
prepareImagePayload: prepareImageURL,
processAvatar: processFirebaseAvatar,
handleImageUpload: uploadImageToFirebase,
getDownloadStream: getFirebaseFileStream,
});
@@ -73,6 +74,7 @@ const localStrategy = () => ({
getFileURL: getLocalFileURL,
saveBuffer: saveLocalBuffer,
deleteFile: deleteLocalFile,
processAvatar: processLocalAvatar,
handleImageUpload: uploadLocalImage,
prepareImagePayload: prepareImagesLocal,
getDownloadStream: getLocalFileStream,
@@ -89,6 +91,7 @@ const s3Strategy = () => ({
deleteFile: deleteFileFromS3,
saveBuffer: saveBufferToS3,
prepareImagePayload: prepareImageURLS3,
processAvatar: processS3Avatar,
handleImageUpload: uploadImageToS3,
getDownloadStream: getS3FileStream,
});
@@ -104,6 +107,7 @@ const azureStrategy = () => ({
deleteFile: deleteFileFromAzure,
saveBuffer: saveBufferToAzure,
prepareImagePayload: prepareAzureImageURL,
processAvatar: processAzureAvatar,
handleImageUpload: uploadImageToAzure,
getDownloadStream: getAzureFileStream,
});
@@ -119,6 +123,8 @@ const vectorStrategy = () => ({
getFileURL: null,
/** @type {typeof saveLocalBuffer | null} */
saveBuffer: null,
/** @type {typeof processLocalAvatar | null} */
processAvatar: null,
/** @type {typeof uploadLocalImage | null} */
handleImageUpload: null,
/** @type {typeof prepareImagesLocal | null} */
@@ -141,6 +147,8 @@ const openAIStrategy = () => ({
getFileURL: null,
/** @type {typeof saveLocalBuffer | null} */
saveBuffer: null,
/** @type {typeof processLocalAvatar | null} */
processAvatar: null,
/** @type {typeof uploadLocalImage | null} */
handleImageUpload: null,
/** @type {typeof prepareImagesLocal | null} */
@@ -162,6 +170,8 @@ const codeOutputStrategy = () => ({
getFileURL: null,
/** @type {typeof saveLocalBuffer | null} */
saveBuffer: null,
/** @type {typeof processLocalAvatar | null} */
processAvatar: null,
/** @type {typeof uploadLocalImage | null} */
handleImageUpload: null,
/** @type {typeof prepareImagesLocal | null} */
@@ -179,6 +189,8 @@ const mistralOCRStrategy = () => ({
getFileURL: null,
/** @type {typeof saveLocalBuffer | null} */
saveBuffer: null,
/** @type {typeof processLocalAvatar | null} */
processAvatar: null,
/** @type {typeof uploadLocalImage | null} */
handleImageUpload: null,
/** @type {typeof prepareImagesLocal | null} */

View File

@@ -0,0 +1,525 @@
const client = require('openid-client');
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { CacheKeys } = require('librechat-data-provider');
const { Client } = require('@microsoft/microsoft-graph-client');
const { getOpenIdConfig } = require('~/strategies/openidStrategy');
const getLogStores = require('~/cache/getLogStores');
/**
* @import { TPrincipalSearchResult, TGraphPerson, TGraphUser, TGraphGroup, TGraphPeopleResponse, TGraphUsersResponse, TGraphGroupsResponse } from 'librechat-data-provider'
*/
/**
* Checks if Entra ID principal search feature is enabled based on environment variables and user authentication
* @param {Object} user - User object from request
* @param {string} user.provider - Authentication provider
* @param {string} user.openidId - OpenID subject identifier
* @returns {boolean} True if Entra ID principal search is enabled and user is authenticated via OpenID
*/
const entraIdPrincipalFeatureEnabled = (user) => {
return (
isEnabled(process.env.USE_ENTRA_ID_FOR_PEOPLE_SEARCH) &&
isEnabled(process.env.OPENID_REUSE_TOKENS) &&
user?.provider === 'openid' &&
user?.openidId
);
};
/**
* Creates a Microsoft Graph client with on-behalf-of token exchange
* @param {string} accessToken - OpenID Connect access token from user
* @param {string} sub - Subject identifier from token claims
* @returns {Promise<Client>} Authenticated Graph API client
*/
const createGraphClient = async (accessToken, sub) => {
try {
// Reason: Use existing OpenID configuration and token exchange pattern from openidStrategy.js
const openidConfig = getOpenIdConfig();
const exchangedToken = await exchangeTokenForGraphAccess(openidConfig, accessToken, sub);
const graphClient = Client.init({
authProvider: (done) => {
done(null, exchangedToken);
},
});
return graphClient;
} catch (error) {
logger.error('[createGraphClient] Error creating Graph client:', error);
throw error;
}
};
/**
* Exchange OpenID token for Graph API access using on-behalf-of flow
* Similar to exchangeAccessTokenIfNeeded in openidStrategy.js but for Graph scopes
* @param {Configuration} config - OpenID configuration
* @param {string} accessToken - Original access token
* @param {string} sub - Subject identifier
* @returns {Promise<string>} Graph API access token
*/
const exchangeTokenForGraphAccess = async (config, accessToken, sub) => {
try {
const tokensCache = getLogStores(CacheKeys.OPENID_EXCHANGED_TOKENS);
const cacheKey = `${sub}:graph`;
const cachedToken = await tokensCache.get(cacheKey);
if (cachedToken) {
return cachedToken.access_token;
}
const graphScopes = process.env.OPENID_GRAPH_SCOPES || 'User.Read,People.Read,Group.Read.All';
const scopeString = graphScopes
.split(',')
.map((scope) => `https://graph.microsoft.com/${scope}`)
.join(' ');
const grantResponse = await client.genericGrantRequest(
config,
'urn:ietf:params:oauth:grant-type:jwt-bearer',
{
scope: scopeString,
assertion: accessToken,
requested_token_use: 'on_behalf_of',
},
);
await tokensCache.set(
cacheKey,
{
access_token: grantResponse.access_token,
},
grantResponse.expires_in * 1000,
);
return grantResponse.access_token;
} catch (error) {
logger.error('[exchangeTokenForGraphAccess] Token exchange failed:', error);
throw error;
}
};
/**
* Search for principals (people and groups) using Microsoft Graph API
* Uses searchContacts first, then searchUsers and searchGroups to fill remaining slots
* @param {string} accessToken - OpenID Connect access token
* @param {string} sub - Subject identifier
* @param {string} query - Search query string
* @param {string} type - Type filter ('users', 'groups', or 'all')
* @param {number} limit - Maximum number of results
* @returns {Promise<TPrincipalSearchResult[]>} Array of principal search results
*/
const searchEntraIdPrincipals = async (accessToken, sub, query, type = 'all', limit = 10) => {
try {
if (!query || query.trim().length < 2) {
return [];
}
const graphClient = await createGraphClient(accessToken, sub);
let allResults = [];
if (type === 'users' || type === 'all') {
const contactResults = await searchContacts(graphClient, query, limit);
allResults.push(...contactResults);
}
if (allResults.length >= limit) {
return allResults.slice(0, limit);
}
if (type === 'users') {
const userResults = await searchUsers(graphClient, query, limit);
allResults.push(...userResults);
} else if (type === 'groups') {
const groupResults = await searchGroups(graphClient, query, limit);
allResults.push(...groupResults);
} else if (type === 'all') {
const [userResults, groupResults] = await Promise.all([
searchUsers(graphClient, query, limit),
searchGroups(graphClient, query, limit),
]);
allResults.push(...userResults, ...groupResults);
}
const seenIds = new Set();
const uniqueResults = allResults.filter((result) => {
if (seenIds.has(result.idOnTheSource)) {
return false;
}
seenIds.add(result.idOnTheSource);
return true;
});
return uniqueResults.slice(0, limit);
} catch (error) {
logger.error('[searchEntraIdPrincipals] Error searching principals:', error);
return [];
}
};
/**
* Get current user's Entra ID group memberships from Microsoft Graph
* Uses /me/memberOf endpoint to get groups the user is a member of
* @param {string} accessToken - OpenID Connect access token
* @param {string} sub - Subject identifier
* @returns {Promise<Array<string>>} Array of group ID strings (GUIDs)
*/
const getUserEntraGroups = async (accessToken, sub) => {
try {
const graphClient = await createGraphClient(accessToken, sub);
const groupsResponse = await graphClient.api('/me/memberOf').select('id').get();
return (groupsResponse.value || []).map((group) => group.id);
} catch (error) {
logger.error('[getUserEntraGroups] Error fetching user groups:', error);
return [];
}
};
/**
* Get current user's owned Entra ID groups from Microsoft Graph
* Uses /me/ownedObjects/microsoft.graph.group endpoint to get groups the user owns
* @param {string} accessToken - OpenID Connect access token
* @param {string} sub - Subject identifier
* @returns {Promise<Array<string>>} Array of group ID strings (GUIDs)
*/
const getUserOwnedEntraGroups = async (accessToken, sub) => {
try {
const graphClient = await createGraphClient(accessToken, sub);
const groupsResponse = await graphClient
.api('/me/ownedObjects/microsoft.graph.group')
.select('id')
.get();
return (groupsResponse.value || []).map((group) => group.id);
} catch (error) {
logger.error('[getUserOwnedEntraGroups] Error fetching user owned groups:', error);
return [];
}
};
/**
* Get group members from Microsoft Graph API
* Recursively fetches all members using pagination (@odata.nextLink)
* @param {string} accessToken - OpenID Connect access token
* @param {string} sub - Subject identifier
* @param {string} groupId - Entra ID group object ID
* @returns {Promise<Array>} Array of member IDs (idOnTheSource values)
*/
const getGroupMembers = async (accessToken, sub, groupId) => {
try {
const graphClient = await createGraphClient(accessToken, sub);
const allMembers = [];
let nextLink = `/groups/${groupId}/members`;
while (nextLink) {
const membersResponse = await graphClient.api(nextLink).select('id').top(999).get();
const members = membersResponse.value || [];
allMembers.push(...members.map((member) => member.id));
nextLink = membersResponse['@odata.nextLink']
? membersResponse['@odata.nextLink'].split('/v1.0')[1]
: null;
}
return allMembers;
} catch (error) {
logger.error('[getGroupMembers] Error fetching group members:', error);
return [];
}
};
/**
* Get group owners from Microsoft Graph API
* Recursively fetches all owners using pagination (@odata.nextLink)
* @param {string} accessToken - OpenID Connect access token
* @param {string} sub - Subject identifier
* @param {string} groupId - Entra ID group object ID
* @returns {Promise<Array>} Array of owner IDs (idOnTheSource values)
*/
const getGroupOwners = async (accessToken, sub, groupId) => {
try {
const graphClient = await createGraphClient(accessToken, sub);
const allOwners = [];
let nextLink = `/groups/${groupId}/owners`;
while (nextLink) {
const ownersResponse = await graphClient.api(nextLink).select('id').top(999).get();
const owners = ownersResponse.value || [];
allOwners.push(...owners.map((member) => member.id));
nextLink = ownersResponse['@odata.nextLink']
? ownersResponse['@odata.nextLink'].split('/v1.0')[1]
: null;
}
return allOwners;
} catch (error) {
logger.error('[getGroupOwners] Error fetching group owners:', error);
return [];
}
};
/**
* Search for contacts (users only) using Microsoft Graph /me/people endpoint
* Returns mapped TPrincipalSearchResult objects for users only
* @param {Client} graphClient - Authenticated Microsoft Graph client
* @param {string} query - Search query string
* @param {number} limit - Maximum number of results (default: 10)
* @returns {Promise<TPrincipalSearchResult[]>} Array of mapped user contact results
*/
const searchContacts = async (graphClient, query, limit = 10) => {
try {
if (!query || query.trim().length < 2) {
return [];
}
if (
process.env.OPENID_GRAPH_SCOPES &&
!process.env.OPENID_GRAPH_SCOPES.toLowerCase().includes('people.read')
) {
logger.warn('[searchContacts] People.Read scope is not enabled, skipping contact search');
return [];
}
// Reason: Search only for OrganizationUser (person) type, not groups
const filter = "personType/subclass eq 'OrganizationUser'";
let apiCall = graphClient
.api('/me/people')
.search(`"${query}"`)
.select(
'id,displayName,givenName,surname,userPrincipalName,jobTitle,department,companyName,scoredEmailAddresses,personType,phones',
)
.header('ConsistencyLevel', 'eventual')
.filter(filter)
.top(limit);
const contactsResponse = await apiCall.get();
return (contactsResponse.value || []).map(mapContactToTPrincipalSearchResult);
} catch (error) {
logger.error('[searchContacts] Error searching contacts:', error);
return [];
}
};
/**
* Search for users using Microsoft Graph /users endpoint
* Returns mapped TPrincipalSearchResult objects
* @param {Client} graphClient - Authenticated Microsoft Graph client
* @param {string} query - Search query string
* @param {number} limit - Maximum number of results (default: 10)
* @returns {Promise<TPrincipalSearchResult[]>} Array of mapped user results
*/
const searchUsers = async (graphClient, query, limit = 10) => {
try {
if (!query || query.trim().length < 2) {
return [];
}
// Reason: Search users by display name, email, and user principal name
const usersResponse = await graphClient
.api('/users')
.search(
`"displayName:${query}" OR "userPrincipalName:${query}" OR "mail:${query}" OR "givenName:${query}" OR "surname:${query}"`,
)
.select(
'id,displayName,givenName,surname,userPrincipalName,jobTitle,department,companyName,mail,phones',
)
.header('ConsistencyLevel', 'eventual')
.top(limit)
.get();
return (usersResponse.value || []).map(mapUserToTPrincipalSearchResult);
} catch (error) {
logger.error('[searchUsers] Error searching users:', error);
return [];
}
};
/**
* Search for groups using Microsoft Graph /groups endpoint
* Returns mapped TPrincipalSearchResult objects, includes all group types
* @param {Client} graphClient - Authenticated Microsoft Graph client
* @param {string} query - Search query string
* @param {number} limit - Maximum number of results (default: 10)
* @returns {Promise<TPrincipalSearchResult[]>} Array of mapped group results
*/
const searchGroups = async (graphClient, query, limit = 10) => {
try {
if (!query || query.trim().length < 2) {
return [];
}
// Reason: Search all groups by display name and email without filtering group types
const groupsResponse = await graphClient
.api('/groups')
.search(`"displayName:${query}" OR "mail:${query}" OR "mailNickname:${query}"`)
.select('id,displayName,mail,mailNickname,description,groupTypes,resourceProvisioningOptions')
.header('ConsistencyLevel', 'eventual')
.top(limit)
.get();
return (groupsResponse.value || []).map(mapGroupToTPrincipalSearchResult);
} catch (error) {
logger.error('[searchGroups] Error searching groups:', error);
return [];
}
};
/**
* Test Graph API connectivity and permissions
* @param {string} accessToken - OpenID Connect access token
* @param {string} sub - Subject identifier
* @returns {Promise<Object>} Test results with available permissions
*/
const testGraphApiAccess = async (accessToken, sub) => {
try {
const graphClient = await createGraphClient(accessToken, sub);
const results = {
userAccess: false,
peopleAccess: false,
groupsAccess: false,
usersEndpointAccess: false,
groupsEndpointAccess: false,
errors: [],
};
// Test User.Read permission
try {
await graphClient.api('/me').select('id,displayName').get();
results.userAccess = true;
} catch (error) {
results.errors.push(`User.Read: ${error.message}`);
}
// Test People.Read permission with OrganizationUser filter
try {
await graphClient
.api('/me/people')
.filter("personType/subclass eq 'OrganizationUser'")
.top(1)
.get();
results.peopleAccess = true;
} catch (error) {
results.errors.push(`People.Read (OrganizationUser): ${error.message}`);
}
// Test People.Read permission with UnifiedGroup filter
try {
await graphClient
.api('/me/people')
.filter("personType/subclass eq 'UnifiedGroup'")
.top(1)
.get();
results.groupsAccess = true;
} catch (error) {
results.errors.push(`People.Read (UnifiedGroup): ${error.message}`);
}
// Test /users endpoint access (requires User.Read.All or similar)
try {
await graphClient
.api('/users')
.search('"displayName:test"')
.select('id,displayName,userPrincipalName')
.top(1)
.get();
results.usersEndpointAccess = true;
} catch (error) {
results.errors.push(`Users endpoint: ${error.message}`);
}
// Test /groups endpoint access (requires Group.Read.All or similar)
try {
await graphClient
.api('/groups')
.search('"displayName:test"')
.select('id,displayName,mail')
.top(1)
.get();
results.groupsEndpointAccess = true;
} catch (error) {
results.errors.push(`Groups endpoint: ${error.message}`);
}
return results;
} catch (error) {
logger.error('[testGraphApiAccess] Error testing Graph API access:', error);
return {
userAccess: false,
peopleAccess: false,
groupsAccess: false,
usersEndpointAccess: false,
groupsEndpointAccess: false,
errors: [error.message],
};
}
};
/**
* Map Graph API user object to TPrincipalSearchResult format
* @param {TGraphUser} user - Raw user object from Graph API
* @returns {TPrincipalSearchResult} Mapped user result
*/
const mapUserToTPrincipalSearchResult = (user) => {
return {
id: null,
type: 'user',
name: user.displayName,
email: user.mail || user.userPrincipalName,
username: user.userPrincipalName,
source: 'entra',
idOnTheSource: user.id,
};
};
/**
* Map Graph API group object to TPrincipalSearchResult format
* @param {TGraphGroup} group - Raw group object from Graph API
* @returns {TPrincipalSearchResult} Mapped group result
*/
const mapGroupToTPrincipalSearchResult = (group) => {
return {
id: null,
type: 'group',
name: group.displayName,
email: group.mail || group.userPrincipalName,
description: group.description,
source: 'entra',
idOnTheSource: group.id,
};
};
/**
* Map Graph API /me/people contact object to TPrincipalSearchResult format
* Handles both user and group contacts from the people endpoint
* @param {TGraphPerson} contact - Raw contact object from Graph API /me/people
* @returns {TPrincipalSearchResult} Mapped contact result
*/
const mapContactToTPrincipalSearchResult = (contact) => {
const isGroup = contact.personType?.class === 'Group';
const primaryEmail = contact.scoredEmailAddresses?.[0]?.address;
return {
id: null,
type: isGroup ? 'group' : 'user',
name: contact.displayName,
email: primaryEmail,
username: !isGroup ? contact.userPrincipalName : undefined,
source: 'entra',
idOnTheSource: contact.id,
};
};
module.exports = {
getGroupMembers,
getGroupOwners,
createGraphClient,
getUserEntraGroups,
getUserOwnedEntraGroups,
testGraphApiAccess,
searchEntraIdPrincipals,
exchangeTokenForGraphAccess,
entraIdPrincipalFeatureEnabled,
};

View File

@@ -0,0 +1,720 @@
jest.mock('@microsoft/microsoft-graph-client');
jest.mock('~/strategies/openidStrategy');
jest.mock('~/cache/getLogStores');
jest.mock('@librechat/data-schemas', () => ({
...jest.requireActual('@librechat/data-schemas'),
logger: {
error: jest.fn(),
debug: jest.fn(),
},
}));
jest.mock('~/config', () => ({
logger: {
error: jest.fn(),
debug: jest.fn(),
},
createAxiosInstance: jest.fn(() => ({
create: jest.fn(),
defaults: {},
})),
}));
jest.mock('~/utils', () => ({
logAxiosError: jest.fn(),
}));
jest.mock('~/server/services/Config', () => ({}));
jest.mock('~/server/services/Files/strategies', () => ({
getStrategyFunctions: jest.fn(),
}));
const mongoose = require('mongoose');
const client = require('openid-client');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { Client } = require('@microsoft/microsoft-graph-client');
const { getOpenIdConfig } = require('~/strategies/openidStrategy');
const getLogStores = require('~/cache/getLogStores');
const GraphApiService = require('./GraphApiService');
describe('GraphApiService', () => {
let mongoServer;
let mockGraphClient;
let mockTokensCache;
let mockOpenIdConfig;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
afterEach(() => {
// Clean up environment variables
delete process.env.OPENID_GRAPH_SCOPES;
});
beforeEach(async () => {
jest.clearAllMocks();
await mongoose.connection.dropDatabase();
// Set up environment variable for People.Read scope
process.env.OPENID_GRAPH_SCOPES = 'User.Read,People.Read,Group.Read.All';
// Mock Graph client
mockGraphClient = {
api: jest.fn().mockReturnThis(),
search: jest.fn().mockReturnThis(),
filter: jest.fn().mockReturnThis(),
select: jest.fn().mockReturnThis(),
header: jest.fn().mockReturnThis(),
top: jest.fn().mockReturnThis(),
get: jest.fn(),
};
Client.init.mockReturnValue(mockGraphClient);
// Mock tokens cache
mockTokensCache = {
get: jest.fn(),
set: jest.fn(),
};
getLogStores.mockReturnValue(mockTokensCache);
// Mock OpenID config
mockOpenIdConfig = {
client_id: 'test-client-id',
issuer: 'https://test-issuer.com',
};
getOpenIdConfig.mockReturnValue(mockOpenIdConfig);
// Mock openid-client (using the existing jest mock configuration)
if (client.genericGrantRequest) {
client.genericGrantRequest.mockResolvedValue({
access_token: 'mocked-graph-token',
expires_in: 3600,
});
}
});
describe('Dependency Contract Tests', () => {
it('should fail if getOpenIdConfig interface changes', () => {
// Reason: Ensure getOpenIdConfig returns expected structure
const config = getOpenIdConfig();
expect(config).toBeDefined();
expect(typeof config).toBe('object');
// Add specific property checks that GraphApiService depends on
expect(config).toHaveProperty('client_id');
expect(config).toHaveProperty('issuer');
// Ensure the function is callable
expect(typeof getOpenIdConfig).toBe('function');
});
it('should fail if openid-client.genericGrantRequest interface changes', () => {
// Reason: Ensure client.genericGrantRequest maintains expected signature
if (client.genericGrantRequest) {
expect(typeof client.genericGrantRequest).toBe('function');
// Test that it accepts the expected parameters
const mockCall = client.genericGrantRequest(
mockOpenIdConfig,
'urn:ietf:params:oauth:grant-type:jwt-bearer',
{
scope: 'test-scope',
assertion: 'test-token',
requested_token_use: 'on_behalf_of',
},
);
expect(mockCall).toBeDefined();
}
});
it('should fail if Microsoft Graph Client interface changes', () => {
// Reason: Ensure Graph Client maintains expected fluent API
expect(typeof Client.init).toBe('function');
const client = Client.init({ authProvider: jest.fn() });
expect(client).toHaveProperty('api');
expect(typeof client.api).toBe('function');
});
});
describe('createGraphClient', () => {
it('should create graph client with exchanged token', async () => {
const accessToken = 'test-access-token';
const sub = 'test-user-id';
const result = await GraphApiService.createGraphClient(accessToken, sub);
expect(getOpenIdConfig).toHaveBeenCalled();
expect(Client.init).toHaveBeenCalledWith({
authProvider: expect.any(Function),
});
expect(result).toBe(mockGraphClient);
});
it('should handle token exchange errors gracefully', async () => {
if (client.genericGrantRequest) {
client.genericGrantRequest.mockRejectedValue(new Error('Token exchange failed'));
}
await expect(GraphApiService.createGraphClient('invalid-token', 'test-user')).rejects.toThrow(
'Token exchange failed',
);
});
});
describe('exchangeTokenForGraphAccess', () => {
it('should return cached token if available', async () => {
const cachedToken = { access_token: 'cached-token' };
mockTokensCache.get.mockResolvedValue(cachedToken);
const result = await GraphApiService.exchangeTokenForGraphAccess(
mockOpenIdConfig,
'test-token',
'test-user',
);
expect(result).toBe('cached-token');
expect(mockTokensCache.get).toHaveBeenCalledWith('test-user:graph');
if (client.genericGrantRequest) {
expect(client.genericGrantRequest).not.toHaveBeenCalled();
}
});
it('should exchange token and cache result', async () => {
mockTokensCache.get.mockResolvedValue(null);
const result = await GraphApiService.exchangeTokenForGraphAccess(
mockOpenIdConfig,
'test-token',
'test-user',
);
if (client.genericGrantRequest) {
expect(client.genericGrantRequest).toHaveBeenCalledWith(
mockOpenIdConfig,
'urn:ietf:params:oauth:grant-type:jwt-bearer',
{
scope:
'https://graph.microsoft.com/User.Read https://graph.microsoft.com/People.Read https://graph.microsoft.com/Group.Read.All',
assertion: 'test-token',
requested_token_use: 'on_behalf_of',
},
);
}
expect(mockTokensCache.set).toHaveBeenCalledWith(
'test-user:graph',
{ access_token: 'mocked-graph-token' },
3600000,
);
expect(result).toBe('mocked-graph-token');
});
it('should use custom scopes from environment', async () => {
const originalEnv = process.env.OPENID_GRAPH_SCOPES;
process.env.OPENID_GRAPH_SCOPES = 'Custom.Read,Custom.Write';
mockTokensCache.get.mockResolvedValue(null);
await GraphApiService.exchangeTokenForGraphAccess(
mockOpenIdConfig,
'test-token',
'test-user',
);
if (client.genericGrantRequest) {
expect(client.genericGrantRequest).toHaveBeenCalledWith(
mockOpenIdConfig,
'urn:ietf:params:oauth:grant-type:jwt-bearer',
{
scope:
'https://graph.microsoft.com/Custom.Read https://graph.microsoft.com/Custom.Write',
assertion: 'test-token',
requested_token_use: 'on_behalf_of',
},
);
}
process.env.OPENID_GRAPH_SCOPES = originalEnv;
});
});
describe('searchEntraIdPrincipals', () => {
// Mock data used by multiple tests
const mockContactsResponse = {
value: [
{
id: 'contact-user-1',
displayName: 'John Doe',
userPrincipalName: 'john@company.com',
mail: 'john@company.com',
personType: { class: 'Person', subclass: 'OrganizationUser' },
scoredEmailAddresses: [{ address: 'john@company.com', relevanceScore: 0.9 }],
},
{
id: 'contact-group-1',
displayName: 'Marketing Team',
mail: 'marketing@company.com',
personType: { class: 'Group', subclass: 'UnifiedGroup' },
scoredEmailAddresses: [{ address: 'marketing@company.com', relevanceScore: 0.8 }],
},
],
};
const mockUsersResponse = {
value: [
{
id: 'dir-user-1',
displayName: 'Jane Smith',
userPrincipalName: 'jane@company.com',
mail: 'jane@company.com',
},
],
};
const mockGroupsResponse = {
value: [
{
id: 'dir-group-1',
displayName: 'Development Team',
mail: 'dev@company.com',
},
],
};
beforeEach(() => {
// Reset mock call history for each test
jest.clearAllMocks();
// Re-apply the Client.init mock after clearAllMocks
Client.init.mockReturnValue(mockGraphClient);
// Re-apply openid-client mock
if (client.genericGrantRequest) {
client.genericGrantRequest.mockResolvedValue({
access_token: 'mocked-graph-token',
expires_in: 3600,
});
}
// Re-apply cache mock
mockTokensCache.get.mockResolvedValue(null); // Force token exchange
mockTokensCache.set.mockResolvedValue();
getLogStores.mockReturnValue(mockTokensCache);
getOpenIdConfig.mockReturnValue(mockOpenIdConfig);
});
it('should return empty results for short queries', async () => {
const result = await GraphApiService.searchEntraIdPrincipals('token', 'user', 'a', 'all', 10);
expect(result).toEqual([]);
expect(mockGraphClient.api).not.toHaveBeenCalled();
});
it('should search contacts first and additional users for users type', async () => {
// Mock responses for this specific test
const contactsFilteredResponse = {
value: [
{
id: 'contact-user-1',
displayName: 'John Doe',
userPrincipalName: 'john@company.com',
mail: 'john@company.com',
personType: { class: 'Person', subclass: 'OrganizationUser' },
scoredEmailAddresses: [{ address: 'john@company.com', relevanceScore: 0.9 }],
},
],
};
mockGraphClient.get
.mockResolvedValueOnce(contactsFilteredResponse) // contacts call
.mockResolvedValueOnce(mockUsersResponse); // users call
const result = await GraphApiService.searchEntraIdPrincipals(
'token',
'user',
'john',
'users',
10,
);
// Should call contacts first with user filter
expect(mockGraphClient.api).toHaveBeenCalledWith('/me/people');
expect(mockGraphClient.search).toHaveBeenCalledWith('"john"');
expect(mockGraphClient.filter).toHaveBeenCalledWith(
"personType/subclass eq 'OrganizationUser'",
);
// Should call users endpoint for additional results
expect(mockGraphClient.api).toHaveBeenCalledWith('/users');
expect(mockGraphClient.search).toHaveBeenCalledWith(
'"displayName:john" OR "userPrincipalName:john" OR "mail:john" OR "givenName:john" OR "surname:john"',
);
// Should return TPrincipalSearchResult array
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(2); // 1 from contacts + 1 from users
expect(result[0]).toMatchObject({
id: null,
type: 'user',
name: 'John Doe',
email: 'john@company.com',
source: 'entra',
idOnTheSource: 'contact-user-1',
});
});
it('should search groups endpoint only for groups type', async () => {
// Mock responses for this specific test - only groups endpoint called
mockGraphClient.get.mockResolvedValueOnce(mockGroupsResponse); // only groups call
const result = await GraphApiService.searchEntraIdPrincipals(
'token',
'user',
'team',
'groups',
10,
);
// Should NOT call contacts for groups type
expect(mockGraphClient.api).not.toHaveBeenCalledWith('/me/people');
// Should call groups endpoint only
expect(mockGraphClient.api).toHaveBeenCalledWith('/groups');
expect(mockGraphClient.search).toHaveBeenCalledWith(
'"displayName:team" OR "mail:team" OR "mailNickname:team"',
);
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(1); // 1 from groups only
});
it('should search all endpoints for all type', async () => {
// Mock responses for this specific test
mockGraphClient.get
.mockResolvedValueOnce(mockContactsResponse) // contacts call (both user and group)
.mockResolvedValueOnce(mockUsersResponse) // users call
.mockResolvedValueOnce(mockGroupsResponse); // groups call
const result = await GraphApiService.searchEntraIdPrincipals(
'token',
'user',
'test',
'all',
10,
);
// Should call contacts first with user filter
expect(mockGraphClient.api).toHaveBeenCalledWith('/me/people');
expect(mockGraphClient.search).toHaveBeenCalledWith('"test"');
expect(mockGraphClient.filter).toHaveBeenCalledWith(
"personType/subclass eq 'OrganizationUser'",
);
// Should call both users and groups endpoints
expect(mockGraphClient.api).toHaveBeenCalledWith('/users');
expect(mockGraphClient.api).toHaveBeenCalledWith('/groups');
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(4); // 2 from contacts + 1 from users + 1 from groups
});
it('should early exit if contacts reach limit', async () => {
// Mock contacts to return exactly the limit
const limitedContactsResponse = {
value: Array(10).fill({
id: 'contact-1',
displayName: 'Contact User',
mail: 'contact@company.com',
personType: { class: 'Person', subclass: 'OrganizationUser' },
}),
};
mockGraphClient.get.mockResolvedValueOnce(limitedContactsResponse);
const result = await GraphApiService.searchEntraIdPrincipals(
'token',
'user',
'test',
'all',
10,
);
// Should call contacts first
expect(mockGraphClient.api).toHaveBeenCalledWith('/me/people');
expect(mockGraphClient.search).toHaveBeenCalledWith('"test"');
// Should not call users endpoint since limit was reached
expect(mockGraphClient.api).not.toHaveBeenCalledWith('/users');
expect(result).toHaveLength(10);
});
it('should deduplicate results based on idOnTheSource', async () => {
// Mock responses with duplicate IDs
const duplicateContactsResponse = {
value: [
{
id: 'duplicate-id',
displayName: 'John Doe',
mail: 'john@company.com',
personType: { class: 'Person', subclass: 'OrganizationUser' },
},
],
};
const duplicateUsersResponse = {
value: [
{
id: 'duplicate-id', // Same ID as contact
displayName: 'John Doe',
mail: 'john@company.com',
},
],
};
mockGraphClient.get
.mockResolvedValueOnce(duplicateContactsResponse)
.mockResolvedValueOnce(duplicateUsersResponse);
const result = await GraphApiService.searchEntraIdPrincipals(
'token',
'user',
'john',
'users',
10,
);
// Should only return one result despite duplicate IDs
expect(result).toHaveLength(1);
expect(result[0].idOnTheSource).toBe('duplicate-id');
});
it('should handle Graph API errors gracefully', async () => {
mockGraphClient.get.mockRejectedValue(new Error('Graph API error'));
const result = await GraphApiService.searchEntraIdPrincipals(
'token',
'user',
'test',
'all',
10,
);
expect(result).toEqual([]);
});
});
describe('getUserEntraGroups', () => {
it('should fetch user groups from memberOf endpoint', async () => {
const mockGroupsResponse = {
value: [
{
id: 'group-1',
},
{
id: 'group-2',
},
],
};
mockGraphClient.get.mockResolvedValue(mockGroupsResponse);
const result = await GraphApiService.getUserEntraGroups('token', 'user');
expect(mockGraphClient.api).toHaveBeenCalledWith('/me/memberOf');
expect(mockGraphClient.select).toHaveBeenCalledWith('id');
expect(result).toHaveLength(2);
expect(result).toEqual(['group-1', 'group-2']);
});
it('should return empty array on error', async () => {
mockGraphClient.get.mockRejectedValue(new Error('API error'));
const result = await GraphApiService.getUserEntraGroups('token', 'user');
expect(result).toEqual([]);
});
it('should handle empty response', async () => {
const mockGroupsResponse = {
value: [],
};
mockGraphClient.get.mockResolvedValue(mockGroupsResponse);
const result = await GraphApiService.getUserEntraGroups('token', 'user');
expect(result).toEqual([]);
});
it('should handle missing value property', async () => {
mockGraphClient.get.mockResolvedValue({});
const result = await GraphApiService.getUserEntraGroups('token', 'user');
expect(result).toEqual([]);
});
});
describe('testGraphApiAccess', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should test all permissions and return success results', async () => {
// Mock successful responses for all tests
mockGraphClient.get
.mockResolvedValueOnce({ id: 'user-123', displayName: 'Test User' }) // /me test
.mockResolvedValueOnce({ value: [] }) // people OrganizationUser test
.mockResolvedValueOnce({ value: [] }) // people UnifiedGroup test
.mockResolvedValueOnce({ value: [] }) // /users endpoint test
.mockResolvedValueOnce({ value: [] }); // /groups endpoint test
const result = await GraphApiService.testGraphApiAccess('token', 'user');
expect(result).toEqual({
userAccess: true,
peopleAccess: true,
groupsAccess: true,
usersEndpointAccess: true,
groupsEndpointAccess: true,
errors: [],
});
// Verify all endpoints were tested
expect(mockGraphClient.api).toHaveBeenCalledWith('/me');
expect(mockGraphClient.api).toHaveBeenCalledWith('/me/people');
expect(mockGraphClient.api).toHaveBeenCalledWith('/users');
expect(mockGraphClient.api).toHaveBeenCalledWith('/groups');
expect(mockGraphClient.filter).toHaveBeenCalledWith(
"personType/subclass eq 'OrganizationUser'",
);
expect(mockGraphClient.filter).toHaveBeenCalledWith("personType/subclass eq 'UnifiedGroup'");
expect(mockGraphClient.search).toHaveBeenCalledWith('"displayName:test"');
});
it('should handle partial failures and record errors', async () => {
// Mock mixed success/failure responses
mockGraphClient.get
.mockResolvedValueOnce({ id: 'user-123', displayName: 'Test User' }) // /me success
.mockRejectedValueOnce(new Error('People access denied')) // people OrganizationUser fail
.mockResolvedValueOnce({ value: [] }) // people UnifiedGroup success
.mockRejectedValueOnce(new Error('Users endpoint access denied')) // /users fail
.mockResolvedValueOnce({ value: [] }); // /groups success
const result = await GraphApiService.testGraphApiAccess('token', 'user');
expect(result).toEqual({
userAccess: true,
peopleAccess: false,
groupsAccess: true,
usersEndpointAccess: false,
groupsEndpointAccess: true,
errors: [
'People.Read (OrganizationUser): People access denied',
'Users endpoint: Users endpoint access denied',
],
});
});
it('should handle complete Graph client creation failure', async () => {
// Mock token exchange failure to test error handling
if (client.genericGrantRequest) {
client.genericGrantRequest.mockRejectedValue(new Error('Token exchange failed'));
}
const result = await GraphApiService.testGraphApiAccess('invalid-token', 'user');
expect(result).toEqual({
userAccess: false,
peopleAccess: false,
groupsAccess: false,
usersEndpointAccess: false,
groupsEndpointAccess: false,
errors: ['Token exchange failed'],
});
});
it('should record all permission errors', async () => {
// Mock all requests to fail
mockGraphClient.get
.mockRejectedValueOnce(new Error('User.Read denied'))
.mockRejectedValueOnce(new Error('People.Read OrganizationUser denied'))
.mockRejectedValueOnce(new Error('People.Read UnifiedGroup denied'))
.mockRejectedValueOnce(new Error('Users directory access denied'))
.mockRejectedValueOnce(new Error('Groups directory access denied'));
const result = await GraphApiService.testGraphApiAccess('token', 'user');
expect(result).toEqual({
userAccess: false,
peopleAccess: false,
groupsAccess: false,
usersEndpointAccess: false,
groupsEndpointAccess: false,
errors: [
'User.Read: User.Read denied',
'People.Read (OrganizationUser): People.Read OrganizationUser denied',
'People.Read (UnifiedGroup): People.Read UnifiedGroup denied',
'Users endpoint: Users directory access denied',
'Groups endpoint: Groups directory access denied',
],
});
});
it('should test new endpoints with correct search patterns', async () => {
// Mock successful responses for endpoint testing
mockGraphClient.get
.mockResolvedValueOnce({ id: 'user-123', displayName: 'Test User' }) // /me
.mockResolvedValueOnce({ value: [] }) // people OrganizationUser
.mockResolvedValueOnce({ value: [] }) // people UnifiedGroup
.mockResolvedValueOnce({ value: [] }) // /users
.mockResolvedValueOnce({ value: [] }); // /groups
await GraphApiService.testGraphApiAccess('token', 'user');
// Verify /users endpoint test
expect(mockGraphClient.api).toHaveBeenCalledWith('/users');
expect(mockGraphClient.search).toHaveBeenCalledWith('"displayName:test"');
expect(mockGraphClient.select).toHaveBeenCalledWith('id,displayName,userPrincipalName');
// Verify /groups endpoint test
expect(mockGraphClient.api).toHaveBeenCalledWith('/groups');
expect(mockGraphClient.select).toHaveBeenCalledWith('id,displayName,mail');
});
it('should handle endpoint-specific permission failures', async () => {
// Mock specific endpoint failures
mockGraphClient.get
.mockResolvedValueOnce({ id: 'user-123', displayName: 'Test User' }) // /me success
.mockResolvedValueOnce({ value: [] }) // people OrganizationUser success
.mockResolvedValueOnce({ value: [] }) // people UnifiedGroup success
.mockRejectedValueOnce(new Error('Insufficient privileges')) // /users fail (User.Read.All needed)
.mockRejectedValueOnce(new Error('Access denied to groups')); // /groups fail (Group.Read.All needed)
const result = await GraphApiService.testGraphApiAccess('token', 'user');
expect(result).toEqual({
userAccess: true,
peopleAccess: true,
groupsAccess: true,
usersEndpointAccess: false,
groupsEndpointAccess: false,
errors: [
'Users endpoint: Insufficient privileges',
'Groups endpoint: Access denied to groups',
],
});
});
});
});

View File

@@ -1,27 +1,111 @@
const { z } = require('zod');
const { tool } = require('@langchain/core/tools');
const { normalizeServerName } = require('@librechat/api');
const { Constants: AgentConstants, Providers } = require('@librechat/agents');
const { logger } = require('@librechat/data-schemas');
const { Time, CacheKeys, StepTypes } = require('librechat-data-provider');
const { sendEvent, normalizeServerName, MCPOAuthHandler } = require('@librechat/api');
const { Constants: AgentConstants, Providers, GraphEvents } = require('@librechat/agents');
const {
Constants,
ContentTypes,
isAssistantsEndpoint,
convertJsonSchemaToZod,
} = require('librechat-data-provider');
const { logger, getMCPManager } = require('~/config');
const { getMCPManager, getFlowStateManager } = require('~/config');
const { findToken, createToken, updateToken } = require('~/models');
const { getCachedTools } = require('./Config');
const { getLogStores } = require('~/cache');
/**
* @param {object} params
* @param {ServerResponse} params.res - The Express response object for sending events.
* @param {string} params.stepId - The ID of the step in the flow.
* @param {ToolCallChunk} params.toolCall - The tool call object containing tool information.
* @param {string} params.loginFlowId - The ID of the login flow.
* @param {FlowStateManager<any>} params.flowManager - The flow manager instance.
*/
function createOAuthStart({ res, stepId, toolCall, loginFlowId, flowManager, signal }) {
/**
* Creates a function to handle OAuth login requests.
* @param {string} authURL - The URL to redirect the user for OAuth authentication.
* @returns {Promise<boolean>} Returns true to indicate the event was sent successfully.
*/
return async function (authURL) {
/** @type {{ id: string; delta: AgentToolCallDelta }} */
const data = {
id: stepId,
delta: {
type: StepTypes.TOOL_CALLS,
tool_calls: [{ ...toolCall, args: '' }],
auth: authURL,
expires_at: Date.now() + Time.TWO_MINUTES,
},
};
/** Used to ensure the handler (use of `sendEvent`) is only invoked once */
await flowManager.createFlowWithHandler(
loginFlowId,
'oauth_login',
async () => {
sendEvent(res, { event: GraphEvents.ON_RUN_STEP_DELTA, data });
logger.debug('Sent OAuth login request to client');
return true;
},
signal,
);
};
}
/**
* @param {object} params
* @param {ServerResponse} params.res - The Express response object for sending events.
* @param {string} params.stepId - The ID of the step in the flow.
* @param {ToolCallChunk} params.toolCall - The tool call object containing tool information.
* @param {string} params.loginFlowId - The ID of the login flow.
* @param {FlowStateManager<any>} params.flowManager - The flow manager instance.
*/
function createOAuthEnd({ res, stepId, toolCall }) {
return async function () {
/** @type {{ id: string; delta: AgentToolCallDelta }} */
const data = {
id: stepId,
delta: {
type: StepTypes.TOOL_CALLS,
tool_calls: [{ ...toolCall }],
},
};
sendEvent(res, { event: GraphEvents.ON_RUN_STEP_DELTA, data });
logger.debug('Sent OAuth login success to client');
};
}
/**
* @param {object} params
* @param {string} params.userId - The ID of the user.
* @param {string} params.serverName - The name of the server.
* @param {string} params.toolName - The name of the tool.
* @param {FlowStateManager<any>} params.flowManager - The flow manager instance.
*/
function createAbortHandler({ userId, serverName, toolName, flowManager }) {
return function () {
logger.info(`[MCP][User: ${userId}][${serverName}][${toolName}] Tool call aborted`);
const flowId = MCPOAuthHandler.generateFlowId(userId, serverName);
flowManager.failFlow(flowId, 'mcp_oauth', new Error('Tool call aborted'));
};
}
/**
* Creates a general tool for an entire action set.
*
* @param {Object} params - The parameters for loading action sets.
* @param {ServerRequest} params.req - The Express request object, containing user/request info.
* @param {ServerResponse} params.res - The Express response object for sending events.
* @param {string} params.toolKey - The toolKey for the tool.
* @param {import('@librechat/agents').Providers | EModelEndpoint} params.provider - The provider for the tool.
* @param {string} params.model - The model for the tool.
* @returns { Promise<typeof tool | { _call: (toolInput: Object | string) => unknown}> } An object with `_call` method to execute the tool input.
*/
async function createMCPTool({ req, toolKey, provider: _provider }) {
const toolDefinition = req.app.locals.availableTools[toolKey]?.function;
async function createMCPTool({ req, res, toolKey, provider: _provider }) {
const availableTools = await getCachedTools({ includeGlobal: true });
const toolDefinition = availableTools?.[toolKey]?.function;
if (!toolDefinition) {
logger.error(`Tool ${toolKey} not found in available tools`);
return null;
@@ -51,10 +135,42 @@ async function createMCPTool({ req, toolKey, provider: _provider }) {
/** @type {(toolArguments: Object | string, config?: GraphRunnableConfig) => Promise<unknown>} */
const _call = async (toolArguments, config) => {
const userId = config?.configurable?.user?.id || config?.configurable?.user_id;
/** @type {ReturnType<typeof createAbortHandler>} */
let abortHandler = null;
/** @type {AbortSignal} */
let derivedSignal = null;
try {
const derivedSignal = config?.signal ? AbortSignal.any([config.signal]) : undefined;
const flowsCache = getLogStores(CacheKeys.FLOWS);
const flowManager = getFlowStateManager(flowsCache);
derivedSignal = config?.signal ? AbortSignal.any([config.signal]) : undefined;
const mcpManager = getMCPManager(userId);
const provider = (config?.metadata?.provider || _provider)?.toLowerCase();
const { args: _args, stepId, ...toolCall } = config.toolCall ?? {};
const loginFlowId = `${serverName}:oauth_login:${config.metadata.thread_id}:${config.metadata.run_id}`;
const oauthStart = createOAuthStart({
res,
stepId,
toolCall,
loginFlowId,
flowManager,
signal: derivedSignal,
});
const oauthEnd = createOAuthEnd({
res,
stepId,
toolCall,
});
if (derivedSignal) {
abortHandler = createAbortHandler({ userId, serverName, toolName, flowManager });
derivedSignal.addEventListener('abort', abortHandler, { once: true });
}
const customUserVars =
config?.configurable?.userMCPAuthMap?.[`${Constants.mcp_prefix}${serverName}`];
const result = await mcpManager.callTool({
serverName,
toolName,
@@ -62,8 +178,17 @@ async function createMCPTool({ req, toolKey, provider: _provider }) {
toolArguments,
options: {
signal: derivedSignal,
user: config?.configurable?.user,
},
user: config?.configurable?.user,
customUserVars,
flowManager,
tokenMethods: {
findToken,
createToken,
updateToken,
},
oauthStart,
oauthEnd,
});
if (isAssistantsEndpoint(provider) && Array.isArray(result)) {
@@ -78,9 +203,28 @@ async function createMCPTool({ req, toolKey, provider: _provider }) {
`[MCP][User: ${userId}][${serverName}] Error calling "${toolName}" MCP tool:`,
error,
);
/** OAuth error, provide a helpful message */
const isOAuthError =
error.message?.includes('401') ||
error.message?.includes('OAuth') ||
error.message?.includes('authentication') ||
error.message?.includes('Non-200 status code (401)');
if (isOAuthError) {
throw new Error(
`OAuth authentication required for ${serverName}. Please check the server logs for the authentication URL.`,
);
}
throw new Error(
`"${toolKey}" tool call failed${error?.message ? `: ${error?.message}` : '.'}`,
);
} finally {
// Clean up abort handler to prevent memory leaks
if (abortHandler && derivedSignal) {
derivedSignal.removeEventListener('abort', abortHandler);
}
}
};

View File

@@ -0,0 +1,721 @@
const mongoose = require('mongoose');
const { getTransactionSupport, logger } = require('@librechat/data-schemas');
const { isEnabled } = require('~/server/utils');
const {
entraIdPrincipalFeatureEnabled,
getUserEntraGroups,
getUserOwnedEntraGroups,
getGroupMembers,
getGroupOwners,
} = require('~/server/services/GraphApiService');
const {
findGroupByExternalId,
findRoleByIdentifier,
getUserPrincipals,
createGroup,
createUser,
updateUser,
findUser,
grantPermission: grantPermissionACL,
findAccessibleResources: findAccessibleResourcesACL,
hasPermission,
getEffectivePermissions: getEffectivePermissionsACL,
findEntriesByPrincipalsAndResource,
} = require('~/models');
const { AclEntry, AccessRole, Group } = require('~/db/models');
/** @type {boolean|null} */
let transactionSupportCache = null;
/**
* @import { TPrincipal } from 'librechat-data-provider'
*/
/**
* Grant a permission to a principal for a resource using a role
* @param {Object} params - Parameters for granting role-based permission
* @param {string} params.principalType - 'user', 'group', or 'public'
* @param {string|mongoose.Types.ObjectId|null} params.principalId - The ID of the principal (null for 'public')
* @param {string} params.resourceType - Type of resource (e.g., 'agent')
* @param {string|mongoose.Types.ObjectId} params.resourceId - The ID of the resource
* @param {string} params.accessRoleId - The ID of the role (e.g., 'agent_viewer', 'agent_editor')
* @param {string|mongoose.Types.ObjectId} params.grantedBy - User ID granting the permission
* @param {mongoose.ClientSession} [params.session] - Optional MongoDB session for transactions
* @returns {Promise<Object>} The created or updated ACL entry
*/
const grantPermission = async ({
principalType,
principalId,
resourceType,
resourceId,
accessRoleId,
grantedBy,
session,
}) => {
try {
if (!['user', 'group', 'public'].includes(principalType)) {
throw new Error(`Invalid principal type: ${principalType}`);
}
if (principalType !== 'public' && !principalId) {
throw new Error('Principal ID is required for user and group principals');
}
if (principalId && !mongoose.Types.ObjectId.isValid(principalId)) {
throw new Error(`Invalid principal ID: ${principalId}`);
}
if (!resourceId || !mongoose.Types.ObjectId.isValid(resourceId)) {
throw new Error(`Invalid resource ID: ${resourceId}`);
}
// Get the role to determine permission bits
const role = await findRoleByIdentifier(accessRoleId);
if (!role) {
throw new Error(`Role ${accessRoleId} not found`);
}
// Ensure the role is for the correct resource type
if (role.resourceType !== resourceType) {
throw new Error(
`Role ${accessRoleId} is for ${role.resourceType} resources, not ${resourceType}`,
);
}
return await grantPermissionACL(
principalType,
principalId,
resourceType,
resourceId,
role.permBits,
grantedBy,
session,
role._id,
);
} catch (error) {
logger.error(`[PermissionService.grantPermission] Error: ${error.message}`);
throw error;
}
};
/**
* Check if a user has specific permission bits on a resource
* @param {Object} params - Parameters for checking permissions
* @param {string|mongoose.Types.ObjectId} params.userId - The ID of the user
* @param {string} params.resourceType - Type of resource (e.g., 'agent')
* @param {string|mongoose.Types.ObjectId} params.resourceId - The ID of the resource
* @param {number} params.requiredPermissions - The permission bits required (e.g., 1 for VIEW, 3 for VIEW+EDIT)
* @returns {Promise<boolean>} Whether the user has the required permission bits
*/
const checkPermission = async ({ userId, resourceType, resourceId, requiredPermission }) => {
try {
if (typeof requiredPermission !== 'number' || requiredPermission < 1) {
throw new Error('requiredPermission must be a positive number');
}
// Get all principals for the user (user + groups + public)
const principals = await getUserPrincipals(userId);
if (principals.length === 0) {
return false;
}
return await hasPermission(principals, resourceType, resourceId, requiredPermission);
} catch (error) {
logger.error(`[PermissionService.checkPermission] Error: ${error.message}`);
// Re-throw validation errors
if (error.message.includes('requiredPermission must be')) {
throw error;
}
return false;
}
};
/**
* Get effective permission bitmask for a user on a resource
* @param {Object} params - Parameters for getting effective permissions
* @param {string|mongoose.Types.ObjectId} params.userId - The ID of the user
* @param {string} params.resourceType - Type of resource (e.g., 'agent')
* @param {string|mongoose.Types.ObjectId} params.resourceId - The ID of the resource
* @returns {Promise<number>} Effective permission bitmask
*/
const getEffectivePermissions = async ({ userId, resourceType, resourceId }) => {
try {
// Get all principals for the user (user + groups + public)
const principals = await getUserPrincipals(userId);
if (principals.length === 0) {
return 0;
}
return await getEffectivePermissionsACL(principals, resourceType, resourceId);
} catch (error) {
logger.error(`[PermissionService.getEffectivePermissions] Error: ${error.message}`);
return 0;
}
};
/**
* Find all resources of a specific type that a user has access to with specific permission bits
* @param {Object} params - Parameters for finding accessible resources
* @param {string|mongoose.Types.ObjectId} params.userId - The ID of the user
* @param {string} params.resourceType - Type of resource (e.g., 'agent')
* @param {number} params.requiredPermissions - The minimum permission bits required (e.g., 1 for VIEW, 3 for VIEW+EDIT)
* @returns {Promise<Array>} Array of resource IDs
*/
const findAccessibleResources = async ({ userId, resourceType, requiredPermissions }) => {
try {
if (typeof requiredPermissions !== 'number' || requiredPermissions < 1) {
throw new Error('requiredPermissions must be a positive number');
}
// Get all principals for the user (user + groups + public)
const principalsList = await getUserPrincipals(userId);
if (principalsList.length === 0) {
return [];
}
return await findAccessibleResourcesACL(principalsList, resourceType, requiredPermissions);
} catch (error) {
logger.error(`[PermissionService.findAccessibleResources] Error: ${error.message}`);
// Re-throw validation errors
if (error.message.includes('requiredPermissions must be')) {
throw error;
}
return [];
}
};
/**
* Find all publicly accessible resources of a specific type
* @param {Object} params - Parameters for finding publicly accessible resources
* @param {string} params.resourceType - Type of resource (e.g., 'agent')
* @param {number} params.requiredPermissions - The minimum permission bits required (e.g., 1 for VIEW, 3 for VIEW+EDIT)
* @returns {Promise<Array>} Array of resource IDs
*/
const findPubliclyAccessibleResources = async ({ resourceType, requiredPermissions }) => {
try {
if (typeof requiredPermissions !== 'number' || requiredPermissions < 1) {
throw new Error('requiredPermissions must be a positive number');
}
// Find all public ACL entries where the public principal has at least the required permission bits
const entries = await AclEntry.find({
principalType: 'public',
resourceType,
permBits: { $bitsAllSet: requiredPermissions },
}).distinct('resourceId');
return entries;
} catch (error) {
logger.error(`[PermissionService.findPubliclyAccessibleResources] Error: ${error.message}`);
// Re-throw validation errors
if (error.message.includes('requiredPermissions must be')) {
throw error;
}
return [];
}
};
/**
* Get available roles for a resource type
* @param {Object} params - Parameters for getting available roles
* @param {string} params.resourceType - Type of resource (e.g., 'agent')
* @returns {Promise<Array>} Array of role definitions
*/
const getAvailableRoles = async ({ resourceType }) => {
try {
return await AccessRole.find({ resourceType }).lean();
} catch (error) {
logger.error(`[PermissionService.getAvailableRoles] Error: ${error.message}`);
return [];
}
};
/**
* Ensures a principal exists in the database based on TPrincipal data
* Creates user if it doesn't exist locally (for Entra ID users)
* @param {Object} principal - TPrincipal object from frontend
* @param {string} principal.type - 'user', 'group', or 'public'
* @param {string} [principal.id] - Local database ID (null for Entra ID principals not yet synced)
* @param {string} principal.name - Display name
* @param {string} [principal.email] - Email address
* @param {string} [principal.source] - 'local' or 'entra'
* @param {string} [principal.idOnTheSource] - Entra ID object ID for external principals
* @returns {Promise<string|null>} Returns the principalId for database operations, null for public
*/
const ensurePrincipalExists = async function (principal) {
if (principal.type === 'public') {
return null;
}
if (principal.id) {
return principal.id;
}
if (principal.type === 'user' && principal.source === 'entra') {
if (!principal.email || !principal.idOnTheSource) {
throw new Error('Entra ID user principals must have email and idOnTheSource');
}
let existingUser = await findUser({ idOnTheSource: principal.idOnTheSource });
if (!existingUser) {
existingUser = await findUser({ email: principal.email.toLowerCase() });
}
if (existingUser) {
if (!existingUser.idOnTheSource && principal.idOnTheSource) {
await updateUser(existingUser._id, {
idOnTheSource: principal.idOnTheSource,
provider: 'openid',
});
}
return existingUser._id.toString();
}
const userData = {
name: principal.name,
email: principal.email.toLowerCase(),
emailVerified: false,
provider: 'openid',
idOnTheSource: principal.idOnTheSource,
};
const userId = await createUser(userData, true, false);
return userId.toString();
}
if (principal.type === 'group') {
throw new Error('Group principals should be handled by group-specific methods');
}
throw new Error(`Unsupported principal type: ${principal.type}`);
};
/**
* Ensures a group principal exists in the database based on TPrincipal data
* Creates group if it doesn't exist locally (for Entra ID groups)
* For Entra ID groups, always synchronizes member IDs when authentication context is provided
* @param {Object} principal - TPrincipal object from frontend
* @param {string} principal.type - Must be 'group'
* @param {string} [principal.id] - Local database ID (null for Entra ID principals not yet synced)
* @param {string} principal.name - Display name
* @param {string} [principal.email] - Email address
* @param {string} [principal.description] - Group description
* @param {string} [principal.source] - 'local' or 'entra'
* @param {string} [principal.idOnTheSource] - Entra ID object ID for external principals
* @param {Object} [authContext] - Optional authentication context for fetching member data
* @param {string} [authContext.accessToken] - Access token for Graph API calls
* @param {string} [authContext.sub] - Subject identifier
* @returns {Promise<string>} Returns the groupId for database operations
*/
const ensureGroupPrincipalExists = async function (principal, authContext = null) {
if (principal.type !== 'group') {
throw new Error(`Invalid principal type: ${principal.type}. Expected 'group'`);
}
if (principal.source === 'entra') {
if (!principal.name || !principal.idOnTheSource) {
throw new Error('Entra ID group principals must have name and idOnTheSource');
}
let memberIds = [];
if (authContext && authContext.accessToken && authContext.sub) {
try {
memberIds = await getGroupMembers(
authContext.accessToken,
authContext.sub,
principal.idOnTheSource,
);
// Include group owners as members if feature is enabled
if (isEnabled(process.env.ENTRA_ID_INCLUDE_OWNERS_AS_MEMBERS)) {
const ownerIds = await getGroupOwners(
authContext.accessToken,
authContext.sub,
principal.idOnTheSource,
);
if (ownerIds && ownerIds.length > 0) {
memberIds.push(...ownerIds);
// Remove duplicates
memberIds = [...new Set(memberIds)];
}
}
} catch (error) {
logger.error('Failed to fetch group members from Graph API:', error);
}
}
let existingGroup = await findGroupByExternalId(principal.idOnTheSource, 'entra');
if (!existingGroup && principal.email) {
existingGroup = await Group.findOne({ email: principal.email.toLowerCase() }).lean();
}
if (existingGroup) {
const updateData = {};
let needsUpdate = false;
if (!existingGroup.idOnTheSource && principal.idOnTheSource) {
updateData.idOnTheSource = principal.idOnTheSource;
updateData.source = 'entra';
needsUpdate = true;
}
if (principal.description && existingGroup.description !== principal.description) {
updateData.description = principal.description;
needsUpdate = true;
}
if (principal.email && existingGroup.email !== principal.email.toLowerCase()) {
updateData.email = principal.email.toLowerCase();
needsUpdate = true;
}
if (authContext && authContext.accessToken && authContext.sub) {
updateData.memberIds = memberIds;
needsUpdate = true;
}
if (needsUpdate) {
await Group.findByIdAndUpdate(existingGroup._id, { $set: updateData }, { new: true });
}
return existingGroup._id.toString();
}
const groupData = {
name: principal.name,
source: 'entra',
idOnTheSource: principal.idOnTheSource,
memberIds: memberIds, // Store idOnTheSource values of group members (empty if no auth context)
};
if (principal.email) {
groupData.email = principal.email.toLowerCase();
}
if (principal.description) {
groupData.description = principal.description;
}
const newGroup = await createGroup(groupData);
return newGroup._id.toString();
}
if (principal.id && authContext == null) {
return principal.id;
}
throw new Error(`Unsupported group principal source: ${principal.source}`);
};
/**
* Synchronize user's Entra ID group memberships on sign-in
* Gets user's group IDs from GraphAPI and updates memberships only for existing groups in database
* Optionally includes groups the user owns if ENTRA_ID_INCLUDE_OWNERS_AS_MEMBERS is enabled
* @param {Object} user - User object with authentication context
* @param {string} user.openidId - User's OpenID subject identifier
* @param {string} user.idOnTheSource - User's Entra ID (oid from token claims)
* @param {string} user.provider - Authentication provider ('openid')
* @param {string} accessToken - Access token for Graph API calls
* @param {mongoose.ClientSession} [session] - Optional MongoDB session for transactions
* @returns {Promise<void>}
*/
const syncUserEntraGroupMemberships = async (user, accessToken, session = null) => {
try {
if (!entraIdPrincipalFeatureEnabled(user) || !accessToken || !user.idOnTheSource) {
return;
}
const memberGroupIds = await getUserEntraGroups(accessToken, user.openidId);
let allGroupIds = [...(memberGroupIds || [])];
// Include owned groups if feature is enabled
if (isEnabled(process.env.ENTRA_ID_INCLUDE_OWNERS_AS_MEMBERS)) {
const ownedGroupIds = await getUserOwnedEntraGroups(accessToken, user.openidId);
if (ownedGroupIds && ownedGroupIds.length > 0) {
allGroupIds.push(...ownedGroupIds);
// Remove duplicates
allGroupIds = [...new Set(allGroupIds)];
}
}
if (!allGroupIds || allGroupIds.length === 0) {
return;
}
const sessionOptions = session ? { session } : {};
await Group.updateMany(
{
idOnTheSource: { $in: allGroupIds },
source: 'entra',
memberIds: { $ne: user.idOnTheSource },
},
{ $addToSet: { memberIds: user.idOnTheSource } },
sessionOptions,
);
await Group.updateMany(
{
source: 'entra',
memberIds: user.idOnTheSource,
idOnTheSource: { $nin: allGroupIds },
},
{ $pull: { memberIds: user.idOnTheSource } },
sessionOptions,
);
} catch (error) {
logger.error(`[PermissionService.syncUserEntraGroupMemberships] Error syncing groups:`, error);
}
};
/**
* Check if public has a specific permission on a resource
* @param {Object} params - Parameters for checking public permission
* @param {string} params.resourceType - Type of resource (e.g., 'agent')
* @param {string|mongoose.Types.ObjectId} params.resourceId - The ID of the resource
* @param {number} params.requiredPermissions - The permission bits required (e.g., 1 for VIEW, 3 for VIEW+EDIT)
* @returns {Promise<boolean>} Whether public has the required permission bits
*/
const hasPublicPermission = async ({ resourceType, resourceId, requiredPermissions }) => {
try {
if (typeof requiredPermissions !== 'number' || requiredPermissions < 1) {
throw new Error('requiredPermissions must be a positive number');
}
// Use public principal to check permissions
const publicPrincipal = [{ principalType: 'public' }];
const entries = await findEntriesByPrincipalsAndResource(
publicPrincipal,
resourceType,
resourceId,
);
// Check if any entry has the required permission bits
return entries.some((entry) => (entry.permBits & requiredPermissions) === requiredPermissions);
} catch (error) {
logger.error(`[PermissionService.hasPublicPermission] Error: ${error.message}`);
// Re-throw validation errors
if (error.message.includes('requiredPermissions must be')) {
throw error;
}
return false;
}
};
/**
* Bulk update permissions for a resource (grant, update, revoke)
* Efficiently handles multiple permission changes in a single transaction
*
* @param {Object} params - Parameters for bulk permission update
* @param {string} params.resourceType - Type of resource (e.g., 'agent')
* @param {string|mongoose.Types.ObjectId} params.resourceId - The ID of the resource
* @param {Array<TPrincipal>} params.updatedPrincipals - Array of principals to grant/update permissions for
* @param {Array<TPrincipal>} params.revokedPrincipals - Array of principals to revoke permissions from
* @param {string|mongoose.Types.ObjectId} params.grantedBy - User ID making the changes
* @param {mongoose.ClientSession} [params.session] - Optional MongoDB session for transactions
* @returns {Promise<Object>} Results object with granted, updated, revoked arrays and error details
*/
const bulkUpdateResourcePermissions = async ({
resourceType,
resourceId,
updatedPrincipals = [],
revokedPrincipals = [],
grantedBy,
session,
}) => {
const supportsTransactions = await getTransactionSupport(mongoose, transactionSupportCache);
transactionSupportCache = supportsTransactions;
let localSession = session;
let shouldEndSession = false;
try {
if (!Array.isArray(updatedPrincipals)) {
throw new Error('updatedPrincipals must be an array');
}
if (!Array.isArray(revokedPrincipals)) {
throw new Error('revokedPrincipals must be an array');
}
if (!resourceId || !mongoose.Types.ObjectId.isValid(resourceId)) {
throw new Error(`Invalid resource ID: ${resourceId}`);
}
if (!localSession && supportsTransactions) {
localSession = await mongoose.startSession();
localSession.startTransaction();
shouldEndSession = true;
}
const sessionOptions = localSession ? { session: localSession } : {};
const roles = await AccessRole.find({ resourceType }).lean();
const rolesMap = new Map();
roles.forEach((role) => {
rolesMap.set(role.accessRoleId, role);
});
const results = {
granted: [],
updated: [],
revoked: [],
errors: [],
};
const bulkWrites = [];
for (const principal of updatedPrincipals) {
try {
if (!principal.accessRoleId) {
results.errors.push({
principal,
error: 'accessRoleId is required for updated principals',
});
continue;
}
const role = rolesMap.get(principal.accessRoleId);
if (!role) {
results.errors.push({
principal,
error: `Role ${principal.accessRoleId} not found`,
});
continue;
}
const query = {
principalType: principal.type,
resourceType,
resourceId,
};
if (principal.type !== 'public') {
query.principalId = principal.id;
}
const update = {
$set: {
permBits: role.permBits,
roleId: role._id,
grantedBy,
grantedAt: new Date(),
},
$setOnInsert: {
principalType: principal.type,
resourceType,
resourceId,
...(principal.type !== 'public' && {
principalId: principal.id,
principalModel: principal.type === 'user' ? 'User' : 'Group',
}),
},
};
bulkWrites.push({
updateOne: {
filter: query,
update: update,
upsert: true,
},
});
results.granted.push({
type: principal.type,
id: principal.id,
name: principal.name,
email: principal.email,
source: principal.source,
avatar: principal.avatar,
description: principal.description,
idOnTheSource: principal.idOnTheSource,
accessRoleId: principal.accessRoleId,
memberCount: principal.memberCount,
memberIds: principal.memberIds,
});
} catch (error) {
results.errors.push({
principal,
error: error.message,
});
}
}
if (bulkWrites.length > 0) {
await AclEntry.bulkWrite(bulkWrites, sessionOptions);
}
const deleteQueries = [];
for (const principal of revokedPrincipals) {
try {
const query = {
principalType: principal.type,
resourceType,
resourceId,
};
if (principal.type !== 'public') {
query.principalId = principal.id;
}
deleteQueries.push(query);
results.revoked.push({
type: principal.type,
id: principal.id,
name: principal.name,
email: principal.email,
source: principal.source,
avatar: principal.avatar,
description: principal.description,
idOnTheSource: principal.idOnTheSource,
memberCount: principal.memberCount,
});
} catch (error) {
results.errors.push({
principal,
error: error.message,
});
}
}
if (deleteQueries.length > 0) {
await AclEntry.deleteMany(
{
$or: deleteQueries,
},
sessionOptions,
);
}
if (shouldEndSession && supportsTransactions) {
await localSession.commitTransaction();
}
return results;
} catch (error) {
if (shouldEndSession && supportsTransactions) {
await localSession.abortTransaction();
}
logger.error(`[PermissionService.bulkUpdateResourcePermissions] Error: ${error.message}`);
throw error;
} finally {
if (shouldEndSession && localSession) {
localSession.endSession();
}
}
};
module.exports = {
grantPermission,
checkPermission,
getEffectivePermissions,
findAccessibleResources,
findPubliclyAccessibleResources,
hasPublicPermission,
getAvailableRoles,
bulkUpdateResourcePermissions,
ensurePrincipalExists,
ensureGroupPrincipalExists,
syncUserEntraGroupMemberships,
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
const { encrypt, decrypt } = require('~/server/utils/crypto');
const { PluginAuth } = require('~/db/models');
const { logger } = require('~/config');
const { logger } = require('@librechat/data-schemas');
const { encrypt, decrypt } = require('@librechat/api');
const { findOnePluginAuth, updatePluginAuth, deletePluginAuth } = require('~/models');
/**
* Asynchronously retrieves and decrypts the authentication value for a user's plugin, based on a specified authentication field.
@@ -25,7 +25,7 @@ const { logger } = require('~/config');
*/
const getUserPluginAuthValue = async (userId, authField, throwError = true) => {
try {
const pluginAuth = await PluginAuth.findOne({ userId, authField }).lean();
const pluginAuth = await findOnePluginAuth({ userId, authField });
if (!pluginAuth) {
throw new Error(`No plugin auth ${authField} found for user ${userId}`);
}
@@ -79,23 +79,12 @@ const getUserPluginAuthValue = async (userId, authField, throwError = true) => {
const updateUserPluginAuth = async (userId, authField, pluginKey, value) => {
try {
const encryptedValue = await encrypt(value);
const pluginAuth = await PluginAuth.findOne({ userId, authField }).lean();
if (pluginAuth) {
return await PluginAuth.findOneAndUpdate(
{ userId, authField },
{ $set: { value: encryptedValue } },
{ new: true, upsert: true },
).lean();
} else {
const newPluginAuth = await new PluginAuth({
userId,
authField,
value: encryptedValue,
pluginKey,
});
await newPluginAuth.save();
return newPluginAuth.toObject();
}
return await updatePluginAuth({
userId,
authField,
pluginKey,
value: encryptedValue,
});
} catch (err) {
logger.error('[updateUserPluginAuth]', err);
return err;
@@ -105,26 +94,25 @@ const updateUserPluginAuth = async (userId, authField, pluginKey, value) => {
/**
* @async
* @param {string} userId
* @param {string} authField
* @param {boolean} [all]
* @param {string | null} authField - The specific authField to delete, or null if `all` is true.
* @param {boolean} [all=false] - Whether to delete all auths for the user (or for a specific pluginKey if provided).
* @param {string} [pluginKey] - Optional. If `all` is true and `pluginKey` is provided, delete all auths for this user and pluginKey.
* @returns {Promise<import('mongoose').DeleteResult>}
* @throws {Error}
*/
const deleteUserPluginAuth = async (userId, authField, all = false) => {
if (all) {
try {
const response = await PluginAuth.deleteMany({ userId });
return response;
} catch (err) {
logger.error('[deleteUserPluginAuth]', err);
return err;
}
}
const deleteUserPluginAuth = async (userId, authField, all = false, pluginKey) => {
try {
return await PluginAuth.deleteOne({ userId, authField });
return await deletePluginAuth({
userId,
authField,
pluginKey,
all,
});
} catch (err) {
logger.error('[deleteUserPluginAuth]', err);
logger.error(
`[deleteUserPluginAuth] Error deleting ${all ? 'all' : 'single'} auth(s) for userId: ${userId}${pluginKey ? ` and pluginKey: ${pluginKey}` : ''}`,
err,
);
return err;
}
};

View File

@@ -1,195 +0,0 @@
const axios = require('axios');
const { logAxiosError } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { TokenExchangeMethodEnum } = require('librechat-data-provider');
const { handleOAuthToken } = require('~/models/Token');
const { decryptV2 } = require('~/server/utils/crypto');
/**
* Processes the access tokens and stores them in the database.
* @param {object} tokenData
* @param {string} tokenData.access_token
* @param {number} tokenData.expires_in
* @param {string} [tokenData.refresh_token]
* @param {number} [tokenData.refresh_token_expires_in]
* @param {object} metadata
* @param {string} metadata.userId
* @param {string} metadata.identifier
* @returns {Promise<void>}
*/
async function processAccessTokens(tokenData, { userId, identifier }) {
const { access_token, expires_in = 3600, refresh_token, refresh_token_expires_in } = tokenData;
if (!access_token) {
logger.error('Access token not found: ', tokenData);
throw new Error('Access token not found');
}
await handleOAuthToken({
identifier,
token: access_token,
expiresIn: expires_in,
userId,
});
if (refresh_token != null) {
logger.debug('Processing refresh token');
await handleOAuthToken({
token: refresh_token,
type: 'oauth_refresh',
userId,
identifier: `${identifier}:refresh`,
expiresIn: refresh_token_expires_in ?? null,
});
}
logger.debug('Access tokens processed');
}
/**
* Refreshes the access token using the refresh token.
* @param {object} fields
* @param {string} fields.userId - The ID of the user.
* @param {string} fields.client_url - The URL of the OAuth provider.
* @param {string} fields.identifier - The identifier for the token.
* @param {string} fields.refresh_token - The refresh token to use.
* @param {string} fields.token_exchange_method - The token exchange method ('default_post' or 'basic_auth_header').
* @param {string} fields.encrypted_oauth_client_id - The client ID for the OAuth provider.
* @param {string} fields.encrypted_oauth_client_secret - The client secret for the OAuth provider.
* @returns {Promise<{
* access_token: string,
* expires_in: number,
* refresh_token?: string,
* refresh_token_expires_in?: number,
* }>}
*/
const refreshAccessToken = async ({
userId,
client_url,
identifier,
refresh_token,
token_exchange_method,
encrypted_oauth_client_id,
encrypted_oauth_client_secret,
}) => {
try {
const oauth_client_id = await decryptV2(encrypted_oauth_client_id);
const oauth_client_secret = await decryptV2(encrypted_oauth_client_secret);
const headers = {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
};
const params = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token,
});
if (token_exchange_method === TokenExchangeMethodEnum.BasicAuthHeader) {
const basicAuth = Buffer.from(`${oauth_client_id}:${oauth_client_secret}`).toString('base64');
headers['Authorization'] = `Basic ${basicAuth}`;
} else {
params.append('client_id', oauth_client_id);
params.append('client_secret', oauth_client_secret);
}
const response = await axios({
method: 'POST',
url: client_url,
headers,
data: params.toString(),
});
await processAccessTokens(response.data, {
userId,
identifier,
});
logger.debug(`Access token refreshed successfully for ${identifier}`);
return response.data;
} catch (error) {
const message = 'Error refreshing OAuth tokens';
throw new Error(
logAxiosError({
message,
error,
}),
);
}
};
/**
* Handles the OAuth callback and exchanges the authorization code for tokens.
* @param {object} fields
* @param {string} fields.code - The authorization code returned by the provider.
* @param {string} fields.userId - The ID of the user.
* @param {string} fields.identifier - The identifier for the token.
* @param {string} fields.client_url - The URL of the OAuth provider.
* @param {string} fields.redirect_uri - The redirect URI for the OAuth provider.
* @param {string} fields.token_exchange_method - The token exchange method ('default_post' or 'basic_auth_header').
* @param {string} fields.encrypted_oauth_client_id - The client ID for the OAuth provider.
* @param {string} fields.encrypted_oauth_client_secret - The client secret for the OAuth provider.
* @returns {Promise<{
* access_token: string,
* expires_in: number,
* refresh_token?: string,
* refresh_token_expires_in?: number,
* }>}
*/
const getAccessToken = async ({
code,
userId,
identifier,
client_url,
redirect_uri,
token_exchange_method,
encrypted_oauth_client_id,
encrypted_oauth_client_secret,
}) => {
const oauth_client_id = await decryptV2(encrypted_oauth_client_id);
const oauth_client_secret = await decryptV2(encrypted_oauth_client_secret);
const headers = {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
};
const params = new URLSearchParams({
code,
grant_type: 'authorization_code',
redirect_uri,
});
if (token_exchange_method === TokenExchangeMethodEnum.BasicAuthHeader) {
const basicAuth = Buffer.from(`${oauth_client_id}:${oauth_client_secret}`).toString('base64');
headers['Authorization'] = `Basic ${basicAuth}`;
} else {
params.append('client_id', oauth_client_id);
params.append('client_secret', oauth_client_secret);
}
try {
const response = await axios({
method: 'POST',
url: client_url,
headers,
data: params.toString(),
});
await processAccessTokens(response.data, {
userId,
identifier,
});
logger.debug(`Access tokens successfully created for ${identifier}`);
return response.data;
} catch (error) {
const message = 'Error exchanging OAuth code';
throw new Error(
logAxiosError({
message,
error,
}),
);
}
};
module.exports = {
getAccessToken,
refreshAccessToken,
};

View File

@@ -1,5 +1,7 @@
const fs = require('fs');
const path = require('path');
const { sleep } = require('@librechat/agents');
const { logger } = require('@librechat/data-schemas');
const { zodToJsonSchema } = require('zod-to-json-schema');
const { Calculator } = require('@langchain/community/tools/calculator');
const { tool: toolFn, Tool, DynamicStructuredTool } = require('@langchain/core/tools');
@@ -31,14 +33,12 @@ const {
toolkits,
} = require('~/app/clients/tools');
const { processFileURL, uploadImageBuffer } = require('~/server/services/Files/process');
const { getEndpointsConfig, getCachedTools } = require('~/server/services/Config');
const { createOnSearchResults } = require('~/server/services/Tools/search');
const { isActionDomainAllowed } = require('~/server/services/domains');
const { getEndpointsConfig } = require('~/server/services/Config');
const { recordUsage } = require('~/server/services/Threads');
const { loadTools } = require('~/app/clients/tools/util');
const { redactMessage } = require('~/config/parsers');
const { sleep } = require('~/server/utils');
const { logger } = require('~/config');
/**
* @param {string} toolName
@@ -226,7 +226,7 @@ async function processRequiredActions(client, requiredActions) {
`[required actions] user: ${client.req.user.id} | thread_id: ${requiredActions[0].thread_id} | run_id: ${requiredActions[0].run_id}`,
requiredActions,
);
const toolDefinitions = client.req.app.locals.availableTools;
const toolDefinitions = await getCachedTools({ includeGlobal: true });
const seenToolkits = new Set();
const tools = requiredActions
.map((action) => {
@@ -553,6 +553,7 @@ async function loadAgentTools({ req, res, agent, tool_resources, openAIApiKey })
tools: _agentTools,
options: {
req,
res,
openAIApiKey,
tool_resources,
processFileURL,

View File

@@ -1,6 +1,6 @@
const { logger } = require('@librechat/data-schemas');
const { encrypt, decrypt } = require('@librechat/api');
const { ErrorTypes } = require('librechat-data-provider');
const { encrypt, decrypt } = require('~/server/utils/crypto');
const { updateUser } = require('~/models');
const { Key } = require('~/db/models');
@@ -70,6 +70,7 @@ const getUserKeyValues = async ({ userId, name }) => {
try {
userValues = JSON.parse(userValues);
} catch (e) {
logger.error('[getUserKeyValues]', e);
throw new Error(
JSON.stringify({
type: ErrorTypes.INVALID_USER_KEY,

View File

@@ -0,0 +1,54 @@
const { logger } = require('@librechat/data-schemas');
const { CacheKeys, processMCPEnv } = require('librechat-data-provider');
const { getMCPManager, getFlowStateManager } = require('~/config');
const { getCachedTools, setCachedTools } = require('./Config');
const { getLogStores } = require('~/cache');
const { findToken, updateToken, createToken, deleteTokens } = require('~/models');
/**
* Initialize MCP servers
* @param {import('express').Application} app - Express app instance
*/
async function initializeMCP(app) {
const mcpServers = app.locals.mcpConfig;
if (!mcpServers) {
return;
}
logger.info('Initializing MCP servers...');
const mcpManager = getMCPManager();
const flowsCache = getLogStores(CacheKeys.FLOWS);
const flowManager = flowsCache ? getFlowStateManager(flowsCache) : null;
try {
await mcpManager.initializeMCP({
mcpServers,
flowManager,
tokenMethods: {
findToken,
updateToken,
createToken,
deleteTokens,
},
processMCPEnv,
});
delete app.locals.mcpConfig;
const availableTools = await getCachedTools();
if (!availableTools) {
logger.warn('No available tools found in cache during MCP initialization');
return;
}
const toolsCopy = { ...availableTools };
await mcpManager.mapAvailableTools(toolsCopy, flowManager);
await setCachedTools(toolsCopy, { isGlobal: true });
logger.info('MCP servers initialized successfully');
} catch (error) {
logger.error('Failed to initialize MCP servers:', error);
}
}
module.exports = initializeMCP;

View File

@@ -5,8 +5,7 @@ const {
conflictingAzureVariables,
extractVariableName,
} = require('librechat-data-provider');
const { isEnabled } = require('~/server/utils');
const { checkEmailConfig } = require('@librechat/auth');
const { isEnabled, checkEmailConfig } = require('~/server/utils');
const { logger } = require('~/config');
const secretDefaults = {

View File

@@ -1,5 +1,5 @@
const { webcrypto } = require('node:crypto');
const { hashBackupCode, decryptV3, decryptV2 } = require('~/server/utils/crypto');
const { hashBackupCode, decryptV3, decryptV2 } = require('@librechat/api');
const { updateUser } = require('~/models');
// Base32 alphabet for TOTP secret encoding.

View File

@@ -5,17 +5,14 @@ const MemoryStore = require('memorystore')(session);
const RedisStore = require('connect-redis').default;
const {
setupOpenId,
getOpenIdConfig,
googleLogin,
githubLogin,
discordLogin,
facebookLogin,
appleLogin,
samlLogin,
setupSaml,
openIdJwtLogin,
} = require('@librechat/auth');
const { CacheKeys } = require('librechat-data-provider');
const getLogStores = require('~/cache/getLogStores');
} = require('~/strategies');
const { isEnabled } = require('~/server/utils');
const keyvRedis = require('~/cache/keyvRedis');
const { logger } = require('~/config');
@@ -67,14 +64,10 @@ const configureSocialLogins = async (app) => {
}
app.use(session(sessionOptions));
app.use(passport.session());
const tokensCache = getLogStores(CacheKeys.OPENID_EXCHANGED_TOKENS);
const openidLogin = await setupOpenId(tokensCache);
passport.use('openid', openidLogin);
const config = await setupOpenId();
if (isEnabled(process.env.OPENID_REUSE_TOKENS)) {
logger.info('OpenID token reuse is enabled.');
passport.use('openidJwt', openIdJwtLogin(getOpenIdConfig()));
passport.use('openidJwt', openIdJwtLogin(config));
}
logger.info('OpenID Connect configured.');
}
@@ -102,8 +95,7 @@ const configureSocialLogins = async (app) => {
}
app.use(session(sessionOptions));
app.use(passport.session());
passport.use('saml', samlLogin());
setupSaml();
logger.info('SAML Connect configured.');
}

View File

@@ -2,17 +2,38 @@ const streamResponse = require('./streamResponse');
const removePorts = require('./removePorts');
const countTokens = require('./countTokens');
const handleText = require('./handleText');
const cryptoUtils = require('./crypto');
const sendEmail = require('./sendEmail');
const queue = require('./queue');
const files = require('./files');
const math = require('./math');
/**
* Check if email configuration is set
* @returns {Boolean}
*/
function checkEmailConfig() {
// Check if Mailgun is configured
const hasMailgunConfig =
!!process.env.MAILGUN_API_KEY && !!process.env.MAILGUN_DOMAIN && !!process.env.EMAIL_FROM;
// Check if SMTP is configured
const hasSMTPConfig =
(!!process.env.EMAIL_SERVICE || !!process.env.EMAIL_HOST) &&
!!process.env.EMAIL_USERNAME &&
!!process.env.EMAIL_PASSWORD &&
!!process.env.EMAIL_FROM;
// Return true if either Mailgun or SMTP is properly configured
return hasMailgunConfig || hasSMTPConfig;
}
module.exports = {
...streamResponse,
...cryptoUtils,
checkEmailConfig,
...handleText,
countTokens,
removePorts,
sendEmail,
...files,
...queue,
math,

View File

@@ -0,0 +1,173 @@
const fs = require('fs');
const path = require('path');
const axios = require('axios');
const FormData = require('form-data');
const nodemailer = require('nodemailer');
const handlebars = require('handlebars');
const { logAxiosError } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { isEnabled } = require('~/server/utils/handleText');
/**
* Sends an email using Mailgun API.
*
* @async
* @function sendEmailViaMailgun
* @param {Object} params - The parameters for sending the email.
* @param {string} params.to - The recipient's email address.
* @param {string} params.from - The sender's email address.
* @param {string} params.subject - The subject of the email.
* @param {string} params.html - The HTML content of the email.
* @returns {Promise<Object>} - A promise that resolves to the response from Mailgun API.
*/
const sendEmailViaMailgun = async ({ to, from, subject, html }) => {
const mailgunApiKey = process.env.MAILGUN_API_KEY;
const mailgunDomain = process.env.MAILGUN_DOMAIN;
const mailgunHost = process.env.MAILGUN_HOST || 'https://api.mailgun.net';
if (!mailgunApiKey || !mailgunDomain) {
throw new Error('Mailgun API key and domain are required');
}
const formData = new FormData();
formData.append('from', from);
formData.append('to', to);
formData.append('subject', subject);
formData.append('html', html);
formData.append('o:tracking-clicks', 'no');
try {
const response = await axios.post(`${mailgunHost}/v3/${mailgunDomain}/messages`, formData, {
headers: {
...formData.getHeaders(),
Authorization: `Basic ${Buffer.from(`api:${mailgunApiKey}`).toString('base64')}`,
},
});
return response.data;
} catch (error) {
throw new Error(logAxiosError({ error, message: 'Failed to send email via Mailgun' }));
}
};
/**
* Sends an email using SMTP via Nodemailer.
*
* @async
* @function sendEmailViaSMTP
* @param {Object} params - The parameters for sending the email.
* @param {Object} params.transporterOptions - The transporter configuration options.
* @param {Object} params.mailOptions - The email options.
* @returns {Promise<Object>} - A promise that resolves to the info object of the sent email.
*/
const sendEmailViaSMTP = async ({ transporterOptions, mailOptions }) => {
const transporter = nodemailer.createTransport(transporterOptions);
return await transporter.sendMail(mailOptions);
};
/**
* Sends an email using the specified template, subject, and payload.
*
* @async
* @function sendEmail
* @param {Object} params - The parameters for sending the email.
* @param {string} params.email - The recipient's email address.
* @param {string} params.subject - The subject of the email.
* @param {Record<string, string>} params.payload - The data to be used in the email template.
* @param {string} params.template - The filename of the email template.
* @param {boolean} [throwError=true] - Whether to throw an error if the email sending process fails.
* @returns {Promise<Object>} - A promise that resolves to the info object of the sent email or the error if sending the email fails.
*
* @example
* const emailData = {
* email: 'recipient@example.com',
* subject: 'Welcome!',
* payload: { name: 'Recipient' },
* template: 'welcome.html'
* };
*
* sendEmail(emailData)
* .then(info => console.log('Email sent:', info))
* .catch(error => console.error('Error sending email:', error));
*
* @throws Will throw an error if the email sending process fails and throwError is `true`.
*/
const sendEmail = async ({ email, subject, payload, template, throwError = true }) => {
try {
// Read and compile the email template
const source = fs.readFileSync(path.join(__dirname, 'emails', template), 'utf8');
const compiledTemplate = handlebars.compile(source);
const html = compiledTemplate(payload);
// Prepare common email data
const fromName = process.env.EMAIL_FROM_NAME || process.env.APP_TITLE;
const fromEmail = process.env.EMAIL_FROM;
const fromAddress = `"${fromName}" <${fromEmail}>`;
const toAddress = `"${payload.name}" <${email}>`;
// Check if Mailgun is configured
if (process.env.MAILGUN_API_KEY && process.env.MAILGUN_DOMAIN) {
logger.debug('[sendEmail] Using Mailgun provider');
return await sendEmailViaMailgun({
from: fromAddress,
to: toAddress,
subject: subject,
html: html,
});
}
// Default to SMTP
logger.debug('[sendEmail] Using SMTP provider');
const transporterOptions = {
// Use STARTTLS by default instead of obligatory TLS
secure: process.env.EMAIL_ENCRYPTION === 'tls',
// If explicit STARTTLS is set, require it when connecting
requireTls: process.env.EMAIL_ENCRYPTION === 'starttls',
tls: {
// Whether to accept unsigned certificates
rejectUnauthorized: !isEnabled(process.env.EMAIL_ALLOW_SELFSIGNED),
},
auth: {
user: process.env.EMAIL_USERNAME,
pass: process.env.EMAIL_PASSWORD,
},
};
if (process.env.EMAIL_ENCRYPTION_HOSTNAME) {
// Check the certificate against this name explicitly
transporterOptions.tls.servername = process.env.EMAIL_ENCRYPTION_HOSTNAME;
}
// Mailer service definition has precedence
if (process.env.EMAIL_SERVICE) {
transporterOptions.service = process.env.EMAIL_SERVICE;
} else {
transporterOptions.host = process.env.EMAIL_HOST;
transporterOptions.port = process.env.EMAIL_PORT ?? 25;
}
const mailOptions = {
// Header address should contain name-addr
from: fromAddress,
to: toAddress,
envelope: {
// Envelope from should contain addr-spec
// Mistake in the Nodemailer documentation?
from: fromEmail,
to: email,
},
subject: subject,
html: html,
};
return await sendEmailViaSMTP({ transporterOptions, mailOptions });
} catch (error) {
if (throwError) {
throw error;
}
logger.error('[sendEmail]', error);
return error;
}
};
module.exports = sendEmail;

View File

@@ -1,8 +1,7 @@
import { Strategy as AppleStrategy } from 'passport-apple';
import { logger } from '@librechat/data-schemas';
import jwt from 'jsonwebtoken';
import { GetProfileDetails, GetProfileDetailsParams } from './types';
import socialLogin from './socialLogin';
const socialLogin = require('./socialLogin');
const { Strategy: AppleStrategy } = require('passport-apple');
const { logger } = require('~/config');
const jwt = require('jsonwebtoken');
/**
* Extract profile details from the decoded idToken
@@ -11,13 +10,13 @@ import socialLogin from './socialLogin';
* @param {Object} params.profile - The profile object (may contain partial info)
* @returns {Object} - The extracted user profile details
*/
const getProfileDetails: GetProfileDetails = ({ profile, idToken }: GetProfileDetailsParams) => {
const getProfileDetails = ({ idToken, profile }) => {
if (!idToken) {
logger.error('idToken is missing');
throw new Error('idToken is missing');
}
const decoded: any = jwt.decode(idToken);
const decoded = jwt.decode(idToken);
logger.debug(`Decoded Apple JWT: ${JSON.stringify(decoded, null, 2)}`);
@@ -26,7 +25,7 @@ const getProfileDetails: GetProfileDetails = ({ profile, idToken }: GetProfileDe
id: decoded.sub,
avatarUrl: null, // Apple does not provide an avatar URL
username: decoded.email ? decoded.email.split('@')[0].toLowerCase() : `user_${decoded.sub}`,
displayName: decoded.name
name: decoded.name
? `${decoded.name.firstName} ${decoded.name.lastName}`
: profile.displayName || null,
emailVerified: true, // Apple verifies the email
@@ -34,9 +33,9 @@ const getProfileDetails: GetProfileDetails = ({ profile, idToken }: GetProfileDe
};
// Initialize the social login handler for Apple
const appleStrategy = socialLogin('apple', getProfileDetails);
const appleLogin = socialLogin('apple', getProfileDetails);
const appleLogin = () =>
module.exports = () =>
new AppleStrategy(
{
clientID: process.env.APPLE_CLIENT_ID,
@@ -46,7 +45,5 @@ const appleLogin = () =>
privateKeyLocation: process.env.APPLE_PRIVATE_KEY_PATH,
passReqToCallback: false, // Set to true if you need to access the request in the callback
},
appleStrategy,
appleLogin,
);
export default appleLogin;

View File

@@ -1,74 +1,47 @@
import mongoose from 'mongoose';
import { Strategy as AppleStrategy, Profile as AppleProfile } from 'passport-apple';
import { MongoMemoryServer } from 'mongodb-memory-server';
import jwt from 'jsonwebtoken';
import { logger, userSchema } from '@librechat/data-schemas';
import { isEnabled } from '@librechat/api';
import { createSocialUser, handleExistingUser } from './helpers';
import { socialLogin } from './socialLogin';
import { IUser } from '@librechat/data-schemas';
const jwt = require('jsonwebtoken');
const mongoose = require('mongoose');
const { logger } = require('@librechat/data-schemas');
const { Strategy: AppleStrategy } = require('passport-apple');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { createSocialUser, handleExistingUser } = require('./process');
const { isEnabled } = require('~/server/utils');
const socialLogin = require('./socialLogin');
const { findUser } = require('~/models');
const { User } = require('~/db/models');
const mockFindUser = jest.fn();
jest.mock('jsonwebtoken');
jest.mock('@librechat/data-schemas', () => {
const actualModule = jest.requireActual('@librechat/data-schemas');
return {
...actualModule,
logger: {
error: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
},
createMethods: jest.fn(() => {
return { findUser: mockFindUser };
}),
};
});
jest.mock('../initAuth', () => {
const actualModule = jest.requireActual('../initAuth');
return {
...actualModule,
getMethods: jest.fn(() => {
return { findUser: mockFindUser };
}),
};
});
jest.mock('./helpers', () => {
const actualModule = jest.requireActual('./helpers');
return {
...actualModule,
createSocialUser: jest.fn(),
handleExistingUser: jest.fn(),
};
});
jest.mock('@librechat/api', () => ({
jest.mock('./process', () => ({
createSocialUser: jest.fn(),
handleExistingUser: jest.fn(),
}));
jest.mock('~/server/utils', () => ({
isEnabled: jest.fn(),
}));
jest.mock('~/models', () => ({
findUser: jest.fn(),
}));
describe('Apple Login Strategy', () => {
let mongoServer: MongoMemoryServer;
let appleStrategyInstance: InstanceType<typeof AppleStrategy>;
let User: any;
let mongoServer;
let appleStrategyInstance;
const OLD_ENV = process.env;
let getProfileDetails: ({
idToken,
profile,
}: {
idToken: string | null;
profile: AppleProfile;
}) => Partial<IUser> & { avatarUrl: null };
let getProfileDetails;
// Start and stop in-memory MongoDB
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
User = mongoose.models.User || mongoose.model('User', userSchema);
});
afterAll(async () => {
@@ -99,7 +72,7 @@ describe('Apple Login Strategy', () => {
throw new Error('idToken is missing');
}
const decoded = jwt.decode(idToken) as any;
const decoded = jwt.decode(idToken);
if (!decoded) {
logger.error('Failed to decode idToken');
throw new Error('idToken is invalid');
@@ -122,11 +95,18 @@ describe('Apple Login Strategy', () => {
};
// Mock isEnabled based on environment variable
(isEnabled as jest.Mock).mockImplementation((flag: string) => flag === 'true');
isEnabled.mockImplementation((flag) => {
if (flag === 'true') {
return true;
}
if (flag === 'false') {
return false;
}
return false;
});
// Initialize the strategy with the mocked getProfileDetails
const appleLogin = socialLogin('apple', getProfileDetails);
appleStrategyInstance = new AppleStrategy(
{
clientID: process.env.APPLE_CLIENT_ID,
@@ -153,7 +133,7 @@ describe('Apple Login Strategy', () => {
});
it('should throw an error if idToken cannot be decoded', () => {
(jwt.decode as jest.Mock).mockReturnValue(null);
jwt.decode.mockReturnValue(null);
expect(() => {
getProfileDetails({ idToken: 'invalid_id_token', profile: mockProfile });
}).toThrow('idToken is invalid');
@@ -170,7 +150,7 @@ describe('Apple Login Strategy', () => {
},
};
(jwt.decode as jest.Mock).mockReturnValue(fakeDecodedToken);
jwt.decode.mockReturnValue(fakeDecodedToken);
const profileDetails = getProfileDetails({
idToken: 'fake_id_token',
@@ -194,7 +174,7 @@ describe('Apple Login Strategy', () => {
sub: 'apple-sub-5678',
};
(jwt.decode as jest.Mock).mockReturnValue(fakeDecodedToken);
jwt.decode.mockReturnValue(fakeDecodedToken);
const profileDetails = getProfileDetails({
idToken: 'fake_id_token',
@@ -229,21 +209,17 @@ describe('Apple Login Strategy', () => {
const fakeAccessToken = 'fake_access_token';
const fakeRefreshToken = 'fake_refresh_token';
beforeEach(async () => {
(jwt.decode as jest.Mock).mockReturnValue(decodedToken);
mockFindUser.mockResolvedValue(null);
const { initAuth } = require('../initAuth');
const saveBufferMock = jest.fn().mockResolvedValue('/fake/path/to/avatar.png');
await initAuth(mongoose, { enabled: false }, saveBufferMock); // mongoose: {}, fake balance config, dummy saveBuffer
beforeEach(() => {
jwt.decode.mockReturnValue(decodedToken);
findUser.mockResolvedValue(null);
});
it('should create a new user if one does not exist and registration is allowed', async () => {
// Mock findUser to return null (user does not exist)
mockFindUser.mockResolvedValue(null);
findUser.mockResolvedValue(null);
// Mock createSocialUser to create a user
(createSocialUser as jest.Mock).mockImplementation(async (userData: any) => {
createSocialUser.mockImplementation(async (userData) => {
const user = new User(userData);
await user.save();
return user;
@@ -258,17 +234,14 @@ describe('Apple Login Strategy', () => {
fakeRefreshToken,
tokenset.id_token,
mockProfile,
(err: Error | null, user: any) => {
(err, user) => {
mockVerifyCallback(err, user);
resolve();
},
);
});
expect(mockVerifyCallback).toHaveBeenCalledWith(
null,
expect.objectContaining({ email: 'jane.doe@example.com' }),
);
expect(mockVerifyCallback).toHaveBeenCalledWith(null, expect.any(User));
const user = mockVerifyCallback.mock.calls[0][1];
expect(user.email).toBe('jane.doe@example.com');
expect(user.username).toBe('jane.doe');
@@ -287,18 +260,15 @@ describe('Apple Login Strategy', () => {
avatarUrl: 'old_avatar.png',
});
console.log('aa', existingUser);
// Mock findUser to return the existing user
mockFindUser.mockResolvedValue(existingUser);
findUser.mockResolvedValue(existingUser);
// Mock handleExistingUser to update avatarUrl without saving to database
(handleExistingUser as jest.Mock).mockImplementation(
async (user: any, avatarUrl: string | null) => {
user.avatarUrl = avatarUrl;
// Don't call save() to avoid database operations
return user;
},
);
handleExistingUser.mockImplementation(async (user, avatarUrl) => {
user.avatarUrl = avatarUrl;
// Don't call save() to avoid database operations
return user;
});
const mockVerifyCallback = jest.fn();
@@ -309,17 +279,16 @@ describe('Apple Login Strategy', () => {
fakeRefreshToken,
tokenset.id_token,
mockProfile,
(err: Error | null, user: any) => {
(err, user) => {
mockVerifyCallback(err, user);
resolve();
},
);
});
console.log('bb', existingUser);
expect(mockVerifyCallback).toHaveBeenCalledWith(null, existingUser);
expect(existingUser.avatarUrl).toBe(''); // As per getProfileDetails
expect(handleExistingUser).toHaveBeenCalledWith(existingUser, '');
expect(existingUser.avatarUrl).toBeNull(); // As per getProfileDetails
expect(handleExistingUser).toHaveBeenCalledWith(existingUser, null);
});
it('should handle missing idToken gracefully', async () => {
@@ -332,7 +301,7 @@ describe('Apple Login Strategy', () => {
fakeRefreshToken,
null, // idToken is missing
mockProfile,
(err: Error | null, user: any) => {
(err, user) => {
mockVerifyCallback(err, user);
resolve();
},
@@ -348,7 +317,7 @@ describe('Apple Login Strategy', () => {
it('should handle decoding errors gracefully', async () => {
// Simulate decoding failure by returning null
(jwt.decode as jest.Mock).mockReturnValue(null);
jwt.decode.mockReturnValue(null);
const mockVerifyCallback = jest.fn();
@@ -359,7 +328,7 @@ describe('Apple Login Strategy', () => {
fakeRefreshToken,
tokenset.id_token,
mockProfile,
(err: Error | null, user: any) => {
(err, user) => {
mockVerifyCallback(err, user);
resolve();
},
@@ -377,10 +346,10 @@ describe('Apple Login Strategy', () => {
it('should handle errors during user creation', async () => {
// Mock findUser to return null (user does not exist)
mockFindUser.mockResolvedValue(null);
findUser.mockResolvedValue(null);
// Mock createSocialUser to throw an error
(createSocialUser as jest.Mock).mockImplementation(() => {
createSocialUser.mockImplementation(() => {
throw new Error('Database error');
});
@@ -393,7 +362,7 @@ describe('Apple Login Strategy', () => {
fakeRefreshToken,
tokenset.id_token,
mockProfile,
(err: Error | null, user: any) => {
(err, user) => {
mockVerifyCallback(err, user);
resolve();
},

View File

@@ -1,8 +1,7 @@
import { Strategy as DiscordStrategy } from 'passport-discord';
import socialLogin from './socialLogin';
import { GetProfileDetails } from './types';
const { Strategy: DiscordStrategy } = require('passport-discord');
const socialLogin = require('./socialLogin');
const getProfileDetails: GetProfileDetails = ({ profile }: any) => {
const getProfileDetails = ({ profile }) => {
let avatarUrl;
if (profile.avatar) {
const format = profile.avatar.startsWith('a_') ? 'gif' : 'png';
@@ -22,9 +21,9 @@ const getProfileDetails: GetProfileDetails = ({ profile }: any) => {
};
};
const discordStrategy = socialLogin('discord', getProfileDetails);
const discordLogin = socialLogin('discord', getProfileDetails);
const discordLogin = () =>
module.exports = () =>
new DiscordStrategy(
{
clientID: process.env.DISCORD_CLIENT_ID,
@@ -33,7 +32,5 @@ const discordLogin = () =>
scope: ['identify', 'email'],
authorizationURL: 'https://discord.com/api/oauth2/authorize?prompt=none',
},
discordStrategy,
discordLogin,
);
export default discordLogin;

View File

@@ -0,0 +1,26 @@
const FacebookStrategy = require('passport-facebook').Strategy;
const socialLogin = require('./socialLogin');
const getProfileDetails = ({ profile }) => ({
email: profile.emails[0]?.value,
id: profile.id,
avatarUrl: profile.photos[0]?.value,
username: profile.displayName,
name: profile.name?.givenName + ' ' + profile.name?.familyName,
emailVerified: true,
});
const facebookLogin = socialLogin('facebook', getProfileDetails);
module.exports = () =>
new FacebookStrategy(
{
clientID: process.env.FACEBOOK_CLIENT_ID,
clientSecret: process.env.FACEBOOK_CLIENT_SECRET,
callbackURL: `${process.env.DOMAIN_SERVER}${process.env.FACEBOOK_CALLBACK_URL}`,
proxy: true,
scope: ['public_profile'],
profileFields: ['id', 'email', 'name'],
},
facebookLogin,
);

View File

@@ -1,8 +1,7 @@
import { Strategy as GitHubStrategy } from 'passport-github2';
import socialLogin from './socialLogin';
import { GetProfileDetails } from './types';
const { Strategy: GitHubStrategy } = require('passport-github2');
const socialLogin = require('./socialLogin');
const getProfileDetails: GetProfileDetails = ({ profile }: any) => ({
const getProfileDetails = ({ profile }) => ({
email: profile.emails[0].value,
id: profile.id,
avatarUrl: profile.photos[0].value,
@@ -11,8 +10,9 @@ const getProfileDetails: GetProfileDetails = ({ profile }: any) => ({
emailVerified: profile.emails[0].verified,
});
const githubStrategy = socialLogin('github', getProfileDetails);
const githubLogin = () =>
const githubLogin = socialLogin('github', getProfileDetails);
module.exports = () =>
new GitHubStrategy(
{
clientID: process.env.GITHUB_CLIENT_ID,
@@ -30,6 +30,5 @@ const githubLogin = () =>
}),
}),
},
githubStrategy,
githubLogin,
);
export default githubLogin;

View File

@@ -1,8 +1,7 @@
import { Strategy as GoogleStrategy, Profile } from 'passport-google-oauth20';
import socialLogin from './socialLogin';
import { GetProfileDetails } from './types';
const { Strategy: GoogleStrategy } = require('passport-google-oauth20');
const socialLogin = require('./socialLogin');
const getProfileDetails: GetProfileDetails = ({ profile }: Profile) => ({
const getProfileDetails = ({ profile }) => ({
email: profile.emails[0].value,
id: profile.id,
avatarUrl: profile.photos[0].value,
@@ -11,9 +10,9 @@ const getProfileDetails: GetProfileDetails = ({ profile }: Profile) => ({
emailVerified: profile.emails[0].verified,
});
const googleStrategy = socialLogin('google', getProfileDetails);
const googleLogin = socialLogin('google', getProfileDetails);
const googleLogin = () =>
module.exports = () =>
new GoogleStrategy(
{
clientID: process.env.GOOGLE_CLIENT_ID,
@@ -21,7 +20,5 @@ const googleLogin = () =>
callbackURL: `${process.env.DOMAIN_SERVER}${process.env.GOOGLE_CALLBACK_URL}`,
proxy: true,
},
googleStrategy,
googleLogin,
);
export default googleLogin;

26
api/strategies/index.js Normal file
View File

@@ -0,0 +1,26 @@
const appleLogin = require('./appleStrategy');
const passportLogin = require('./localStrategy');
const googleLogin = require('./googleStrategy');
const githubLogin = require('./githubStrategy');
const discordLogin = require('./discordStrategy');
const facebookLogin = require('./facebookStrategy');
const { setupOpenId, getOpenIdConfig } = require('./openidStrategy');
const jwtLogin = require('./jwtStrategy');
const ldapLogin = require('./ldapStrategy');
const { setupSaml } = require('./samlStrategy');
const openIdJwtLogin = require('./openIdJwtStrategy');
module.exports = {
appleLogin,
passportLogin,
googleLogin,
githubLogin,
discordLogin,
jwtLogin,
facebookLogin,
setupOpenId,
getOpenIdConfig,
ldapLogin,
setupSaml,
openIdJwtLogin,
};

View File

@@ -1,24 +1,16 @@
import { getMethods } from '../initAuth';
import { logger } from '@librechat/data-schemas';
import { SystemRoles } from 'librechat-data-provider';
import {
Strategy as JwtStrategy,
ExtractJwt,
StrategyOptionsWithoutRequest,
VerifiedCallback,
} from 'passport-jwt';
import { Strategy as PassportStrategy } from 'passport-strategy';
import { JwtPayload } from './types';
const { logger } = require('@librechat/data-schemas');
const { SystemRoles } = require('librechat-data-provider');
const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt');
const { getUserById, updateUser } = require('~/models');
// JWT strategy
const jwtLogin = (): PassportStrategy =>
const jwtLogin = () =>
new JwtStrategy(
{
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: process.env.JWT_SECRET,
} as StrategyOptionsWithoutRequest,
async (payload: JwtPayload, done: VerifiedCallback) => {
const { updateUser, getUserById } = getMethods();
},
async (payload, done) => {
try {
const user = await getUserById(payload?.id, '-password -__v -totpSecret');
if (user) {
@@ -38,4 +30,4 @@ const jwtLogin = (): PassportStrategy =>
},
);
export default jwtLogin;
module.exports = jwtLogin;

View File

@@ -0,0 +1,148 @@
const fs = require('fs');
const LdapStrategy = require('passport-ldapauth');
const { SystemRoles } = require('librechat-data-provider');
const { logger } = require('@librechat/data-schemas');
const { createUser, findUser, updateUser, countUsers } = require('~/models');
const { getBalanceConfig } = require('~/server/services/Config');
const { isEnabled } = require('~/server/utils');
const {
LDAP_URL,
LDAP_BIND_DN,
LDAP_BIND_CREDENTIALS,
LDAP_USER_SEARCH_BASE,
LDAP_SEARCH_FILTER,
LDAP_CA_CERT_PATH,
LDAP_FULL_NAME,
LDAP_ID,
LDAP_USERNAME,
LDAP_EMAIL,
LDAP_TLS_REJECT_UNAUTHORIZED,
LDAP_STARTTLS,
} = process.env;
// Check required environment variables
if (!LDAP_URL || !LDAP_USER_SEARCH_BASE) {
module.exports = null;
}
const searchAttributes = [
'displayName',
'mail',
'uid',
'cn',
'name',
'commonname',
'givenName',
'sn',
'sAMAccountName',
];
if (LDAP_FULL_NAME) {
searchAttributes.push(...LDAP_FULL_NAME.split(','));
}
if (LDAP_ID) {
searchAttributes.push(LDAP_ID);
}
if (LDAP_USERNAME) {
searchAttributes.push(LDAP_USERNAME);
}
if (LDAP_EMAIL) {
searchAttributes.push(LDAP_EMAIL);
}
const rejectUnauthorized = isEnabled(LDAP_TLS_REJECT_UNAUTHORIZED);
const startTLS = isEnabled(LDAP_STARTTLS);
const ldapOptions = {
server: {
url: LDAP_URL,
bindDN: LDAP_BIND_DN,
bindCredentials: LDAP_BIND_CREDENTIALS,
searchBase: LDAP_USER_SEARCH_BASE,
searchFilter: LDAP_SEARCH_FILTER || 'mail={{username}}',
searchAttributes: [...new Set(searchAttributes)],
...(LDAP_CA_CERT_PATH && {
tlsOptions: {
rejectUnauthorized,
ca: (() => {
try {
return [fs.readFileSync(LDAP_CA_CERT_PATH)];
} catch (err) {
logger.error('[ldapStrategy]', 'Failed to read CA certificate', err);
throw err;
}
})(),
},
}),
...(startTLS && { starttls: true }),
},
usernameField: 'email',
passwordField: 'password',
};
const ldapLogin = new LdapStrategy(ldapOptions, async (userinfo, done) => {
if (!userinfo) {
return done(null, false, { message: 'Invalid credentials' });
}
try {
const ldapId =
(LDAP_ID && userinfo[LDAP_ID]) || userinfo.uid || userinfo.sAMAccountName || userinfo.mail;
let user = await findUser({ ldapId });
const fullNameAttributes = LDAP_FULL_NAME && LDAP_FULL_NAME.split(',');
const fullName =
fullNameAttributes && fullNameAttributes.length > 0
? fullNameAttributes.map((attr) => userinfo[attr]).join(' ')
: userinfo.cn || userinfo.name || userinfo.commonname || userinfo.displayName;
const username =
(LDAP_USERNAME && userinfo[LDAP_USERNAME]) || userinfo.givenName || userinfo.mail;
const mail = (LDAP_EMAIL && userinfo[LDAP_EMAIL]) || userinfo.mail || username + '@ldap.local';
if (!userinfo.mail && !(LDAP_EMAIL && userinfo[LDAP_EMAIL])) {
logger.warn(
'[ldapStrategy]',
`No valid email attribute found in LDAP userinfo. Using fallback email: ${username}@ldap.local`,
`LDAP_EMAIL env var: ${LDAP_EMAIL || 'not set'}`,
`Available userinfo attributes: ${Object.keys(userinfo).join(', ')}`,
'Full userinfo:',
JSON.stringify(userinfo, null, 2),
);
}
if (!user) {
const isFirstRegisteredUser = (await countUsers()) === 0;
user = {
provider: 'ldap',
ldapId,
username,
email: mail,
emailVerified: true, // The ldap server administrator should verify the email
name: fullName,
role: isFirstRegisteredUser ? SystemRoles.ADMIN : SystemRoles.USER,
};
const balanceConfig = await getBalanceConfig();
const userId = await createUser(user, balanceConfig);
user._id = userId;
} else {
// Users registered in LDAP are assumed to have their user information managed in LDAP,
// so update the user information with the values registered in LDAP
user.provider = 'ldap';
user.ldapId = ldapId;
user.email = mail;
user.username = username;
user.name = fullName;
}
user = await updateUser(user._id, user);
done(null, user);
} catch (err) {
logger.error('[ldapStrategy]', err);
done(err);
}
});
module.exports = ldapLogin;

View File

@@ -1,48 +1,19 @@
import { IUser, logger } from '@librechat/data-schemas';
import { errorsToString } from 'librechat-data-provider';
import { Strategy as PassportLocalStrategy } from 'passport-local';
import { getMethods } from '../initAuth';
import { checkEmailConfig, isEnabled } from '../utils';
import { loginSchema } from './validators';
import bcrypt from 'bcryptjs';
import { Request } from 'express';
const { logger } = require('@librechat/data-schemas');
const { errorsToString } = require('librechat-data-provider');
const { Strategy: PassportLocalStrategy } = require('passport-local');
const { isEnabled, checkEmailConfig } = require('~/server/utils');
const { findUser, comparePassword, updateUser } = require('~/models');
const { loginSchema } = require('./validators');
// Unix timestamp for 2024-06-07 15:20:18 Eastern Time
const verificationEnabledTimestamp = 1717788018;
async function validateLoginRequest(req: Request) {
async function validateLoginRequest(req) {
const { error } = loginSchema.safeParse(req.body);
return error ? errorsToString(error.errors) : null;
}
/**
* Compares the provided password with the user's password.
*
* @param {MongoUser} user - The user to compare the password for.
* @param {string} candidatePassword - The password to test against the user's password.
* @returns {Promise<boolean>} A promise that resolves to a boolean indicating if the password matches.
*/
const comparePassword = async (user: IUser, candidatePassword: string) => {
if (!user) {
throw new Error('No user provided');
}
return new Promise((resolve, reject) => {
bcrypt.compare(candidatePassword, user.password ?? '', (err, isMatch) => {
if (err) {
reject(err);
}
resolve(isMatch);
});
});
};
async function passportStrategy(
req: Request,
email: string,
password: string,
done: (error: any, user?: any, options?: { message: string }) => void,
) {
async function passportLogin(req, email, password, done) {
try {
const validationError = await validateLoginRequest(req);
if (validationError) {
@@ -51,7 +22,6 @@ async function passportStrategy(
return done(null, false, { message: validationError });
}
const { findUser, updateUser } = getMethods();
const user = await findUser({ email: email.trim() });
if (!user) {
logError('Passport Local Strategy - User Not Found', { email });
@@ -84,7 +54,7 @@ async function passportStrategy(
user.emailVerified = true;
}
const unverifiedAllowed = isEnabled(process.env.ALLOW_UNVERIFIED_EMAIL_LOGIN ?? '');
const unverifiedAllowed = isEnabled(process.env.ALLOW_UNVERIFIED_EMAIL_LOGIN);
if (user.expiresAt && unverifiedAllowed) {
await updateUser(user._id, {});
}
@@ -102,12 +72,12 @@ async function passportStrategy(
}
}
function logError(title: string, parameters: any) {
function logError(title, parameters) {
const entries = Object.entries(parameters).map(([name, value]) => ({ name, value }));
logger.error(title, { parameters: entries });
}
const passportLogin = () =>
module.exports = () =>
new PassportLocalStrategy(
{
usernameField: 'email',
@@ -115,7 +85,5 @@ const passportLogin = () =>
session: false,
passReqToCallback: true,
},
passportStrategy,
passportLogin,
);
export default passportLogin;

View File

@@ -1,11 +1,9 @@
import { SystemRoles } from 'librechat-data-provider';
import { Strategy as JwtStrategy, ExtractJwt } from 'passport-jwt';
import jwksRsa from 'jwks-rsa';
import { isEnabled } from 'src/utils';
import { getMethods } from 'src/initAuth';
import { logger } from '@librechat/data-schemas';
import * as client from 'openid-client';
const { SystemRoles } = require('librechat-data-provider');
const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt');
const { updateUser, findUser } = require('~/models');
const { logger } = require('~/config');
const jwksRsa = require('jwks-rsa');
const { isEnabled } = require('~/server/utils');
/**
* @function openIdJwtLogin
* @param {import('openid-client').Configuration} openIdConfig - Configuration object for the JWT strategy.
@@ -15,20 +13,19 @@ import * as client from 'openid-client';
* The strategy extracts the JWT from the Authorization header as a Bearer token.
* The JWT is then verified using the signing key, and the user is retrieved from the database.
*/
const openIdJwtLogin = (openIdConfig: client.Configuration) =>
const openIdJwtLogin = (openIdConfig) =>
new JwtStrategy(
{
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKeyProvider: jwksRsa.passportJwtSecret({
cache: isEnabled(process.env.OPENID_JWKS_URL_CACHE_ENABLED || 'true'),
cache: isEnabled(process.env.OPENID_JWKS_URL_CACHE_ENABLED) || true,
cacheMaxAge: process.env.OPENID_JWKS_URL_CACHE_TIME
? eval(process.env.OPENID_JWKS_URL_CACHE_TIME)
: 60000,
jwksUri: openIdConfig.serverMetadata().jwks_uri ?? '',
jwksUri: openIdConfig.serverMetadata().jwks_uri,
}),
},
async (payload, done) => {
const { findUser, updateUser } = getMethods();
try {
const user = await findUser({ openidId: payload?.sub });
@@ -52,4 +49,4 @@ const openIdJwtLogin = (openIdConfig: client.Configuration) =>
},
);
export default openIdJwtLogin;
module.exports = openIdJwtLogin;

View File

@@ -1,16 +1,17 @@
import * as client from 'openid-client';
// @ts-ignore
import { Strategy as OpenIDStrategy, VerifyCallback } from 'openid-client/passport';
import jwt from 'jsonwebtoken';
import { HttpsProxyAgent } from 'https-proxy-agent';
import { hashToken, logger } from '@librechat/data-schemas';
import { isEnabled } from '../utils';
import { safeStringify, logHeaders } from '@librechat/api';
import * as oauth from 'oauth4webapi';
import { getBalanceConfig, getMethods, getSaveBufferStrategy } from '../initAuth';
import { fetch, Response as UndiciResponse, Headers } from 'undici';
import { Request } from 'express';
let crypto: typeof import('node:crypto') | undefined;
const undici = require('undici');
const fetch = require('node-fetch');
const passport = require('passport');
const client = require('openid-client');
const jwtDecode = require('jsonwebtoken/decode');
const { CacheKeys } = require('librechat-data-provider');
const { HttpsProxyAgent } = require('https-proxy-agent');
const { hashToken, logger } = require('@librechat/data-schemas');
const { Strategy: OpenIDStrategy } = require('openid-client/passport');
const { isEnabled, safeStringify, logHeaders } = require('@librechat/api');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { findUser, createUser, updateUser } = require('~/models');
const { getBalanceConfig } = require('~/server/services/Config');
const getLogStores = require('~/cache/getLogStores');
/**
* @typedef {import('openid-client').ClientMetadata} ClientMetadata
@@ -21,10 +22,10 @@ let crypto: typeof import('node:crypto') | undefined;
* @param {string} url
* @param {client.CustomFetchOptions} options
*/
export async function customFetch(url: URL | string, options: any): Promise<UndiciResponse> {
async function customFetch(url, options) {
const urlStr = url.toString();
logger.debug(`[openidStrategy] Request to: ${urlStr}`);
const debugOpenId = isEnabled(process.env.DEBUG_OPENID_REQUESTS ?? '');
const debugOpenId = isEnabled(process.env.DEBUG_OPENID_REQUESTS);
if (debugOpenId) {
logger.debug(`[openidStrategy] Request method: ${options.method || 'GET'}`);
logger.debug(`[openidStrategy] Request headers: ${logHeaders(options.headers)}`);
@@ -48,15 +49,15 @@ export async function customFetch(url: URL | string, options: any): Promise<Undi
logger.info(`[openidStrategy] proxy agent configured: ${process.env.PROXY}`);
fetchOptions = {
...options,
dispatcher: new HttpsProxyAgent(process.env.PROXY ?? ''),
dispatcher: new HttpsProxyAgent(process.env.PROXY),
};
}
const response: UndiciResponse = await fetch(url, fetchOptions);
const response = await undici.fetch(url, fetchOptions);
if (debugOpenId) {
logger.debug(`[openidStrategy] Response status: ${response.status} ${response.statusText}`);
// logger.debug(`[openidStrategy] Response headers: ${logHeaders(response.headers)}`);
logger.debug(`[openidStrategy] Response headers: ${logHeaders(response.headers)}`);
}
if (response.status === 200 && response.headers.has('www-authenticate')) {
@@ -73,7 +74,7 @@ This violates RFC 7235 and may cause issues with strict OAuth clients. Removing
}
}
return new UndiciResponse(responseBody, {
return new Response(responseBody, {
status: response.status,
statusText: response.statusText,
headers: newHeaders,
@@ -81,35 +82,32 @@ This violates RFC 7235 and may cause issues with strict OAuth clients. Removing
}
return response;
} catch (error: any) {
} catch (error) {
logger.error(`[openidStrategy] Fetch error: ${error.message}`);
throw error;
}
}
/** @typedef {Configuration | null} */
let openidConfig = null;
//overload currenturl function because of express version 4 buggy req.host doesn't include port
//More info https://github.com/panva/openid-client/pull/713
let openidConfig: client.Configuration;
class CustomOpenIDStrategy extends OpenIDStrategy {
constructor(options: any, verify: VerifyCallback) {
super(options, verify);
}
currentUrl(req: Request): URL {
const hostAndProtocol = process.env.DOMAIN_SERVER!;
currentUrl(req) {
const hostAndProtocol = process.env.DOMAIN_SERVER;
return new URL(`${hostAndProtocol}${req.originalUrl ?? req.url}`);
}
authorizationRequestParams(req: Request, options: any): URLSearchParams {
const params = super.authorizationRequestParams(req, options) as URLSearchParams;
if (options?.state && !params?.has('state')) {
params?.set('state', options.state);
authorizationRequestParams(req, options) {
const params = super.authorizationRequestParams(req, options);
if (options?.state && !params.has('state')) {
params.set('state', options.state);
}
return params;
}
}
let tokensCache: any;
/**
* Exchange the access token for a new access token using the on-behalf-of flow if required.
* @param {Configuration} config
@@ -118,19 +116,12 @@ let tokensCache: any;
* @param {boolean} fromCache - Indicates whether to use cached tokens.
* @returns {Promise<string>} The new access token if exchanged, otherwise the original access token.
*/
const exchangeAccessTokenIfNeeded = async (
config: client.Configuration,
accessToken: string,
sub: string,
fromCache: boolean = false,
) => {
const onBehalfFlowRequired = isEnabled(
process.env.OPENID_ON_BEHALF_FLOW_FOR_USERINFRO_REQUIRED ?? '',
);
const exchangeAccessTokenIfNeeded = async (config, accessToken, sub, fromCache = false) => {
const tokensCache = getLogStores(CacheKeys.OPENID_EXCHANGED_TOKENS);
const onBehalfFlowRequired = isEnabled(process.env.OPENID_ON_BEHALF_FLOW_FOR_USERINFRO_REQUIRED);
if (onBehalfFlowRequired) {
if (fromCache) {
const cachedToken = await tokensCache.get(sub);
if (cachedToken) {
return cachedToken.access_token;
}
@@ -149,7 +140,7 @@ const exchangeAccessTokenIfNeeded = async (
{
access_token: grantResponse.access_token,
},
(grantResponse?.expires_in ?? 0) * 1000,
grantResponse.expires_in * 1000,
);
return grantResponse.access_token;
}
@@ -163,11 +154,7 @@ const exchangeAccessTokenIfNeeded = async (
* @param {string} sub - The subject identifier of the user. usually found as "sub" in the claims of the token
* @returns {Promise<Object|null>}
*/
const getUserInfo = async (
config: client.Configuration,
accessToken: string,
sub: string,
): Promise<oauth.UserInfoResponse | null> => {
const getUserInfo = async (config, accessToken, sub) => {
try {
const exchangedAccessToken = await exchangeAccessTokenIfNeeded(config, accessToken, sub);
return await client.fetchUserInfo(config, exchangedAccessToken, sub);
@@ -176,6 +163,7 @@ const getUserInfo = async (
return null;
}
};
/**
* Downloads an image from a URL using an access token.
* @param {string} url
@@ -184,19 +172,14 @@ const getUserInfo = async (
* @param {string} sub - The subject identifier of the user. usually found as "sub" in the claims of the token
* @returns {Promise<Buffer | string>} The image buffer or an empty string if the download fails.
*/
const downloadImage = async (
url: string,
config: client.Configuration,
accessToken: string,
sub: string,
) => {
const downloadImage = async (url, config, accessToken, sub) => {
const exchangedAccessToken = await exchangeAccessTokenIfNeeded(config, accessToken, sub, true);
if (!url) {
return '';
}
try {
const options: any = {
const options = {
method: 'GET',
headers: {
Authorization: `Bearer ${exchangedAccessToken}`,
@@ -206,10 +189,11 @@ const downloadImage = async (
if (process.env.PROXY) {
options.agent = new HttpsProxyAgent(process.env.PROXY);
}
const response: UndiciResponse = await fetch(url, options);
const response = await fetch(url, options);
if (response.ok) {
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const buffer = await response.buffer();
return buffer;
} else {
throw new Error(`${response.statusText} (HTTP ${response.status})`);
@@ -232,10 +216,9 @@ const downloadImage = async (
* @param {string} [userinfo.email] - The user's email address
* @returns {string} The determined full name of the user
*/
function getFullName(userinfo: client.UserInfoResponse & { username?: string }): string {
const nameClaim = process.env.OPENID_NAME_CLAIM;
if (nameClaim && typeof userinfo[nameClaim] === 'string') {
return userinfo[nameClaim] as string;
function getFullName(userinfo) {
if (process.env.OPENID_NAME_CLAIM) {
return userinfo[process.env.OPENID_NAME_CLAIM];
}
if (userinfo.given_name && userinfo.family_name) {
@@ -250,7 +233,7 @@ function getFullName(userinfo: client.UserInfoResponse & { username?: string }):
return userinfo.family_name;
}
return (userinfo?.username || userinfo?.email) ?? '';
return userinfo.username || userinfo.email;
}
/**
@@ -263,7 +246,7 @@ function getFullName(userinfo: client.UserInfoResponse & { username?: string }):
* @param {string} [defaultValue=''] - The default value to return if the input is falsy.
* @returns {string} The processed input as a string suitable for a username.
*/
function convertToUsername(input: string | string[], defaultValue: string = '') {
function convertToUsername(input, defaultValue = '') {
if (typeof input === 'string') {
return input;
} else if (Array.isArray(input)) {
@@ -283,78 +266,74 @@ function convertToUsername(input: string | string[], defaultValue: string = '')
* @returns {Promise<Configuration | null>} A promise that resolves when the OpenID strategy is set up and returns the openid client config object.
* @throws {Error} If an error occurs during the setup process.
*/
async function setupOpenId(tokensCacheKv: any): Promise<any | null> {
async function setupOpenId() {
try {
tokensCache = tokensCacheKv;
/** @type {ClientMetadata} */
const clientMetadata = {
client_id: process.env.OPENID_CLIENT_ID,
client_secret: process.env.OPENID_CLIENT_SECRET,
};
/** @type {Configuration} */
openidConfig = await client.discovery(
new URL(process.env.OPENID_ISSUER ?? ''),
process.env.OPENID_CLIENT_ID ?? '',
new URL(process.env.OPENID_ISSUER),
process.env.OPENID_CLIENT_ID,
clientMetadata,
undefined,
{
//@ts-ignore
[client.customFetch]: customFetch,
},
);
const { findUser, createUser, updateUser } = getMethods();
const requiredRole = process.env.OPENID_REQUIRED_ROLE;
const requiredRoleParameterPath = process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH;
const requiredRoleTokenKind = process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND;
const usePKCE: boolean = isEnabled(process.env.OPENID_USE_PKCE ?? '');
const usePKCE = isEnabled(process.env.OPENID_USE_PKCE);
const openidLogin = new CustomOpenIDStrategy(
{
config: openidConfig,
scope: process.env.OPENID_SCOPE,
callbackURL: `${process.env.DOMAIN_SERVER}${process.env.OPENID_CALLBACK_URL}`,
callbackURL: process.env.DOMAIN_SERVER + process.env.OPENID_CALLBACK_URL,
usePKCE,
},
async (tokenset: any, done) => {
async (tokenset, done) => {
try {
const claims: oauth.IDToken | undefined = tokenset.claims();
let user = await findUser({ openidId: claims?.sub });
const claims = tokenset.claims();
let user = await findUser({ openidId: claims.sub });
logger.info(
`[openidStrategy] user ${user ? 'found' : 'not found'} with openidId: ${claims?.sub}`,
`[openidStrategy] user ${user ? 'found' : 'not found'} with openidId: ${claims.sub}`,
);
if (!user) {
user = await findUser({ email: claims?.email });
user = await findUser({ email: claims.email });
logger.info(
`[openidStrategy] user ${user ? 'found' : 'not found'} with email: ${
claims?.email
} for openidId: ${claims?.sub}`,
claims.email
} for openidId: ${claims.sub}`,
);
}
const userinfo: any = {
const userinfo = {
...claims,
...(await getUserInfo(openidConfig, tokenset.access_token, claims?.sub ?? '')),
...(await getUserInfo(openidConfig, tokenset.access_token, claims.sub)),
};
const fullName = getFullName(userinfo);
if (requiredRole) {
let decodedToken = null;
let decodedToken = '';
if (requiredRoleTokenKind === 'access') {
decodedToken = jwt.decode(tokenset.access_token);
decodedToken = jwtDecode(tokenset.access_token);
} else if (requiredRoleTokenKind === 'id') {
decodedToken = jwt.decode(tokenset.id_token ?? '');
decodedToken = jwtDecode(tokenset.id_token);
}
const pathParts = requiredRoleParameterPath?.split('.');
const pathParts = requiredRoleParameterPath.split('.');
let found = true;
let roles: any = decodedToken;
if (pathParts) {
for (const key of pathParts) {
if (roles && typeof roles === 'object' && key in roles) {
roles = (roles as Record<string, unknown>)[key];
} else {
found = false;
break;
}
let roles = pathParts.reduce((o, key) => {
if (o === null || o === undefined || !(key in o)) {
found = false;
return [];
}
}
return o[key];
}, decodedToken);
if (!found) {
logger.error(
@@ -362,7 +341,7 @@ async function setupOpenId(tokensCacheKv: any): Promise<any | null> {
);
}
if (!roles?.includes(requiredRole)) {
if (!roles.includes(requiredRole)) {
return done(null, false, {
message: `You must have the "${requiredRole}" role to log in.`,
});
@@ -370,11 +349,11 @@ async function setupOpenId(tokensCacheKv: any): Promise<any | null> {
}
let username = '';
if (process.env.OPENID_USERNAME_CLAIM && userinfo[process.env.OPENID_USERNAME_CLAIM]) {
username = userinfo[process.env.OPENID_USERNAME_CLAIM] as string;
if (process.env.OPENID_USERNAME_CLAIM) {
username = userinfo[process.env.OPENID_USERNAME_CLAIM];
} else {
username = convertToUsername(
userinfo?.username ?? userinfo?.given_name ?? userinfo?.email,
userinfo.username || userinfo.given_name || userinfo.email,
);
}
@@ -386,32 +365,31 @@ async function setupOpenId(tokensCacheKv: any): Promise<any | null> {
email: userinfo.email || '',
emailVerified: userinfo.email_verified || false,
name: fullName,
idOnTheSource: userinfo.oid,
};
const balanceConfig = getBalanceConfig();
const balanceConfig = await getBalanceConfig();
user = await createUser(user, balanceConfig, true, true);
} else {
user.provider = 'openid';
user.openidId = userinfo.sub;
user.username = username;
user.name = fullName;
user.idOnTheSource = userinfo.oid;
}
if (!!userinfo && userinfo.picture && !user?.avatar?.includes('manual=true')) {
if (!!userinfo && userinfo.picture && !user.avatar?.includes('manual=true')) {
/** @type {string | undefined} */
const imageUrl = userinfo.picture;
let fileName;
try {
crypto = await import('node:crypto');
} catch (err) {
logger.error('[openidStrategy] crypto support is disabled!', err);
}
let fileName;
if (crypto) {
fileName = (await hashToken(userinfo.sub)) + '.png';
} else {
fileName = userinfo.sub + '.png';
}
const imageBuffer = await downloadImage(
imageUrl,
openidConfig,
@@ -419,7 +397,7 @@ async function setupOpenId(tokensCacheKv: any): Promise<any | null> {
userinfo.sub,
);
if (imageBuffer) {
const saveBuffer = getSaveBufferStrategy();
const { saveBuffer } = getStrategyFunctions(process.env.CDN_PROVIDER);
const imagePath = await saveBuffer({
fileName,
userId: user._id.toString(),
@@ -428,7 +406,9 @@ async function setupOpenId(tokensCacheKv: any): Promise<any | null> {
user.avatar = imagePath ?? '';
}
}
user = await updateUser(user?._id, user);
user = await updateUser(user._id, user);
logger.info(
`[openidStrategy] login success openidId: ${user.openidId} | email: ${user.email} | username: ${user.username} `,
{
@@ -448,23 +428,27 @@ async function setupOpenId(tokensCacheKv: any): Promise<any | null> {
}
},
);
return openidLogin;
passport.use('openid', openidLogin);
return openidConfig;
} catch (err) {
logger.error('[openidStrategy]', err);
return null;
}
}
/**
* @function getOpenIdConfig
* @description Returns the OpenID client instance.
* @throws {Error} If the OpenID client is not initialized.
* @returns {Configuration}
*/
function getOpenIdConfig(): client.Configuration {
function getOpenIdConfig() {
if (!openidConfig) {
throw new Error('OpenID client is not initialized. Please call setupOpenId first.');
}
return openidConfig;
}
export { setupOpenId, getOpenIdConfig };
module.exports = {
setupOpenId,
getOpenIdConfig,
};

View File

@@ -1,355 +1,349 @@
import passport from 'passport';
import mongoose from 'mongoose';
// --- Mocks ---
jest.mock('jsonwebtoken');
jest.mock('undici', () => {
const ActualUndici = jest.requireActual('undici');
return {
...ActualUndici,
fetch: jest.fn(() => {
return new ActualUndici.Response(Buffer.from('fake image'), {
status: 200,
headers: { 'content-type': 'image/png' },
});
}),
};
});
const fetchMock = jest.fn().mockResolvedValue(
new Response(Buffer.from('fake image'), {
status: 200,
headers: { 'content-type': 'image/png' },
}),
);
const mockedMethods = {
findUser: jest.fn(),
createUser: jest.fn(),
updateUser: jest.fn(),
};
jest.mock('@librechat/data-schemas', () => {
const actual = jest.requireActual('@librechat/data-schemas');
return {
...actual,
createMethods: jest.fn(() => mockedMethods),
};
});
// Mock the openid-client module and all its dependencies
jest.mock('openid-client', () => {
// const actual = jest.requireActual('openid-client');
return {
// ...actual,
discovery: jest.fn().mockResolvedValue({
clientId: 'fake_client_id',
clientSecret: 'fake_client_secret',
issuer: 'https://fake-issuer.com',
// Add any other properties needed by the implementation
}),
fetchUserInfo: jest.fn().mockImplementation((config, accessToken, sub) => {
// Only return additional properties, but don't override any claims
return Promise.resolve({
preferred_username: 'preferred_username',
});
}),
customFetch: Symbol('customFetch'),
};
});
jest.mock('openid-client/passport', () => {
let verifyCallback: (...args: any[]) => any;
const mockConstructor = jest.fn((options, verify) => {
verifyCallback = verify;
return {
name: 'openid',
options,
verify,
};
});
return {
Strategy: mockConstructor,
__getVerifyCallback: () => verifyCallback,
};
});
// Mock passport
jest.mock('passport', () => ({
use: jest.fn(),
}));
import undici from 'undici';
import { setupOpenId } from './openidStrategy';
import { initAuth } from '../initAuth';
import jwt from 'jsonwebtoken';
describe('setupOpenId', () => {
// Store a reference to the verify callback once it's set up
let verifyCallback: (...args: any[]) => any;
// Helper to wrap the verify callback in a promise
const validate = (tokenset: any) =>
new Promise((resolve, reject) => {
verifyCallback(tokenset, (err: Error | null, user: any, details: any) => {
if (err) {
reject(err);
} else {
resolve({ user, details });
}
});
});
const tokenset = {
id_token: 'fake_id_token',
access_token: 'fake_access_token',
claims: () => ({
sub: '1234',
email: 'test@example.com',
email_verified: true,
given_name: 'First',
family_name: 'Last',
name: 'My Full',
username: 'flast',
picture: 'https://example.com/avatar.png',
}),
};
beforeEach(async () => {
// Clear previous mock calls and reset implementations
jest.clearAllMocks();
// Reset environment variables needed by the strategy
process.env.OPENID_ISSUER = 'https://fake-issuer.com';
process.env.OPENID_CLIENT_ID = 'fake_client_id';
process.env.OPENID_CLIENT_SECRET = 'fake_client_secret';
process.env.DOMAIN_SERVER = 'https://example.com';
process.env.OPENID_CALLBACK_URL = '/callback';
process.env.OPENID_SCOPE = 'openid profile email';
process.env.OPENID_REQUIRED_ROLE = 'requiredRole';
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roles';
process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id';
delete process.env.OPENID_USERNAME_CLAIM;
delete process.env.OPENID_NAME_CLAIM;
delete process.env.PROXY;
delete process.env.OPENID_USE_PKCE;
// Default jwtDecode mock returns a token that includes the required role.
(jwt.decode as jest.Mock).mockReturnValue({
roles: ['requiredRole'],
});
// By default, assume that no user is found, so createUser will be called
mockedMethods.findUser.mockResolvedValue(null);
mockedMethods.createUser.mockImplementation(async (userData) => {
// simulate created user with an _id property
return { _id: 'newUserId', ...userData };
});
mockedMethods.updateUser.mockImplementation(async (id, userData) => {
return { _id: id, ...userData };
});
try {
// const { setupOpenId } = require('@librechat/auth');
const saveBufferMock = jest.fn().mockResolvedValue('/fake/path/to/avatar.png');
await initAuth(mongoose, { enabled: false }, saveBufferMock); // mongoose: {}, fake balance config, dummy saveBuffer
const openidLogin = await setupOpenId({});
// Simulate the app's `passport.use(...)`
passport.use('openid', openidLogin);
verifyCallback = require('openid-client/passport').__getVerifyCallback();
} catch (e) {
console.log(e);
}
});
it('should create a new user with correct username when username claim exists', async () => {
// Arrange our userinfo already has username 'flast'
const userinfo = tokenset.claims();
// Act
const { user } = (await validate(tokenset)) as any;
// Assert
expect(user.username).toBe(userinfo.username);
expect(mockedMethods.createUser).toHaveBeenCalledWith(
expect.objectContaining({
provider: 'openid',
openidId: userinfo.sub,
username: userinfo.username,
email: userinfo.email,
name: `${userinfo.given_name} ${userinfo.family_name}`,
}),
{ enabled: false },
true,
true,
);
});
it('should use given_name as username when username claim is missing', async () => {
// Arrange remove username from userinfo
const userinfo: any = { ...tokenset.claims() };
delete userinfo.username;
// Expect the username to be the given name (unchanged case)
const expectUsername = userinfo.given_name;
// Act
const { user } = (await validate({ ...tokenset, claims: () => userinfo })) as any;
// Assert
expect(user.username).toBe(expectUsername);
expect(mockedMethods.createUser).toHaveBeenCalledWith(
expect.objectContaining({ username: expectUsername }),
{ enabled: false },
true,
true,
);
});
it('should use email as username when username and given_name are missing', async () => {
// Arrange remove username and given_name
const userinfo: any = { ...tokenset.claims() };
delete userinfo.username;
delete userinfo.given_name;
const expectUsername = userinfo.email;
// Act
const { user } = (await validate({ ...tokenset, claims: () => userinfo })) as any;
// Assert
expect(user.username).toBe(expectUsername);
expect(mockedMethods.createUser).toHaveBeenCalledWith(
expect.objectContaining({ username: expectUsername }),
{ enabled: false },
true,
true,
);
});
it('should override username with OPENID_USERNAME_CLAIM when set', async () => {
// Arrange set OPENID_USERNAME_CLAIM so that the sub claim is used
process.env.OPENID_USERNAME_CLAIM = 'sub';
const userinfo = tokenset.claims();
// Act
const { user } = (await validate(tokenset)) as any;
// Assert username should equal the sub (converted as-is)
expect(user.username).toBe(userinfo.sub);
expect(mockedMethods.createUser).toHaveBeenCalledWith(
expect.objectContaining({ username: userinfo.sub }),
{ enabled: false },
true,
true,
);
});
it('should set the full name correctly when given_name and family_name exist', async () => {
// Arrange
const userinfo = tokenset.claims();
const expectedFullName = `${userinfo.given_name} ${userinfo.family_name}`;
// Act
const { user } = (await validate(tokenset)) as any;
// Assert
expect(user.name).toBe(expectedFullName);
});
it('should override full name with OPENID_NAME_CLAIM when set', async () => {
// Arrange use the name claim as the full name
process.env.OPENID_NAME_CLAIM = 'name';
const userinfo = { ...tokenset.claims(), name: 'Custom Name' };
// Act
const { user } = (await validate({ ...tokenset, claims: () => userinfo })) as any;
// Assert
expect(user.name).toBe('Custom Name');
});
it('should update an existing user on login', async () => {
// Arrange simulate that a user already exists
const existingUser = {
_id: 'existingUserId',
provider: 'local',
email: tokenset.claims().email,
openidId: '',
username: '',
name: '',
};
mockedMethods.findUser.mockImplementation(async (query) => {
if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) {
return existingUser;
}
return null;
});
const userinfo = tokenset.claims();
// Act
await validate(tokenset);
// Assert updateUser should be called and the user object updated
expect(mockedMethods.updateUser).toHaveBeenCalledWith(
existingUser._id,
expect.objectContaining({
provider: 'openid',
openidId: userinfo.sub,
username: userinfo.username,
name: `${userinfo.given_name} ${userinfo.family_name}`,
}),
);
});
it('should enforce the required role and reject login if missing', async () => {
// Arrange simulate a token without the required role.
(jwt.decode as jest.Mock).mockReturnValue({
roles: ['SomeOtherRole'],
});
// Act
const { user, details } = (await validate(tokenset)) as any;
// Assert verify that the strategy rejects login
expect(user).toBe(false);
expect(details.message).toBe('You must have the "requiredRole" role to log in.');
});
it.skip('should attempt to download and save the avatar if picture is provided', async () => {
// Act
const { user } = (await validate(tokenset)) as any;
// Assert verify that download was attempted and the avatar field was set via updateUser
expect(undici.fetch).toHaveBeenCalled();
// Our mock getStrategyFunctions.saveBuffer returns '/fake/path/to/avatar.png'
expect(user.avatar).toBe('/fake/path/to/avatar.png');
});
it('should not attempt to download avatar if picture is not provided', async () => {
// Arrange remove picture
const userinfo: any = { ...tokenset.claims() };
delete userinfo.picture;
// Act
await validate({ ...tokenset, claims: () => userinfo });
// Assert fetch should not be called and avatar should remain undefined or empty
expect(undici.fetch).not.toHaveBeenCalled();
// Depending on your implementation, user.avatar may be undefined or an empty string.
});
it('should default to usePKCE false when OPENID_USE_PKCE is not defined', async () => {
const OpenIDStrategy = require('openid-client/passport').Strategy;
delete process.env.OPENID_USE_PKCE;
const { setupOpenId } = require('./openidStrategy');
await setupOpenId({});
const callOptions = OpenIDStrategy.mock.calls[OpenIDStrategy.mock.calls.length - 1][0];
expect(callOptions.usePKCE).toBe(false);
expect(callOptions.params?.code_challenge_method).toBeUndefined();
});
});
const fetch = require('node-fetch');
const jwtDecode = require('jsonwebtoken/decode');
const { setupOpenId } = require('./openidStrategy');
const { findUser, createUser, updateUser } = require('~/models');
// --- Mocks ---
jest.mock('node-fetch');
jest.mock('jsonwebtoken/decode');
jest.mock('~/server/services/Files/strategies', () => ({
getStrategyFunctions: jest.fn(() => ({
saveBuffer: jest.fn().mockResolvedValue('/fake/path/to/avatar.png'),
})),
}));
jest.mock('~/server/services/Config', () => ({
getBalanceConfig: jest.fn(() => ({
enabled: false,
})),
}));
jest.mock('~/models', () => ({
findUser: jest.fn(),
createUser: jest.fn(),
updateUser: jest.fn(),
}));
jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
isEnabled: jest.fn(() => false),
}));
jest.mock('@librechat/data-schemas', () => ({
...jest.requireActual('@librechat/api'),
logger: {
info: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
},
hashToken: jest.fn().mockResolvedValue('hashed-token'),
}));
jest.mock('~/cache/getLogStores', () =>
jest.fn(() => ({
get: jest.fn(),
set: jest.fn(),
})),
);
// Mock the openid-client module and all its dependencies
jest.mock('openid-client', () => {
return {
discovery: jest.fn().mockResolvedValue({
clientId: 'fake_client_id',
clientSecret: 'fake_client_secret',
issuer: 'https://fake-issuer.com',
// Add any other properties needed by the implementation
}),
fetchUserInfo: jest.fn().mockImplementation((config, accessToken, sub) => {
// Only return additional properties, but don't override any claims
return Promise.resolve({
preferred_username: 'preferred_username',
});
}),
customFetch: Symbol('customFetch'),
};
});
jest.mock('openid-client/passport', () => {
let verifyCallback;
const mockStrategy = jest.fn((options, verify) => {
verifyCallback = verify;
return { name: 'openid', options, verify };
});
return {
Strategy: mockStrategy,
__getVerifyCallback: () => verifyCallback,
};
});
// Mock passport
jest.mock('passport', () => ({
use: jest.fn(),
}));
describe('setupOpenId', () => {
// Store a reference to the verify callback once it's set up
let verifyCallback;
// Helper to wrap the verify callback in a promise
const validate = (tokenset) =>
new Promise((resolve, reject) => {
verifyCallback(tokenset, (err, user, details) => {
if (err) {
reject(err);
} else {
resolve({ user, details });
}
});
});
const tokenset = {
id_token: 'fake_id_token',
access_token: 'fake_access_token',
claims: () => ({
sub: '1234',
email: 'test@example.com',
email_verified: true,
given_name: 'First',
family_name: 'Last',
name: 'My Full',
username: 'flast',
picture: 'https://example.com/avatar.png',
}),
};
beforeEach(async () => {
// Clear previous mock calls and reset implementations
jest.clearAllMocks();
// Reset environment variables needed by the strategy
process.env.OPENID_ISSUER = 'https://fake-issuer.com';
process.env.OPENID_CLIENT_ID = 'fake_client_id';
process.env.OPENID_CLIENT_SECRET = 'fake_client_secret';
process.env.DOMAIN_SERVER = 'https://example.com';
process.env.OPENID_CALLBACK_URL = '/callback';
process.env.OPENID_SCOPE = 'openid profile email';
process.env.OPENID_REQUIRED_ROLE = 'requiredRole';
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roles';
process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id';
delete process.env.OPENID_USERNAME_CLAIM;
delete process.env.OPENID_NAME_CLAIM;
delete process.env.PROXY;
delete process.env.OPENID_USE_PKCE;
// Default jwtDecode mock returns a token that includes the required role.
jwtDecode.mockReturnValue({
roles: ['requiredRole'],
});
// By default, assume that no user is found, so createUser will be called
findUser.mockResolvedValue(null);
createUser.mockImplementation(async (userData) => {
// simulate created user with an _id property
return { _id: 'newUserId', ...userData };
});
updateUser.mockImplementation(async (id, userData) => {
return { _id: id, ...userData };
});
// For image download, simulate a successful response
const fakeBuffer = Buffer.from('fake image');
const fakeResponse = {
ok: true,
buffer: jest.fn().mockResolvedValue(fakeBuffer),
};
fetch.mockResolvedValue(fakeResponse);
// Call the setup function and capture the verify callback
await setupOpenId();
verifyCallback = require('openid-client/passport').__getVerifyCallback();
});
it('should create a new user with correct username when username claim exists', async () => {
// Arrange our userinfo already has username 'flast'
const userinfo = tokenset.claims();
// Act
const { user } = await validate(tokenset);
// Assert
expect(user.username).toBe(userinfo.username);
expect(createUser).toHaveBeenCalledWith(
expect.objectContaining({
provider: 'openid',
openidId: userinfo.sub,
username: userinfo.username,
email: userinfo.email,
name: `${userinfo.given_name} ${userinfo.family_name}`,
}),
{ enabled: false },
true,
true,
);
});
it('should use given_name as username when username claim is missing', async () => {
// Arrange remove username from userinfo
const userinfo = { ...tokenset.claims() };
delete userinfo.username;
// Expect the username to be the given name (unchanged case)
const expectUsername = userinfo.given_name;
// Act
const { user } = await validate({ ...tokenset, claims: () => userinfo });
// Assert
expect(user.username).toBe(expectUsername);
expect(createUser).toHaveBeenCalledWith(
expect.objectContaining({ username: expectUsername }),
{ enabled: false },
true,
true,
);
});
it('should use email as username when username and given_name are missing', async () => {
// Arrange remove username and given_name
const userinfo = { ...tokenset.claims() };
delete userinfo.username;
delete userinfo.given_name;
const expectUsername = userinfo.email;
// Act
const { user } = await validate({ ...tokenset, claims: () => userinfo });
// Assert
expect(user.username).toBe(expectUsername);
expect(createUser).toHaveBeenCalledWith(
expect.objectContaining({ username: expectUsername }),
{ enabled: false },
true,
true,
);
});
it('should override username with OPENID_USERNAME_CLAIM when set', async () => {
// Arrange set OPENID_USERNAME_CLAIM so that the sub claim is used
process.env.OPENID_USERNAME_CLAIM = 'sub';
const userinfo = tokenset.claims();
// Act
const { user } = await validate(tokenset);
// Assert username should equal the sub (converted as-is)
expect(user.username).toBe(userinfo.sub);
expect(createUser).toHaveBeenCalledWith(
expect.objectContaining({ username: userinfo.sub }),
{ enabled: false },
true,
true,
);
});
it('should set the full name correctly when given_name and family_name exist', async () => {
// Arrange
const userinfo = tokenset.claims();
const expectedFullName = `${userinfo.given_name} ${userinfo.family_name}`;
// Act
const { user } = await validate(tokenset);
// Assert
expect(user.name).toBe(expectedFullName);
});
it('should override full name with OPENID_NAME_CLAIM when set', async () => {
// Arrange use the name claim as the full name
process.env.OPENID_NAME_CLAIM = 'name';
const userinfo = { ...tokenset.claims(), name: 'Custom Name' };
// Act
const { user } = await validate({ ...tokenset, claims: () => userinfo });
// Assert
expect(user.name).toBe('Custom Name');
});
it('should update an existing user on login', async () => {
// Arrange simulate that a user already exists
const existingUser = {
_id: 'existingUserId',
provider: 'local',
email: tokenset.claims().email,
openidId: '',
username: '',
name: '',
};
findUser.mockImplementation(async (query) => {
if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) {
return existingUser;
}
return null;
});
const userinfo = tokenset.claims();
// Act
await validate(tokenset);
// Assert updateUser should be called and the user object updated
expect(updateUser).toHaveBeenCalledWith(
existingUser._id,
expect.objectContaining({
provider: 'openid',
openidId: userinfo.sub,
username: userinfo.username,
name: `${userinfo.given_name} ${userinfo.family_name}`,
}),
);
});
it('should enforce the required role and reject login if missing', async () => {
// Arrange simulate a token without the required role.
jwtDecode.mockReturnValue({
roles: ['SomeOtherRole'],
});
const userinfo = tokenset.claims();
// Act
const { user, details } = await validate(tokenset);
// Assert verify that the strategy rejects login
expect(user).toBe(false);
expect(details.message).toBe('You must have the "requiredRole" role to log in.');
});
it('should attempt to download and save the avatar if picture is provided', async () => {
// Arrange ensure userinfo contains a picture URL
const userinfo = tokenset.claims();
// Act
const { user } = await validate(tokenset);
// Assert verify that download was attempted and the avatar field was set via updateUser
expect(fetch).toHaveBeenCalled();
// Our mock getStrategyFunctions.saveBuffer returns '/fake/path/to/avatar.png'
expect(user.avatar).toBe('/fake/path/to/avatar.png');
});
it('should not attempt to download avatar if picture is not provided', async () => {
// Arrange remove picture
const userinfo = { ...tokenset.claims() };
delete userinfo.picture;
// Act
await validate({ ...tokenset, claims: () => userinfo });
// Assert fetch should not be called and avatar should remain undefined or empty
expect(fetch).not.toHaveBeenCalled();
// Depending on your implementation, user.avatar may be undefined or an empty string.
});
it('should default to usePKCE false when OPENID_USE_PKCE is not defined', async () => {
const OpenIDStrategy = require('openid-client/passport').Strategy;
delete process.env.OPENID_USE_PKCE;
await setupOpenId();
const callOptions = OpenIDStrategy.mock.calls[OpenIDStrategy.mock.calls.length - 1][0];
expect(callOptions.usePKCE).toBe(false);
expect(callOptions.params?.code_challenge_method).toBeUndefined();
});
});

View File

@@ -1,8 +1,8 @@
import { IUser } from '@librechat/data-schemas';
import { FileSources } from 'librechat-data-provider';
import { getBalanceConfig, getMethods } from '../initAuth';
import { getAvatarProcessFunction, resizeAvatar } from '../utils/avatar';
import { CreateSocialUserParams } from './types';
const { FileSources } = require('librechat-data-provider');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { resizeAvatar } = require('~/server/services/Files/images/avatar');
const { updateUser, createUser, getUserById } = require('~/models');
const { getBalanceConfig } = require('~/server/services/Config');
/**
* Updates the avatar URL of an existing user. If the user's avatar URL does not include the query parameter
@@ -17,25 +17,24 @@ import { CreateSocialUserParams } from './types';
*
* @throws {Error} Throws an error if there's an issue saving the updated user object.
*/
const handleExistingUser = async (oldUser: IUser, avatarUrl: string) => {
const fileStrategy = process.env.CDN_PROVIDER ?? FileSources.local;
const handleExistingUser = async (oldUser, avatarUrl) => {
const fileStrategy = process.env.CDN_PROVIDER;
const isLocal = fileStrategy === FileSources.local;
let updatedAvatar = '';
if (isLocal && (oldUser.avatar === null || !oldUser.avatar?.includes('?manual=true'))) {
let updatedAvatar = false;
if (isLocal && (oldUser.avatar === null || !oldUser.avatar.includes('?manual=true'))) {
updatedAvatar = avatarUrl;
} else if (!isLocal && (oldUser.avatar === null || !oldUser.avatar?.includes('?manual=true'))) {
const userId = oldUser.id ?? '';
} else if (!isLocal && (oldUser.avatar === null || !oldUser.avatar.includes('?manual=true'))) {
const userId = oldUser._id;
const resizedBuffer = await resizeAvatar({
userId,
input: avatarUrl,
});
const processAvatar = getAvatarProcessFunction(fileStrategy);
updatedAvatar = await processAvatar({ buffer: resizedBuffer, userId });
const { processAvatar } = getStrategyFunctions(fileStrategy);
updatedAvatar = await processAvatar({ buffer: resizedBuffer, userId, manual: 'false' });
}
if (updatedAvatar != '') {
const { updateUser } = getMethods();
if (updatedAvatar) {
await updateUser(oldUser._id, { avatar: updatedAvatar });
}
};
@@ -69,7 +68,7 @@ const createSocialUser = async ({
username,
name,
emailVerified,
}: CreateSocialUserParams): Promise<IUser> => {
}) => {
const update = {
email,
avatar: avatarUrl,
@@ -79,10 +78,10 @@ const createSocialUser = async ({
name,
emailVerified,
};
const balanceConfig = getBalanceConfig();
const { createUser, getUserById, updateUser } = getMethods();
const balanceConfig = await getBalanceConfig();
const newUserId = await createUser(update, balanceConfig);
const fileStrategy = process.env.CDN_PROVIDER ?? FileSources.local;
const fileStrategy = process.env.CDN_PROVIDER;
const isLocal = fileStrategy === FileSources.local;
if (!isLocal) {
@@ -90,11 +89,19 @@ const createSocialUser = async ({
userId: newUserId,
input: avatarUrl,
});
const processAvatar = getAvatarProcessFunction(fileStrategy);
const avatar = await processAvatar({ buffer: resizedBuffer, userId: newUserId });
const { processAvatar } = getStrategyFunctions(fileStrategy);
const avatar = await processAvatar({
buffer: resizedBuffer,
userId: newUserId,
manual: 'false',
});
await updateUser(newUserId, { avatar });
}
return await getUserById(newUserId);
};
export { handleExistingUser, createSocialUser };
module.exports = {
handleExistingUser,
createSocialUser,
};

Some files were not shown because too many files have changed in this diff Show More