mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +00:00
🧬 fix: Rebind Request Context After Remote Agent Auth (#14685)
This commit is contained in:
parent
1bccc2bc18
commit
ed9542ed75
2 changed files with 148 additions and 3 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue