mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-22 01:53:33 +00:00
🔐 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:
parent
04e4fd6b68
commit
aec2121923
18 changed files with 1508 additions and 293 deletions
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
65
api/server/routes/agents/__tests__/management.spec.js
Normal file
65
api/server/routes/agents/__tests__/management.spec.js
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
16
api/server/routes/agents/management.js
Normal file
16
api/server/routes/agents/management.js
Normal 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;
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
95
packages/api/src/auth/oidc.spec.ts
Normal file
95
packages/api/src/auth/oidc.spec.ts
Normal 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 }),
|
||||
);
|
||||
});
|
||||
248
packages/api/src/auth/oidc.ts
Normal file
248
packages/api/src/auth/oidc.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -24,3 +24,4 @@ export * from './messageFilterPii';
|
|||
export * from './messageValidation';
|
||||
export * from './feedback';
|
||||
export * from './generationRetry';
|
||||
export * from './management';
|
||||
|
|
|
|||
127
packages/api/src/middleware/management.database.spec.ts
Normal file
127
packages/api/src/middleware/management.database.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
427
packages/api/src/middleware/management.spec.ts
Normal file
427
packages/api/src/middleware/management.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
209
packages/api/src/middleware/management.ts
Normal file
209
packages/api/src/middleware/management.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue