mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-06-11 10:37:22 +00:00
Some checks failed
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Sync Helm Chart Tags / Ignore non-main push (push) Waiting to run
Sync Helm Chart Tags / Sync chart tags (push) Waiting to run
Publish `librechat-data-provider` to NPM / pack (push) Has been cancelled
Publish `@librechat/data-schemas` to NPM / pack (push) Has been cancelled
Publish `librechat-data-provider` to NPM / publish-npm (push) Has been cancelled
Publish `@librechat/data-schemas` to NPM / publish-npm (push) Has been cancelled
* Add OBO (On-Behalf-Of) token exchange support for MCP server connections Enables transparent authentication to Entra ID-backed MCP servers using the logged-in user's federated token via the OAuth 2.0 jwt-bearer grant. Configured via obo.scopes in librechat.yaml server config. - Extract generic OboTokenService from GraphTokenService (jwt-bearer grant + cache) - Refactor GraphTokenService to thin wrapper delegating to OboTokenService - Add obo schema field to BaseOptionsSchema in data-provider - Add resolveOboToken in packages/api/src/mcp/oauth/obo.ts (validates federated token, calls resolver, returns MCPOAuthTokens) - Wire oboTokenResolver through MCPConnectionFactory, MCPManager, UserConnectionManager - OBO tokens injected via request headers (not OAuth transport), refreshed on each tool call - Explicit error on OBO failure (no fallthrough to standard OAuth redirect) - Add unit tests for both resolveOboToken (9 tests) and exchangeOboToken (14 tests) * Add OBO authentication option to MCP server UI configuration Enable users to configure On-Behalf-Of (OBO) token exchange for MCP servers created via the UI (MongoDB-stored), in addition to the existing YAML-based configuration. - Add "On-Behalf-Of (OBO)" radio option to MCP server auth section with scopes input field - Remove obo from omitServerManagedFields so the field passes UI schema validation - Add OBO to AuthTypeEnum, obo_scopes to AuthConfig, and OBO handling in form defaults and submission - Add .min(1) validation on obo.scopes to reject empty strings - Add English localization keys: com_ui_obo, com_ui_obo_scopes, com_ui_obo_scopes_description - Add 5 schema validation tests for OBO field acceptance, transport compatibility, and edge cases * 🧊 fix: Add obo to safe properties in redactServerSecrets. Fixes the OBO configuration not showing up in the MCP UI after app restart * Address linter errors * 🧊 fix: fail closed on OBO refresh errors and retry transient token exchange failures - stop tool calls from falling back to stale Authorization headers when per-call OBO refresh fails - add one-time retry for transient Entra OBO exchange failures (network/429/5xx) - preserve structured OBO failure reasons and retryability in resolveOboToken - improve OBO auth error messaging for connection setup and tool execution - add tests for transient vs permanent OBO failure paths * Addressing linting errors / warnings * 🧊 fix: isolate OBO MCP auth to user-scoped connections - block OBO-enabled servers from app-level shared MCP connections - bypass shared connection lookup for OBO servers in MCPManager.getConnection - add regressions covering OBO connection scoping and preserve non-OBO app connection reuse * 🛠️ refactor: centralize MCP user-scoped connection policy - add shared requiresUserScopedConnection helper for OAuth, OBO, and customUserVars - use the shared predicate in MCPManager and ConnectionsRepository - add utils coverage for user-scoped connection policy * 🧊 fix: restrict MCP OBO config to header-capable transports - Move OBO configuration out of the shared MCP base options schema and allow it only on SSE and streamable-http transports, where request headers are applied. - Explicitly reject OBO on stdio and websocket configs to avoid accepted-but- nonfunctional server definitions. Add schema coverage for admin/config parsing and user-input websocket validation. * 🧊 fix: single-flight concurrent OBO token exchanges Concurrent tool calls that arrive on a cache miss were each issuing their own jwt-bearer request to the IdP. Under that fan-out, Entra intermittently returned errors that the retry classifier saw as non-retryable, surfacing as: "The identity provider rejected the OBO token exchange. Cannot execute tool <name>. Re-authenticate the user or verify the configured OBO scopes and retry." A user retry then hit the populated cache and succeeded, which matches the observed flakiness — the cache was empty at the moment of fan-out but populated by the time the user clicked retry. - Coalesce concurrent exchanges in `OboTokenService.exchangeOboToken` keyed by `${openidId}:${scopes}`. Callers that arrive while an exchange is in flight share the same upstream request and receive the same result. `fromCache=false` continues to force a fresh, independent exchange (and is not joined by `fromCache=true` callers). The IdP call, single-retry path, and cache write are unchanged — they were moved into a `performOboExchange` helper so the coalescing wrapper stays small. - Tests cover: coalescing on the same key, isolation between different keys, cleanup on success, cleanup on failure, and the `fromCache=false` bypass. * 🔒 feat: gate MCP OBO config behind MCP_SERVERS.CONFIGURE_OBO permission OBO silently mints per-user delegated tokens from the caller's federated access token and forwards them to whatever URL the server config points at. Previously, anyone with MCP_SERVERS.CREATE could configure obo.scopes — so if server creation is ever delegated beyond admins, a user could stand up an attacker-controlled server, attach it to a shared agent, and exfiltrate other users' downstream tokens on tool invocation. Add a dedicated MCP_SERVERS.CONFIGURE_OBO permission (ADMIN: true, USER: false by default) and enforce it at three layers so the safety property no longer depends on CREATE staying admin-only: - Create/update: POST/PATCH /api/mcp/servers returns 403 when the body carries `obo` and the caller's role lacks the permission. - Runtime fail-closed: for DB-sourced configs, MCPConnectionFactory and MCPManager.callTool re-check the original author's role before each OBO exchange. If the author has been downgraded, the exchange is skipped (factory) or refused (callTool) — retained configs lose their privileges automatically. - UI: the OBO option is hidden in the MCP server dialog for users without the permission; a CONFIGURE_OBO toggle is exposed in the MCP admin role editor. Existing role docs receive the new sub-key via the permission backfill in updateInterfacePermissions on next startup, preserving any operator-set values. YAML/Config-sourced server configs are unaffected since they're admin-controlled at the deployment level. * 🧊 fix: wire OBO machinery for servers with requiresOAuth: false The discovery and user-connection paths gated OAuth wiring (flow manager, token methods, oboTokenResolver, oboTrustChecker) behind isOAuthServer(), which only considers requiresOAuth/oauth fields. A DB-stored OBO server with requiresOAuth: false therefore landed in the non-OAuth branch, never received an oboTokenResolver, and the factory's usesObo getter evaluated to false — sending a bare request that the upstream rejected with invalid_token. Add requiresOAuthMachinery() (OAuth OR OBO) and use it at those two gates. isOAuthServer remains for the OAuth-handshake-only check (shouldInitiateOAuthBeforeConnect), where OBO must not initiate a handshake. Plumb the OBO resolver/trust-checker through ToolDiscoveryOptions so reinitMCPServer can pass them on the discovery path. * 🧊 fix: lock all OBO-target fields (URL, proxy, headers, auth) without CONFIGURE_OBO The CONFIGURE_OBO permission was meant to gate control of the endpoint that receives OBO-minted per-user delegated tokens and the scopes that are requested. The previous frontend lock + backend gate only covered obo.scopes and the auth section, leaving url/proxy/headers/etc. editable by anyone with UPDATE — meaning a non-permission user could still redirect an existing OBO server's token flow to an attacker endpoint. Switch to an allowlist policy: when editing an OBO server without CONFIGURE_OBO, only title/description/iconPath are mutable. Backend rejects any other field change with 403; frontend disables the non-allowlist sections (URL, transport, auth, trust) via fieldset. The comparison surface (MCP_USER_INPUT_FIELDS) is derived from MCPServerUserInputSchema's union members so it stays in sync with the schema. New schema fields land in the locked set by default — adding to the allowlist is the only way to unlock them, which preserves the security-review boundary. * 🧊 fix: skip unauthenticated MCP inspection for OBO-only servers MCPServerInspector.inspectServer() ran an unauthenticated temp connection unless the config had requiresOAuth or customUserVars set. For OBO-only servers without standard MCP OAuth advertisement, this caused MCPConnectionFactory.create to attempt the connection without a user or oboTokenResolver — failing on servers that reject the MCP initialize handshake without a valid bearer token, which surfaced as MCP_INSPECTION_FAILED on create/update. Add `obo` to the skip list alongside requiresOAuth and customUserVars, matching the existing pattern for user-scoped auth modes. * Addressed linting error: watchedTitle is declared but never referenced (the auto-fill logic at line 156 uses getValues('title') instead). Deleted constant.
194 lines
6 KiB
JavaScript
194 lines
6 KiB
JavaScript
const client = require('openid-client');
|
|
const { logger } = require('@librechat/data-schemas');
|
|
const { CacheKeys } = require('librechat-data-provider');
|
|
const { getOpenIdConfig } = require('~/strategies/openidStrategy');
|
|
const getLogStores = require('~/cache/getLogStores');
|
|
|
|
const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503, 504]);
|
|
const RETRYABLE_ERROR_CODES = new Set(['ETIMEDOUT', 'ECONNRESET', 'EAI_AGAIN', 'ENOTFOUND']);
|
|
const OBO_RETRY_DELAY_MS = 300;
|
|
|
|
/**
|
|
* In-flight OBO exchanges keyed by `${openidId}:${scopes}`.
|
|
*
|
|
* Without coalescing, parallel tool calls that arrive on a cache miss each issue
|
|
* their own jwt-bearer request to the IdP. Under fan-out, Entra intermittently
|
|
* returns errors that look non-retryable, surfacing as "identity provider
|
|
* rejected the OBO token exchange." A user retry then hits the populated cache
|
|
* and succeeds, which matches the observed flakiness. Sharing a single upstream
|
|
* exchange per key removes the thundering herd.
|
|
*/
|
|
const inFlightExchanges = new Map();
|
|
|
|
function getErrorStatus(error) {
|
|
return error?.status ?? error?.statusCode ?? error?.response?.status;
|
|
}
|
|
|
|
function getErrorCode(error) {
|
|
return typeof error?.code === 'string' ? error.code.toUpperCase() : undefined;
|
|
}
|
|
|
|
function isRetryableOboExchangeError(error) {
|
|
const status = getErrorStatus(error);
|
|
if (status != null && RETRYABLE_STATUS_CODES.has(status)) {
|
|
return true;
|
|
}
|
|
|
|
const code = getErrorCode(error);
|
|
if (code != null && RETRYABLE_ERROR_CODES.has(code)) {
|
|
return true;
|
|
}
|
|
|
|
const message = String(error?.message ?? '').toLowerCase();
|
|
return (
|
|
message.includes('timed out') ||
|
|
message.includes('timeout') ||
|
|
message.includes('econnreset') ||
|
|
message.includes('socket hang up') ||
|
|
message.includes('temporarily unavailable') ||
|
|
message.includes('too many requests') ||
|
|
message.includes('service unavailable')
|
|
);
|
|
}
|
|
|
|
function tagOboExchangeError(error, retryable) {
|
|
if (error && typeof error === 'object') {
|
|
error.retryable = retryable;
|
|
error.oboFailureReason = 'exchange_failed';
|
|
}
|
|
return error;
|
|
}
|
|
|
|
async function delay(ms) {
|
|
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
async function performOboExchange({ user, accessToken, scopes, config, tokensCache, cacheKey }) {
|
|
const requestGrant = async () =>
|
|
client.genericGrantRequest(config, 'urn:ietf:params:oauth:grant-type:jwt-bearer', {
|
|
scope: scopes,
|
|
assertion: accessToken,
|
|
requested_token_use: 'on_behalf_of',
|
|
});
|
|
|
|
let grantResponse;
|
|
try {
|
|
grantResponse = await requestGrant();
|
|
} catch (error) {
|
|
const retryable = isRetryableOboExchangeError(error);
|
|
if (!retryable) {
|
|
throw tagOboExchangeError(error, false);
|
|
}
|
|
|
|
logger.warn(
|
|
`[OboTokenService] Transient OBO exchange failure for user: ${user.openidId}, retrying once`,
|
|
error,
|
|
);
|
|
await delay(OBO_RETRY_DELAY_MS);
|
|
|
|
try {
|
|
grantResponse = await requestGrant();
|
|
} catch (retryError) {
|
|
throw tagOboExchangeError(retryError, isRetryableOboExchangeError(retryError));
|
|
}
|
|
}
|
|
|
|
const tokenResponse = {
|
|
access_token: grantResponse.access_token,
|
|
token_type: 'Bearer',
|
|
expires_in: grantResponse.expires_in || 3600,
|
|
scope: scopes,
|
|
};
|
|
|
|
await tokensCache.set(cacheKey, tokenResponse, (grantResponse.expires_in || 3600) * 1000);
|
|
|
|
logger.debug(
|
|
`[OboTokenService] Successfully obtained and cached OBO token for user: ${user.openidId}`,
|
|
);
|
|
return tokenResponse;
|
|
}
|
|
|
|
/**
|
|
* Exchange a user's access token for a downstream-scoped token via the
|
|
* OAuth 2.0 On-Behalf-Of (jwt-bearer) grant.
|
|
*
|
|
* Concurrent callers for the same `${openidId}:${scopes}` key share a single
|
|
* upstream exchange (see `inFlightExchanges`) so a fan-out of tool calls right
|
|
* after a cache miss does not produce N parallel requests to the IdP.
|
|
*
|
|
* @param {Object} user - User object with OpenID information
|
|
* @param {string} accessToken - Federated access token used as OBO assertion
|
|
* @param {string} scopes - Scopes to request for the downstream service
|
|
* @param {boolean} [fromCache=true] - When true, read from cache and join any
|
|
* in-flight exchange. When false, bypass both and force a fresh exchange.
|
|
* @returns {Promise<Object>} Token response with access_token and expires_in
|
|
*/
|
|
async function exchangeOboToken(user, accessToken, scopes, fromCache = true) {
|
|
if (!user.openidId) {
|
|
throw new Error('User must be authenticated via OpenID to perform OBO token exchange');
|
|
}
|
|
|
|
if (!accessToken) {
|
|
throw new Error('Access token is required for OBO exchange');
|
|
}
|
|
|
|
if (!scopes) {
|
|
throw new Error('Scopes are required for OBO exchange');
|
|
}
|
|
|
|
const config = getOpenIdConfig();
|
|
if (!config) {
|
|
throw new Error('OpenID configuration not available');
|
|
}
|
|
|
|
const cacheKey = `${user.openidId}:${scopes}`;
|
|
const tokensCache = getLogStores(CacheKeys.OPENID_EXCHANGED_TOKENS);
|
|
|
|
if (fromCache) {
|
|
const cachedToken = await tokensCache.get(cacheKey);
|
|
if (cachedToken) {
|
|
logger.debug(`[OboTokenService] Using cached token for user: ${user.openidId}`);
|
|
return cachedToken;
|
|
}
|
|
|
|
const inFlight = inFlightExchanges.get(cacheKey);
|
|
if (inFlight) {
|
|
logger.debug(`[OboTokenService] Joining in-flight OBO exchange for user: ${user.openidId}`);
|
|
return inFlight;
|
|
}
|
|
}
|
|
|
|
logger.debug(
|
|
`[OboTokenService] Requesting new OBO token for user: ${user.openidId}, scopes: ${scopes}`,
|
|
);
|
|
|
|
const exchangePromise = performOboExchange({
|
|
user,
|
|
accessToken,
|
|
scopes,
|
|
config,
|
|
tokensCache,
|
|
cacheKey,
|
|
});
|
|
|
|
if (fromCache) {
|
|
inFlightExchanges.set(cacheKey, exchangePromise);
|
|
exchangePromise
|
|
.finally(() => {
|
|
if (inFlightExchanges.get(cacheKey) === exchangePromise) {
|
|
inFlightExchanges.delete(cacheKey);
|
|
}
|
|
})
|
|
.catch(() => {
|
|
/* The original rejection is delivered to the awaiting caller; this
|
|
* chain exists only to run cleanup, so swallow it here to avoid an
|
|
* unhandled-rejection warning on the cleanup promise. */
|
|
});
|
|
}
|
|
|
|
return exchangePromise;
|
|
}
|
|
|
|
module.exports = {
|
|
exchangeOboToken,
|
|
};
|