LibreChat/api/server/services/OboTokenService.js
jcbartle 268f095c1a
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
🔒 feat: Add On-Behalf-Of (OBO) token exchange support for MCP Servers (#13429)
* 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.
2026-06-01 22:36:18 -04:00

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,
};