mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-04-07 00:15:23 +02:00
Merge 7b36e24079 into 8ed0bcf5ca
This commit is contained in:
commit
0aa531ab6b
6 changed files with 869 additions and 4 deletions
|
|
@ -21,6 +21,7 @@ const { PermissionTypes, Permissions } = require('librechat-data-provider');
|
|||
const {
|
||||
generateCheckAccess,
|
||||
createRequireApiKeyAuth,
|
||||
createRemoteAgentAuth,
|
||||
createCheckRemoteAgentAccess,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
|
|
@ -30,15 +31,23 @@ const {
|
|||
} = require('~/server/controllers/agents/openai');
|
||||
const { getEffectivePermissions } = require('~/server/services/PermissionService');
|
||||
const { configMiddleware } = require('~/server/middleware');
|
||||
const { getAppConfig } = require('~/server/services/Config');
|
||||
const db = require('~/models');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const requireApiKeyAuth = createRequireApiKeyAuth({
|
||||
const apiKeyMiddleware = createRequireApiKeyAuth({
|
||||
validateAgentApiKey: db.validateAgentApiKey,
|
||||
findUser: db.findUser,
|
||||
});
|
||||
|
||||
const requireRemoteAgentAuth = createRemoteAgentAuth({
|
||||
apiKeyMiddleware,
|
||||
findUser: db.findUser,
|
||||
updateUser: db.updateUser,
|
||||
getAppConfig,
|
||||
});
|
||||
|
||||
const checkRemoteAgentsFeature = generateCheckAccess({
|
||||
permissionType: PermissionTypes.REMOTE_AGENTS,
|
||||
permissions: [Permissions.USE],
|
||||
|
|
@ -50,7 +59,7 @@ const checkAgentPermission = createCheckRemoteAgentAccess({
|
|||
getEffectivePermissions,
|
||||
});
|
||||
|
||||
router.use(requireApiKeyAuth);
|
||||
router.use(requireRemoteAgentAuth);
|
||||
router.use(configMiddleware);
|
||||
router.use(checkRemoteAgentsFeature);
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ const { PermissionTypes, Permissions } = require('librechat-data-provider');
|
|||
const {
|
||||
generateCheckAccess,
|
||||
createRequireApiKeyAuth,
|
||||
createRemoteAgentAuth,
|
||||
createCheckRemoteAgentAccess,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
|
|
@ -33,15 +34,23 @@ const {
|
|||
} = require('~/server/controllers/agents/responses');
|
||||
const { getEffectivePermissions } = require('~/server/services/PermissionService');
|
||||
const { configMiddleware } = require('~/server/middleware');
|
||||
const { getAppConfig } = require('~/server/services/Config');
|
||||
const db = require('~/models');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const requireApiKeyAuth = createRequireApiKeyAuth({
|
||||
const apiKeyMiddleware = createRequireApiKeyAuth({
|
||||
validateAgentApiKey: db.validateAgentApiKey,
|
||||
findUser: db.findUser,
|
||||
});
|
||||
|
||||
const requireRemoteAgentAuth = createRemoteAgentAuth({
|
||||
apiKeyMiddleware,
|
||||
findUser: db.findUser,
|
||||
updateUser: db.updateUser,
|
||||
getAppConfig,
|
||||
});
|
||||
|
||||
const checkRemoteAgentsFeature = generateCheckAccess({
|
||||
permissionType: PermissionTypes.REMOTE_AGENTS,
|
||||
permissions: [Permissions.USE],
|
||||
|
|
@ -53,7 +62,7 @@ const checkAgentPermission = createCheckRemoteAgentAccess({
|
|||
getEffectivePermissions,
|
||||
});
|
||||
|
||||
router.use(requireApiKeyAuth);
|
||||
router.use(requireRemoteAgentAuth);
|
||||
router.use(configMiddleware);
|
||||
router.use(checkRemoteAgentsFeature);
|
||||
|
||||
|
|
|
|||
|
|
@ -9,3 +9,4 @@ export { tenantContextMiddleware } from './tenant';
|
|||
export { preAuthTenantMiddleware } from './preAuthTenant';
|
||||
export * from './concurrency';
|
||||
export * from './checkBalance';
|
||||
export * from './remoteAgentAuth';
|
||||
|
|
|
|||
560
packages/api/src/middleware/remoteAgentAuth.spec.ts
Normal file
560
packages/api/src/middleware/remoteAgentAuth.spec.ts
Normal file
|
|
@ -0,0 +1,560 @@
|
|||
import type { Request, Response } from 'express';
|
||||
import type { AppConfig, IUser } from '@librechat/data-schemas';
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('~/utils', () => ({
|
||||
isEnabled: jest.fn(() => false),
|
||||
math: jest.fn(() => 60000),
|
||||
}));
|
||||
|
||||
const mockGetSigningKey = jest.fn();
|
||||
const mockGetKeys = jest.fn();
|
||||
|
||||
jest.mock('jwks-rsa', () =>
|
||||
jest.fn(() => ({ getSigningKey: mockGetSigningKey, getKeys: mockGetKeys })),
|
||||
);
|
||||
|
||||
jest.mock('jsonwebtoken', () => ({
|
||||
decode: jest.fn(),
|
||||
verify: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../auth/openid', () => ({
|
||||
findOpenIDUser: jest.fn(),
|
||||
}));
|
||||
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import { findOpenIDUser } from '../auth/openid';
|
||||
import { createRemoteAgentAuth } from './remoteAgentAuth';
|
||||
|
||||
const fetchMock = jest.fn();
|
||||
|
||||
beforeAll(() => {
|
||||
(global as unknown as Record<string, unknown>).fetch = fetchMock;
|
||||
});
|
||||
|
||||
const FAKE_TOKEN = 'header.payload.signature';
|
||||
const BASE_ISSUER = 'https://auth.example.com/realms/test';
|
||||
const BASE_JWKS_URI = `${BASE_ISSUER}/protocol/openid-connect/certs`;
|
||||
|
||||
type SigningKeyCallback = (err: Error | null, key?: { getPublicKey: () => string }) => void;
|
||||
type JwtVerifyCallback = (err: Error | null, payload?: object) => void;
|
||||
|
||||
const mockUser = { _id: 'uid123', id: 'uid123', email: 'agent@test.com' };
|
||||
|
||||
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 makeConfig(oidcOverrides?: object, apiKeyOverrides?: object): AppConfig {
|
||||
return {
|
||||
endpoints: {
|
||||
agents: {
|
||||
remoteApi: {
|
||||
auth: {
|
||||
oidc: {
|
||||
enabled: true,
|
||||
issuer: BASE_ISSUER,
|
||||
jwksUri: BASE_JWKS_URI,
|
||||
...oidcOverrides,
|
||||
},
|
||||
apiKey: { enabled: true, ...apiKeyOverrides },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as AppConfig;
|
||||
}
|
||||
|
||||
function makeDeps(appConfig: AppConfig | null = makeConfig()) {
|
||||
return {
|
||||
findUser: jest.fn(),
|
||||
updateUser: jest.fn(),
|
||||
getAppConfig: jest.fn().mockResolvedValue(appConfig),
|
||||
apiKeyMiddleware: jest.fn((_req: unknown, _res: unknown, next: () => void) => next()),
|
||||
};
|
||||
}
|
||||
|
||||
function setupOidcMocks(payload: object, kid = 'test-kid') {
|
||||
(jwt.decode as jest.Mock).mockReturnValue({ header: { kid }, payload });
|
||||
mockGetSigningKey.mockImplementation((_k: string, cb: SigningKeyCallback) =>
|
||||
cb(null, { getPublicKey: () => 'public-key' }),
|
||||
);
|
||||
(jwt.verify as jest.Mock).mockImplementation(
|
||||
(_t: string, _k: string, _o: object, cb: JwtVerifyCallback) => cb(null, payload),
|
||||
);
|
||||
}
|
||||
|
||||
describe('createRemoteAgentAuth', () => {
|
||||
let mockNext: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
fetchMock.mockReset();
|
||||
mockNext = jest.fn();
|
||||
});
|
||||
|
||||
describe('when OIDC is not enabled', () => {
|
||||
it('falls back to apiKeyMiddleware when getAppConfig returns null', async () => {
|
||||
const deps = makeDeps(null);
|
||||
const mw = createRemoteAgentAuth(deps);
|
||||
const req = makeReq();
|
||||
const { res } = makeRes();
|
||||
|
||||
await mw(req as Request, res, mockNext);
|
||||
|
||||
expect(deps.apiKeyMiddleware).toHaveBeenCalledWith(req, res, mockNext);
|
||||
expect(mockNext).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to apiKeyMiddleware when oidc.enabled is false', async () => {
|
||||
const deps = makeDeps(makeConfig({ enabled: false }));
|
||||
await createRemoteAgentAuth(deps)(makeReq() as Request, makeRes().res, mockNext);
|
||||
expect(deps.apiKeyMiddleware).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to apiKeyMiddleware when remoteApi auth is absent', async () => {
|
||||
const deps = makeDeps({ endpoints: { agents: {} } } as unknown as AppConfig);
|
||||
await createRemoteAgentAuth(deps)(makeReq() as Request, makeRes().res, mockNext);
|
||||
expect(deps.apiKeyMiddleware).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when OIDC enabled but no Bearer token', () => {
|
||||
it('falls back to apiKeyMiddleware when apiKey is enabled', async () => {
|
||||
const deps = makeDeps(makeConfig({}, { enabled: true }));
|
||||
const { res } = makeRes();
|
||||
|
||||
await createRemoteAgentAuth(deps)(makeReq() as Request, res, mockNext);
|
||||
|
||||
expect(deps.apiKeyMiddleware).toHaveBeenCalled();
|
||||
expect(mockNext).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 401 when apiKey is disabled and no token present', async () => {
|
||||
const deps = makeDeps(makeConfig({}, { enabled: false }));
|
||||
const { res, status, json } = makeRes();
|
||||
|
||||
await createRemoteAgentAuth(deps)(makeReq() as Request, res, mockNext);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(401);
|
||||
expect(json).toHaveBeenCalledWith({ error: 'Bearer token required' });
|
||||
expect(mockNext).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when OIDC verification succeeds', () => {
|
||||
beforeEach(() => {
|
||||
(findOpenIDUser as jest.Mock).mockResolvedValue({ user: { ...mockUser }, error: null });
|
||||
});
|
||||
|
||||
it('sets req.user and calls next()', async () => {
|
||||
setupOidcMocks({ sub: 'sub123', email: 'agent@test.com', exp: 9999999999 });
|
||||
const deps = makeDeps();
|
||||
const req = makeReq({ authorization: `Bearer ${FAKE_TOKEN}` });
|
||||
const { res } = makeRes();
|
||||
|
||||
await createRemoteAgentAuth(deps)(req as Request, res, mockNext);
|
||||
|
||||
expect(req.user).toMatchObject({ id: 'uid123', email: 'agent@test.com' });
|
||||
expect(mockNext).toHaveBeenCalledWith();
|
||||
expect(deps.apiKeyMiddleware).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('attaches federatedTokens with access_token and expires_at', async () => {
|
||||
const exp = 1234567890;
|
||||
setupOidcMocks({ sub: 'sub123', email: 'agent@test.com', exp });
|
||||
const deps = makeDeps();
|
||||
const req = makeReq({ authorization: `Bearer ${FAKE_TOKEN}` });
|
||||
|
||||
await createRemoteAgentAuth(deps)(req as Request, makeRes().res, mockNext);
|
||||
|
||||
expect((req.user as IUser).federatedTokens).toEqual({
|
||||
access_token: FAKE_TOKEN,
|
||||
expires_at: exp,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to apiKeyMiddleware when user is not found and apiKey is enabled', async () => {
|
||||
setupOidcMocks({ sub: 'sub123', email: 'agent@test.com' });
|
||||
(findOpenIDUser as jest.Mock).mockResolvedValue({ user: null, error: null });
|
||||
|
||||
const deps = makeDeps(makeConfig({}, { enabled: true }));
|
||||
const { res } = makeRes();
|
||||
|
||||
await createRemoteAgentAuth(deps)(
|
||||
makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request,
|
||||
res,
|
||||
mockNext,
|
||||
);
|
||||
|
||||
expect(deps.apiKeyMiddleware).toHaveBeenCalled();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('no matching LibreChat user'),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 401 when user is not found and apiKey is disabled', async () => {
|
||||
setupOidcMocks({ sub: 'sub123', email: 'agent@test.com' });
|
||||
(findOpenIDUser as jest.Mock).mockResolvedValue({ user: null, error: null });
|
||||
|
||||
const deps = makeDeps(makeConfig({}, { enabled: false }));
|
||||
const { res, status, json } = makeRes();
|
||||
|
||||
await createRemoteAgentAuth(deps)(
|
||||
makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request,
|
||||
res,
|
||||
mockNext,
|
||||
);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(401);
|
||||
expect(json).toHaveBeenCalledWith({ error: 'Unauthorized' });
|
||||
expect(mockNext).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when OIDC verification fails', () => {
|
||||
beforeEach(() => {
|
||||
(jwt.decode as jest.Mock).mockReturnValue({ header: { kid: 'kid' }, payload: {} });
|
||||
mockGetSigningKey.mockImplementation((_k: string, cb: SigningKeyCallback) =>
|
||||
cb(new Error('Signing key not found')),
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to apiKeyMiddleware when apiKey is enabled', async () => {
|
||||
const deps = makeDeps(makeConfig({}, { enabled: true }));
|
||||
const { res } = makeRes();
|
||||
|
||||
await createRemoteAgentAuth(deps)(
|
||||
makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request,
|
||||
res,
|
||||
mockNext,
|
||||
);
|
||||
|
||||
expect(deps.apiKeyMiddleware).toHaveBeenCalled();
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('OIDC verification failed'),
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 401 when apiKey is disabled', async () => {
|
||||
const deps = makeDeps(makeConfig({}, { enabled: false }));
|
||||
const { res, status, json } = makeRes();
|
||||
|
||||
await createRemoteAgentAuth(deps)(
|
||||
makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request,
|
||||
res,
|
||||
mockNext,
|
||||
);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(401);
|
||||
expect(json).toHaveBeenCalledWith({ error: 'Unauthorized' });
|
||||
});
|
||||
|
||||
it('returns 401 when JWT cannot be decoded', async () => {
|
||||
(jwt.decode as jest.Mock).mockReturnValue(null);
|
||||
const deps = makeDeps(makeConfig({}, { enabled: false }));
|
||||
const { res, status } = makeRes();
|
||||
|
||||
await createRemoteAgentAuth(deps)(
|
||||
makeReq({ authorization: 'Bearer not.a.jwt' }) as Request,
|
||||
res,
|
||||
mockNext,
|
||||
);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unexpected errors', () => {
|
||||
it('returns 500 when getAppConfig throws', async () => {
|
||||
const deps = {
|
||||
...makeDeps(),
|
||||
getAppConfig: jest.fn().mockRejectedValue(new Error('DB down')),
|
||||
};
|
||||
const { res, status, json } = makeRes();
|
||||
|
||||
await createRemoteAgentAuth(deps)(makeReq() as Request, res, mockNext);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(500);
|
||||
expect(json).toHaveBeenCalledWith({ error: 'Internal server error' });
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Unexpected error'),
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 401 when findOpenIDUser throws and apiKey is disabled', async () => {
|
||||
setupOidcMocks({ sub: 'sub123', email: 'agent@test.com' });
|
||||
(findOpenIDUser as jest.Mock).mockRejectedValue(new Error('DB error'));
|
||||
|
||||
const deps = makeDeps(makeConfig({}, { enabled: false }));
|
||||
const { res, status, json } = makeRes();
|
||||
|
||||
await createRemoteAgentAuth(deps)(
|
||||
makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request,
|
||||
res,
|
||||
mockNext,
|
||||
);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(401);
|
||||
expect(json).toHaveBeenCalledWith({ error: 'Unauthorized' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('JWKS URI resolution', () => {
|
||||
beforeEach(() => {
|
||||
setupOidcMocks({ sub: 'sub1', email: 'a@b.com' });
|
||||
(findOpenIDUser as jest.Mock).mockResolvedValue({ user: { ...mockUser }, error: null });
|
||||
});
|
||||
|
||||
it('uses jwksUri from config and skips discovery', async () => {
|
||||
const deps = makeDeps(
|
||||
makeConfig({
|
||||
jwksUri: 'https://explicit-1.example.com/jwks',
|
||||
issuer: 'https://issuer-explicit-1.example.com',
|
||||
}),
|
||||
);
|
||||
|
||||
await createRemoteAgentAuth(deps)(
|
||||
makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request,
|
||||
makeRes().res,
|
||||
mockNext,
|
||||
);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(mockNext).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses OPENID_JWKS_URL env var and skips discovery', async () => {
|
||||
process.env.OPENID_JWKS_URL = 'https://env.example.com/jwks';
|
||||
const deps = makeDeps(
|
||||
makeConfig({ jwksUri: undefined, issuer: 'https://issuer-env-1.example.com' }),
|
||||
);
|
||||
|
||||
await createRemoteAgentAuth(deps)(
|
||||
makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request,
|
||||
makeRes().res,
|
||||
mockNext,
|
||||
);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(mockNext).toHaveBeenCalled();
|
||||
|
||||
delete process.env.OPENID_JWKS_URL;
|
||||
});
|
||||
|
||||
it('fetches discovery document when jwksUri and env var are absent', async () => {
|
||||
delete process.env.OPENID_JWKS_URL;
|
||||
const issuer = 'https://issuer-discovery-1.example.com';
|
||||
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ jwks_uri: `${issuer}/protocol/openid-connect/certs` }),
|
||||
});
|
||||
|
||||
const deps = makeDeps(makeConfig({ jwksUri: undefined, issuer }));
|
||||
|
||||
await createRemoteAgentAuth(deps)(
|
||||
makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request,
|
||||
makeRes().res,
|
||||
mockNext,
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(`${issuer}/.well-known/openid-configuration`);
|
||||
expect(mockNext).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 401 when discovery returns non-ok response', async () => {
|
||||
delete process.env.OPENID_JWKS_URL;
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 404, statusText: 'Not Found' });
|
||||
|
||||
const deps = makeDeps(
|
||||
makeConfig(
|
||||
{ jwksUri: undefined, issuer: 'https://issuer-discovery-fail-1.example.com' },
|
||||
{ enabled: false },
|
||||
),
|
||||
);
|
||||
const { res, status } = makeRes();
|
||||
|
||||
await createRemoteAgentAuth(deps)(
|
||||
makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request,
|
||||
res,
|
||||
mockNext,
|
||||
);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(401);
|
||||
});
|
||||
|
||||
it('returns 401 when discovery response is missing jwks_uri field', async () => {
|
||||
delete process.env.OPENID_JWKS_URL;
|
||||
fetchMock.mockResolvedValue({ ok: true, json: async () => ({}) });
|
||||
|
||||
const deps = makeDeps(
|
||||
makeConfig(
|
||||
{ jwksUri: undefined, issuer: 'https://issuer-missing-jwks-1.example.com' },
|
||||
{ enabled: false },
|
||||
),
|
||||
);
|
||||
const { res, status } = makeRes();
|
||||
|
||||
await createRemoteAgentAuth(deps)(
|
||||
makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request,
|
||||
res,
|
||||
mockNext,
|
||||
);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('email claim resolution', () => {
|
||||
async function captureEmailArg(claims: object): Promise<string | undefined> {
|
||||
setupOidcMocks(claims);
|
||||
(findOpenIDUser as jest.Mock).mockResolvedValue({ user: { ...mockUser }, error: null });
|
||||
|
||||
const deps = makeDeps();
|
||||
await createRemoteAgentAuth(deps)(
|
||||
makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request,
|
||||
makeRes().res,
|
||||
mockNext,
|
||||
);
|
||||
|
||||
return (findOpenIDUser as jest.Mock).mock.calls[0][0].email;
|
||||
}
|
||||
|
||||
it('uses email claim', async () => {
|
||||
expect(await captureEmailArg({ sub: 's1', email: 'user@example.com' })).toBe(
|
||||
'user@example.com',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to preferred_username when email is absent', async () => {
|
||||
expect(await captureEmailArg({ sub: 's2', preferred_username: 'agent-user' })).toBe(
|
||||
'agent-user',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to upn when email and preferred_username are absent', async () => {
|
||||
expect(await captureEmailArg({ sub: 's3', upn: 'upn@corp.com' })).toBe('upn@corp.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('update user and migration scenarios', () => {
|
||||
it('persists openidId binding when migration is needed', async () => {
|
||||
const mockUpdateUser = jest.fn().mockResolvedValue(undefined);
|
||||
setupOidcMocks({ sub: 'sub-new', email: 'existing@test.com' });
|
||||
(findOpenIDUser as jest.Mock).mockResolvedValue({
|
||||
user: { ...mockUser, openidId: undefined, role: 'user' },
|
||||
error: null,
|
||||
migration: true,
|
||||
});
|
||||
|
||||
const deps = { ...makeDeps(), updateUser: mockUpdateUser };
|
||||
await createRemoteAgentAuth(deps)(
|
||||
makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request,
|
||||
makeRes().res,
|
||||
mockNext,
|
||||
);
|
||||
|
||||
expect(mockUpdateUser).toHaveBeenCalledWith(
|
||||
mockUser.id,
|
||||
expect.objectContaining({ provider: 'openid', openidId: 'sub-new' }),
|
||||
);
|
||||
expect(mockNext).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call updateUser when migration is false and role exists', async () => {
|
||||
const mockUpdateUser = jest.fn();
|
||||
setupOidcMocks({ sub: 'sub123', email: 'agent@test.com' });
|
||||
(findOpenIDUser as jest.Mock).mockResolvedValue({
|
||||
user: { ...mockUser, role: 'user' },
|
||||
error: null,
|
||||
migration: false,
|
||||
});
|
||||
|
||||
const deps = { ...makeDeps(), updateUser: mockUpdateUser };
|
||||
await createRemoteAgentAuth(deps)(
|
||||
makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request,
|
||||
makeRes().res,
|
||||
mockNext,
|
||||
);
|
||||
|
||||
expect(mockUpdateUser).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('scope validation', () => {
|
||||
it('returns 401 when required scope is missing from token', async () => {
|
||||
setupOidcMocks({ sub: 'sub123', email: 'agent@test.com', scope: 'openid profile' });
|
||||
|
||||
const deps = makeDeps(makeConfig({ scope: 'remote_agent' }, { enabled: false }));
|
||||
const { res, status, json } = makeRes();
|
||||
|
||||
await createRemoteAgentAuth(deps)(
|
||||
makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request,
|
||||
res,
|
||||
mockNext,
|
||||
);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(401);
|
||||
expect(json).toHaveBeenCalledWith({ error: 'Unauthorized' });
|
||||
});
|
||||
|
||||
it('passes when required scope is present in token', async () => {
|
||||
setupOidcMocks({
|
||||
sub: 'sub123',
|
||||
email: 'agent@test.com',
|
||||
scope: 'openid profile remote_agent',
|
||||
});
|
||||
(findOpenIDUser as jest.Mock).mockResolvedValue({
|
||||
user: { ...mockUser },
|
||||
error: null,
|
||||
migration: false,
|
||||
});
|
||||
|
||||
const deps = makeDeps(makeConfig({ scope: 'remote_agent' }));
|
||||
await createRemoteAgentAuth(deps)(
|
||||
makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request,
|
||||
makeRes().res,
|
||||
mockNext,
|
||||
);
|
||||
|
||||
expect(mockNext).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes when scope is not configured (backward compat)', async () => {
|
||||
setupOidcMocks({ sub: 'sub123', email: 'agent@test.com' }); // no scope claim at all
|
||||
(findOpenIDUser as jest.Mock).mockResolvedValue({
|
||||
user: { ...mockUser },
|
||||
error: null,
|
||||
migration: false,
|
||||
});
|
||||
|
||||
const deps = makeDeps(makeConfig({ scope: undefined }));
|
||||
await createRemoteAgentAuth(deps)(
|
||||
makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request,
|
||||
makeRes().res,
|
||||
mockNext,
|
||||
);
|
||||
|
||||
expect(mockNext).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
264
packages/api/src/middleware/remoteAgentAuth.ts
Normal file
264
packages/api/src/middleware/remoteAgentAuth.ts
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
import jwt from 'jsonwebtoken';
|
||||
import jwksRsa from 'jwks-rsa';
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import type { RequestHandler, Request, Response, NextFunction } from 'express';
|
||||
import type { JwtPayload } from 'jsonwebtoken';
|
||||
import type { AppConfig, IUser, UserMethods } from '@librechat/data-schemas';
|
||||
import type { TAgentsEndpoint } from 'librechat-data-provider';
|
||||
import { SystemRoles } from 'librechat-data-provider';
|
||||
import { isEnabled, math } from '~/utils';
|
||||
import { findOpenIDUser } from '../auth/openid';
|
||||
|
||||
export interface RemoteAgentAuthDeps {
|
||||
apiKeyMiddleware: RequestHandler;
|
||||
findUser: UserMethods['findUser'];
|
||||
updateUser: UserMethods['updateUser'];
|
||||
getAppConfig: () => Promise<AppConfig | null>;
|
||||
}
|
||||
|
||||
type OidcConfig = NonNullable<
|
||||
NonNullable<NonNullable<TAgentsEndpoint['remoteApi']>['auth']>['oidc']
|
||||
>;
|
||||
|
||||
const jwksClientCache = new Map<string, Promise<jwksRsa.JwksClient>>();
|
||||
|
||||
function extractBearer(authHeader: string | undefined): string | null {
|
||||
if (!authHeader?.startsWith('Bearer ')) return null;
|
||||
return authHeader.slice(7);
|
||||
}
|
||||
|
||||
function getEmail(payload: JwtPayload): string | undefined {
|
||||
return (
|
||||
(payload['email'] as string | undefined) ??
|
||||
(payload['preferred_username'] as string | undefined) ??
|
||||
(payload['upn'] as string | undefined)
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveJwksUri(oidcConfig: OidcConfig): Promise<string> {
|
||||
if (oidcConfig.jwksUri) return oidcConfig.jwksUri;
|
||||
if (process.env.OPENID_JWKS_URL) return process.env.OPENID_JWKS_URL;
|
||||
|
||||
const issuer = oidcConfig.issuer.replace(/\/$/, '');
|
||||
const discoveryUrl = `${issuer}/.well-known/openid-configuration`;
|
||||
|
||||
const res = await fetch(discoveryUrl);
|
||||
if (!res.ok) throw new Error(`OIDC discovery failed: ${res.status} ${res.statusText}`);
|
||||
|
||||
const meta = (await res.json()) as { jwks_uri?: string };
|
||||
if (!meta.jwks_uri) throw new Error('OIDC discovery response missing jwks_uri');
|
||||
|
||||
return meta.jwks_uri;
|
||||
}
|
||||
|
||||
function buildJwksClient(uri: string): jwksRsa.JwksClient {
|
||||
const options: jwksRsa.Options = {
|
||||
cache: isEnabled(process.env.OPENID_JWKS_URL_CACHE_ENABLED) || true,
|
||||
cacheMaxAge: math(process.env.OPENID_JWKS_URL_CACHE_TIME, 60000),
|
||||
jwksUri: uri,
|
||||
};
|
||||
|
||||
if (process.env.PROXY) {
|
||||
options.requestAgent = new HttpsProxyAgent(process.env.PROXY);
|
||||
}
|
||||
|
||||
return jwksRsa(options);
|
||||
}
|
||||
|
||||
async function getJwksClient(oidcConfig: OidcConfig): Promise<jwksRsa.JwksClient> {
|
||||
const cacheKey = oidcConfig.jwksUri ?? oidcConfig.issuer;
|
||||
|
||||
const cached = jwksClientCache.get(cacheKey);
|
||||
if (cached != null) return cached;
|
||||
|
||||
const promise = resolveJwksUri(oidcConfig)
|
||||
.then((uri) => {
|
||||
return buildJwksClient(uri);
|
||||
})
|
||||
.catch((err) => {
|
||||
jwksClientCache.delete(cacheKey); // не кэшируем ошибку
|
||||
throw err;
|
||||
});
|
||||
|
||||
jwksClientCache.set(cacheKey, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
function verifyOidcBearer(token: string, oidcConfig: OidcConfig): Promise<JwtPayload> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const decoded = jwt.decode(token, { complete: true });
|
||||
if (decoded == null) return reject(new Error('Invalid JWT: cannot decode'));
|
||||
|
||||
const kid = decoded.header?.kid as string | undefined;
|
||||
|
||||
getJwksClient(oidcConfig)
|
||||
.then((client) => {
|
||||
const fetchKey = (callback: (key: jwksRsa.SigningKey) => void) => {
|
||||
if (kid != null) {
|
||||
client.getSigningKey(kid, (err, key) => {
|
||||
if (err != null || key == null)
|
||||
return reject(err ?? new Error('No signing key for kid'));
|
||||
callback(key);
|
||||
});
|
||||
} else {
|
||||
client
|
||||
.getKeys()
|
||||
.then((keys: unknown) => {
|
||||
const jwkKeys = keys as Array<{ kid: string }>;
|
||||
if (jwkKeys.length === 0) return reject(new Error('No keys in JWKS'));
|
||||
client.getSigningKey(jwkKeys[0].kid, (keyErr, key) => {
|
||||
if (keyErr != null || key == null)
|
||||
return reject(keyErr ?? new Error('No signing key'));
|
||||
callback(key);
|
||||
});
|
||||
})
|
||||
.catch((err: unknown) => reject(err));
|
||||
}
|
||||
};
|
||||
|
||||
fetchKey((signingKey) => {
|
||||
jwt.verify(
|
||||
token,
|
||||
signingKey.getPublicKey(),
|
||||
{
|
||||
algorithms: ['RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512'],
|
||||
...(oidcConfig.issuer ? { issuer: oidcConfig.issuer } : {}),
|
||||
...(oidcConfig.audience ? { audience: oidcConfig.audience } : {}),
|
||||
},
|
||||
(err, payload) => {
|
||||
if (err != null || payload == null) {
|
||||
return reject(err ?? new Error('Empty payload'));
|
||||
}
|
||||
resolve(payload as JwtPayload);
|
||||
},
|
||||
);
|
||||
});
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveUser(
|
||||
token: string,
|
||||
payload: JwtPayload,
|
||||
findUser: UserMethods['findUser'],
|
||||
updateUser: UserMethods['updateUser'],
|
||||
): Promise<IUser | null> {
|
||||
const { user, error, migration } = await findOpenIDUser({
|
||||
findUser,
|
||||
email: getEmail(payload),
|
||||
openidId: payload.sub ?? '',
|
||||
idOnTheSource: payload['oid'] as string | undefined,
|
||||
strategyName: 'remoteAgentAuth',
|
||||
});
|
||||
|
||||
if (error != null || user == null) return null;
|
||||
|
||||
user.id = String(user._id);
|
||||
|
||||
const updateData: Partial<IUser> = {};
|
||||
|
||||
if (migration && payload.sub != null) {
|
||||
updateData.provider = 'openid';
|
||||
updateData.openidId = payload.sub;
|
||||
}
|
||||
|
||||
if (!user.role) {
|
||||
user.role = SystemRoles.USER;
|
||||
updateData.role = SystemRoles.USER;
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await updateUser(user.id, updateData);
|
||||
}
|
||||
|
||||
user.federatedTokens = { access_token: token, expires_at: payload.exp };
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory for Remote Agent API auth middleware.
|
||||
*
|
||||
* Validates Bearer tokens against configured OIDC issuer via JWKS,
|
||||
* falling back to API key auth when enabled. Stateless — no session dependency.
|
||||
*
|
||||
* ```yaml
|
||||
* endpoints:
|
||||
* agents:
|
||||
* remoteApi:
|
||||
* auth:
|
||||
* apiKey:
|
||||
* enabled: false
|
||||
* oidc:
|
||||
* enabled: true
|
||||
* issuer: <issuer>
|
||||
* jwksUri: <jwksUri>
|
||||
* audience: <audience>
|
||||
* scope: <scope>
|
||||
* ```
|
||||
*/
|
||||
export function createRemoteAgentAuth({
|
||||
apiKeyMiddleware,
|
||||
findUser,
|
||||
updateUser,
|
||||
getAppConfig,
|
||||
}: RemoteAgentAuthDeps): RequestHandler {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const config = await getAppConfig();
|
||||
const authConfig = config?.endpoints?.agents?.remoteApi?.auth;
|
||||
|
||||
if (authConfig?.oidc?.enabled !== true) {
|
||||
return apiKeyMiddleware(req, res, next);
|
||||
}
|
||||
|
||||
const token = extractBearer(req.headers.authorization);
|
||||
const apiKeyEnabled = authConfig.apiKey?.enabled !== false;
|
||||
|
||||
if (token == null) {
|
||||
if (apiKeyEnabled) return apiKeyMiddleware(req, res, next);
|
||||
res.status(401).json({ error: 'Bearer token required' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await verifyOidcBearer(token, authConfig.oidc);
|
||||
|
||||
if (authConfig.oidc.scope != null) {
|
||||
const rawScope = payload['scp'] ?? payload['scope'];
|
||||
const tokenScopes: string[] = Array.isArray(rawScope)
|
||||
? rawScope
|
||||
: ((rawScope as string | undefined)?.split(' ') ?? []);
|
||||
if (!tokenScopes.includes(authConfig.oidc.scope)) {
|
||||
logger.warn(`[remoteAgentAuth] Token missing required scope: ${authConfig.oidc.scope}`);
|
||||
if (apiKeyEnabled) return apiKeyMiddleware(req, res, next);
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const user = await resolveUser(token, payload, findUser, updateUser);
|
||||
|
||||
if (user == null) {
|
||||
logger.warn('[remoteAgentAuth] OIDC token valid but no matching LibreChat user');
|
||||
if (apiKeyEnabled) return apiKeyMiddleware(req, res, next);
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
req.user = user;
|
||||
return next();
|
||||
} catch (oidcErr) {
|
||||
logger.error('[remoteAgentAuth] OIDC verification failed:', oidcErr);
|
||||
if (apiKeyEnabled) return apiKeyMiddleware(req, res, next);
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('[remoteAgentAuth] Unexpected error', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
return;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -318,6 +318,27 @@ export const defaultAgentCapabilities = [
|
|||
AgentCapabilities.ocr,
|
||||
];
|
||||
|
||||
const remoteApiAuthSchema = z.object({
|
||||
apiKey: z
|
||||
.object({
|
||||
enabled: z.boolean().default(true),
|
||||
})
|
||||
.optional(),
|
||||
oidc: z
|
||||
.object({
|
||||
enabled: z.boolean().default(false),
|
||||
issuer: z.string(),
|
||||
audience: z.string().optional(),
|
||||
jwksUri: z.string().optional(),
|
||||
scope: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const remoteApiSchema = z.object({
|
||||
auth: remoteApiAuthSchema.optional(),
|
||||
});
|
||||
|
||||
export const agentsEndpointSchema = baseEndpointSchema
|
||||
.omit({ baseURL: true })
|
||||
.merge(
|
||||
|
|
@ -334,6 +355,7 @@ export const agentsEndpointSchema = baseEndpointSchema
|
|||
.array(z.nativeEnum(AgentCapabilities))
|
||||
.optional()
|
||||
.default(defaultAgentCapabilities),
|
||||
remoteApi: remoteApiSchema.optional(),
|
||||
}),
|
||||
)
|
||||
.default({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue