LibreChat/api/server/services/OboTokenService.spec.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

342 lines
12 KiB
JavaScript

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(),
warn: jest.fn(),
},
}));
const client = require('openid-client');
const { getOpenIdConfig } = require('~/strategies/openidStrategy');
const getLogStores = require('~/cache/getLogStores');
const { exchangeOboToken } = require('./OboTokenService');
describe('OboTokenService', () => {
let mockTokensCache;
let mockOpenIdConfig;
const mockUser = {
openidId: 'oidc-sub-123',
email: 'test@example.com',
name: 'Test User',
};
beforeEach(() => {
jest.clearAllMocks();
mockTokensCache = {
get: jest.fn().mockResolvedValue(null),
set: jest.fn().mockResolvedValue(undefined),
};
getLogStores.mockReturnValue(mockTokensCache);
mockOpenIdConfig = {
client_id: 'test-client-id',
issuer: 'https://login.microsoftonline.com/tenant-id/v2.0',
};
getOpenIdConfig.mockReturnValue(mockOpenIdConfig);
client.genericGrantRequest.mockResolvedValue({
access_token: 'exchanged-obo-token',
expires_in: 3600,
});
});
describe('input validation', () => {
it('should throw when user has no openidId', async () => {
await expect(
exchangeOboToken({ email: 'test@example.com' }, 'access-token', 'api://scope'),
).rejects.toThrow('User must be authenticated via OpenID to perform OBO token exchange');
});
it('should throw when accessToken is missing', async () => {
await expect(exchangeOboToken(mockUser, '', 'api://scope')).rejects.toThrow(
'Access token is required for OBO exchange',
);
});
it('should throw when scopes are missing', async () => {
await expect(exchangeOboToken(mockUser, 'access-token', '')).rejects.toThrow(
'Scopes are required for OBO exchange',
);
});
it('should throw when OpenID config is not available', async () => {
getOpenIdConfig.mockReturnValue(null);
await expect(exchangeOboToken(mockUser, 'access-token', 'api://scope')).rejects.toThrow(
'OpenID configuration not available',
);
});
});
describe('cache behavior', () => {
it('should return cached token when fromCache is true and cache hit', async () => {
const cachedToken = {
access_token: 'cached-obo-token',
token_type: 'Bearer',
expires_in: 1800,
scope: 'api://mcp-server/Scope.Read',
};
mockTokensCache.get.mockResolvedValue(cachedToken);
const result = await exchangeOboToken(
mockUser,
'access-token',
'api://mcp-server/Scope.Read',
true,
);
expect(result).toBe(cachedToken);
expect(mockTokensCache.get).toHaveBeenCalledWith('oidc-sub-123:api://mcp-server/Scope.Read');
expect(client.genericGrantRequest).not.toHaveBeenCalled();
});
it('should skip cache when fromCache is false', async () => {
const cachedToken = { access_token: 'cached-obo-token' };
mockTokensCache.get.mockResolvedValue(cachedToken);
const result = await exchangeOboToken(
mockUser,
'access-token',
'api://mcp-server/Scope.Read',
false,
);
expect(mockTokensCache.get).not.toHaveBeenCalled();
expect(client.genericGrantRequest).toHaveBeenCalled();
expect(result.access_token).toBe('exchanged-obo-token');
});
it('should default fromCache to true', async () => {
mockTokensCache.get.mockResolvedValue(null);
await exchangeOboToken(mockUser, 'access-token', 'api://scope');
expect(mockTokensCache.get).toHaveBeenCalledWith('oidc-sub-123:api://scope');
});
});
describe('OBO token exchange', () => {
it('should call genericGrantRequest with jwt-bearer grant type', async () => {
await exchangeOboToken(mockUser, 'user-access-token', 'api://mcp-server/Tools.ReadWrite');
expect(client.genericGrantRequest).toHaveBeenCalledWith(
mockOpenIdConfig,
'urn:ietf:params:oauth:grant-type:jwt-bearer',
{
scope: 'api://mcp-server/Tools.ReadWrite',
assertion: 'user-access-token',
requested_token_use: 'on_behalf_of',
},
);
});
it('should return token response with correct structure', async () => {
const result = await exchangeOboToken(
mockUser,
'access-token',
'api://mcp-server/Tools.ReadWrite',
);
expect(result).toEqual({
access_token: 'exchanged-obo-token',
token_type: 'Bearer',
expires_in: 3600,
scope: 'api://mcp-server/Tools.ReadWrite',
});
});
it('should cache the exchanged token with correct TTL', async () => {
client.genericGrantRequest.mockResolvedValue({
access_token: 'new-obo-token',
expires_in: 1800,
});
await exchangeOboToken(mockUser, 'access-token', 'api://scope');
expect(mockTokensCache.set).toHaveBeenCalledWith(
'oidc-sub-123:api://scope',
{
access_token: 'new-obo-token',
token_type: 'Bearer',
expires_in: 1800,
scope: 'api://scope',
},
1800 * 1000,
);
});
it('should default expires_in to 3600 when not in response', async () => {
client.genericGrantRequest.mockResolvedValue({
access_token: 'no-expiry-token',
});
const result = await exchangeOboToken(mockUser, 'access-token', 'api://scope');
expect(result.expires_in).toBe(3600);
expect(mockTokensCache.set).toHaveBeenCalledWith(
'oidc-sub-123:api://scope',
expect.objectContaining({ expires_in: 3600 }),
3600 * 1000,
);
});
it('should propagate errors from genericGrantRequest', async () => {
client.genericGrantRequest.mockRejectedValue(
new Error('invalid_grant: AADSTS50013: Assertion failed signature validation'),
);
await expect(exchangeOboToken(mockUser, 'bad-token', 'api://scope')).rejects.toThrow(
'invalid_grant: AADSTS50013: Assertion failed signature validation',
);
});
it('should retry once for transient Entra failures and succeed on the second attempt', async () => {
const transientError = Object.assign(new Error('Service unavailable'), { status: 503 });
const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation((callback) => {
callback();
return 0;
});
try {
client.genericGrantRequest.mockRejectedValueOnce(transientError).mockResolvedValueOnce({
access_token: 'retried-obo-token',
expires_in: 1800,
});
const result = await exchangeOboToken(mockUser, 'access-token', 'api://scope');
expect(client.genericGrantRequest).toHaveBeenCalledTimes(2);
expect(result).toEqual({
access_token: 'retried-obo-token',
token_type: 'Bearer',
expires_in: 1800,
scope: 'api://scope',
});
} finally {
setTimeoutSpy.mockRestore();
}
});
it('should not retry permanent OBO exchange failures', async () => {
const permanentError = new Error(
'invalid_grant: AADSTS50013: Assertion failed signature validation',
);
client.genericGrantRequest.mockRejectedValue(permanentError);
await expect(exchangeOboToken(mockUser, 'bad-token', 'api://scope')).rejects.toThrow(
'invalid_grant: AADSTS50013: Assertion failed signature validation',
);
expect(client.genericGrantRequest).toHaveBeenCalledTimes(1);
});
});
describe('cache key isolation', () => {
it('should use different cache keys for different scopes', async () => {
await exchangeOboToken(mockUser, 'access-token', 'api://server-a/Scope.A');
await exchangeOboToken(mockUser, 'access-token', 'api://server-b/Scope.B');
expect(mockTokensCache.get).toHaveBeenCalledWith('oidc-sub-123:api://server-a/Scope.A');
expect(mockTokensCache.get).toHaveBeenCalledWith('oidc-sub-123:api://server-b/Scope.B');
});
it('should use different cache keys for different users', async () => {
const otherUser = { openidId: 'oidc-sub-456', email: 'other@example.com' };
await exchangeOboToken(mockUser, 'access-token', 'api://scope');
await exchangeOboToken(otherUser, 'access-token', 'api://scope');
expect(mockTokensCache.get).toHaveBeenCalledWith('oidc-sub-123:api://scope');
expect(mockTokensCache.get).toHaveBeenCalledWith('oidc-sub-456:api://scope');
});
});
describe('single-flight coalescing', () => {
/** Yields long enough for both pending callers to advance past their cache lookup. */
const flushMicrotasks = () => new Promise((resolve) => setImmediate(resolve));
it('coalesces concurrent exchanges for the same key into one IdP call', async () => {
let resolveGrant;
client.genericGrantRequest.mockReturnValueOnce(
new Promise((resolve) => {
resolveGrant = resolve;
}),
);
const callA = exchangeOboToken(mockUser, 'access-token', 'api://shared');
const callB = exchangeOboToken(mockUser, 'access-token', 'api://shared');
await flushMicrotasks();
expect(client.genericGrantRequest).toHaveBeenCalledTimes(1);
resolveGrant({ access_token: 'shared-obo-token', expires_in: 3600 });
const [resultA, resultB] = await Promise.all([callA, callB]);
expect(resultA).toEqual(resultB);
expect(resultA.access_token).toBe('shared-obo-token');
expect(client.genericGrantRequest).toHaveBeenCalledTimes(1);
expect(mockTokensCache.set).toHaveBeenCalledTimes(1);
});
it('does not coalesce exchanges for different keys', async () => {
await Promise.all([
exchangeOboToken(mockUser, 'access-token', 'api://scope-a'),
exchangeOboToken(mockUser, 'access-token', 'api://scope-b'),
]);
expect(client.genericGrantRequest).toHaveBeenCalledTimes(2);
});
it('clears the in-flight slot after a successful exchange', async () => {
await exchangeOboToken(mockUser, 'access-token', 'api://scope');
expect(client.genericGrantRequest).toHaveBeenCalledTimes(1);
await exchangeOboToken(mockUser, 'access-token', 'api://scope');
expect(client.genericGrantRequest).toHaveBeenCalledTimes(2);
});
it('clears the in-flight slot after a failed exchange', async () => {
client.genericGrantRequest
.mockRejectedValueOnce(
new Error('invalid_grant: AADSTS50013: Assertion failed signature validation'),
)
.mockResolvedValueOnce({ access_token: 'fresh-token', expires_in: 3600 });
await expect(exchangeOboToken(mockUser, 'access-token', 'api://scope')).rejects.toThrow(
'invalid_grant',
);
const result = await exchangeOboToken(mockUser, 'access-token', 'api://scope');
expect(result.access_token).toBe('fresh-token');
expect(client.genericGrantRequest).toHaveBeenCalledTimes(2);
});
it('bypasses in-flight coalescing when fromCache is false', async () => {
let resolveFirst;
client.genericGrantRequest
.mockReturnValueOnce(
new Promise((resolve) => {
resolveFirst = resolve;
}),
)
.mockResolvedValueOnce({ access_token: 'forced-fresh-token', expires_in: 3600 });
const callA = exchangeOboToken(mockUser, 'access-token', 'api://scope', true);
await flushMicrotasks();
const callB = exchangeOboToken(mockUser, 'access-token', 'api://scope', false);
expect(client.genericGrantRequest).toHaveBeenCalledTimes(2);
resolveFirst({ access_token: 'in-flight-token', expires_in: 3600 });
const [resultA, resultB] = await Promise.all([callA, callB]);
expect(resultA.access_token).toBe('in-flight-token');
expect(resultB.access_token).toBe('forced-fresh-token');
});
});
});