This commit is contained in:
Artyom Bogachenko 2026-04-04 20:23:52 +03:00 committed by GitHub
commit 0aa531ab6b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 869 additions and 4 deletions

View file

@ -21,6 +21,7 @@ const { PermissionTypes, Permissions } = require('librechat-data-provider');
const {
generateCheckAccess,
createRequireApiKeyAuth,
createRemoteAgentAuth,
createCheckRemoteAgentAccess,
} = require('@librechat/api');
const {
@ -30,15 +31,23 @@ const {
} = require('~/server/controllers/agents/openai');
const { getEffectivePermissions } = require('~/server/services/PermissionService');
const { configMiddleware } = require('~/server/middleware');
const { getAppConfig } = require('~/server/services/Config');
const db = require('~/models');
const router = express.Router();
const requireApiKeyAuth = createRequireApiKeyAuth({
const apiKeyMiddleware = createRequireApiKeyAuth({
validateAgentApiKey: db.validateAgentApiKey,
findUser: db.findUser,
});
const requireRemoteAgentAuth = createRemoteAgentAuth({
apiKeyMiddleware,
findUser: db.findUser,
updateUser: db.updateUser,
getAppConfig,
});
const checkRemoteAgentsFeature = generateCheckAccess({
permissionType: PermissionTypes.REMOTE_AGENTS,
permissions: [Permissions.USE],
@ -50,7 +59,7 @@ const checkAgentPermission = createCheckRemoteAgentAccess({
getEffectivePermissions,
});
router.use(requireApiKeyAuth);
router.use(requireRemoteAgentAuth);
router.use(configMiddleware);
router.use(checkRemoteAgentsFeature);

View file

@ -24,6 +24,7 @@ const { PermissionTypes, Permissions } = require('librechat-data-provider');
const {
generateCheckAccess,
createRequireApiKeyAuth,
createRemoteAgentAuth,
createCheckRemoteAgentAccess,
} = require('@librechat/api');
const {
@ -33,15 +34,23 @@ const {
} = require('~/server/controllers/agents/responses');
const { getEffectivePermissions } = require('~/server/services/PermissionService');
const { configMiddleware } = require('~/server/middleware');
const { getAppConfig } = require('~/server/services/Config');
const db = require('~/models');
const router = express.Router();
const requireApiKeyAuth = createRequireApiKeyAuth({
const apiKeyMiddleware = createRequireApiKeyAuth({
validateAgentApiKey: db.validateAgentApiKey,
findUser: db.findUser,
});
const requireRemoteAgentAuth = createRemoteAgentAuth({
apiKeyMiddleware,
findUser: db.findUser,
updateUser: db.updateUser,
getAppConfig,
});
const checkRemoteAgentsFeature = generateCheckAccess({
permissionType: PermissionTypes.REMOTE_AGENTS,
permissions: [Permissions.USE],
@ -53,7 +62,7 @@ const checkAgentPermission = createCheckRemoteAgentAccess({
getEffectivePermissions,
});
router.use(requireApiKeyAuth);
router.use(requireRemoteAgentAuth);
router.use(configMiddleware);
router.use(checkRemoteAgentsFeature);

View file

@ -9,3 +9,4 @@ export { tenantContextMiddleware } from './tenant';
export { preAuthTenantMiddleware } from './preAuthTenant';
export * from './concurrency';
export * from './checkBalance';
export * from './remoteAgentAuth';

View file

@ -0,0 +1,560 @@
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<string, unknown>).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<string, string> = {}): Partial<Request> {
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(),
updateUser: 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<string | undefined> {
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');
});
});
describe('update user and migration scenarios', () => {
it('persists openidId binding when migration is needed', async () => {
const mockUpdateUser = jest.fn().mockResolvedValue(undefined);
setupOidcMocks({ sub: 'sub-new', email: 'existing@test.com' });
(findOpenIDUser as jest.Mock).mockResolvedValue({
user: { ...mockUser, openidId: undefined, role: 'user' },
error: null,
migration: true,
});
const deps = { ...makeDeps(), updateUser: mockUpdateUser };
await createRemoteAgentAuth(deps)(
makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request,
makeRes().res,
mockNext,
);
expect(mockUpdateUser).toHaveBeenCalledWith(
mockUser.id,
expect.objectContaining({ provider: 'openid', openidId: 'sub-new' }),
);
expect(mockNext).toHaveBeenCalled();
});
it('does not call updateUser when migration is false and role exists', async () => {
const mockUpdateUser = jest.fn();
setupOidcMocks({ sub: 'sub123', email: 'agent@test.com' });
(findOpenIDUser as jest.Mock).mockResolvedValue({
user: { ...mockUser, role: 'user' },
error: null,
migration: false,
});
const deps = { ...makeDeps(), updateUser: mockUpdateUser };
await createRemoteAgentAuth(deps)(
makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request,
makeRes().res,
mockNext,
);
expect(mockUpdateUser).not.toHaveBeenCalled();
});
});
describe('scope validation', () => {
it('returns 401 when required scope is missing from token', async () => {
setupOidcMocks({ sub: 'sub123', email: 'agent@test.com', scope: 'openid profile' });
const deps = makeDeps(makeConfig({ scope: 'remote_agent' }, { 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('passes when required scope is present in token', async () => {
setupOidcMocks({
sub: 'sub123',
email: 'agent@test.com',
scope: 'openid profile remote_agent',
});
(findOpenIDUser as jest.Mock).mockResolvedValue({
user: { ...mockUser },
error: null,
migration: false,
});
const deps = makeDeps(makeConfig({ scope: 'remote_agent' }));
await createRemoteAgentAuth(deps)(
makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request,
makeRes().res,
mockNext,
);
expect(mockNext).toHaveBeenCalled();
});
it('passes when scope is not configured (backward compat)', async () => {
setupOidcMocks({ sub: 'sub123', email: 'agent@test.com' }); // no scope claim at all
(findOpenIDUser as jest.Mock).mockResolvedValue({
user: { ...mockUser },
error: null,
migration: false,
});
const deps = makeDeps(makeConfig({ scope: undefined }));
await createRemoteAgentAuth(deps)(
makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request,
makeRes().res,
mockNext,
);
expect(mockNext).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,264 @@
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 { SystemRoles } from 'librechat-data-provider';
import { isEnabled, math } from '~/utils';
import { findOpenIDUser } from '../auth/openid';
export interface RemoteAgentAuthDeps {
apiKeyMiddleware: RequestHandler;
findUser: UserMethods['findUser'];
updateUser: UserMethods['updateUser'];
getAppConfig: () => Promise<AppConfig | null>;
}
type OidcConfig = NonNullable<
NonNullable<NonNullable<TAgentsEndpoint['remoteApi']>['auth']>['oidc']
>;
const jwksClientCache = new Map<string, Promise<jwksRsa.JwksClient>>();
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<string> {
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<jwksRsa.JwksClient> {
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<JwtPayload> {
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'],
updateUser: UserMethods['updateUser'],
): Promise<IUser | null> {
const { user, error, migration } = 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);
const updateData: Partial<IUser> = {};
if (migration && payload.sub != null) {
updateData.provider = 'openid';
updateData.openidId = payload.sub;
}
if (!user.role) {
user.role = SystemRoles.USER;
updateData.role = SystemRoles.USER;
}
if (Object.keys(updateData).length > 0) {
await updateUser(user.id, updateData);
}
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: <issuer>
* jwksUri: <jwksUri>
* audience: <audience>
* scope: <scope>
* ```
*/
export function createRemoteAgentAuth({
apiKeyMiddleware,
findUser,
updateUser,
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);
if (authConfig.oidc.scope != null) {
const rawScope = payload['scp'] ?? payload['scope'];
const tokenScopes: string[] = Array.isArray(rawScope)
? rawScope
: ((rawScope as string | undefined)?.split(' ') ?? []);
if (!tokenScopes.includes(authConfig.oidc.scope)) {
logger.warn(`[remoteAgentAuth] Token missing required scope: ${authConfig.oidc.scope}`);
if (apiKeyEnabled) return apiKeyMiddleware(req, res, next);
res.status(401).json({ error: 'Unauthorized' });
return;
}
}
const user = await resolveUser(token, payload, findUser, updateUser);
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;
}
};
}

View file

@ -318,6 +318,27 @@ 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(),
scope: z.string().optional(),
})
.optional(),
});
const remoteApiSchema = z.object({
auth: remoteApiAuthSchema.optional(),
});
export const agentsEndpointSchema = baseEndpointSchema
.omit({ baseURL: true })
.merge(
@ -334,6 +355,7 @@ export const agentsEndpointSchema = baseEndpointSchema
.array(z.nativeEnum(AgentCapabilities))
.optional()
.default(defaultAgentCapabilities),
remoteApi: remoteApiSchema.optional(),
}),
)
.default({