🧬 fix: Rebind Request Context After Remote Agent Auth (#14685)

This commit is contained in:
Danny Avila 2026-08-08 14:27:22 -04:00 committed by GitHub
parent 1bccc2bc18
commit ed9542ed75
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 148 additions and 3 deletions

View file

@ -53,7 +53,7 @@ import jwt from 'jsonwebtoken';
import jwksRsa from 'jwks-rsa';
import { SystemRoles } from 'librechat-data-provider';
import { fetch as undiciFetch } from 'undici';
import { logger, tenantStorage } from '@librechat/data-schemas';
import { getTenantId, logger, tenantStorage } from '@librechat/data-schemas';
import { clearRemoteAgentAuthCache, createRemoteAgentAuth } from './remoteAgentAuth';
import { findOpenIDUser, getOpenIdEmail } from '../auth/openid';
import { isEnabled, math } from '~/utils';
@ -395,6 +395,45 @@ describe('createRemoteAgentAuth', () => {
expect(mockNext).toHaveBeenCalled();
});
it('restores tenant context from the API key user before continuing', async () => {
const deps = makeDeps(makeConfig({}, { enabled: true }));
const req = makeReq();
let observedTenantId: string | undefined;
const next = jest.fn(() => {
observedTenantId = getTenantId();
});
deps.apiKeyMiddleware.mockImplementation((request: unknown, _res: unknown, next) => {
(request as Request).user = makeUser({ tenantId: 'tenant-api-key' });
next();
});
await createRemoteAgentAuth(asDeps(deps))(req as Request, makeRes().res, next);
expect(observedTenantId).toBe('tenant-api-key');
expect(next).toHaveBeenCalledWith();
});
it('preserves pre-auth tenant context for a tenantless API key user', async () => {
const deps = makeDeps(makeConfig({}, { enabled: true }));
const req = makeReq();
let observedTenantId: string | undefined;
const next = jest.fn(() => {
observedTenantId = getTenantId();
});
deps.apiKeyMiddleware.mockImplementation((request: unknown, _res: unknown, next) => {
(request as Request).user = makeUser({ tenantId: undefined });
next();
});
await tenantStorage.run({ tenantId: 'tenant-preauth' }, async () => {
await createRemoteAgentAuth(asDeps(deps))(req as Request, makeRes().res, next);
});
expect(observedTenantId).toBe('tenant-preauth');
expect(req).toMatchObject({ tenantId: 'tenant-preauth' });
expect(next).toHaveBeenCalledWith();
});
it('returns 401 when apiKey is disabled and no token present', async () => {
const deps = makeDeps(makeConfig({}, { enabled: false }));
const { res, status, json } = makeRes();
@ -446,6 +485,67 @@ describe('createRemoteAgentAuth', () => {
expect(deps.apiKeyMiddleware).not.toHaveBeenCalled();
});
it('restores tenant context from the OIDC user before continuing', async () => {
setupOidcMocks({ sub: 'sub123', email: 'agent@test.com' });
const deps = makeDeps();
deps.findUser = makeFindUser(makeUser({ tenantId: 'tenant-oidc' }));
const req = makeReq({ authorization: `Bearer ${FAKE_TOKEN}` });
let observedTenantId: string | undefined;
const next = jest.fn(() => {
observedTenantId = getTenantId();
});
await createRemoteAgentAuth(asDeps(deps))(req as Request, makeRes().res, next);
expect(observedTenantId).toBe('tenant-oidc');
expect(next).toHaveBeenCalledWith();
});
it('preserves pre-auth tenant context for a tenantless OIDC user', async () => {
setupOidcMocks({ sub: 'sub123', email: 'agent@test.com' });
const deps = makeDeps();
const req = makeReq({ authorization: `Bearer ${FAKE_TOKEN}` });
let observedTenantId: string | undefined;
const next = jest.fn(() => {
observedTenantId = getTenantId();
});
await tenantStorage.run({ tenantId: 'tenant-preauth' }, async () => {
await createRemoteAgentAuth(asDeps(deps))(req as Request, makeRes().res, next);
});
expect(observedTenantId).toBe('tenant-preauth');
expect(req).toMatchObject({ tenantId: 'tenant-preauth' });
expect(next).toHaveBeenCalledWith();
});
it('rejects a tenant context that conflicts with the resolved OIDC user', async () => {
setupOidcMocks({ sub: 'sub123', email: 'agent@test.com' });
const deps = makeDeps();
deps.findUser = makeFindUser(
makeUser({
tenantId: 'tenant-user',
provider: undefined,
openidId: undefined,
openidIssuer: undefined,
}),
);
const { res, status, json } = makeRes();
await tenantStorage.run({ tenantId: 'tenant-request' }, async () => {
await createRemoteAgentAuth(asDeps(deps))(
makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request,
res,
mockNext,
);
});
expect(status).toHaveBeenCalledWith(401);
expect(json).toHaveBeenCalledWith({ error: 'Unauthorized' });
expect(deps.updateUser).not.toHaveBeenCalled();
expect(mockNext).not.toHaveBeenCalled();
});
it('re-evaluates OIDC auth config after resolving the user tenant', async () => {
setupOidcMocks({ sub: 'sub123', email: 'agent@test.com', scope: 'remote_agent' });
const deps = makeDeps();

View file

@ -9,6 +9,8 @@ import type { Algorithm, JwtPayload, VerifyOptions } from 'jsonwebtoken';
import type { TAgentsEndpoint } from 'librechat-data-provider';
import type { RequestInit } from 'undici';
import type { GetAppConfigOptions } from '../app/service';
import type { ServerRequest } from '~/types/http';
import type { ContextRequest } from './tenant';
import {
getLibreChatRolesForOpenIdSync,
getOpenIdRolesForOpenIdSync,
@ -17,6 +19,7 @@ import {
} from '../auth/openidRoleSync';
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 {
@ -293,12 +296,50 @@ function isApiKeyEnabled(config: AppConfig): boolean {
return getRemoteAuthConfig(config)?.apiKey?.enabled !== false;
}
function rejectTenantContextConflict(
requestTenantId: string | undefined,
userTenantId: string | undefined,
res: Response,
): boolean {
if (!requestTenantId || !userTenantId || requestTenantId === userTenantId) {
return false;
}
logger.warn('[remoteAgentAuth] Authenticated user tenant conflicts with request tenant context');
res.status(401).json({ error: 'Unauthorized' });
return true;
}
function continueWithAuthenticatedTenantContext(
req: Request,
res: Response,
next: NextFunction,
): void {
const requestTenantId = getTenantId();
const userTenantId = (req.user as { tenantId?: string } | undefined)?.tenantId;
if (rejectTenantContextConflict(requestTenantId, userTenantId, res)) {
return;
}
const contextRequest = req as ContextRequest;
if (requestTenantId) {
contextRequest.tenantId = requestTenantId;
}
tenantContextMiddleware(req as ServerRequest, res, next);
}
async function enforceApiKeyTenantPolicy(
req: Request,
res: Response,
next: NextFunction,
getAppConfig: RemoteAgentAuthDeps['getAppConfig'],
): Promise<void> {
const userTenantId = (req.user as { tenantId?: string } | undefined)?.tenantId;
if (rejectTenantContextConflict(getTenantId(), userTenantId, res)) {
return;
}
const config = await getAppConfig(getConfigOptions(req));
if (!isApiKeyEnabled(config)) {
@ -307,7 +348,7 @@ async function enforceApiKeyTenantPolicy(
return;
}
next();
continueWithAuthenticatedTenantContext(req, res, next);
}
async function runApiKeyAuth(
@ -648,6 +689,10 @@ export function createRemoteAgentAuth({
return;
}
if (rejectTenantContextConflict(getTenantId(), userResolution.user.tenantId, res)) {
return;
}
if (
!(await enforceOidcTenantPolicy(
token,
@ -687,7 +732,7 @@ export function createRemoteAgentAuth({
await updateResolvedUser(userResolution, updateUser);
req.user = userResolution.user;
return next();
return continueWithAuthenticatedTenantContext(req, res, next);
} catch (err) {
logger.error('[remoteAgentAuth] Unexpected error', err);
res.status(500).json({ error: 'Internal server error' });