diff --git a/packages/api/src/middleware/remoteAgentAuth.spec.ts b/packages/api/src/middleware/remoteAgentAuth.spec.ts index 3e87378915..51c0164d28 100644 --- a/packages/api/src/middleware/remoteAgentAuth.spec.ts +++ b/packages/api/src/middleware/remoteAgentAuth.spec.ts @@ -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(); diff --git a/packages/api/src/middleware/remoteAgentAuth.ts b/packages/api/src/middleware/remoteAgentAuth.ts index c198292dc2..5aaaf951c3 100644 --- a/packages/api/src/middleware/remoteAgentAuth.ts +++ b/packages/api/src/middleware/remoteAgentAuth.ts @@ -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 { + 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' });