feat: add m2m auth for remote agent crud

This commit is contained in:
Danny Avila 2026-05-23 08:39:10 -04:00
parent 01af63cb52
commit 80730cf559
11 changed files with 954 additions and 0 deletions

View file

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

View file

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

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

View file

@ -1,5 +1,6 @@
export * from './domain';
export * from './openid';
export * from './oauthJwt';
export * from './exchange';
export * from './refresh';
export * from './agent';

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

View file

@ -14,4 +14,5 @@ export { preAuthTenantMiddleware } from './preAuthTenant';
export * from './concurrency';
export * from './checkBalance';
export * from './remoteAgentAuth';
export * from './remoteAgentM2MAuth';
export * from './share';

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

View 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;
};
}

View file

@ -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. */

View file

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

View file

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