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