🔐 feat: Add M2M Authentication for Agent Management (#15555)

* feat: add M2M authentication for Agent Management

* fix: Harden Agent Management authentication

* fix: Preserve OIDC authentication safeguards

* fix: Normalize management principal IDs

* fix: Bound OIDC signing key lookups

* fix: Allow non-browser Agent Management clients
This commit is contained in:
Dustin Healy 2026-09-03 17:07:00 -07:00 • committed by GitHub
parent 04e4fd6b68
commit aec2121923
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1508 additions and 293 deletions

View file

@ -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');

View file

@ -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();

View file

@ -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);
});
});

View file

@ -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();

View file

@ -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)

View file

@ -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;

View file

@ -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,
};

View file

@ -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

View file

@ -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 }),
);
});

View file

@ -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<T> = {
expiresAt: number;
promise: Promise<T>;
};
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<string, CacheEntry<string>>();
const jwksClientCache = new Map<string, CacheEntry<jwksRsa.JwksClient>>();
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<T>(cache: Map<string, CacheEntry<T>>): void {
const now = Date.now();
for (const [key, entry] of cache) {
if (entry.expiresAt <= now) cache.delete(key);
}
}
function setCacheEntry<T>(
cache: Map<string, CacheEntry<T>>,
key: string,
entry: CacheEntry<T>,
): 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<string> {
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<string> {
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<jwksRsa.JwksClient> {
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<JwtPayload> {
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<JwtPayload> {
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);
}

View file

@ -24,3 +24,4 @@ export * from './messageFilterPii';
export * from './messageValidation';
export * from './feedback';
export * from './generationRetry';
export * from './management';

View file

@ -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<IUser>;
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<IUser>;
});
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();
});
});

View file

@ -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> = {}): 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> = {}): 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<void> {
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<IUser>((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();
});
});

View file

@ -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<boolean>;
getAppConfig: (options?: GetAppConfigOptions) => Promise<AppConfig>;
verifyAccessToken?: (token: string, config: OidcAccessTokenConfig) => Promise<JwtPayload>;
}
type ManagementApi = NonNullable<TAgentsEndpoint['managementApi']>;
type ManagementAuth = NonNullable<ManagementApi['auth']>;
type ManagementOidc = NonNullable<ManagementAuth['oidc']>;
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<AgentManagementAuthDeps, 'findUser' | 'isPrincipalActive'>,
): Promise<PrincipalResolution> {
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;
}

View file

@ -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');

View file

@ -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<NonNullable<TAgentsEndpoint['remoteApi']>['auth']>;
type EnabledOidcConfig = OidcConfig & { audience: string; issuer: string };
type JwksCacheOptions = {
enabled: boolean;
maxAge: number;
};
type CacheEntry<T> = {
expiresAt: number;
promise: Promise<T>;
};
type ScopeClaim = string | string[] | undefined;
type UserResolution =
| { status: 'resolved'; user: IUser; updateData: Partial<IUser> }
| { 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<string, CacheEntry<string>>();
const jwksClientCache = new Map<string, CacheEntry<jwksRsa.JwksClient>>();
export function clearRemoteAgentAuthCache(): void {
jwksUriCache.clear();
jwksClientCache.clear();
}
function pruneExpiredEntries<T>(cache: Map<string, CacheEntry<T>>): void {
const now = Date.now();
for (const [key, entry] of cache) {
if (entry.expiresAt <= now) cache.delete(key);
}
}
function setCacheEntry<T>(
cache: Map<string, CacheEntry<T>>,
key: string,
entry: CacheEntry<T>,
): 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<string> {
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<string> {
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<jwksRsa.JwksClient> {
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<JwtPayload> {
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<JwtPayload> {
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<JwtPayload> {
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<JwtPayload> {
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' });

View file

@ -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<string, unknown>) => ({
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({

View file

@ -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<typeof oidcAccessTokenSchema>,
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<string>();
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,