From 80730cf5592cc17bbd74d0492a2bb30bb444d498 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 23 May 2026 08:39:10 -0400 Subject: [PATCH] feat: add m2m auth for remote agent crud --- api/server/routes/agents/index.js | 7 + api/server/routes/agents/middleware.js | 7 + api/server/routes/agents/remoteCrud.js | 83 ++++++ packages/api/src/auth/index.ts | 1 + packages/api/src/auth/oauthJwt.ts | 279 ++++++++++++++++++ packages/api/src/middleware/index.ts | 1 + .../src/middleware/remoteAgentM2MAuth.spec.ts | 239 +++++++++++++++ .../api/src/middleware/remoteAgentM2MAuth.ts | 215 ++++++++++++++ packages/api/src/types/http.ts | 4 + .../specs/config-schemas.spec.ts | 70 +++++ packages/data-provider/src/config.ts | 48 +++ 11 files changed, 954 insertions(+) create mode 100644 api/server/routes/agents/remoteCrud.js create mode 100644 packages/api/src/auth/oauthJwt.ts create mode 100644 packages/api/src/middleware/remoteAgentM2MAuth.spec.ts create mode 100644 packages/api/src/middleware/remoteAgentM2MAuth.ts diff --git a/api/server/routes/agents/index.js b/api/server/routes/agents/index.js index f7076ce153..c1c456e574 100644 --- a/api/server/routes/agents/index.js +++ b/api/server/routes/agents/index.js @@ -13,6 +13,7 @@ const { const { saveMessage } = require('~/models'); const responses = require('./responses'); const openai = require('./openai'); +const remoteCrud = require('./remoteCrud'); const { v1 } = require('./v1'); const chat = require('./chat'); @@ -33,6 +34,12 @@ const router = express.Router(); */ router.use('/v1/responses', responses); +/** + * Remote Agent CRUD API routes (M2M authentication handled in route file) + * Mounted at /agents/v1/agents (full path: /api/agents/v1/agents) + */ +router.use('/v1/agents', remoteCrud); + /** * 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/middleware.js b/api/server/routes/agents/middleware.js index f71c25c6f8..89d9ef36c8 100644 --- a/api/server/routes/agents/middleware.js +++ b/api/server/routes/agents/middleware.js @@ -4,6 +4,7 @@ const { preAuthTenantMiddleware, createRequireApiKeyAuth, createRemoteAgentAuth, + createRemoteAgentM2MAuth, createCheckRemoteAgentAccess, } = require('@librechat/api'); const { getEffectivePermissions } = require('~/server/services/PermissionService'); @@ -22,6 +23,11 @@ const requireRemoteAgentAuth = createRemoteAgentAuth({ getAppConfig, }); +const requireRemoteAgentM2MAuth = createRemoteAgentM2MAuth({ + findUser: db.findUser, + getAppConfig, +}); + const checkRemoteAgentsFeature = generateCheckAccess({ permissionType: PermissionTypes.REMOTE_AGENTS, permissions: [Permissions.USE], @@ -37,5 +43,6 @@ module.exports = { checkAgentPermission, preAuthTenantMiddleware, requireRemoteAgentAuth, + requireRemoteAgentM2MAuth, checkRemoteAgentsFeature, }; diff --git a/api/server/routes/agents/remoteCrud.js b/api/server/routes/agents/remoteCrud.js new file mode 100644 index 0000000000..b780b7e82b --- /dev/null +++ b/api/server/routes/agents/remoteCrud.js @@ -0,0 +1,83 @@ +const express = require('express'); +const { generateCheckAccess, tenantContextMiddleware } = require('@librechat/api'); +const { PermissionTypes, Permissions, PermissionBits } = require('librechat-data-provider'); +const { configMiddleware, canAccessAgentResource } = require('~/server/middleware'); +const v1 = require('~/server/controllers/agents/v1'); +const { getRoleByName } = require('~/models'); +const { + preAuthTenantMiddleware, + requireRemoteAgentM2MAuth, + checkRemoteAgentsFeature, +} = require('./middleware'); + +const router = express.Router(); + +const checkAgentAccess = generateCheckAccess({ + permissionType: PermissionTypes.AGENTS, + permissions: [Permissions.USE], + getRoleByName, +}); + +const checkAgentCreate = generateCheckAccess({ + permissionType: PermissionTypes.AGENTS, + permissions: [Permissions.USE, Permissions.CREATE], + getRoleByName, +}); + +const m2mAuth = (action) => [ + preAuthTenantMiddleware, + requireRemoteAgentM2MAuth({ action }), + tenantContextMiddleware, + configMiddleware, + checkRemoteAgentsFeature, +]; + +router.get('/', m2mAuth('read'), checkAgentAccess, v1.getListAgents); + +router.post('/', m2mAuth('create'), checkAgentCreate, v1.createAgent); + +router.get( + '/:id/expanded', + m2mAuth('read'), + checkAgentAccess, + canAccessAgentResource({ + requiredPermission: PermissionBits.EDIT, + resourceIdParam: 'id', + }), + (req, res) => v1.getAgent(req, res, true), +); + +router.get( + '/:id', + m2mAuth('read'), + checkAgentAccess, + canAccessAgentResource({ + requiredPermission: PermissionBits.VIEW, + resourceIdParam: 'id', + }), + v1.getAgent, +); + +router.patch( + '/:id', + m2mAuth('update'), + checkAgentCreate, + canAccessAgentResource({ + requiredPermission: PermissionBits.EDIT, + resourceIdParam: 'id', + }), + v1.updateAgent, +); + +router.delete( + '/:id', + m2mAuth('delete'), + checkAgentCreate, + canAccessAgentResource({ + requiredPermission: PermissionBits.DELETE, + resourceIdParam: 'id', + }), + v1.deleteAgent, +); + +module.exports = router; diff --git a/packages/api/src/auth/index.ts b/packages/api/src/auth/index.ts index d3e08deb14..7710074e3e 100644 --- a/packages/api/src/auth/index.ts +++ b/packages/api/src/auth/index.ts @@ -1,5 +1,6 @@ export * from './domain'; export * from './openid'; +export * from './oauthJwt'; export * from './exchange'; export * from './refresh'; export * from './agent'; diff --git a/packages/api/src/auth/oauthJwt.ts b/packages/api/src/auth/oauthJwt.ts new file mode 100644 index 0000000000..ca23b487fa --- /dev/null +++ b/packages/api/src/auth/oauthJwt.ts @@ -0,0 +1,279 @@ +import jwt from 'jsonwebtoken'; +import jwksRsa from 'jwks-rsa'; +import { HttpsProxyAgent } from 'https-proxy-agent'; +import { ProxyAgent, fetch as undiciFetch } from 'undici'; +import { isRemoteOidcUrlAllowed } from 'librechat-data-provider'; +import type { Algorithm, JwtPayload, VerifyOptions } from 'jsonwebtoken'; +import type { RequestInit } from 'undici'; +import { normalizeOpenIdIssuer } from './openid'; +import { isEnabled, math } from '~/utils'; + +export type OAuthJwtIssuerConfig = { + issuer: string; + audience?: string; + jwksUri?: string; +}; + +export type ScopeClaim = string | string[] | undefined; + +type JwksCacheOptions = { + enabled: boolean; + maxAge: number; +}; + +type CacheEntry = { + expiresAt: number; + promise: Promise; +}; + +const OAUTH_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 clearOAuthJwtVerifierCache(): void { + jwksUriCache.clear(); + jwksClientCache.clear(); +} + +export function extractBearer(authHeader: string | undefined): string | null { + const match = authHeader?.match(/^Bearer\s+(\S+)\s*$/i); + return match?.[1] ?? null; +} + +export function splitScopes(scopes: string): string[] { + return scopes.trim().split(/\s+/).filter(Boolean); +} + +export function getTokenScopes(scopeClaim: ScopeClaim): string[] { + if (Array.isArray(scopeClaim)) return scopeClaim.flatMap(splitScopes); + return scopeClaim ? splitScopes(scopeClaim) : []; +} + +export function hasRequiredScopes(requiredScope: string | undefined, payload: JwtPayload): boolean { + if (!requiredScope) return true; + + const requiredScopes = splitScopes(requiredScope); + if (requiredScopes.length === 0) return true; + + const rawScope = (payload['scp'] ?? payload['scope']) as ScopeClaim; + const tokenScopes = getTokenScopes(rawScope); + return requiredScopes.every((scope) => tokenScopes.includes(scope)); +} + +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 }; + + if (process.env.PROXY) { + options.dispatcher = new ProxyAgent(process.env.PROXY); + } + + 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, 'OAuth issuer'), + ); + if (!normalizedIssuer) throw new Error('OAuth issuer is required'); + + const discoveryUrl = `${normalizedIssuer}/.well-known/openid-configuration`; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), OAUTH_DISCOVERY_TIMEOUT_MS); + + try { + const res = await undiciFetch(discoveryUrl, buildDiscoveryOptions(controller)); + if (!res.ok) throw new Error(`OAuth discovery failed: ${res.status} ${res.statusText}`); + + const meta = (await res.json()) as { jwks_uri?: string }; + if (!meta.jwks_uri) throw new Error('OAuth discovery response missing jwks_uri'); + + return ensureRemoteOidcUrlAllowed(meta.jwks_uri, 'OAuth JWKS URI'); + } finally { + clearTimeout(timeout); + } +} + +async function resolveJwksUri( + issuerConfig: OAuthJwtIssuerConfig, + cacheOptions: JwksCacheOptions, +): Promise { + if (issuerConfig.jwksUri) { + return ensureRemoteOidcUrlAllowed(issuerConfig.jwksUri, 'OAuth JWKS URI'); + } + + if (!cacheOptions.enabled) return discoverJwksUri(issuerConfig.issuer); + + const cacheKey = issuerConfig.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(issuerConfig.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, + }; + + if (process.env.PROXY) { + options.requestAgent = new HttpsProxyAgent(process.env.PROXY); + } + + return jwksRsa(options); +} + +async function getJwksClient(issuerConfig: OAuthJwtIssuerConfig): Promise { + const cacheOptions = getJwksCacheOptions(); + const uri = await resolveJwksUri(issuerConfig, 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(issuerConfig: OAuthJwtIssuerConfig): VerifyOptions { + const normalizedIssuer = normalizeOpenIdIssuer(issuerConfig.issuer); + const issuer = + normalizedIssuer && normalizedIssuer !== issuerConfig.issuer + ? [issuerConfig.issuer, normalizedIssuer] + : issuerConfig.issuer; + + return { + algorithms: JWT_ALGORITHMS, + ...(issuerConfig.audience ? { audience: issuerConfig.audience } : {}), + issuer, + }; +} + +function verifyJwt( + token: string, + signingKey: jwksRsa.SigningKey, + issuerConfig: OAuthJwtIssuerConfig, +): Promise { + return new Promise((resolve, reject) => { + jwt.verify(token, signingKey.getPublicKey(), getVerifyOptions(issuerConfig), (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[], + issuerConfig: OAuthJwtIssuerConfig, +): Promise { + let lastError: Error | null = null; + + for (const signingKey of signingKeys) { + try { + return await verifyJwt(token, signingKey, issuerConfig); + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + } + } + + throw lastError ?? new Error('No signing keys in JWKS'); +} + +export async function verifyOAuthJwtBearer( + token: string, + issuerConfig: OAuthJwtIssuerConfig, +): Promise { + ensureRemoteOidcUrlAllowed(issuerConfig.issuer, 'OAuth 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(issuerConfig); + + if (kid != null) { + const signingKey = await client.getSigningKey(kid); + return verifyJwt(token, signingKey, issuerConfig); + } + + return verifyWithSigningKeys(token, await client.getSigningKeys(), issuerConfig); +} diff --git a/packages/api/src/middleware/index.ts b/packages/api/src/middleware/index.ts index cac5d637b9..b95d3fdec4 100644 --- a/packages/api/src/middleware/index.ts +++ b/packages/api/src/middleware/index.ts @@ -14,4 +14,5 @@ export { preAuthTenantMiddleware } from './preAuthTenant'; export * from './concurrency'; export * from './checkBalance'; export * from './remoteAgentAuth'; +export * from './remoteAgentM2MAuth'; export * from './share'; diff --git a/packages/api/src/middleware/remoteAgentM2MAuth.spec.ts b/packages/api/src/middleware/remoteAgentM2MAuth.spec.ts new file mode 100644 index 0000000000..cc20e929dc --- /dev/null +++ b/packages/api/src/middleware/remoteAgentM2MAuth.spec.ts @@ -0,0 +1,239 @@ +import type { AppConfig, IUser, UserMethods } from '@librechat/data-schemas'; +import type { JwtPayload } from 'jsonwebtoken'; +import type { Request, Response } from 'express'; +import { createRemoteAgentM2MAuth } from './remoteAgentM2MAuth'; + +jest.mock('@librechat/data-schemas', () => { + const actual = jest.requireActual('@librechat/data-schemas'); + return { + ...actual, + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, + }; +}); + +const BASE_ISSUER = 'https://cognito-idp.us-west-2.amazonaws.com/us-west-2_test'; +const BASE_JWKS_URI = `${BASE_ISSUER}/.well-known/jwks.json`; +const FAKE_TOKEN = 'header.payload.signature'; + +function makeRes() { + const json = jest.fn(); + const status = jest.fn().mockReturnValue({ json }); + return { res: { status, json } as unknown as Response, status, json }; +} + +function makeReq(headers: Record = {}): Partial { + return { headers }; +} + +function makeUser(overrides: Partial = {}): IUser { + return { + _id: 'user-123', + id: 'user-123', + email: 'service-account@example.com', + role: 'user', + ...overrides, + } as unknown as IUser; +} + +function makeConfig(overrides: Record = {}): AppConfig { + return { + endpoints: { + agents: { + remoteApi: { + auth: { + m2m: { + enabled: true, + issuer: BASE_ISSUER, + audience: 'librechat-agents-api', + jwksUri: BASE_JWKS_URI, + clients: [{ clientId: 'dwh-dwaine-updater', userId: 'user-123' }], + ...overrides, + }, + }, + }, + }, + }, + } as unknown as AppConfig; +} + +function makeDeps(appConfig: AppConfig = makeConfig(), payload?: JwtPayload) { + return { + findUser: jest.fn().mockResolvedValue(makeUser()) as jest.MockedFunction< + UserMethods['findUser'] + >, + getAppConfig: jest.fn().mockResolvedValue(appConfig), + verifyBearer: jest.fn().mockResolvedValue( + payload ?? { + token_use: 'access', + client_id: 'dwh-dwaine-updater', + scope: 'librechat.agents:read librechat.agents:update', + }, + ), + }; +} + +describe('createRemoteAgentM2MAuth', () => { + let next: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + next = jest.fn(); + }); + + it('authenticates a mapped M2M client and attaches auth info', async () => { + const deps = makeDeps(); + const req = makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }); + + await createRemoteAgentM2MAuth(deps)({ action: 'update' })(req as Request, makeRes().res, next); + + expect(deps.getAppConfig).toHaveBeenCalledWith({ baseOnly: true }); + expect(deps.verifyBearer).toHaveBeenCalledWith(FAKE_TOKEN, { + issuer: BASE_ISSUER, + audience: 'librechat-agents-api', + jwksUri: BASE_JWKS_URI, + }); + expect(deps.findUser).toHaveBeenCalledWith({ _id: 'user-123' }); + expect(req.user).toMatchObject({ id: 'user-123', role: 'user' }); + expect((req as Request & { authInfo?: Record }).authInfo).toMatchObject({ + type: 'm2m', + issuer: BASE_ISSUER, + clientId: 'dwh-dwaine-updater', + action: 'update', + scopes: ['librechat.agents:read', 'librechat.agents:update'], + }); + expect(next).toHaveBeenCalledWith(); + }); + + it('rejects requests when M2M auth is disabled', async () => { + const deps = makeDeps(makeConfig({ enabled: false })); + const { res, status, json } = makeRes(); + + await createRemoteAgentM2MAuth(deps)({ action: 'read' })( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + res, + next, + ); + + expect(status).toHaveBeenCalledWith(401); + expect(json).toHaveBeenCalledWith({ error: 'M2M authentication required' }); + expect(deps.verifyBearer).not.toHaveBeenCalled(); + expect(next).not.toHaveBeenCalled(); + }); + + it('rejects requests without a bearer token', async () => { + const deps = makeDeps(); + const { res, status, json } = makeRes(); + + await createRemoteAgentM2MAuth(deps)({ action: 'read' })(makeReq() as Request, res, next); + + expect(status).toHaveBeenCalledWith(401); + expect(json).toHaveBeenCalledWith({ error: 'Bearer token required' }); + expect(deps.verifyBearer).not.toHaveBeenCalled(); + expect(next).not.toHaveBeenCalled(); + }); + + it('rejects verified tokens with the wrong token_use', async () => { + const deps = makeDeps(makeConfig(), { + token_use: 'id', + client_id: 'dwh-dwaine-updater', + scope: 'librechat.agents:read', + }); + const { res, status, json } = makeRes(); + + await createRemoteAgentM2MAuth(deps)({ action: 'read' })( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + res, + next, + ); + + expect(status).toHaveBeenCalledWith(401); + expect(json).toHaveBeenCalledWith({ error: 'Unauthorized' }); + expect(deps.findUser).not.toHaveBeenCalled(); + expect(next).not.toHaveBeenCalled(); + }); + + it('rejects verified tokens missing the action scope', async () => { + const deps = makeDeps(makeConfig(), { + token_use: 'access', + client_id: 'dwh-dwaine-updater', + scope: 'librechat.agents:read', + }); + const { res, status, json } = makeRes(); + + await createRemoteAgentM2MAuth(deps)({ action: 'delete' })( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + res, + next, + ); + + expect(status).toHaveBeenCalledWith(401); + expect(json).toHaveBeenCalledWith({ error: 'Unauthorized' }); + expect(deps.findUser).not.toHaveBeenCalled(); + expect(next).not.toHaveBeenCalled(); + }); + + it('supports configured client ID claims and scopes', async () => { + const deps = makeDeps( + makeConfig({ + clientIdClaim: 'azp', + scopes: { delete: 'agents.delete' }, + }), + { + token_use: 'access', + azp: 'dwh-dwaine-updater', + scp: ['agents.delete'], + }, + ); + const req = makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }); + + await createRemoteAgentM2MAuth(deps)({ action: 'delete' })(req as Request, makeRes().res, next); + + expect(req.user).toMatchObject({ id: 'user-123' }); + expect(next).toHaveBeenCalledWith(); + }); + + it('rejects verified tokens from unmapped clients', async () => { + const deps = makeDeps(makeConfig(), { + token_use: 'access', + client_id: 'unknown-client', + scope: 'librechat.agents:read', + }); + const { res, status, json } = makeRes(); + + await createRemoteAgentM2MAuth(deps)({ action: 'read' })( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + res, + next, + ); + + expect(status).toHaveBeenCalledWith(401); + expect(json).toHaveBeenCalledWith({ error: 'Unauthorized' }); + expect(deps.findUser).not.toHaveBeenCalled(); + expect(next).not.toHaveBeenCalled(); + }); + + it('rejects mapped users whose tenant does not match the client mapping', async () => { + const deps = makeDeps( + makeConfig({ + clients: [{ clientId: 'dwh-dwaine-updater', userId: 'user-123', tenantId: 'tenant-a' }], + }), + ); + deps.findUser.mockResolvedValue(makeUser({ tenantId: 'tenant-b' })); + const { res, status, json } = makeRes(); + + await createRemoteAgentM2MAuth(deps)({ action: 'read' })( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + res, + next, + ); + + expect(status).toHaveBeenCalledWith(401); + expect(json).toHaveBeenCalledWith({ error: 'Unauthorized' }); + expect(next).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/api/src/middleware/remoteAgentM2MAuth.ts b/packages/api/src/middleware/remoteAgentM2MAuth.ts new file mode 100644 index 0000000000..21522a1151 --- /dev/null +++ b/packages/api/src/middleware/remoteAgentM2MAuth.ts @@ -0,0 +1,215 @@ +import { getTenantId, logger } from '@librechat/data-schemas'; +import type { RequestHandler, Request, Response, NextFunction } from 'express'; +import type { AppConfig, IUser, UserMethods } from '@librechat/data-schemas'; +import type { JwtPayload } from 'jsonwebtoken'; +import type { TAgentsEndpoint } from 'librechat-data-provider'; +import type { OAuthJwtIssuerConfig } from '../auth/oauthJwt'; +import type { GetAppConfigOptions } from '../app/service'; +import { extractBearer, hasRequiredScopes, verifyOAuthJwtBearer } from '../auth/oauthJwt'; + +export type RemoteAgentM2MAction = 'read' | 'create' | 'update' | 'delete'; + +export type RemoteAgentM2MAuthInfo = { + type: 'm2m'; + issuer: string; + clientId: string; + action: RemoteAgentM2MAction; + scopes: string[]; +}; + +export interface RemoteAgentM2MAuthDeps { + findUser: UserMethods['findUser']; + getAppConfig: (options?: GetAppConfigOptions) => Promise; + verifyBearer?: (token: string, issuerConfig: OAuthJwtIssuerConfig) => Promise; +} + +type AgentAuthConfig = NonNullable['auth']>; +type M2MConfig = NonNullable; +type M2MClientConfig = NonNullable[number]; +type EnabledM2MConfig = M2MConfig & { issuer: string; clients: M2MClientConfig[] }; +type M2MRequest = Request & { + user?: IUser; + authInfo?: RemoteAgentM2MAuthInfo; +}; +type ScopeClaim = string | string[] | undefined; + +const DEFAULT_ACTION_SCOPES: Record = { + read: 'librechat.agents:read', + create: 'librechat.agents:create', + update: 'librechat.agents:update', + delete: 'librechat.agents:delete', +}; + +function getConfigOptions(): GetAppConfigOptions { + const tenantId = getTenantId(); + if (tenantId) return { tenantId }; + return { baseOnly: true }; +} + +function getRemoteAuthConfig(config: AppConfig): AgentAuthConfig | undefined { + return config.endpoints?.agents?.remoteApi?.auth; +} + +function getEnabledM2MConfig( + authConfig: AgentAuthConfig | undefined, +): EnabledM2MConfig | undefined { + if (authConfig?.m2m?.enabled !== true) return undefined; + if (!authConfig.m2m.issuer) throw new Error('M2M issuer is required when M2M auth is enabled'); + if (!authConfig.m2m.clients || authConfig.m2m.clients.length === 0) { + throw new Error('M2M clients are required when M2M auth is enabled'); + } + return { + ...authConfig.m2m, + issuer: authConfig.m2m.issuer, + clients: authConfig.m2m.clients, + }; +} + +function getRequiredScope(config: EnabledM2MConfig, action: RemoteAgentM2MAction): string { + return config.scopes?.[action] ?? DEFAULT_ACTION_SCOPES[action]; +} + +function getStringClaim(payload: JwtPayload, claimName: string): string | undefined { + const value = payload[claimName]; + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + +function getTokenScopes(payload: JwtPayload): string[] { + const scopeClaim = (payload['scp'] ?? payload['scope']) as ScopeClaim; + if (Array.isArray(scopeClaim)) { + return scopeClaim.flatMap((scope) => scope.trim().split(/\s+/).filter(Boolean)); + } + return typeof scopeClaim === 'string' ? scopeClaim.trim().split(/\s+/).filter(Boolean) : []; +} + +function getIssuerConfig(config: EnabledM2MConfig): OAuthJwtIssuerConfig { + return { + issuer: config.issuer, + ...(config.audience ? { audience: config.audience } : {}), + ...(config.jwksUri ? { jwksUri: config.jwksUri } : {}), + }; +} + +function hasExpectedTokenUse(config: EnabledM2MConfig, payload: JwtPayload): boolean { + const tokenUseClaim = config.tokenUseClaim ?? 'token_use'; + const tokenUseValue = config.tokenUseValue ?? 'access'; + return getStringClaim(payload, tokenUseClaim) === tokenUseValue; +} + +function getClientMapping( + config: EnabledM2MConfig, + payload: JwtPayload, +): { clientId: string; mapping: M2MClientConfig } | null { + const clientIdClaim = config.clientIdClaim ?? 'client_id'; + const clientId = getStringClaim(payload, clientIdClaim); + if (!clientId) return null; + + const mapping = config.clients.find((client) => client.clientId === clientId); + if (!mapping) return null; + + return { clientId, mapping }; +} + +function normalizeId(value: unknown): string | undefined { + if (typeof value === 'string') return value; + if (value && typeof value === 'object' && 'toString' in value) { + return String(value); + } + return undefined; +} + +function tenantMatches(user: IUser, mapping: M2MClientConfig): boolean { + if (!mapping.tenantId) return true; + return user.tenantId === mapping.tenantId; +} + +async function resolveMappedUser( + mapping: M2MClientConfig, + findUser: UserMethods['findUser'], +): Promise { + const user = await findUser({ _id: mapping.userId }); + if (!user) return null; + + const id = normalizeId(user._id) ?? user.id ?? mapping.userId; + user.id = id; + return user; +} + +export function createRemoteAgentM2MAuth({ + findUser, + getAppConfig, + verifyBearer = verifyOAuthJwtBearer, +}: RemoteAgentM2MAuthDeps): (options: { action: RemoteAgentM2MAction }) => RequestHandler { + return ({ action }) => { + const handler = async (req: M2MRequest, res: Response, next: NextFunction) => { + try { + const config = await getAppConfig(getConfigOptions()); + const m2mConfig = getEnabledM2MConfig(getRemoteAuthConfig(config)); + + if (!m2mConfig) { + res.status(401).json({ error: 'M2M authentication required' }); + return; + } + + const token = extractBearer(req.headers.authorization); + if (!token) { + res.status(401).json({ error: 'Bearer token required' }); + return; + } + + let payload: JwtPayload; + try { + payload = await verifyBearer(token, getIssuerConfig(m2mConfig)); + } catch (err) { + logger.warn('[remoteAgentM2MAuth] Token verification failed:', err); + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + if (!hasExpectedTokenUse(m2mConfig, payload)) { + logger.warn('[remoteAgentM2MAuth] Token rejected: invalid token use'); + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + const requiredScope = getRequiredScope(m2mConfig, action); + if (!hasRequiredScopes(requiredScope, payload)) { + logger.warn(`[remoteAgentM2MAuth] Token missing required scope: ${requiredScope}`); + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + const clientMapping = getClientMapping(m2mConfig, payload); + if (!clientMapping) { + logger.warn('[remoteAgentM2MAuth] Token rejected: unmapped client'); + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + const user = await resolveMappedUser(clientMapping.mapping, findUser); + if (!user || !user.role || !tenantMatches(user, clientMapping.mapping)) { + logger.warn('[remoteAgentM2MAuth] Token rejected: mapped user unavailable'); + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + req.user = user; + req.authInfo = { + type: 'm2m', + issuer: m2mConfig.issuer, + clientId: clientMapping.clientId, + action, + scopes: getTokenScopes(payload), + }; + + return next(); + } catch (err) { + logger.error('[remoteAgentM2MAuth] Unexpected error', err); + res.status(500).json({ error: 'Internal server error' }); + return; + } + }; + + return handler as RequestHandler; + }; +} diff --git a/packages/api/src/types/http.ts b/packages/api/src/types/http.ts index 219b2729f7..82513c4848 100644 --- a/packages/api/src/types/http.ts +++ b/packages/api/src/types/http.ts @@ -21,6 +21,10 @@ export type RequestBody = { export type ServerRequest = Request & { user?: IUser; config?: AppConfig; + authInfo?: { + type: string; + [key: string]: unknown; + }; /** Server-captured conversation creation time used to anchor dynamic prompt variables. */ conversationCreatedAt?: string; /** Conversation loaded while resolving the prompt timestamp anchor, reused by save logic. */ diff --git a/packages/data-provider/specs/config-schemas.spec.ts b/packages/data-provider/specs/config-schemas.spec.ts index 5420fb7354..89bcffb9b0 100644 --- a/packages/data-provider/specs/config-schemas.spec.ts +++ b/packages/data-provider/specs/config-schemas.spec.ts @@ -472,6 +472,76 @@ describe('agentsEndpointSchema', () => { expect(result.success).toBe(false); }); + + it('allows remote M2M auth with client mappings', () => { + const result = agentsEndpointSchema.safeParse({ + remoteApi: { + auth: { + m2m: { + enabled: true, + issuer: 'https://cognito-idp.us-west-2.amazonaws.com/us-west-2_test', + jwksUri: + 'https://cognito-idp.us-west-2.amazonaws.com/us-west-2_test/.well-known/jwks.json', + audience: 'librechat-agents-api', + scopes: { + read: 'librechat.agents:read', + create: 'librechat.agents:create', + update: 'librechat.agents:update', + delete: 'librechat.agents:delete', + }, + clients: [{ clientId: 'dwh-dwaine-updater', userId: 'user-123' }], + }, + }, + }, + }); + + expect(result.success).toBe(true); + }); + + it('requires remote M2M issuer and client mappings when enabled', () => { + const missingIssuer = agentsEndpointSchema.safeParse({ + remoteApi: { + auth: { + m2m: { + enabled: true, + clients: [{ clientId: 'dwh-dwaine-updater', userId: 'user-123' }], + }, + }, + }, + }); + const missingClients = agentsEndpointSchema.safeParse({ + remoteApi: { + auth: { + m2m: { + enabled: true, + issuer: 'https://cognito-idp.us-west-2.amazonaws.com/us-west-2_test', + }, + }, + }, + }); + + expect(missingIssuer.success).toBe(false); + expect(missingClients.success).toBe(false); + }); + + it('requires space-separated remote M2M scopes', () => { + const result = agentsEndpointSchema.safeParse({ + remoteApi: { + auth: { + m2m: { + enabled: true, + issuer: 'https://cognito-idp.us-west-2.amazonaws.com/us-west-2_test', + scopes: { + update: 'librechat.agents:update,librechat.agents:delete', + }, + clients: [{ clientId: 'dwh-dwaine-updater', userId: 'user-123' }], + }, + }, + }, + }); + + expect(result.success).toBe(false); + }); }); describe('azureEndpointSchema', () => { diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 077c72757e..8a1466a961 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -521,6 +521,53 @@ const remoteApiOidcSchema = z } }); +const remoteApiM2MClientSchema = z.object({ + clientId: z.string().min(1), + userId: z.string().min(1), + tenantId: z.string().min(1).optional(), +}); + +const remoteApiM2MScopesSchema = z + .object({ + read: remoteApiOidcScopeSchema.optional(), + create: remoteApiOidcScopeSchema.optional(), + update: remoteApiOidcScopeSchema.optional(), + delete: remoteApiOidcScopeSchema.optional(), + }) + .optional(); + +const remoteApiM2MSchema = z + .object({ + enabled: z.boolean().default(false), + issuer: remoteApiOidcUrlSchema.optional(), + audience: z.string().min(1).optional(), + jwksUri: remoteApiOidcUrlSchema.optional(), + tokenUseClaim: z.string().min(1).default('token_use'), + tokenUseValue: z.string().min(1).default('access'), + clientIdClaim: z.string().min(1).default('client_id'), + scopes: remoteApiM2MScopesSchema, + clients: z.array(remoteApiM2MClientSchema).default([]), + }) + .superRefine((m2m, ctx) => { + if (m2m.enabled !== true) { + return; + } + if (!m2m.issuer) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['issuer'], + message: 'issuer is required when M2M auth is enabled', + }); + } + if (m2m.clients.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['clients'], + message: 'at least one client mapping is required when M2M auth is enabled', + }); + } + }); + const remoteApiAuthSchema = z.object({ apiKey: z .object({ @@ -528,6 +575,7 @@ const remoteApiAuthSchema = z.object({ }) .optional(), oidc: remoteApiOidcSchema.optional(), + m2m: remoteApiM2MSchema.optional(), }); const remoteApiSchema = z.object({