From f51fe917d166cd6f2fee1f10e8e55f4476d14a21 Mon Sep 17 00:00:00 2001 From: Artyom Bogachenko Date: Wed, 18 Mar 2026 17:54:39 +0300 Subject: [PATCH] Remote Agent Auth middleware --- api/server/routes/agents/openai.js | 12 +- api/server/routes/agents/responses.js | 12 +- packages/api/src/middleware/index.ts | 1 + .../src/middleware/remoteAgentAuth.spec.ts | 457 ++++++++++++++++++ .../api/src/middleware/remoteAgentAuth.ts | 228 +++++++++ packages/data-provider/src/config.ts | 21 + 6 files changed, 725 insertions(+), 6 deletions(-) create mode 100644 packages/api/src/middleware/remoteAgentAuth.spec.ts create mode 100644 packages/api/src/middleware/remoteAgentAuth.ts diff --git a/api/server/routes/agents/openai.js b/api/server/routes/agents/openai.js index 72e3da6c5a..0e4a7d6eeb 100644 --- a/api/server/routes/agents/openai.js +++ b/api/server/routes/agents/openai.js @@ -21,6 +21,7 @@ const { PermissionTypes, Permissions } = require('librechat-data-provider'); const { generateCheckAccess, createRequireApiKeyAuth, + createRemoteAgentAuth, createCheckRemoteAgentAccess, } = require('@librechat/api'); const { @@ -29,14 +30,19 @@ const { GetModelController, } = require('~/server/controllers/agents/openai'); const { getEffectivePermissions } = require('~/server/services/PermissionService'); +const { validateAgentApiKey, findUser } = require('~/models'); const { configMiddleware } = require('~/server/middleware'); +const { getAppConfig } = require('~/server/services/Config'); const db = require('~/models'); const router = express.Router(); -const requireApiKeyAuth = createRequireApiKeyAuth({ - validateAgentApiKey: db.validateAgentApiKey, +const apiKeyMiddleware = createRequireApiKeyAuth({ validateAgentApiKey, findUser }); + +const requireRemoteAgentAuth = createRemoteAgentAuth({ + apiKeyMiddleware, findUser: db.findUser, + getAppConfig, }); const checkRemoteAgentsFeature = generateCheckAccess({ @@ -50,7 +56,7 @@ const checkAgentPermission = createCheckRemoteAgentAccess({ getEffectivePermissions, }); -router.use(requireApiKeyAuth); +router.use(requireRemoteAgentAuth); router.use(configMiddleware); router.use(checkRemoteAgentsFeature); diff --git a/api/server/routes/agents/responses.js b/api/server/routes/agents/responses.js index 2c118e0597..1e2fc10972 100644 --- a/api/server/routes/agents/responses.js +++ b/api/server/routes/agents/responses.js @@ -24,6 +24,7 @@ const { PermissionTypes, Permissions } = require('librechat-data-provider'); const { generateCheckAccess, createRequireApiKeyAuth, + createRemoteAgentAuth, createCheckRemoteAgentAccess, } = require('@librechat/api'); const { @@ -32,14 +33,19 @@ const { listModels, } = require('~/server/controllers/agents/responses'); const { getEffectivePermissions } = require('~/server/services/PermissionService'); +const { validateAgentApiKey, findUser } = require('~/models'); const { configMiddleware } = require('~/server/middleware'); +const { getAppConfig } = require('~/server/services/Config'); const db = require('~/models'); const router = express.Router(); -const requireApiKeyAuth = createRequireApiKeyAuth({ - validateAgentApiKey: db.validateAgentApiKey, +const apiKeyMiddleware = createRequireApiKeyAuth({ validateAgentApiKey, findUser }); + +const requireRemoteAgentAuth = createRemoteAgentAuth({ + apiKeyMiddleware, findUser: db.findUser, + getAppConfig, }); const checkRemoteAgentsFeature = generateCheckAccess({ @@ -53,7 +59,7 @@ const checkAgentPermission = createCheckRemoteAgentAccess({ getEffectivePermissions, }); -router.use(requireApiKeyAuth); +router.use(requireRemoteAgentAuth); router.use(configMiddleware); router.use(checkRemoteAgentsFeature); diff --git a/packages/api/src/middleware/index.ts b/packages/api/src/middleware/index.ts index b91fee2999..9fccfa6780 100644 --- a/packages/api/src/middleware/index.ts +++ b/packages/api/src/middleware/index.ts @@ -9,3 +9,4 @@ export { tenantContextMiddleware } from './tenant'; export { preAuthTenantMiddleware } from './preAuthTenant'; export * from './concurrency'; export * from './checkBalance'; +export * from './remoteAgentAuth'; diff --git a/packages/api/src/middleware/remoteAgentAuth.spec.ts b/packages/api/src/middleware/remoteAgentAuth.spec.ts new file mode 100644 index 0000000000..1ef78302e1 --- /dev/null +++ b/packages/api/src/middleware/remoteAgentAuth.spec.ts @@ -0,0 +1,457 @@ +import type { Request, Response } from 'express'; +import type { AppConfig, IUser } from '@librechat/data-schemas'; + +jest.mock('@librechat/data-schemas', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})); + +jest.mock('~/utils', () => ({ + isEnabled: jest.fn(() => false), + math: jest.fn(() => 60000), +})); + +const mockGetSigningKey = jest.fn(); +const mockGetKeys = jest.fn(); + +jest.mock('jwks-rsa', () => + jest.fn(() => ({ getSigningKey: mockGetSigningKey, getKeys: mockGetKeys })), +); + +jest.mock('jsonwebtoken', () => ({ + decode: jest.fn(), + verify: jest.fn(), +})); + +jest.mock('../auth/openid', () => ({ + findOpenIDUser: jest.fn(), +})); + +import jwt from 'jsonwebtoken'; +import { logger } from '@librechat/data-schemas'; +import { findOpenIDUser } from '../auth/openid'; +import { createRemoteAgentAuth } from './remoteAgentAuth'; + +const fetchMock = jest.fn(); + +beforeAll(() => { + (global as unknown as Record).fetch = fetchMock; +}); + +const FAKE_TOKEN = 'header.payload.signature'; +const BASE_ISSUER = 'https://auth.example.com/realms/test'; +const BASE_JWKS_URI = `${BASE_ISSUER}/protocol/openid-connect/certs`; + +type SigningKeyCallback = (err: Error | null, key?: { getPublicKey: () => string }) => void; +type JwtVerifyCallback = (err: Error | null, payload?: object) => void; + +const mockUser = { _id: 'uid123', id: 'uid123', email: 'agent@test.com' }; + +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 makeConfig(oidcOverrides?: object, apiKeyOverrides?: object): AppConfig { + return { + endpoints: { + agents: { + remoteApi: { + auth: { + oidc: { + enabled: true, + issuer: BASE_ISSUER, + jwksUri: BASE_JWKS_URI, + ...oidcOverrides, + }, + apiKey: { enabled: true, ...apiKeyOverrides }, + }, + }, + }, + }, + } as unknown as AppConfig; +} + +function makeDeps(appConfig: AppConfig | null = makeConfig()) { + return { + findUser: jest.fn(), + getAppConfig: jest.fn().mockResolvedValue(appConfig), + apiKeyMiddleware: jest.fn((_req: unknown, _res: unknown, next: () => void) => next()), + }; +} + +function setupOidcMocks(payload: object, kid = 'test-kid') { + (jwt.decode as jest.Mock).mockReturnValue({ header: { kid }, payload }); + mockGetSigningKey.mockImplementation((_k: string, cb: SigningKeyCallback) => + cb(null, { getPublicKey: () => 'public-key' }), + ); + (jwt.verify as jest.Mock).mockImplementation( + (_t: string, _k: string, _o: object, cb: JwtVerifyCallback) => cb(null, payload), + ); +} + +describe('createRemoteAgentAuth', () => { + let mockNext: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + fetchMock.mockReset(); + mockNext = jest.fn(); + }); + + describe('when OIDC is not enabled', () => { + it('falls back to apiKeyMiddleware when getAppConfig returns null', async () => { + const deps = makeDeps(null); + const mw = createRemoteAgentAuth(deps); + const req = makeReq(); + const { res } = makeRes(); + + await mw(req as Request, res, mockNext); + + expect(deps.apiKeyMiddleware).toHaveBeenCalledWith(req, res, mockNext); + expect(mockNext).toHaveBeenCalled(); + }); + + it('falls back to apiKeyMiddleware when oidc.enabled is false', async () => { + const deps = makeDeps(makeConfig({ enabled: false })); + await createRemoteAgentAuth(deps)(makeReq() as Request, makeRes().res, mockNext); + expect(deps.apiKeyMiddleware).toHaveBeenCalled(); + }); + + it('falls back to apiKeyMiddleware when remoteApi auth is absent', async () => { + const deps = makeDeps({ endpoints: { agents: {} } } as unknown as AppConfig); + await createRemoteAgentAuth(deps)(makeReq() as Request, makeRes().res, mockNext); + expect(deps.apiKeyMiddleware).toHaveBeenCalled(); + }); + }); + + describe('when OIDC enabled but no Bearer token', () => { + it('falls back to apiKeyMiddleware when apiKey is enabled', async () => { + const deps = makeDeps(makeConfig({}, { enabled: true })); + const { res } = makeRes(); + + await createRemoteAgentAuth(deps)(makeReq() as Request, res, mockNext); + + expect(deps.apiKeyMiddleware).toHaveBeenCalled(); + expect(mockNext).toHaveBeenCalled(); + }); + + it('returns 401 when apiKey is disabled and no token present', async () => { + const deps = makeDeps(makeConfig({}, { enabled: false })); + const { res, status, json } = makeRes(); + + await createRemoteAgentAuth(deps)(makeReq() as Request, res, mockNext); + + expect(status).toHaveBeenCalledWith(401); + expect(json).toHaveBeenCalledWith({ error: 'Bearer token required' }); + expect(mockNext).not.toHaveBeenCalled(); + }); + }); + + describe('when OIDC verification succeeds', () => { + beforeEach(() => { + (findOpenIDUser as jest.Mock).mockResolvedValue({ user: { ...mockUser }, error: null }); + }); + + it('sets req.user and calls next()', async () => { + setupOidcMocks({ sub: 'sub123', email: 'agent@test.com', exp: 9999999999 }); + const deps = makeDeps(); + const req = makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }); + const { res } = makeRes(); + + await createRemoteAgentAuth(deps)(req as Request, res, mockNext); + + expect(req.user).toMatchObject({ id: 'uid123', email: 'agent@test.com' }); + expect(mockNext).toHaveBeenCalledWith(); + expect(deps.apiKeyMiddleware).not.toHaveBeenCalled(); + }); + + it('attaches federatedTokens with access_token and expires_at', async () => { + const exp = 1234567890; + setupOidcMocks({ sub: 'sub123', email: 'agent@test.com', exp }); + const deps = makeDeps(); + const req = makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }); + + await createRemoteAgentAuth(deps)(req as Request, makeRes().res, mockNext); + + expect((req.user as IUser).federatedTokens).toEqual({ + access_token: FAKE_TOKEN, + expires_at: exp, + }); + }); + + it('falls back to apiKeyMiddleware when user is not found and apiKey is enabled', async () => { + setupOidcMocks({ sub: 'sub123', email: 'agent@test.com' }); + (findOpenIDUser as jest.Mock).mockResolvedValue({ user: null, error: null }); + + const deps = makeDeps(makeConfig({}, { enabled: true })); + const { res } = makeRes(); + + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + res, + mockNext, + ); + + expect(deps.apiKeyMiddleware).toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('no matching LibreChat user'), + ); + }); + + it('returns 401 when user is not found and apiKey is disabled', async () => { + setupOidcMocks({ sub: 'sub123', email: 'agent@test.com' }); + (findOpenIDUser as jest.Mock).mockResolvedValue({ user: null, error: null }); + + const deps = makeDeps(makeConfig({}, { enabled: false })); + const { res, status, json } = makeRes(); + + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + res, + mockNext, + ); + + expect(status).toHaveBeenCalledWith(401); + expect(json).toHaveBeenCalledWith({ error: 'Unauthorized' }); + expect(mockNext).not.toHaveBeenCalled(); + }); + }); + + describe('when OIDC verification fails', () => { + beforeEach(() => { + (jwt.decode as jest.Mock).mockReturnValue({ header: { kid: 'kid' }, payload: {} }); + mockGetSigningKey.mockImplementation((_k: string, cb: SigningKeyCallback) => + cb(new Error('Signing key not found')), + ); + }); + + it('falls back to apiKeyMiddleware when apiKey is enabled', async () => { + const deps = makeDeps(makeConfig({}, { enabled: true })); + const { res } = makeRes(); + + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + res, + mockNext, + ); + + expect(deps.apiKeyMiddleware).toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('OIDC verification failed'), + expect.any(Error), + ); + }); + + it('returns 401 when apiKey is disabled', async () => { + const deps = makeDeps(makeConfig({}, { enabled: false })); + const { res, status, json } = makeRes(); + + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + res, + mockNext, + ); + + expect(status).toHaveBeenCalledWith(401); + expect(json).toHaveBeenCalledWith({ error: 'Unauthorized' }); + }); + + it('returns 401 when JWT cannot be decoded', async () => { + (jwt.decode as jest.Mock).mockReturnValue(null); + const deps = makeDeps(makeConfig({}, { enabled: false })); + const { res, status } = makeRes(); + + await createRemoteAgentAuth(deps)( + makeReq({ authorization: 'Bearer not.a.jwt' }) as Request, + res, + mockNext, + ); + + expect(status).toHaveBeenCalledWith(401); + }); + }); + + describe('unexpected errors', () => { + it('returns 500 when getAppConfig throws', async () => { + const deps = { + ...makeDeps(), + getAppConfig: jest.fn().mockRejectedValue(new Error('DB down')), + }; + const { res, status, json } = makeRes(); + + await createRemoteAgentAuth(deps)(makeReq() as Request, res, mockNext); + + expect(status).toHaveBeenCalledWith(500); + expect(json).toHaveBeenCalledWith({ error: 'Internal server error' }); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Unexpected error'), + expect.any(Error), + ); + }); + + it('returns 401 when findOpenIDUser throws and apiKey is disabled', async () => { + setupOidcMocks({ sub: 'sub123', email: 'agent@test.com' }); + (findOpenIDUser as jest.Mock).mockRejectedValue(new Error('DB error')); + + const deps = makeDeps(makeConfig({}, { enabled: false })); + const { res, status, json } = makeRes(); + + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + res, + mockNext, + ); + + expect(status).toHaveBeenCalledWith(401); + expect(json).toHaveBeenCalledWith({ error: 'Unauthorized' }); + }); + }); + + describe('JWKS URI resolution', () => { + beforeEach(() => { + setupOidcMocks({ sub: 'sub1', email: 'a@b.com' }); + (findOpenIDUser as jest.Mock).mockResolvedValue({ user: { ...mockUser }, error: null }); + }); + + it('uses jwksUri from config and skips discovery', async () => { + const deps = makeDeps( + makeConfig({ + jwksUri: 'https://explicit-1.example.com/jwks', + issuer: 'https://issuer-explicit-1.example.com', + }), + ); + + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + makeRes().res, + mockNext, + ); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(mockNext).toHaveBeenCalled(); + }); + + it('uses OPENID_JWKS_URL env var and skips discovery', async () => { + process.env.OPENID_JWKS_URL = 'https://env.example.com/jwks'; + const deps = makeDeps( + makeConfig({ jwksUri: undefined, issuer: 'https://issuer-env-1.example.com' }), + ); + + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + makeRes().res, + mockNext, + ); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(mockNext).toHaveBeenCalled(); + + delete process.env.OPENID_JWKS_URL; + }); + + it('fetches discovery document when jwksUri and env var are absent', async () => { + delete process.env.OPENID_JWKS_URL; + const issuer = 'https://issuer-discovery-1.example.com'; + + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ jwks_uri: `${issuer}/protocol/openid-connect/certs` }), + }); + + const deps = makeDeps(makeConfig({ jwksUri: undefined, issuer })); + + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + makeRes().res, + mockNext, + ); + + expect(fetchMock).toHaveBeenCalledWith(`${issuer}/.well-known/openid-configuration`); + expect(mockNext).toHaveBeenCalled(); + }); + + it('returns 401 when discovery returns non-ok response', async () => { + delete process.env.OPENID_JWKS_URL; + fetchMock.mockResolvedValue({ ok: false, status: 404, statusText: 'Not Found' }); + + const deps = makeDeps( + makeConfig( + { jwksUri: undefined, issuer: 'https://issuer-discovery-fail-1.example.com' }, + { enabled: false }, + ), + ); + const { res, status } = makeRes(); + + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + res, + mockNext, + ); + + expect(status).toHaveBeenCalledWith(401); + }); + + it('returns 401 when discovery response is missing jwks_uri field', async () => { + delete process.env.OPENID_JWKS_URL; + fetchMock.mockResolvedValue({ ok: true, json: async () => ({}) }); + + const deps = makeDeps( + makeConfig( + { jwksUri: undefined, issuer: 'https://issuer-missing-jwks-1.example.com' }, + { enabled: false }, + ), + ); + const { res, status } = makeRes(); + + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + res, + mockNext, + ); + + expect(status).toHaveBeenCalledWith(401); + }); + }); + + describe('email claim resolution', () => { + async function captureEmailArg(claims: object): Promise { + setupOidcMocks(claims); + (findOpenIDUser as jest.Mock).mockResolvedValue({ user: { ...mockUser }, error: null }); + + const deps = makeDeps(); + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + makeRes().res, + mockNext, + ); + + return (findOpenIDUser as jest.Mock).mock.calls[0][0].email; + } + + it('uses email claim', async () => { + expect(await captureEmailArg({ sub: 's1', email: 'user@example.com' })).toBe( + 'user@example.com', + ); + }); + + it('falls back to preferred_username when email is absent', async () => { + expect(await captureEmailArg({ sub: 's2', preferred_username: 'agent-user' })).toBe( + 'agent-user', + ); + }); + + it('falls back to upn when email and preferred_username are absent', async () => { + expect(await captureEmailArg({ sub: 's3', upn: 'upn@corp.com' })).toBe('upn@corp.com'); + }); + }); +}); diff --git a/packages/api/src/middleware/remoteAgentAuth.ts b/packages/api/src/middleware/remoteAgentAuth.ts new file mode 100644 index 0000000000..38830914cb --- /dev/null +++ b/packages/api/src/middleware/remoteAgentAuth.ts @@ -0,0 +1,228 @@ +import jwt from 'jsonwebtoken'; +import jwksRsa from 'jwks-rsa'; +import { HttpsProxyAgent } from 'https-proxy-agent'; +import { logger } from '@librechat/data-schemas'; +import type { RequestHandler, Request, Response, NextFunction } from 'express'; +import type { JwtPayload } from 'jsonwebtoken'; +import type { AppConfig, IUser, UserMethods } from '@librechat/data-schemas'; +import type { TAgentsEndpoint } from 'librechat-data-provider'; +import { isEnabled, math } from '~/utils'; +import { findOpenIDUser } from '../auth/openid'; + +export interface RemoteAgentAuthDeps { + apiKeyMiddleware: RequestHandler; + findUser: UserMethods['findUser']; + getAppConfig: () => Promise; +} + +type OidcConfig = NonNullable< + NonNullable['auth']>['oidc'] +>; + +const jwksClientCache = new Map>(); + +function extractBearer(authHeader: string | undefined): string | null { + if (!authHeader?.startsWith('Bearer ')) return null; + return authHeader.slice(7); +} + +function getEmail(payload: JwtPayload): string | undefined { + return ( + (payload['email'] as string | undefined) ?? + (payload['preferred_username'] as string | undefined) ?? + (payload['upn'] as string | undefined) + ); +} + +async function resolveJwksUri(oidcConfig: OidcConfig): Promise { + if (oidcConfig.jwksUri) return oidcConfig.jwksUri; + if (process.env.OPENID_JWKS_URL) return process.env.OPENID_JWKS_URL; + + const issuer = oidcConfig.issuer.replace(/\/$/, ''); + const discoveryUrl = `${issuer}/.well-known/openid-configuration`; + + const res = await fetch(discoveryUrl); + 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 meta.jwks_uri; +} + +function buildJwksClient(uri: string): jwksRsa.JwksClient { + const options: jwksRsa.Options = { + cache: isEnabled(process.env.OPENID_JWKS_URL_CACHE_ENABLED) || true, + cacheMaxAge: math(process.env.OPENID_JWKS_URL_CACHE_TIME, 60000), + jwksUri: uri, + }; + + if (process.env.PROXY) { + options.requestAgent = new HttpsProxyAgent(process.env.PROXY); + } + + return jwksRsa(options); +} + +async function getJwksClient(oidcConfig: OidcConfig): Promise { + const cacheKey = oidcConfig.jwksUri ?? oidcConfig.issuer; + + const cached = jwksClientCache.get(cacheKey); + if (cached != null) return cached; + + const promise = resolveJwksUri(oidcConfig) + .then((uri) => { + return buildJwksClient(uri); + }) + .catch((err) => { + jwksClientCache.delete(cacheKey); // не кэшируем ошибку + throw err; + }); + + jwksClientCache.set(cacheKey, promise); + return promise; +} + +function verifyOidcBearer(token: string, oidcConfig: OidcConfig): Promise { + return new Promise((resolve, reject) => { + const decoded = jwt.decode(token, { complete: true }); + if (decoded == null) return reject(new Error('Invalid JWT: cannot decode')); + + const kid = decoded.header?.kid as string | undefined; + + getJwksClient(oidcConfig) + .then((client) => { + const fetchKey = (callback: (key: jwksRsa.SigningKey) => void) => { + if (kid != null) { + client.getSigningKey(kid, (err, key) => { + if (err != null || key == null) + return reject(err ?? new Error('No signing key for kid')); + callback(key); + }); + } else { + client + .getKeys() + .then((keys: unknown) => { + const jwkKeys = keys as Array<{ kid: string }>; + if (jwkKeys.length === 0) return reject(new Error('No keys in JWKS')); + client.getSigningKey(jwkKeys[0].kid, (keyErr, key) => { + if (keyErr != null || key == null) + return reject(keyErr ?? new Error('No signing key')); + callback(key); + }); + }) + .catch((err: unknown) => reject(err)); + } + }; + + fetchKey((signingKey) => { + jwt.verify( + token, + signingKey.getPublicKey(), + { + algorithms: ['RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512'], + ...(oidcConfig.issuer ? { issuer: oidcConfig.issuer } : {}), + ...(oidcConfig.audience ? { audience: oidcConfig.audience } : {}), + }, + (err, payload) => { + if (err != null || payload == null) { + return reject(err ?? new Error('Empty payload')); + } + resolve(payload as JwtPayload); + }, + ); + }); + }) + .catch(reject); + }); +} + +async function resolveUser( + token: string, + payload: JwtPayload, + findUser: UserMethods['findUser'], +): Promise { + const { user, error } = await findOpenIDUser({ + findUser, + email: getEmail(payload), + openidId: payload.sub ?? '', + idOnTheSource: payload['oid'] as string | undefined, + strategyName: 'remoteAgentAuth', + }); + + if (error != null || user == null) return null; + + user.id = String(user._id); + user.federatedTokens = { access_token: token, expires_at: payload.exp }; + return user; +} + +/** + * Factory for Remote Agent API auth middleware. + * + * Validates Bearer tokens against configured OIDC issuer via JWKS, + * falling back to API key auth when enabled. Stateless — no session dependency. + * + * ```yaml + * endpoints: + * agents: + * remoteApi: + * auth: + * apiKey: + * enabled: false + * oidc: + * enabled: true + * issuer: + * jwksUri: + * audience: + * ``` + */ +export function createRemoteAgentAuth({ + apiKeyMiddleware, + findUser, + getAppConfig, +}: RemoteAgentAuthDeps): RequestHandler { + return async (req: Request, res: Response, next: NextFunction) => { + try { + const config = await getAppConfig(); + const authConfig = config?.endpoints?.agents?.remoteApi?.auth; + + if (authConfig?.oidc?.enabled !== true) { + return apiKeyMiddleware(req, res, next); + } + + const token = extractBearer(req.headers.authorization); + const apiKeyEnabled = authConfig.apiKey?.enabled !== false; + + if (token == null) { + if (apiKeyEnabled) return apiKeyMiddleware(req, res, next); + res.status(401).json({ error: 'Bearer token required' }); + return; + } + + try { + const payload = await verifyOidcBearer(token, authConfig.oidc); + const user = await resolveUser(token, payload, findUser); + + if (user == null) { + logger.warn('[remoteAgentAuth] OIDC token valid but no matching LibreChat user'); + if (apiKeyEnabled) return apiKeyMiddleware(req, res, next); + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + req.user = user; + return next(); + } catch (oidcErr) { + logger.error('[remoteAgentAuth] OIDC verification failed:', oidcErr); + if (apiKeyEnabled) return apiKeyMiddleware(req, res, next); + res.status(401).json({ error: 'Unauthorized' }); + return; + } + } catch (err) { + logger.error('[remoteAgentAuth] Unexpected error', err); + res.status(500).json({ error: 'Internal server error' }); + return; + } + }; +} diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index ae3f5b9560..fe763fb18e 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -279,6 +279,26 @@ export const defaultAgentCapabilities = [ AgentCapabilities.ocr, ]; +const remoteApiAuthSchema = z.object({ + apiKey: z + .object({ + enabled: z.boolean().default(true), + }) + .optional(), + oidc: z + .object({ + enabled: z.boolean().default(false), + issuer: z.string(), + audience: z.string().optional(), + jwksUri: z.string().optional(), + }) + .optional(), +}); + +const remoteApiSchema = z.object({ + auth: remoteApiAuthSchema.optional(), +}); + export const agentsEndpointSchema = baseEndpointSchema .omit({ baseURL: true }) .merge( @@ -295,6 +315,7 @@ export const agentsEndpointSchema = baseEndpointSchema .array(z.nativeEnum(AgentCapabilities)) .optional() .default(defaultAgentCapabilities), + remoteApi: remoteApiSchema.optional(), }), ) .default({