diff --git a/api/server/routes/agents/__tests__/abort.spec.js b/api/server/routes/agents/__tests__/abort.spec.js index 3d3c748b06..82a42dd098 100644 --- a/api/server/routes/agents/__tests__/abort.spec.js +++ b/api/server/routes/agents/__tests__/abort.spec.js @@ -77,6 +77,7 @@ jest.mock('~/server/routes/agents/chat', () => require('express').Router()); jest.mock('~/server/routes/agents/v1', () => ({ v1: require('express').Router(), })); +jest.mock('~/server/routes/agents/management', () => require('express').Router()); // Import after mocks const agentRoutes = require('~/server/routes/agents/index'); diff --git a/api/server/routes/agents/__tests__/idempotencyLimiter.spec.js b/api/server/routes/agents/__tests__/idempotencyLimiter.spec.js index 2624a654ee..51c422c8df 100644 --- a/api/server/routes/agents/__tests__/idempotencyLimiter.spec.js +++ b/api/server/routes/agents/__tests__/idempotencyLimiter.spec.js @@ -60,6 +60,11 @@ jest.mock('~/server/routes/agents/v1', () => ({ })); jest.mock('~/server/routes/agents/openai', () => require('express').Router()); jest.mock('~/server/routes/agents/responses', () => require('express').Router()); +jest.mock('~/server/routes/agents/management', () => { + const router = require('express').Router(); + router.use((_req, res) => res.status(200).json({ surface: 'management' })); + return router; +}); jest.mock('~/server/controllers/agents/steer', () => { const controller = (_req, _res, next) => next(); controller.SteerDeliveryController = (_req, _res, next) => next(); @@ -80,6 +85,15 @@ const app = express(); app.use(express.json()); app.use('/agents', agentsRouter); +describe('Agent Management route precedence', () => { + it('reaches management before the catch-all execution router', async () => { + const response = await request(app).get('/agents/v1/agents'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ surface: 'management' }); + }); +}); + describe('start-generation idempotency before message limiters', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/api/server/routes/agents/__tests__/management.spec.js b/api/server/routes/agents/__tests__/management.spec.js new file mode 100644 index 0000000000..b8328804b2 --- /dev/null +++ b/api/server/routes/agents/__tests__/management.spec.js @@ -0,0 +1,65 @@ +const express = require('express'); +const request = require('supertest'); + +const mockMapAgentManagementError = jest.fn(() => ({ + status: 404, + body: { error: { code: 'not_found', message: 'Agent not found' } }, +})); +const mockCheckBan = jest.fn((_req, _res, next) => next()); +const mockUaParser = jest.fn((_req, _res, next) => next()); + +const mockRequireAgentManagementAuth = jest.fn((req, res, next) => { + if (req.headers.authorization !== 'Bearer valid-token') { + return res.status(401).json({ error: 'Unauthorized' }); + } + req.user = { id: 'integration-user', tenantId: 'tenant-a', role: 'USER' }; + next(); +}); + +jest.mock('../middleware', () => ({ + requireAgentManagementAuth: mockRequireAgentManagementAuth, +})); +jest.mock('@librechat/api', () => ({ + mapAgentManagementError: mockMapAgentManagementError, +})); +jest.mock('~/server/middleware', () => ({ + checkBan: mockCheckBan, + uaParser: mockUaParser, +})); + +const router = require('../management'); + +describe('Agent Management route boundary', () => { + const app = express(); + app.use('/api/agents/v1/agents', router); + + beforeEach(() => { + mockRequireAgentManagementAuth.mockClear(); + mockCheckBan.mockClear(); + mockUaParser.mockClear(); + mockMapAgentManagementError.mockClear(); + }); + + it('rejects a request before reaching management routes without machine authentication', async () => { + const response = await request(app).get('/api/agents/v1/agents'); + + expect(response.status).toBe(401); + expect(mockRequireAgentManagementAuth).toHaveBeenCalledTimes(1); + expect(mockCheckBan).not.toHaveBeenCalled(); + expect(mockUaParser).not.toHaveBeenCalled(); + }); + + it('allows an authenticated non-browser client into the management router', async () => { + const response = await request(app) + .get('/api/agents/v1/agents/not-a-management-operation') + .set('Authorization', 'Bearer valid-token') + .set('User-Agent', 'curl/8.0.0'); + + expect(response.status).toBe(404); + expect(response.body).toEqual({ error: { code: 'not_found', message: 'Agent not found' } }); + expect(mockCheckBan).toHaveBeenCalledTimes(1); + expect(mockUaParser).not.toHaveBeenCalled(); + expect(mockMapAgentManagementError).toHaveBeenCalledWith('not_found'); + expect(mockRequireAgentManagementAuth).toHaveBeenCalledTimes(1); + }); +}); diff --git a/api/server/routes/agents/__tests__/streamTenant.spec.js b/api/server/routes/agents/__tests__/streamTenant.spec.js index 194caa12fd..e23338c937 100644 --- a/api/server/routes/agents/__tests__/streamTenant.spec.js +++ b/api/server/routes/agents/__tests__/streamTenant.spec.js @@ -64,6 +64,7 @@ jest.mock('~/server/routes/agents/v1', () => { }); jest.mock('~/server/routes/agents/openai', () => require('express').Router()); jest.mock('~/server/routes/agents/responses', () => require('express').Router()); +jest.mock('~/server/routes/agents/management', () => require('express').Router()); const agentsRouter = require('../index'); const app = express(); diff --git a/api/server/routes/agents/index.js b/api/server/routes/agents/index.js index 46c50cd88f..f3bdd73c75 100644 --- a/api/server/routes/agents/index.js +++ b/api/server/routes/agents/index.js @@ -54,6 +54,7 @@ const { acknowledgeScheduledStopPersistence, } = require('~/server/services/Schedules'); const responses = require('./responses'); +const management = require('./management'); const openai = require('./openai'); const { v1 } = require('./v1'); const chat = require('./chat'); @@ -120,6 +121,13 @@ const router = express.Router(); */ router.use('/v1/responses', responses); +/** + * Machine-authenticated Agent Management routes. + * Mounted before the catch-all execution router so management requests cannot + * inherit execution authentication or API-key fallback behavior. + */ +router.use('/v1/agents', management); + /** * OpenAI-compatible API routes (API key authentication handled in route file) * Mounted at /agents/v1 (full path: /api/agents/v1/chat/completions) diff --git a/api/server/routes/agents/management.js b/api/server/routes/agents/management.js new file mode 100644 index 0000000000..a1b3c2d00f --- /dev/null +++ b/api/server/routes/agents/management.js @@ -0,0 +1,16 @@ +const express = require('express'); +const { mapAgentManagementError } = require('@librechat/api'); +const { checkBan } = require('~/server/middleware'); +const { requireAgentManagementAuth } = require('./middleware'); + +const router = express.Router(); + +router.use(requireAgentManagementAuth); +router.use(checkBan); + +router.use((_req, res) => { + const { status, body } = mapAgentManagementError('not_found'); + return res.status(status).json(body); +}); + +module.exports = router; diff --git a/api/server/routes/agents/middleware.js b/api/server/routes/agents/middleware.js index 34add78631..44f9befb1d 100644 --- a/api/server/routes/agents/middleware.js +++ b/api/server/routes/agents/middleware.js @@ -4,6 +4,7 @@ const { preAuthTenantMiddleware, createRequireApiKeyAuth, createRemoteAgentAuth, + createAgentManagementAuth, createCheckAgentTriggerAccess, createCheckRemoteAgentAccess, } = require('@librechat/api'); @@ -26,6 +27,12 @@ const requireRemoteAgentAuth = createRemoteAgentAuth({ getAppConfig, }); +const requireAgentManagementAuth = createAgentManagementAuth({ + findUser: db.findUser, + isPrincipalActive: db.isAgentTriggerPrincipalActive, + getAppConfig, +}); + const checkRemoteAgentsFeature = generateCheckAccess({ permissionType: PermissionTypes.REMOTE_AGENTS, permissions: [Permissions.USE], @@ -45,5 +52,6 @@ module.exports = { checkAgentTriggerPermission, preAuthTenantMiddleware, requireRemoteAgentAuth, + requireAgentManagementAuth, checkRemoteAgentsFeature, }; diff --git a/librechat.example.yaml b/librechat.example.yaml index da82f4d51f..cbe09b3735 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -625,6 +625,21 @@ endpoints: # checkpointer: # type: mongo # mongo (default) or memory (single-process development only) # ttl: 86400 # Approval window in seconds; defaults to 24 hours + # # (optional) Authenticate machine clients for the Agent Management API. + # # Each verified OAuth client is bound server-side to one existing LibreChat + # # user and tenant; that user's normal role and Agent ACLs still apply. + # managementApi: + # auth: + # oidc: + # enabled: true + # issuer: 'https://identity.example.com/' + # audience: 'https://librechat.example.com/agents' + # # jwksUri: 'https://identity.example.com/.well-known/jwks.json' + # clients: + # - clientId: 'machine-client-id' + # # subject: 'provider-specific-service-principal-subject' + # userId: '507f1f77bcf86cd799439011' + # tenantId: 'tenant-id' # (optional) Custom request headers for the built-in OpenAI / Google endpoints. # Forwarded on every request to the provider (or an AI gateway / reverse proxy diff --git a/packages/api/src/auth/oidc.spec.ts b/packages/api/src/auth/oidc.spec.ts new file mode 100644 index 0000000000..7153f491f0 --- /dev/null +++ b/packages/api/src/auth/oidc.spec.ts @@ -0,0 +1,95 @@ +import jwt from 'jsonwebtoken'; +import jwksRsa from 'jwks-rsa'; +import { fetch as undiciFetch } from 'undici'; +import type { JwtPayload } from 'jsonwebtoken'; +import { clearOidcAccessTokenCache, verifyOidcAccessToken } from './oidc'; + +const mockGetSigningKey = jest.fn(); + +jest.mock('jwks-rsa', () => jest.fn(() => ({ getSigningKey: mockGetSigningKey }))); +jest.mock('undici', () => ({ fetch: jest.fn() })); +jest.mock('jsonwebtoken', () => ({ decode: jest.fn(), verify: jest.fn() })); +jest.mock('~/utils', () => ({ isEnabled: jest.fn(() => true), math: jest.fn(() => 60000) })); +jest.mock('~/utils/proxy', () => ({ + getEnvProxyDispatcher: jest.fn(), + getHttpsProxyAgent: jest.fn(), +})); + +const mockFetch = undiciFetch as jest.Mock; +const mockDecode = jwt.decode as jest.Mock; +const mockVerify = jwt.verify as jest.Mock; +const originalOpenIdJwksUrl = process.env.OPENID_JWKS_URL; +type JwtVerifyCallback = (error: Error | null, payload?: JwtPayload) => void; + +afterEach(() => { + clearOidcAccessTokenCache(); + jest.clearAllMocks(); + if (originalOpenIdJwksUrl == null) { + delete process.env.OPENID_JWKS_URL; + } else { + process.env.OPENID_JWKS_URL = originalOpenIdJwksUrl; + } +}); + +it('does not use the interactive OpenID JWKS override unless explicitly enabled', async () => { + const issuer = 'https://management-issuer.example.com'; + const discoveredJwksUri = `${issuer}/jwks`; + process.env.OPENID_JWKS_URL = 'https://interactive-login.example.com/jwks'; + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ jwks_uri: discoveredJwksUri }), + }); + mockDecode.mockReturnValue({ header: { kid: 'management-key' } }); + mockGetSigningKey.mockResolvedValue({ getPublicKey: () => 'public-key' }); + mockVerify.mockImplementation( + (_token: string, _key: string, _options: object, callback: JwtVerifyCallback) => + callback(null, { sub: 'machine-client@clients' } satisfies JwtPayload), + ); + + await verifyOidcAccessToken('access-token', { + issuer, + audience: 'agent-management', + }); + + expect(mockFetch).toHaveBeenCalledWith( + `${issuer}/.well-known/openid-configuration`, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect(jwksRsa).toHaveBeenCalledWith( + expect.objectContaining({ + jwksUri: discoveredJwksUri, + rateLimit: true, + jwksRequestsPerMinute: 10, + }), + ); +}); + +it('does not reuse an interactive OpenID JWKS override for a machine-token caller', async () => { + const issuer = 'https://shared-issuer.example.com'; + const interactiveJwksUri = 'https://interactive-login.example.com/jwks'; + const discoveredJwksUri = `${issuer}/machine-jwks`; + process.env.OPENID_JWKS_URL = interactiveJwksUri; + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ jwks_uri: discoveredJwksUri }), + }); + mockDecode.mockReturnValue({ header: { kid: 'shared-key' } }); + mockGetSigningKey.mockResolvedValue({ getPublicKey: () => 'public-key' }); + mockVerify.mockImplementation( + (_token: string, _key: string, _options: object, callback: JwtVerifyCallback) => + callback(null, { sub: 'machine-client@clients' } satisfies JwtPayload), + ); + const config = { issuer, audience: 'agent-management' }; + + await verifyOidcAccessToken('interactive-token', config, { useOpenIdJwksEnv: true }); + await verifyOidcAccessToken('machine-token', config); + + expect(jwksRsa).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ jwksUri: interactiveJwksUri }), + ); + expect(jwksRsa).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ jwksUri: discoveredJwksUri }), + ); +}); diff --git a/packages/api/src/auth/oidc.ts b/packages/api/src/auth/oidc.ts new file mode 100644 index 0000000000..8f6444476b --- /dev/null +++ b/packages/api/src/auth/oidc.ts @@ -0,0 +1,248 @@ +import jwt from 'jsonwebtoken'; +import jwksRsa from 'jwks-rsa'; +import { fetch as undiciFetch } from 'undici'; +import { isRemoteOidcUrlAllowed } from 'librechat-data-provider'; +import type { Algorithm, JwtPayload, VerifyOptions } from 'jsonwebtoken'; +import type { RequestInit } from 'undici'; +import { getEnvProxyDispatcher, getHttpsProxyAgent } from '~/utils/proxy'; +import { normalizeOpenIdIssuer } from './openid'; +import { isEnabled, math } from '~/utils'; + +export interface OidcAccessTokenConfig { + audience: string; + issuer: string; + jwksUri?: string; +} + +export interface OidcAccessTokenOptions { + useOpenIdJwksEnv?: boolean; +} + +type JwksCacheOptions = { + enabled: boolean; + maxAge: number; +}; + +type CacheEntry = { + expiresAt: number; + promise: Promise; +}; + +const OIDC_DISCOVERY_TIMEOUT_MS = 10000; +const JWKS_REQUESTS_PER_MINUTE = 10; +const OIDC_THROTTLE_WINDOW_MS = 60000; +const MAX_JWKS_CACHE_ENTRIES = 100; +const JWT_ALGORITHMS: Algorithm[] = [ + 'RS256', + 'RS384', + 'RS512', + 'PS256', + 'PS384', + 'PS512', + 'ES256', + 'ES384', + 'ES512', +]; +const jwksUriCache = new Map>(); +const jwksClientCache = new Map>(); + +export function clearOidcAccessTokenCache(): void { + jwksUriCache.clear(); + jwksClientCache.clear(); +} + +export function extractBearerToken(authHeader: string | undefined): string | null { + const match = authHeader?.match(/^Bearer\s+(\S+)\s*$/i); + return match?.[1] ?? null; +} + +function pruneExpiredEntries(cache: Map>): void { + const now = Date.now(); + for (const [key, entry] of cache) { + if (entry.expiresAt <= now) cache.delete(key); + } +} + +function setCacheEntry( + cache: Map>, + key: string, + entry: CacheEntry, +): void { + pruneExpiredEntries(cache); + + while (cache.size >= MAX_JWKS_CACHE_ENTRIES) { + const oldestKey = cache.keys().next().value; + if (oldestKey == null) break; + cache.delete(oldestKey); + } + + cache.set(key, entry); +} + +function getJwksCacheOptions(): JwksCacheOptions { + return { + enabled: process.env.OPENID_JWKS_URL_CACHE_ENABLED + ? isEnabled(process.env.OPENID_JWKS_URL_CACHE_ENABLED) + : true, + maxAge: Math.max(math(process.env.OPENID_JWKS_URL_CACHE_TIME, 60000), 0), + }; +} + +function buildDiscoveryOptions(controller: AbortController): RequestInit { + const options: RequestInit = { signal: controller.signal }; + const dispatcher = getEnvProxyDispatcher(); + + if (dispatcher) { + options.dispatcher = dispatcher; + } + + return options; +} + +function ensureRemoteOidcUrlAllowed(value: string, label: string): string { + if (isRemoteOidcUrlAllowed(value)) return value; + throw new Error(`${label} must use https:// unless targeting localhost`); +} + +async function discoverJwksUri(issuer: string): Promise { + const normalizedIssuer = normalizeOpenIdIssuer(ensureRemoteOidcUrlAllowed(issuer, 'OIDC issuer')); + if (!normalizedIssuer) throw new Error('OIDC issuer is required'); + + const discoveryUrl = `${normalizedIssuer}/.well-known/openid-configuration`; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), OIDC_DISCOVERY_TIMEOUT_MS); + + try { + const res = await undiciFetch(discoveryUrl, buildDiscoveryOptions(controller)); + if (!res.ok) throw new Error(`OIDC discovery failed: ${res.status} ${res.statusText}`); + + const meta = (await res.json()) as { jwks_uri?: string }; + if (!meta.jwks_uri) throw new Error('OIDC discovery response missing jwks_uri'); + + return ensureRemoteOidcUrlAllowed(meta.jwks_uri, 'OIDC JWKS URI'); + } finally { + clearTimeout(timeout); + } +} + +async function resolveJwksUri( + oidcConfig: OidcAccessTokenConfig, + cacheOptions: JwksCacheOptions, + options: OidcAccessTokenOptions, +): Promise { + if (oidcConfig.jwksUri) return ensureRemoteOidcUrlAllowed(oidcConfig.jwksUri, 'OIDC JWKS URI'); + if (options.useOpenIdJwksEnv === true && process.env.OPENID_JWKS_URL) { + return ensureRemoteOidcUrlAllowed(process.env.OPENID_JWKS_URL, 'OIDC JWKS URI'); + } + + const cacheKey = oidcConfig.issuer; + const cached = jwksUriCache.get(cacheKey); + if (cached != null && cached.expiresAt > Date.now()) return cached.promise; + if (cached != null) jwksUriCache.delete(cacheKey); + + const promise = discoverJwksUri(oidcConfig.issuer).catch((err) => { + jwksUriCache.delete(cacheKey); + throw err; + }); + + setCacheEntry(jwksUriCache, cacheKey, { + promise, + expiresAt: Date.now() + Math.max(cacheOptions.maxAge, OIDC_THROTTLE_WINDOW_MS), + }); + return promise; +} + +function buildJwksClient(uri: string, cacheOptions: JwksCacheOptions): jwksRsa.JwksClient { + const cacheMaxAge = cacheOptions.enabled + ? Math.max(cacheOptions.maxAge, OIDC_THROTTLE_WINDOW_MS) + : OIDC_THROTTLE_WINDOW_MS; + const options: jwksRsa.Options = { + cache: true, + cacheMaxAge, + jwksUri: uri, + rateLimit: true, + jwksRequestsPerMinute: JWKS_REQUESTS_PER_MINUTE, + }; + + const requestAgent = getHttpsProxyAgent(uri); + if (requestAgent) { + options.requestAgent = requestAgent; + } + + return jwksRsa(options); +} + +async function getJwksClient( + oidcConfig: OidcAccessTokenConfig, + options: OidcAccessTokenOptions, +): Promise { + const cacheOptions = getJwksCacheOptions(); + const uri = await resolveJwksUri(oidcConfig, cacheOptions, options); + + const cacheKey = uri; + const cached = jwksClientCache.get(cacheKey); + if (cached != null && cached.expiresAt > Date.now()) return cached.promise; + if (cached != null) jwksClientCache.delete(cacheKey); + + let client: jwksRsa.JwksClient; + try { + client = buildJwksClient(uri, cacheOptions); + } catch (err) { + jwksClientCache.delete(cacheKey); + throw err; + } + + const promise = Promise.resolve(client); + + setCacheEntry(jwksClientCache, cacheKey, { + promise, + expiresAt: Date.now() + Math.max(cacheOptions.maxAge, OIDC_THROTTLE_WINDOW_MS), + }); + return promise; +} + +function getVerifyOptions(oidcConfig: OidcAccessTokenConfig): VerifyOptions { + const normalizedIssuer = normalizeOpenIdIssuer(oidcConfig.issuer); + const issuer = + normalizedIssuer && normalizedIssuer !== oidcConfig.issuer + ? [oidcConfig.issuer, normalizedIssuer] + : oidcConfig.issuer; + + return { + algorithms: JWT_ALGORITHMS, + audience: oidcConfig.audience, + issuer, + }; +} + +function verifyJwt( + token: string, + signingKey: jwksRsa.SigningKey, + oidcConfig: OidcAccessTokenConfig, +): Promise { + return new Promise((resolve, reject) => { + jwt.verify(token, signingKey.getPublicKey(), getVerifyOptions(oidcConfig), (err, payload) => { + if (err != null || payload == null) return reject(err ?? new Error('Empty payload')); + if (typeof payload === 'string') return reject(new Error('Invalid JWT payload')); + resolve(payload); + }); + }); +} + +export async function verifyOidcAccessToken( + token: string, + oidcConfig: OidcAccessTokenConfig, + options: OidcAccessTokenOptions = {}, +): Promise { + ensureRemoteOidcUrlAllowed(oidcConfig.issuer, 'OIDC issuer'); + + const decoded = jwt.decode(token, { complete: true }); + if (decoded == null || typeof decoded === 'string') throw new Error('Invalid JWT: cannot decode'); + + const kid = typeof decoded.header?.kid === 'string' ? decoded.header.kid : undefined; + if (!kid) throw new Error('Invalid JWT: missing signing key ID'); + + const client = await getJwksClient(oidcConfig, options); + const signingKey = await client.getSigningKey(kid); + return verifyJwt(token, signingKey, oidcConfig); +} diff --git a/packages/api/src/middleware/index.ts b/packages/api/src/middleware/index.ts index 5b2ee6d8be..3415491f8f 100644 --- a/packages/api/src/middleware/index.ts +++ b/packages/api/src/middleware/index.ts @@ -24,3 +24,4 @@ export * from './messageFilterPii'; export * from './messageValidation'; export * from './feedback'; export * from './generationRetry'; +export * from './management'; diff --git a/packages/api/src/middleware/management.database.spec.ts b/packages/api/src/middleware/management.database.spec.ts new file mode 100644 index 0000000000..c6b2eea764 --- /dev/null +++ b/packages/api/src/middleware/management.database.spec.ts @@ -0,0 +1,127 @@ +import mongoose from 'mongoose'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import { createMethods, createModels, getTenantId, tenantStorage } from '@librechat/data-schemas'; +import type { AllMethods, AppConfig, IUser } from '@librechat/data-schemas'; +import type { Request, Response } from 'express'; +import { createAgentManagementAuth } from './management'; + +const CLIENT_ID = 'machine-client'; +const TENANT_ID = 'tenant-a'; +const OTHER_TENANT_ID = 'tenant-b'; + +let mongoServer: MongoMemoryServer | undefined; +let methods: AllMethods; +let User: mongoose.Model; +let originalStrictMode: string | undefined; + +function createConfig(userId: string, tenantId: string): AppConfig { + return { + endpoints: { + agents: { + managementApi: { + auth: { + oidc: { + enabled: true, + issuer: 'https://issuer.example.com', + audience: 'https://agents.example.com', + }, + clients: [{ clientId: CLIENT_ID, userId, tenantId, enabled: true }], + }, + }, + }, + }, + } as AppConfig; +} + +function createRequest(): Request { + return { headers: { authorization: 'Bearer signed-access-token' } } as Request; +} + +function createResponse(): Response { + const res = { + status: jest.fn(), + json: jest.fn(), + }; + res.status.mockReturnValue(res); + res.json.mockReturnValue(res); + return res as unknown as Response; +} + +beforeAll(async () => { + originalStrictMode = process.env.TENANT_ISOLATION_STRICT; + process.env.TENANT_ISOLATION_STRICT = 'true'; + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + createModels(mongoose); + methods = createMethods(mongoose); + User = mongoose.models.User as mongoose.Model; +}); + +afterAll(async () => { + if (originalStrictMode == null) { + delete process.env.TENANT_ISOLATION_STRICT; + } else { + process.env.TENANT_ISOLATION_STRICT = originalStrictMode; + } + await mongoose.disconnect(); + await mongoServer?.stop(); +}); + +beforeEach(async () => { + await mongoose.connection.dropDatabase(); +}); + +describe('Agent Management principal resolution with tenant isolation', () => { + it('loads a bound User only from the configured tenant', async () => { + const user = await tenantStorage.run({ tenantId: TENANT_ID }, () => + User.create({ + email: 'integration@example.com', + name: 'Integration', + username: 'integration', + provider: 'local', + role: 'USER', + tenantId: TENANT_ID, + }), + ); + const userId = user._id.toString(); + const verifyAccessToken = jest.fn().mockResolvedValue({ + sub: `${CLIENT_ID}@clients`, + azp: CLIENT_ID, + exp: Math.floor(Date.now() / 1000) + 60, + }); + let downstreamTenant: string | undefined; + const validRequest = createRequest(); + const validResponse = createResponse(); + const validAuth = createAgentManagementAuth({ + findUser: methods.findUser, + isPrincipalActive: methods.isAgentTriggerPrincipalActive, + getAppConfig: jest.fn().mockResolvedValue(createConfig(userId, TENANT_ID)), + verifyAccessToken, + }); + + await validAuth(validRequest, validResponse, () => { + downstreamTenant = getTenantId(); + }); + + expect(downstreamTenant).toBe(TENANT_ID); + expect((validRequest as Request & { user?: IUser }).user).toMatchObject({ + id: userId, + tenantId: TENANT_ID, + }); + expect(validResponse.status).not.toHaveBeenCalled(); + + const crossTenantResponse = createResponse(); + const crossTenantNext = jest.fn(); + const crossTenantAuth = createAgentManagementAuth({ + findUser: methods.findUser, + isPrincipalActive: methods.isAgentTriggerPrincipalActive, + getAppConfig: jest.fn().mockResolvedValue(createConfig(userId, OTHER_TENANT_ID)), + verifyAccessToken, + }); + + await crossTenantAuth(createRequest(), crossTenantResponse, crossTenantNext); + + expect(crossTenantResponse.status).toHaveBeenCalledWith(401); + expect(crossTenantNext).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/api/src/middleware/management.spec.ts b/packages/api/src/middleware/management.spec.ts new file mode 100644 index 0000000000..1423ad8db2 --- /dev/null +++ b/packages/api/src/middleware/management.spec.ts @@ -0,0 +1,427 @@ +import { Types } from 'mongoose'; +import { getTenantId } from '@librechat/data-schemas'; +import type { AppConfig, IUser } from '@librechat/data-schemas'; +import type { Request, Response, NextFunction } from 'express'; +import type { JwtPayload } from 'jsonwebtoken'; +import type { AgentManagementAuthDeps } from './management'; +import { createAgentManagementAuth, getMachineClientId } from './management'; + +const USER_ID = '507f1f77bcf86cd799439011'; +const TENANT_ID = 'tenant-a'; +const CLIENT_ID = 'machine-client'; +const TOKEN = 'signed-access-token'; + +function createConfig(enabled = true): AppConfig { + return { + endpoints: { + agents: { + managementApi: { + auth: { + oidc: { + enabled, + issuer: 'https://issuer.example.com', + audience: 'https://agents.example.com', + }, + clients: [ + { + clientId: CLIENT_ID, + userId: USER_ID, + tenantId: TENANT_ID, + enabled: true, + }, + ], + }, + }, + }, + }, + } as AppConfig; +} + +function createUser(overrides: Partial = {}): IUser { + return { + _id: new Types.ObjectId(USER_ID), + email: 'integration@example.com', + name: 'Integration', + username: 'integration', + provider: 'local', + role: 'USER', + tenantId: TENANT_ID, + ...overrides, + } as IUser; +} + +function createRequest(headers: Request['headers'] = {}): Request { + return { headers: { authorization: `Bearer ${TOKEN}`, ...headers } } as Request; +} + +function createResponse(): Response { + const res = { + status: jest.fn(), + json: jest.fn(), + }; + res.status.mockReturnValue(res); + res.json.mockReturnValue(res); + return res as unknown as Response; +} + +function createPayload(overrides: JwtPayload = {}): JwtPayload { + return { + iss: 'https://issuer.example.com', + aud: 'https://agents.example.com', + sub: `${CLIENT_ID}@clients`, + azp: CLIENT_ID, + exp: Math.floor(Date.now() / 1000) + 60, + ...overrides, + }; +} + +function createDeps(overrides: Partial = {}): AgentManagementAuthDeps { + return { + getAppConfig: jest.fn().mockResolvedValue(createConfig()), + findUser: jest.fn().mockResolvedValue(createUser()), + isPrincipalActive: jest.fn().mockResolvedValue(true), + verifyAccessToken: jest.fn().mockResolvedValue(createPayload()), + ...overrides, + }; +} + +async function runMiddleware( + deps: AgentManagementAuthDeps, + req: Request, + res: Response, + next: NextFunction, +): Promise { + await Promise.resolve(createAgentManagementAuth(deps)(req, res, next)); +} + +describe('getMachineClientId', () => { + it.each([ + ['Auth0', { azp: CLIENT_ID }], + ['RFC 9068', { client_id: CLIENT_ID }], + ['matching dual-profile', { azp: CLIENT_ID, client_id: CLIENT_ID }], + ])('accepts the %s client identifier', (_profile, claims) => { + expect(getMachineClientId(createPayload({ azp: undefined, ...claims }))).toBe(CLIENT_ID); + }); + + it.each([ + ['missing client identifier', { azp: undefined }], + ['conflicting client identifiers', { azp: CLIENT_ID, client_id: 'other-client' }], + ['missing expiration', { exp: undefined }], + ['a non-finite expiration', { exp: Number.POSITIVE_INFINITY }], + ['expired token', { exp: Math.floor(Date.now() / 1000) - 1 }], + ])('rejects %s', (_case, claims) => { + expect(() => getMachineClientId(createPayload(claims))).toThrow(); + }); + + it('accepts a fractional NumericDate expiration', () => { + expect(getMachineClientId(createPayload({ exp: Date.now() / 1000 + 60.5 }))).toBe(CLIENT_ID); + }); + + it('accepts a configured provider-specific token subject', async () => { + const config = createConfig(); + const binding = config.endpoints?.agents?.managementApi?.auth?.clients[0]; + if (binding) binding.subject = 'opaque-service-principal-subject'; + const deps = createDeps({ + getAppConfig: jest.fn().mockResolvedValue(config), + verifyAccessToken: jest + .fn() + .mockResolvedValue(createPayload({ sub: 'opaque-service-principal-subject' })), + }); + const next = jest.fn(); + + await runMiddleware(deps, createRequest(), createResponse(), next); + + expect(next).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['the default subject rule', createConfig(), createPayload({ sub: 'auth0|human-user' })], + [ + 'a configured provider subject', + (() => { + const config = createConfig(); + const binding = config.endpoints?.agents?.managementApi?.auth?.clients[0]; + if (binding) binding.subject = 'expected-service-principal'; + return config; + })(), + createPayload({ sub: 'other-service-principal' }), + ], + ])('rejects a token that violates %s before querying a User', async (_case, config, payload) => { + const deps = createDeps({ + getAppConfig: jest.fn().mockResolvedValue(config), + verifyAccessToken: jest.fn().mockResolvedValue(payload), + }); + const res = createResponse(); + + await runMiddleware(deps, createRequest(), res, jest.fn()); + + expect(deps.findUser).not.toHaveBeenCalled(); + expect(deps.isPrincipalActive).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(401); + }); +}); + +describe('createAgentManagementAuth', () => { + it('resolves the configured User inside the bound tenant context', async () => { + let lookupTenant: string | undefined; + let downstreamTenant: string | undefined; + const deps = createDeps({ + findUser: jest.fn().mockImplementation(async () => { + lookupTenant = getTenantId(); + return createUser(); + }), + }); + const req = createRequest(); + const res = createResponse(); + const next = jest.fn(() => { + downstreamTenant = getTenantId(); + }); + + await runMiddleware(deps, req, res, next); + + expect(deps.getAppConfig).toHaveBeenCalledWith({ baseOnly: true }); + expect(deps.verifyAccessToken).toHaveBeenCalledWith(TOKEN, { + enabled: true, + issuer: 'https://issuer.example.com', + audience: 'https://agents.example.com', + }); + expect(deps.findUser).toHaveBeenCalledWith({ _id: USER_ID, tenantId: TENANT_ID }); + expect(lookupTenant).toBe(TENANT_ID); + expect(downstreamTenant).toBe(TENANT_ID); + expect(req.user).toMatchObject({ id: USER_ID, tenantId: TENANT_ID, role: 'USER' }); + expect(req.user).not.toHaveProperty('federatedTokens'); + expect((req as Request & { authStrategy?: string }).authStrategy).toBe('agentManagementM2M'); + expect(next).toHaveBeenCalledTimes(1); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('accepts a binding with an uppercase User ObjectId', async () => { + const config = createConfig(); + const binding = config.endpoints?.agents?.managementApi?.auth?.clients[0]; + if (binding) binding.userId = USER_ID.toUpperCase(); + const deps = createDeps({ getAppConfig: jest.fn().mockResolvedValue(config) }); + const next = jest.fn(); + + await runMiddleware(deps, createRequest(), createResponse(), next); + + expect(deps.findUser).toHaveBeenCalledWith({ + _id: USER_ID.toUpperCase(), + tenantId: TENANT_ID, + }); + expect(next).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['absent', undefined], + ['malformed', 'Basic credentials'], + ['empty', 'Bearer '], + ])('rejects an %s bearer token before verification', async (_case, authorization) => { + const deps = createDeps(); + const req = createRequest({ authorization }); + const res = createResponse(); + const next = jest.fn(); + + await runMiddleware(deps, req, res, next); + + expect(deps.verifyAccessToken).not.toHaveBeenCalled(); + expect(deps.findUser).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(401); + expect(next).not.toHaveBeenCalled(); + }); + + it('rejects a verification failure before resolving a binding', async () => { + const deps = createDeps({ + verifyAccessToken: jest.fn().mockRejectedValue(new Error('invalid signature')), + }); + const res = createResponse(); + + await runMiddleware(deps, createRequest(), res, jest.fn()); + + expect(deps.findUser).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(401); + }); + + it.each([ + ['unknown', createPayload({ azp: 'unknown', sub: 'unknown@clients' }), createConfig()], + [ + 'disabled', + createPayload(), + { + ...createConfig(), + endpoints: { + agents: { + managementApi: { + auth: { + ...createConfig().endpoints?.agents?.managementApi?.auth, + clients: [ + { + clientId: CLIENT_ID, + userId: USER_ID, + tenantId: TENANT_ID, + enabled: false, + }, + ], + }, + }, + }, + }, + } as AppConfig, + ], + ])('rejects an %s client before querying a User', async (_case, payload, config) => { + const deps = createDeps({ + getAppConfig: jest.fn().mockResolvedValue(config), + verifyAccessToken: jest.fn().mockResolvedValue(payload), + }); + const res = createResponse(); + + await runMiddleware(deps, createRequest(), res, jest.fn()); + + expect(deps.findUser).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(401); + }); + + it('ignores caller-supplied identity and tenant headers', async () => { + const deps = createDeps(); + const req = createRequest({ + 'x-tenant-id': 'forged-tenant', + 'x-user-id': 'forged-user', + 'x-user-role': 'ADMIN', + }); + let downstreamTenant: string | undefined; + + await runMiddleware(deps, req, createResponse(), () => { + downstreamTenant = getTenantId(); + }); + + expect(downstreamTenant).toBe(TENANT_ID); + expect(req.user).toMatchObject({ id: USER_ID, tenantId: TENANT_ID, role: 'USER' }); + }); + + it.each([ + ['a missing User', null], + ['a User from another tenant', createUser({ tenantId: 'tenant-b' })], + ['a different User ID', createUser({ _id: new Types.ObjectId() })], + ])('fails closed for %s', async (_case, user) => { + const deps = createDeps({ findUser: jest.fn().mockResolvedValue(user) }); + const res = createResponse(); + const next = jest.fn(); + + await runMiddleware(deps, createRequest(), res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(next).not.toHaveBeenCalled(); + }); + + it('returns the established deletion-fence response for an inactive User', async () => { + const deps = createDeps({ isPrincipalActive: jest.fn().mockResolvedValue(false) }); + const res = createResponse(); + + await runMiddleware(deps, createRequest(), res, jest.fn()); + + expect(res.status).toHaveBeenCalledWith(409); + expect(res.json).toHaveBeenCalledWith({ + error: 'Account deletion is in progress', + code: 'ACCOUNT_DELETION_IN_PROGRESS', + }); + }); + + it('checks the deletion fence after resolving the bound User', async () => { + let resolveUser: ((user: IUser) => void) | undefined; + const findUser = jest.fn( + () => + new Promise((resolve) => { + resolveUser = resolve; + }), + ); + const isPrincipalActive = jest.fn().mockResolvedValue(true); + const deps = createDeps({ findUser, isPrincipalActive }); + const next = jest.fn(); + + const pending = runMiddleware(deps, createRequest(), createResponse(), next); + await Promise.resolve(); + await Promise.resolve(); + + expect(findUser).toHaveBeenCalledTimes(1); + expect(isPrincipalActive).not.toHaveBeenCalled(); + resolveUser?.(createUser()); + await pending; + + expect(isPrincipalActive).toHaveBeenCalledWith(USER_ID); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('does not retain a removed binding after app config reload', async () => { + const deps = createDeps({ + getAppConfig: jest + .fn() + .mockResolvedValueOnce(createConfig()) + .mockResolvedValueOnce(createConfig(false)), + }); + const firstNext = jest.fn(); + const secondNext = jest.fn(); + const secondResponse = createResponse(); + + await runMiddleware(deps, createRequest(), createResponse(), firstNext); + await runMiddleware(deps, createRequest(), secondResponse, secondNext); + + expect(firstNext).toHaveBeenCalledTimes(1); + expect(secondResponse.status).toHaveBeenCalledWith(401); + expect(secondNext).not.toHaveBeenCalled(); + }); + + it('keeps concurrent tenant contexts isolated', async () => { + const tenantBUserId = new Types.ObjectId().toString(); + const config = createConfig(); + const auth = config.endpoints?.agents?.managementApi?.auth; + auth?.clients.push({ + clientId: 'machine-client-b', + userId: tenantBUserId, + tenantId: 'tenant-b', + enabled: true, + }); + const deps = createDeps({ + getAppConfig: jest.fn().mockResolvedValue(config), + verifyAccessToken: jest + .fn() + .mockImplementation(async (token) => + token === TOKEN + ? createPayload() + : createPayload({ azp: 'machine-client-b', sub: 'machine-client-b@clients' }), + ), + findUser: jest.fn().mockImplementation(async ({ _id }) => { + await Promise.resolve(); + return getTenantId() === TENANT_ID + ? createUser() + : createUser({ _id: new Types.ObjectId(String(_id)), tenantId: 'tenant-b' }); + }), + }); + const observed: string[] = []; + const requestA = createRequest(); + const requestB = createRequest({ authorization: 'Bearer signed-access-token-b' }); + + await Promise.all([ + runMiddleware(deps, requestA, createResponse(), () => { + observed.push(getTenantId() ?? 'missing'); + }), + runMiddleware(deps, requestB, createResponse(), () => { + observed.push(getTenantId() ?? 'missing'); + }), + ]); + + expect(observed.sort()).toEqual([TENANT_ID, 'tenant-b'].sort()); + expect((requestA as Request & { user?: IUser }).user?.tenantId).toBe(TENANT_ID); + expect((requestB as Request & { user?: IUser }).user?.tenantId).toBe('tenant-b'); + }); + + it('returns 500 when base configuration cannot be loaded', async () => { + const deps = createDeps({ + getAppConfig: jest.fn().mockRejectedValue(new Error('configuration unavailable')), + }); + const res = createResponse(); + + await runMiddleware(deps, createRequest(), res, jest.fn()); + + expect(res.status).toHaveBeenCalledWith(500); + expect(deps.findUser).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/api/src/middleware/management.ts b/packages/api/src/middleware/management.ts new file mode 100644 index 0000000000..ce42e1b73b --- /dev/null +++ b/packages/api/src/middleware/management.ts @@ -0,0 +1,209 @@ +import { logger, tenantStorage } from '@librechat/data-schemas'; +import type { RequestHandler, Request, Response, NextFunction } from 'express'; +import type { AppConfig, IUser, UserMethods } from '@librechat/data-schemas'; +import type { TAgentsEndpoint } from 'librechat-data-provider'; +import type { JwtPayload } from 'jsonwebtoken'; +import type { GetAppConfigOptions } from '../app/service'; +import type { OidcAccessTokenConfig } from '../auth/oidc'; +import type { ServerRequest } from '~/types/http'; +import type { ContextRequest } from './tenant'; +import { extractBearerToken, verifyOidcAccessToken } from '../auth/oidc'; +import { tenantContextMiddleware } from './tenant'; + +export interface AgentManagementAuthDeps { + findUser: UserMethods['findUser']; + isPrincipalActive: (userId: string) => Promise; + getAppConfig: (options?: GetAppConfigOptions) => Promise; + verifyAccessToken?: (token: string, config: OidcAccessTokenConfig) => Promise; +} + +type ManagementApi = NonNullable; +type ManagementAuth = NonNullable; +type ManagementOidc = NonNullable; +type ManagementClient = ManagementAuth['clients'][number]; +type EnabledManagementOidc = ManagementOidc & { audience: string; issuer: string }; + +type PrincipalResolution = + | { status: 'resolved'; user: IUser } + | { status: 'missing' } + | { status: 'inactive' }; + +class AgentManagementAuthError extends Error { + constructor(message: string) { + super(message); + this.name = 'AgentManagementAuthError'; + } +} + +function sendAuthenticationError(res: Response): void { + res.status(401).json({ error: 'Unauthorized' }); +} + +function sendServerError(res: Response): void { + res.status(500).json({ error: 'Internal server error' }); +} + +function getEnabledAuth( + config: AppConfig, +): { auth: ManagementAuth; oidc: EnabledManagementOidc } | undefined { + const auth = config.endpoints?.agents?.managementApi?.auth; + if (auth?.oidc?.enabled !== true || !auth.oidc.issuer || !auth.oidc.audience) { + return; + } + return { + auth, + oidc: { + ...auth.oidc, + audience: auth.oidc.audience, + issuer: auth.oidc.issuer, + }, + }; +} + +function getStringClaim(payload: JwtPayload, key: string): string | undefined { + const claim = payload[key]; + if (typeof claim !== 'string') return; + const value = claim.trim(); + return value || undefined; +} + +export function getMachineClientId(payload: JwtPayload): string { + const auth0ClientId = getStringClaim(payload, 'azp'); + const rfcClientId = getStringClaim(payload, 'client_id'); + + if (auth0ClientId && rfcClientId && auth0ClientId !== rfcClientId) { + throw new AgentManagementAuthError('Conflicting OAuth client identifiers'); + } + + const clientId = auth0ClientId ?? rfcClientId; + if (!clientId) { + throw new AgentManagementAuthError('Missing OAuth client identifier'); + } + + if ( + typeof payload.exp !== 'number' || + !Number.isFinite(payload.exp) || + payload.exp <= Date.now() / 1000 + ) { + throw new AgentManagementAuthError('Token expiration is missing or invalid'); + } + + return clientId; +} + +function hasExpectedMachineSubject(payload: JwtPayload, binding: ManagementClient): boolean { + const subject = getStringClaim(payload, 'sub'); + const expectedSubject = binding.subject; + + if (expectedSubject) return subject === expectedSubject; + return subject === binding.clientId || subject === `${binding.clientId}@clients`; +} + +function findClientBinding(auth: ManagementAuth, clientId: string): ManagementClient | undefined { + return auth.clients.find((client) => client.enabled !== false && client.clientId === clientId); +} + +async function resolvePrincipal( + binding: ManagementClient, + deps: Pick, +): Promise { + return tenantStorage.run({ tenantId: binding.tenantId }, async () => { + const user = await deps.findUser({ _id: binding.userId, tenantId: binding.tenantId }); + if (!user) return { status: 'missing' }; + + const userId = String(user._id); + if (userId !== binding.userId.toLowerCase() || user.tenantId !== binding.tenantId) { + return { status: 'missing' }; + } + + if (!(await deps.isPrincipalActive(userId))) { + return { status: 'inactive' }; + } + + user.id = userId; + return { status: 'resolved', user }; + }); +} + +function continueWithPrincipal( + req: Request, + res: Response, + next: NextFunction, + binding: ManagementClient, + user: IUser, +): void { + const serverRequest = req as ServerRequest; + const contextRequest = req as ContextRequest; + serverRequest.user = user; + serverRequest.authStrategy = 'agentManagementM2M'; + contextRequest.tenantId = binding.tenantId; + tenantContextMiddleware(serverRequest, res, next); +} + +export function createAgentManagementAuth(deps: AgentManagementAuthDeps): RequestHandler { + const handler = async (req: Request, res: Response, next: NextFunction) => { + try { + const config = await deps.getAppConfig({ baseOnly: true }); + const enabledAuth = getEnabledAuth(config); + if (!enabledAuth) { + sendAuthenticationError(res); + return; + } + + const token = extractBearerToken(req.headers.authorization); + if (!token) { + sendAuthenticationError(res); + return; + } + + let payload: JwtPayload; + try { + payload = await (deps.verifyAccessToken ?? verifyOidcAccessToken)(token, enabledAuth.oidc); + } catch { + logger.warn('[agentManagementAuth] M2M token verification failed'); + sendAuthenticationError(res); + return; + } + + let clientId: string; + try { + clientId = getMachineClientId(payload); + } catch { + logger.warn('[agentManagementAuth] M2M token claims rejected'); + sendAuthenticationError(res); + return; + } + const binding = findClientBinding(enabledAuth.auth, clientId); + if (!binding) { + logger.warn('[agentManagementAuth] Verified token has no enabled client binding'); + sendAuthenticationError(res); + return; + } + if (!hasExpectedMachineSubject(payload, binding)) { + logger.warn('[agentManagementAuth] M2M token subject rejected'); + sendAuthenticationError(res); + return; + } + + const principal = await resolvePrincipal(binding, deps); + if (principal.status === 'missing') { + logger.warn('[agentManagementAuth] Client binding has no matching tenant user'); + sendAuthenticationError(res); + return; + } + if (principal.status === 'inactive') { + res.status(409).json({ + error: 'Account deletion is in progress', + code: 'ACCOUNT_DELETION_IN_PROGRESS', + }); + return; + } + + continueWithPrincipal(req, res, next, binding, principal.user); + } catch (err) { + logger.error('[agentManagementAuth] Unexpected authentication error', err); + sendServerError(res); + } + }; + return handler as RequestHandler; +} diff --git a/packages/api/src/middleware/remoteAgentAuth.spec.ts b/packages/api/src/middleware/remoteAgentAuth.spec.ts index c55e33c959..1fe57d8782 100644 --- a/packages/api/src/middleware/remoteAgentAuth.spec.ts +++ b/packages/api/src/middleware/remoteAgentAuth.spec.ts @@ -760,34 +760,23 @@ describe('createRemoteAgentAuth', () => { ); }); - it('tries signing keys until a token without kid verifies', async () => { + it('rejects a token without kid before querying JWKS', async () => { const payload = { sub: 'sub123', email: 'agent@test.com' }; setupOidcMocks(payload, null); - mockGetSigningKeys.mockResolvedValue([ - { kid: 'first-kid', getPublicKey: () => 'first-public-key' }, - { kid: 'second-kid', getPublicKey: () => 'second-public-key' }, - ]); - (jwt.verify as jest.Mock).mockImplementation( - (_t: string, key: string, _o: VerifyOptions, cb: JwtVerifyCallback) => { - if (key === 'first-public-key') { - cb(new Error('invalid signature')); - return; - } - cb(null, payload); - }, + const deps = makeDeps(makeConfig({}, { enabled: false })); + const { res, status } = makeRes(); + + await createRemoteAgentAuth(asDeps(deps))( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + res, + mockNext, ); - const deps = makeDeps(); - const req = makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }); - - await createRemoteAgentAuth(asDeps(deps))(req as Request, makeRes().res, mockNext); - + expect(status).toHaveBeenCalledWith(401); + expect(jwksRsa).not.toHaveBeenCalled(); expect(mockGetSigningKey).not.toHaveBeenCalled(); - expect(jwt.verify).toHaveBeenCalledTimes(2); - expect((jwt.verify as jest.Mock).mock.calls[0][1]).toBe('first-public-key'); - expect((jwt.verify as jest.Mock).mock.calls[1][1]).toBe('second-public-key'); - expect(req.user).toMatchObject({ id: 'uid123', email: 'agent@test.com' }); - expect(mockNext).toHaveBeenCalledWith(); + expect(jwt.verify).not.toHaveBeenCalled(); + expect(mockNext).not.toHaveBeenCalled(); }); it('attaches federatedTokens with access_token and expires_at', async () => { @@ -1270,14 +1259,18 @@ describe('createRemoteAgentAuth', () => { ); }); - it('honors disabled JWKS caching', async () => { + it('retains a short signing-key safety cache when global JWKS caching is disabled', async () => { process.env.OPENID_JWKS_URL_CACHE_ENABLED = 'false'; const deps = makeDeps( makeConfig({ - jwksUri: 'https://cache-disabled.example.com/jwks', + jwksUri: undefined, issuer: 'https://issuer-cache-disabled.example.com', }), ); + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ jwks_uri: 'https://cache-disabled.example.com/jwks' }), + }); await createRemoteAgentAuth(asDeps(deps))( makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, @@ -1291,7 +1284,16 @@ describe('createRemoteAgentAuth', () => { mockNext, ); - expect(jwksRsa).toHaveBeenCalledTimes(2); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(jwksRsa).toHaveBeenCalledTimes(1); + expect(jwksRsa).toHaveBeenCalledWith( + expect.objectContaining({ + cache: true, + cacheMaxAge: 60000, + rateLimit: true, + jwksRequestsPerMinute: 10, + }), + ); }); it('evicts the oldest JWKS client entry when the cache exceeds its limit', async () => { @@ -1347,7 +1349,7 @@ describe('createRemoteAgentAuth', () => { await runRequest(`expired-${i}`); } - nowSpy.mockReturnValue(2000); + nowSpy.mockReturnValue(61000); mockMath.mockReturnValue(60000); await runRequest('new'); diff --git a/packages/api/src/middleware/remoteAgentAuth.ts b/packages/api/src/middleware/remoteAgentAuth.ts index 0d3cef44ff..1384a8fc4d 100644 --- a/packages/api/src/middleware/remoteAgentAuth.ts +++ b/packages/api/src/middleware/remoteAgentAuth.ts @@ -1,13 +1,9 @@ -import jwt from 'jsonwebtoken'; -import jwksRsa from 'jwks-rsa'; -import { fetch as undiciFetch } from 'undici'; +import { SystemRoles } from 'librechat-data-provider'; import { getTenantId, logger, tenantStorage } from '@librechat/data-schemas'; -import { SystemRoles, isRemoteOidcUrlAllowed } from 'librechat-data-provider'; import type { AppConfig, IUser, RoleMethods, UserMethods } from '@librechat/data-schemas'; import type { RequestHandler, Request, Response, NextFunction } from 'express'; -import type { Algorithm, JwtPayload, VerifyOptions } from 'jsonwebtoken'; import type { TAgentsEndpoint } from 'librechat-data-provider'; -import type { RequestInit } from 'undici'; +import type { JwtPayload } from 'jsonwebtoken'; import type { GetAppConfigOptions } from '../app/service'; import type { ServerRequest } from '~/types/http'; import type { ContextRequest } from './tenant'; @@ -17,10 +13,9 @@ import { getOpenIdRoleSyncOptions, selectOpenIdRole, } from '../auth/openidRoleSync'; +import { clearOidcAccessTokenCache, extractBearerToken, verifyOidcAccessToken } from '../auth/oidc'; import { findOpenIDUser, getOpenIdEmail, normalizeOpenIdIssuer } from '../auth/openid'; -import { getEnvProxyDispatcher, getHttpsProxyAgent } from '~/utils/proxy'; import { tenantContextMiddleware } from './tenant'; -import { isEnabled, math } from '~/utils'; export interface RemoteAgentAuthDeps { apiKeyMiddleware: RequestHandler; @@ -37,67 +32,14 @@ type OidcConfig = NonNullable< type AgentAuthConfig = NonNullable['auth']>; type EnabledOidcConfig = OidcConfig & { audience: string; issuer: string }; -type JwksCacheOptions = { - enabled: boolean; - maxAge: number; -}; -type CacheEntry = { - expiresAt: number; - promise: Promise; -}; type ScopeClaim = string | string[] | undefined; type UserResolution = | { status: 'resolved'; user: IUser; updateData: Partial } | { status: 'missing' } | { status: 'rejected'; error: string }; -const OIDC_DISCOVERY_TIMEOUT_MS = 10000; -const MAX_JWKS_CACHE_ENTRIES = 100; -const JWT_ALGORITHMS: Algorithm[] = [ - 'RS256', - 'RS384', - 'RS512', - 'PS256', - 'PS384', - 'PS512', - 'ES256', - 'ES384', - 'ES512', -]; -const jwksUriCache = new Map>(); -const jwksClientCache = new Map>(); - export function clearRemoteAgentAuthCache(): void { - jwksUriCache.clear(); - jwksClientCache.clear(); -} - -function pruneExpiredEntries(cache: Map>): void { - const now = Date.now(); - for (const [key, entry] of cache) { - if (entry.expiresAt <= now) cache.delete(key); - } -} - -function setCacheEntry( - cache: Map>, - key: string, - entry: CacheEntry, -): void { - pruneExpiredEntries(cache); - - while (cache.size >= MAX_JWKS_CACHE_ENTRIES) { - const oldestKey = cache.keys().next().value; - if (oldestKey == null) break; - cache.delete(oldestKey); - } - - cache.set(key, entry); -} - -function extractBearer(authHeader: string | undefined): string | null { - const match = authHeader?.match(/^Bearer\s+(\S+)\s*$/i); - return match?.[1] ?? null; + clearOidcAccessTokenCache(); } function splitScopes(scopes: string): string[] { @@ -120,135 +62,11 @@ function hasRequiredScopes(requiredScope: string | undefined, payload: JwtPayloa return requiredScopes.every((scope) => tokenScopes.includes(scope)); } -function getJwksCacheOptions(): JwksCacheOptions { - return { - enabled: process.env.OPENID_JWKS_URL_CACHE_ENABLED - ? isEnabled(process.env.OPENID_JWKS_URL_CACHE_ENABLED) - : true, - maxAge: Math.max(math(process.env.OPENID_JWKS_URL_CACHE_TIME, 60000), 0), - }; -} - -function buildDiscoveryOptions(controller: AbortController): RequestInit { - const options: RequestInit = { signal: controller.signal }; - const dispatcher = getEnvProxyDispatcher(); - - if (dispatcher) { - options.dispatcher = dispatcher; - } - - return options; -} - -function ensureRemoteOidcUrlAllowed(value: string, label: string): string { - if (isRemoteOidcUrlAllowed(value)) return value; - throw new Error(`${label} must use https:// unless targeting localhost`); -} - -async function discoverJwksUri(issuer: string): Promise { - const normalizedIssuer = normalizeOpenIdIssuer(ensureRemoteOidcUrlAllowed(issuer, 'OIDC issuer')); - if (!normalizedIssuer) throw new Error('OIDC issuer is required'); - - const discoveryUrl = `${normalizedIssuer}/.well-known/openid-configuration`; - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), OIDC_DISCOVERY_TIMEOUT_MS); - - try { - const res = await undiciFetch(discoveryUrl, buildDiscoveryOptions(controller)); - if (!res.ok) throw new Error(`OIDC discovery failed: ${res.status} ${res.statusText}`); - - const meta = (await res.json()) as { jwks_uri?: string }; - if (!meta.jwks_uri) throw new Error('OIDC discovery response missing jwks_uri'); - - return ensureRemoteOidcUrlAllowed(meta.jwks_uri, 'OIDC JWKS URI'); - } finally { - clearTimeout(timeout); - } -} - -async function resolveJwksUri( +function verifyRemoteOidcAccessToken( + token: string, oidcConfig: EnabledOidcConfig, - cacheOptions: JwksCacheOptions, -): Promise { - if (oidcConfig.jwksUri) return ensureRemoteOidcUrlAllowed(oidcConfig.jwksUri, 'OIDC JWKS URI'); - if (process.env.OPENID_JWKS_URL) { - return ensureRemoteOidcUrlAllowed(process.env.OPENID_JWKS_URL, 'OIDC JWKS URI'); - } - - if (!cacheOptions.enabled) return discoverJwksUri(oidcConfig.issuer); - - const cacheKey = oidcConfig.issuer; - const cached = jwksUriCache.get(cacheKey); - if (cached != null && cached.expiresAt > Date.now()) return cached.promise; - if (cached != null) jwksUriCache.delete(cacheKey); - - const promise = discoverJwksUri(oidcConfig.issuer).catch((err) => { - jwksUriCache.delete(cacheKey); - throw err; - }); - - setCacheEntry(jwksUriCache, cacheKey, { - promise, - expiresAt: Date.now() + cacheOptions.maxAge, - }); - return promise; -} - -function buildJwksClient(uri: string, cacheOptions: JwksCacheOptions): jwksRsa.JwksClient { - const options: jwksRsa.Options = { - cache: cacheOptions.enabled, - cacheMaxAge: cacheOptions.maxAge, - jwksUri: uri, - }; - - const requestAgent = getHttpsProxyAgent(uri); - if (requestAgent) { - options.requestAgent = requestAgent; - } - - return jwksRsa(options); -} - -async function getJwksClient(oidcConfig: EnabledOidcConfig): Promise { - const cacheOptions = getJwksCacheOptions(); - const uri = await resolveJwksUri(oidcConfig, cacheOptions); - - if (!cacheOptions.enabled) return buildJwksClient(uri, cacheOptions); - - const cacheKey = uri; - const cached = jwksClientCache.get(cacheKey); - if (cached != null && cached.expiresAt > Date.now()) return cached.promise; - if (cached != null) jwksClientCache.delete(cacheKey); - - let client: jwksRsa.JwksClient; - try { - client = buildJwksClient(uri, cacheOptions); - } catch (err) { - jwksClientCache.delete(cacheKey); - throw err; - } - - const promise = Promise.resolve(client); - - setCacheEntry(jwksClientCache, cacheKey, { - promise, - expiresAt: Date.now() + cacheOptions.maxAge, - }); - return promise; -} - -function getVerifyOptions(oidcConfig: EnabledOidcConfig): VerifyOptions { - const normalizedIssuer = normalizeOpenIdIssuer(oidcConfig.issuer); - const issuer = - normalizedIssuer && normalizedIssuer !== oidcConfig.issuer - ? [oidcConfig.issuer, normalizedIssuer] - : oidcConfig.issuer; - - return { - algorithms: JWT_ALGORITHMS, - audience: oidcConfig.audience, - issuer, - }; +): Promise { + return verifyOidcAccessToken(token, oidcConfig, { useOpenIdJwksEnv: true }); } function getConfigOptions(req: Request): GetAppConfigOptions { @@ -390,7 +208,7 @@ async function enforceOidcTenantPolicy( } try { - const payload = await verifyOidcBearer(token, oidcConfig); + const payload = await verifyRemoteOidcAccessToken(token, oidcConfig); if (hasRequiredScopes(oidcConfig.scope, payload)) return true; logger.warn( `[remoteAgentAuth] Token missing resolved tenant required scope: ${oidcConfig.scope}`, @@ -402,55 +220,6 @@ async function enforceOidcTenantPolicy( return false; } -function verifyJwt( - token: string, - signingKey: jwksRsa.SigningKey, - oidcConfig: EnabledOidcConfig, -): Promise { - return new Promise((resolve, reject) => { - jwt.verify(token, signingKey.getPublicKey(), getVerifyOptions(oidcConfig), (err, payload) => { - if (err != null || payload == null) return reject(err ?? new Error('Empty payload')); - if (typeof payload === 'string') return reject(new Error('Invalid JWT payload')); - resolve(payload); - }); - }); -} - -async function verifyWithSigningKeys( - token: string, - signingKeys: jwksRsa.SigningKey[], - oidcConfig: EnabledOidcConfig, -): Promise { - let lastError: Error | null = null; - - for (const signingKey of signingKeys) { - try { - return await verifyJwt(token, signingKey, oidcConfig); - } catch (err) { - lastError = err instanceof Error ? err : new Error(String(err)); - } - } - - throw lastError ?? new Error('No signing keys in JWKS'); -} - -async function verifyOidcBearer(token: string, oidcConfig: EnabledOidcConfig): Promise { - ensureRemoteOidcUrlAllowed(oidcConfig.issuer, 'OIDC issuer'); - - const decoded = jwt.decode(token, { complete: true }); - if (decoded == null || typeof decoded === 'string') throw new Error('Invalid JWT: cannot decode'); - - const kid = typeof decoded.header?.kid === 'string' ? decoded.header.kid : undefined; - const client = await getJwksClient(oidcConfig); - - if (kid != null) { - const signingKey = await client.getSigningKey(kid); - return verifyJwt(token, signingKey, oidcConfig); - } - - return verifyWithSigningKeys(token, await client.getSigningKeys(), oidcConfig); -} - async function resolveUser( token: string, payload: JwtPayload, @@ -644,7 +413,7 @@ export function createRemoteAgentAuth({ const oidcConfig = getEnabledOidcConfig(authConfig); if (!oidcConfig) throw new Error('OIDC configuration is required when OIDC auth is enabled'); - const token = extractBearer(req.headers.authorization); + const token = extractBearerToken(req.headers.authorization); if (token == null) { if (apiKeyEnabled) { await runApiKeyAuth(req, res, next, apiKeyMiddleware, getAppConfig); @@ -657,7 +426,7 @@ export function createRemoteAgentAuth({ let payload: JwtPayload; try { - payload = await verifyOidcBearer(token, oidcConfig); + payload = await verifyRemoteOidcAccessToken(token, oidcConfig); if (!hasRequiredScopes(oidcConfig.scope, payload)) { logger.warn(`[remoteAgentAuth] Token missing required scope: ${oidcConfig.scope}`); res.status(401).json({ error: 'Unauthorized' }); diff --git a/packages/data-provider/src/config.spec.ts b/packages/data-provider/src/config.spec.ts index a7b6629f28..cd18870865 100644 --- a/packages/data-provider/src/config.spec.ts +++ b/packages/data-provider/src/config.spec.ts @@ -66,6 +66,155 @@ describe('bedrockEndpointSchema', () => { }); }); +describe('Agent Management authentication config', () => { + const binding = { + clientId: 'machine-client', + userId: '507f1f77bcf86cd799439011', + tenantId: 'tenant-a', + }; + + it('accepts an enabled OIDC configuration with a client binding', () => { + const result = configSchema.safeParse({ + version: '1.0', + endpoints: { + agents: { + managementApi: { + auth: { + oidc: { + enabled: true, + issuer: 'https://issuer.example.com', + audience: 'https://agents.example.com', + }, + clients: [binding], + }, + }, + }, + }, + }); + + expect(result.success).toBe(true); + if (!result.success) { + return; + } + expect(result.data.endpoints?.agents?.managementApi?.auth?.clients).toEqual([ + { ...binding, enabled: true }, + ]); + }); + + it('accepts a provider-specific token subject in a client binding', () => { + const subject = 'opaque-service-principal-subject'; + const result = configSchema.safeParse({ + version: '1.0', + endpoints: { + agents: { + managementApi: { + auth: { + oidc: {}, + clients: [{ ...binding, subject }], + }, + }, + }, + }, + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.endpoints?.agents?.managementApi?.auth?.clients[0].subject).toBe(subject); + }); + + it('normalizes binding User ObjectIds', () => { + const result = configSchema.safeParse({ + version: '1.0', + endpoints: { + agents: { + managementApi: { + auth: { + oidc: {}, + clients: [{ ...binding, userId: binding.userId.toUpperCase() }], + }, + }, + }, + }, + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.endpoints?.agents?.managementApi?.auth?.clients[0].userId).toBe( + binding.userId, + ); + }); + + it('rejects enabled auth without a client binding', () => { + const result = configSchema.safeParse({ + version: '1.0', + endpoints: { + agents: { + managementApi: { + auth: { + oidc: { + enabled: true, + issuer: 'https://issuer.example.com', + audience: 'https://agents.example.com', + }, + }, + }, + }, + }, + }); + + expect(result.success).toBe(false); + }); + + it.each([ + ['duplicate client IDs', [binding, { ...binding, tenantId: 'tenant-b' }]], + ['an invalid user ID', [{ ...binding, userId: 'not-an-object-id' }]], + ['an invalid tenant ID', [{ ...binding, tenantId: '-tenant' }]], + ['the system tenant', [{ ...binding, tenantId: '__SYSTEM__' }]], + ['an empty token subject', [{ ...binding, subject: ' ' }]], + ])('rejects %s', (_label, clients) => { + const result = configSchema.safeParse({ + version: '1.0', + endpoints: { + agents: { + managementApi: { + auth: { + oidc: {}, + clients, + }, + }, + }, + }, + }); + + expect(result.success).toBe(false); + }); + + it('rejects remote-execution scopes and unknown management auth fields', () => { + const makeConfig = (auth: Record) => ({ + version: '1.0', + endpoints: { agents: { managementApi: { auth } } }, + }); + + expect( + configSchema.safeParse( + makeConfig({ + oidc: { scope: 'remote_agent' }, + clients: [binding], + }), + ).success, + ).toBe(false); + expect( + configSchema.safeParse( + makeConfig({ + oidc: {}, + clients: [binding], + apiKey: { enabled: true }, + }), + ).success, + ).toBe(false); + }); +}); + describe('attached code environment user config schema', () => { it('accepts typed permission controls exposed by the administrator', () => { const result = configSchema.safeParse({ diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 48eded8597..7cbea42d19 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -845,30 +845,36 @@ const remoteApiOidcScopeSchema = z.string().refine((scope) => !scope.includes(', message: 'scopes must be space-separated', }); -const remoteApiOidcSchema = z - .object({ - enabled: z.boolean().default(false), - issuer: remoteApiOidcUrlSchema.optional(), - audience: z.string().min(1).optional(), - jwksUri: remoteApiOidcUrlSchema.optional(), - scope: remoteApiOidcScopeSchema.optional(), - }) - .superRefine((oidc, ctx) => { - if (oidc.enabled === true && !oidc.issuer) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['issuer'], - message: 'issuer is required when OIDC auth is enabled', - }); - } - if (oidc.enabled === true && !oidc.audience) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['audience'], - message: 'audience is required when OIDC auth is enabled', - }); - } - }); +const oidcAccessTokenSchema = z.object({ + enabled: z.boolean().default(false), + issuer: remoteApiOidcUrlSchema.optional(), + audience: z.string().min(1).optional(), + jwksUri: remoteApiOidcUrlSchema.optional(), +}); + +function validateEnabledOidc( + oidc: z.infer, + ctx: z.RefinementCtx, +): void { + if (oidc.enabled === true && !oidc.issuer) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['issuer'], + message: 'issuer is required when OIDC auth is enabled', + }); + } + if (oidc.enabled === true && !oidc.audience) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['audience'], + message: 'audience is required when OIDC auth is enabled', + }); + } +} + +const remoteApiOidcSchema = oidcAccessTokenSchema + .extend({ scope: remoteApiOidcScopeSchema.optional() }) + .superRefine(validateEnabledOidc); const remoteApiAuthSchema = z.object({ apiKey: z @@ -883,6 +889,59 @@ const remoteApiSchema = z.object({ auth: remoteApiAuthSchema.optional(), }); +const managementClientBindingSchema = z + .object({ + clientId: z.string().trim().min(1).max(128), + subject: z.string().trim().min(1).max(512).optional(), + userId: z + .string() + .trim() + .regex(/^[a-f\d]{24}$/i, 'must be a MongoDB ObjectId') + .transform((userId) => userId.toLowerCase()), + tenantId: z + .string() + .trim() + .min(1) + .max(128) + .regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/, 'must be a valid tenant id') + .refine((tenantId) => tenantId !== '__SYSTEM__', 'system tenant is not allowed'), + enabled: z.boolean().default(true), + }) + .strict(); + +const managementApiOidcSchema = oidcAccessTokenSchema.strict().superRefine(validateEnabledOidc); + +const managementApiAuthSchema = z + .object({ + oidc: managementApiOidcSchema, + clients: z.array(managementClientBindingSchema).max(100).default([]), + }) + .strict() + .superRefine((auth, ctx) => { + if (auth.oidc.enabled === true && auth.clients.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['clients'], + message: 'at least one client binding is required when management auth is enabled', + }); + } + + const clientIds = new Set(); + for (let index = 0; index < auth.clients.length; index++) { + const client = auth.clients[index]; + if (clientIds.has(client.clientId)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['clients', index, 'clientId'], + message: 'client IDs must be unique', + }); + } + clientIds.add(client.clientId); + } + }); + +const managementApiSchema = z.object({ auth: managementApiAuthSchema.optional() }).strict(); + /** * Permission mode applied to a tool call. Mirrors `@librechat/agents`'s * `ToolPolicyMode` 1:1. @@ -1269,6 +1328,7 @@ export const agentsEndpointSchema = baseEndpointSchema maxCatalogSkills: z.number().int().min(1).max(100).optional(), }) .optional(), + managementApi: managementApiSchema.optional(), remoteApi: remoteApiSchema.optional(), /** Human-in-the-loop tool approval policy. Off by default. */ toolApproval: toolApprovalPolicySchema,