mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-04-07 00:15:23 +02:00
502 lines
16 KiB
TypeScript
502 lines
16 KiB
TypeScript
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 as any)(
|
|
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 as any)(
|
|
makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request,
|
|
makeRes().res,
|
|
mockNext,
|
|
);
|
|
|
|
expect(mockUpdateUser).not.toHaveBeenCalled();
|
|
});
|
|
})
|
|
});
|