From 51ebd60782bba3531f71ad5eaed19477cb682902 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 23 May 2026 08:48:57 -0400 Subject: [PATCH] fix: enforce tenant m2m policy for agents --- .../src/middleware/remoteAgentM2MAuth.spec.ts | 55 +++++++ .../api/src/middleware/remoteAgentM2MAuth.ts | 148 ++++++++++++++---- 2 files changed, 175 insertions(+), 28 deletions(-) diff --git a/packages/api/src/middleware/remoteAgentM2MAuth.spec.ts b/packages/api/src/middleware/remoteAgentM2MAuth.spec.ts index cc20e929dc..23f498db33 100644 --- a/packages/api/src/middleware/remoteAgentM2MAuth.spec.ts +++ b/packages/api/src/middleware/remoteAgentM2MAuth.spec.ts @@ -109,6 +109,61 @@ describe('createRemoteAgentM2MAuth', () => { expect(next).toHaveBeenCalledWith(); }); + it('revalidates M2M policy after resolving the mapped user tenant', async () => { + const deps = makeDeps(makeConfig(), { + token_use: 'access', + client_id: 'dwh-dwaine-updater', + scope: 'librechat.agents:update tenant.agents:update', + }); + deps.findUser.mockResolvedValue(makeUser({ tenantId: 'tenant-a' })); + deps.getAppConfig.mockResolvedValueOnce(makeConfig()).mockResolvedValueOnce( + makeConfig({ + scopes: { update: 'tenant.agents:update' }, + clients: [{ clientId: 'dwh-dwaine-updater', userId: 'user-123', tenantId: 'tenant-a' }], + }), + ); + const req = makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }); + + await createRemoteAgentM2MAuth(deps)({ action: 'update' })(req as Request, makeRes().res, next); + + expect(deps.getAppConfig).toHaveBeenNthCalledWith(1, { baseOnly: true }); + expect(deps.getAppConfig).toHaveBeenNthCalledWith(2, { + tenantId: 'tenant-a', + userId: 'user-123', + role: 'user', + }); + expect(deps.verifyBearer).toHaveBeenCalledTimes(2); + expect(req.user).toMatchObject({ id: 'user-123', tenantId: 'tenant-a' }); + expect(next).toHaveBeenCalledWith(); + }); + + it('rejects mapped users when resolved tenant M2M policy tightens scopes', async () => { + const deps = makeDeps(makeConfig(), { + token_use: 'access', + client_id: 'dwh-dwaine-updater', + scope: 'librechat.agents:update', + }); + deps.findUser.mockResolvedValue(makeUser({ tenantId: 'tenant-a' })); + deps.getAppConfig.mockResolvedValueOnce(makeConfig()).mockResolvedValueOnce( + makeConfig({ + scopes: { update: 'tenant.agents:update' }, + clients: [{ clientId: 'dwh-dwaine-updater', userId: 'user-123', tenantId: 'tenant-a' }], + }), + ); + const { res, status, json } = makeRes(); + + await createRemoteAgentM2MAuth(deps)({ action: 'update' })( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + res, + next, + ); + + expect(status).toHaveBeenCalledWith(401); + expect(json).toHaveBeenCalledWith({ error: 'Unauthorized' }); + expect(deps.verifyBearer).toHaveBeenCalledTimes(2); + expect(next).not.toHaveBeenCalled(); + }); + it('rejects requests when M2M auth is disabled', async () => { const deps = makeDeps(makeConfig({ enabled: false })); const { res, status, json } = makeRes(); diff --git a/packages/api/src/middleware/remoteAgentM2MAuth.ts b/packages/api/src/middleware/remoteAgentM2MAuth.ts index 21522a1151..f62665b43e 100644 --- a/packages/api/src/middleware/remoteAgentM2MAuth.ts +++ b/packages/api/src/middleware/remoteAgentM2MAuth.ts @@ -32,6 +32,14 @@ type M2MRequest = Request & { authInfo?: RemoteAgentM2MAuthInfo; }; type ScopeClaim = string | string[] | undefined; +type M2MTokenValidation = { + payload: JwtPayload; + clientMapping: { clientId: string; mapping: M2MClientConfig }; +}; +type M2MPolicy = { + m2mConfig: EnabledM2MConfig; + validation: M2MTokenValidation; +}; const DEFAULT_ACTION_SCOPES: Record = { read: 'librechat.agents:read', @@ -46,6 +54,21 @@ function getConfigOptions(): GetAppConfigOptions { return { baseOnly: true }; } +function getUserConfigOptions(user: IUser): GetAppConfigOptions { + if (user.tenantId) return { role: user.role, userId: user.id, tenantId: user.tenantId }; + return { baseOnly: true }; +} + +function isResolvedUserConfigScope(initialOptions: GetAppConfigOptions, user: IUser): boolean { + const userOptions = getUserConfigOptions(user); + return ( + initialOptions.tenantId === userOptions.tenantId && + initialOptions.userId === userOptions.userId && + initialOptions.role === userOptions.role && + initialOptions.baseOnly === userOptions.baseOnly + ); +} + function getRemoteAuthConfig(config: AppConfig): AgentAuthConfig | undefined { return config.endpoints?.agents?.remoteApi?.auth; } @@ -135,6 +158,80 @@ async function resolveMappedUser( return user; } +async function validateTokenAgainstConfig( + token: string, + config: EnabledM2MConfig, + action: RemoteAgentM2MAction, + verifyBearer: NonNullable, +): Promise { + const payload = await verifyBearer(token, getIssuerConfig(config)); + + if (!hasExpectedTokenUse(config, payload)) { + logger.warn('[remoteAgentM2MAuth] Token rejected: invalid token use'); + throw new Error('invalid_token_use'); + } + + const requiredScope = getRequiredScope(config, action); + if (!hasRequiredScopes(requiredScope, payload)) { + logger.warn(`[remoteAgentM2MAuth] Token missing required scope: ${requiredScope}`); + throw new Error('missing_scope'); + } + + const clientMapping = getClientMapping(config, payload); + if (!clientMapping) { + logger.warn('[remoteAgentM2MAuth] Token rejected: unmapped client'); + throw new Error('unmapped_client'); + } + + return { payload, clientMapping }; +} + +function mappingTargetsUser(mapping: M2MClientConfig, user: IUser): boolean { + return mapping.userId === user.id; +} + +async function enforceResolvedTenantPolicy({ + token, + user, + action, + initialOptions, + initialPolicy, + getAppConfig, + verifyBearer, +}: { + token: string; + user: IUser; + action: RemoteAgentM2MAction; + initialOptions: GetAppConfigOptions; + initialPolicy: M2MPolicy; + getAppConfig: RemoteAgentM2MAuthDeps['getAppConfig']; + verifyBearer: NonNullable; +}): Promise { + if (isResolvedUserConfigScope(initialOptions, user)) return initialPolicy; + + const config = await getAppConfig(getUserConfigOptions(user)); + const m2mConfig = getEnabledM2MConfig(getRemoteAuthConfig(config)); + if (!m2mConfig) { + logger.warn('[remoteAgentM2MAuth] Token rejected by resolved tenant auth policy'); + return null; + } + + try { + const validation = await validateTokenAgainstConfig(token, m2mConfig, action, verifyBearer); + if ( + mappingTargetsUser(validation.clientMapping.mapping, user) && + tenantMatches(user, validation.clientMapping.mapping) + ) { + return { m2mConfig, validation }; + } + logger.warn('[remoteAgentM2MAuth] Token rejected by resolved tenant client mapping'); + } catch (err) { + logger.warn('[remoteAgentM2MAuth] Token rejected by resolved tenant auth policy:', err); + } + + return null; +} + export function createRemoteAgentM2MAuth({ findUser, getAppConfig, @@ -143,7 +240,8 @@ export function createRemoteAgentM2MAuth({ return ({ action }) => { const handler = async (req: M2MRequest, res: Response, next: NextFunction) => { try { - const config = await getAppConfig(getConfigOptions()); + const initialOptions = getConfigOptions(); + const config = await getAppConfig(initialOptions); const m2mConfig = getEnabledM2MConfig(getRemoteAuthConfig(config)); if (!m2mConfig) { @@ -157,49 +255,43 @@ export function createRemoteAgentM2MAuth({ return; } - let payload: JwtPayload; + let validation: M2MTokenValidation; try { - payload = await verifyBearer(token, getIssuerConfig(m2mConfig)); + validation = await validateTokenAgainstConfig(token, m2mConfig, action, verifyBearer); } 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)) { + const user = await resolveMappedUser(validation.clientMapping.mapping, findUser); + if (!user || !user.role || !tenantMatches(user, validation.clientMapping.mapping)) { logger.warn('[remoteAgentM2MAuth] Token rejected: mapped user unavailable'); res.status(401).json({ error: 'Unauthorized' }); return; } + const policy = await enforceResolvedTenantPolicy({ + token, + user, + action, + initialOptions, + initialPolicy: { m2mConfig, validation }, + getAppConfig, + verifyBearer, + }); + if (!policy) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + req.user = user; req.authInfo = { type: 'm2m', - issuer: m2mConfig.issuer, - clientId: clientMapping.clientId, + issuer: policy.m2mConfig.issuer, + clientId: policy.validation.clientMapping.clientId, action, - scopes: getTokenScopes(payload), + scopes: getTokenScopes(policy.validation.payload), }; return next();