mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-02 12:22:22 +00:00
* fix: centralize outbound proxy handling * chore: sort proxy imports * test: update proxy helper mocks * fix: honor proxy bypasses consistently * fix: support http axios proxy targets
537 lines
17 KiB
JavaScript
537 lines
17 KiB
JavaScript
const { SystemRoles } = require('librechat-data-provider');
|
||
|
||
// --- Capture JwtStrategy inputs ---
|
||
let capturedStrategyOptions;
|
||
let capturedVerifyCallback;
|
||
jest.mock('passport-jwt', () => ({
|
||
Strategy: jest.fn((opts, verifyCallback) => {
|
||
capturedStrategyOptions = opts;
|
||
capturedVerifyCallback = verifyCallback;
|
||
return { name: 'jwt' };
|
||
}),
|
||
ExtractJwt: {
|
||
fromAuthHeaderAsBearerToken: jest.fn(() => 'mock-extractor'),
|
||
},
|
||
}));
|
||
jest.mock('jwks-rsa', () => ({
|
||
passportJwtSecret: jest.fn(() => 'mock-secret-provider'),
|
||
}));
|
||
jest.mock('https-proxy-agent', () => ({
|
||
HttpsProxyAgent: jest.fn(),
|
||
}));
|
||
jest.mock('@librechat/data-schemas', () => ({
|
||
logger: { info: jest.fn(), warn: jest.fn(), debug: jest.fn(), error: jest.fn() },
|
||
}));
|
||
jest.mock('@librechat/api', () => ({
|
||
isEnabled: jest.fn(() => false),
|
||
findOpenIDUser: jest.fn(),
|
||
getOpenIdEmail: jest.requireActual('@librechat/api').getOpenIdEmail,
|
||
getOpenIdIssuer: jest.fn(() => 'https://issuer.example.com'),
|
||
normalizeOpenIdIssuer: jest.requireActual('@librechat/api').normalizeOpenIdIssuer,
|
||
getHttpsProxyAgent: jest.fn(() => undefined),
|
||
math: jest.fn((val, fallback) => fallback),
|
||
}));
|
||
jest.mock('~/models', () => ({
|
||
findUser: jest.fn(),
|
||
updateUser: jest.fn(),
|
||
}));
|
||
jest.mock('~/server/services/Files/strategies', () => ({
|
||
getStrategyFunctions: jest.fn(() => ({
|
||
saveBuffer: jest.fn().mockResolvedValue('/fake/path/to/avatar.png'),
|
||
})),
|
||
}));
|
||
jest.mock('~/server/services/Config', () => ({
|
||
getAppConfig: jest.fn().mockResolvedValue({}),
|
||
}));
|
||
jest.mock('~/cache/getLogStores', () =>
|
||
jest.fn().mockReturnValue({ get: jest.fn(), set: jest.fn() }),
|
||
);
|
||
|
||
const { findOpenIDUser } = require('@librechat/api');
|
||
const openIdJwtLogin = require('./openIdJwtStrategy');
|
||
const { findUser, updateUser } = require('~/models');
|
||
|
||
function withEnv(env, callback) {
|
||
const previous = Object.fromEntries(Object.keys(env).map((key) => [key, process.env[key]]));
|
||
Object.entries(env).forEach(([key, value]) => {
|
||
if (value === undefined) {
|
||
delete process.env[key];
|
||
return;
|
||
}
|
||
process.env[key] = value;
|
||
});
|
||
try {
|
||
callback();
|
||
} finally {
|
||
Object.entries(previous).forEach(([key, value]) => {
|
||
if (value === undefined) {
|
||
delete process.env[key];
|
||
return;
|
||
}
|
||
process.env[key] = value;
|
||
});
|
||
}
|
||
}
|
||
|
||
// Helper: build a mock openIdConfig
|
||
const mockOpenIdConfig = {
|
||
serverMetadata: () => ({
|
||
issuer: 'https://issuer.example.com',
|
||
jwks_uri: 'https://example.com/.well-known/jwks.json',
|
||
}),
|
||
};
|
||
|
||
// Helper: invoke the captured verify callback
|
||
async function invokeVerify(req, payload) {
|
||
return new Promise((resolve, reject) => {
|
||
capturedVerifyCallback(req, payload, (err, user, info) => {
|
||
if (err) {
|
||
return reject(err);
|
||
}
|
||
resolve({ user, info });
|
||
});
|
||
});
|
||
}
|
||
|
||
describe('openIdJwtStrategy – token validation', () => {
|
||
beforeEach(() => {
|
||
jest.clearAllMocks();
|
||
});
|
||
|
||
it('requires OpenID JWTs to match the configured client audience and issuer', () => {
|
||
withEnv({ OPENID_CLIENT_ID: 'librechat-client-id', OPENID_AUDIENCE: undefined }, () => {
|
||
openIdJwtLogin(mockOpenIdConfig);
|
||
});
|
||
|
||
expect(capturedStrategyOptions).toMatchObject({
|
||
audience: 'librechat-client-id',
|
||
passReqToCallback: true,
|
||
});
|
||
expect(capturedStrategyOptions).not.toHaveProperty('issuer');
|
||
});
|
||
|
||
it('also accepts OPENID_AUDIENCE for providers that mint resource-bound JWTs', () => {
|
||
withEnv({ OPENID_CLIENT_ID: 'librechat-client-id', OPENID_AUDIENCE: 'api://librechat' }, () => {
|
||
openIdJwtLogin(mockOpenIdConfig);
|
||
});
|
||
|
||
expect(capturedStrategyOptions).toMatchObject({
|
||
audience: ['librechat-client-id', 'api://librechat'],
|
||
});
|
||
});
|
||
|
||
it('uses a single OPENID_AUDIENCE value when no client ID is configured', () => {
|
||
withEnv({ OPENID_CLIENT_ID: undefined, OPENID_AUDIENCE: 'librechat' }, () => {
|
||
openIdJwtLogin(mockOpenIdConfig);
|
||
});
|
||
|
||
expect(capturedStrategyOptions.audience).toBe('librechat');
|
||
});
|
||
|
||
it('splits comma-separated OPENID_AUDIENCE values into multiple accepted audiences', () => {
|
||
withEnv({ OPENID_CLIENT_ID: undefined, OPENID_AUDIENCE: 'librechat,control-plane-web' }, () => {
|
||
openIdJwtLogin(mockOpenIdConfig);
|
||
});
|
||
|
||
expect(capturedStrategyOptions.audience).toEqual(['librechat', 'control-plane-web']);
|
||
});
|
||
|
||
it('trims whitespace around comma-separated OPENID_AUDIENCE values', () => {
|
||
withEnv(
|
||
{ OPENID_CLIENT_ID: undefined, OPENID_AUDIENCE: ' librechat , control-plane-web ' },
|
||
() => {
|
||
openIdJwtLogin(mockOpenIdConfig);
|
||
},
|
||
);
|
||
|
||
expect(capturedStrategyOptions.audience).toEqual(['librechat', 'control-plane-web']);
|
||
});
|
||
|
||
it('falls back to OPENID_CLIENT_ID when OPENID_AUDIENCE is empty', () => {
|
||
withEnv({ OPENID_CLIENT_ID: 'client-id-only', OPENID_AUDIENCE: '' }, () => {
|
||
openIdJwtLogin(mockOpenIdConfig);
|
||
});
|
||
|
||
expect(capturedStrategyOptions.audience).toBe('client-id-only');
|
||
});
|
||
|
||
it('combines OPENID_CLIENT_ID with comma-separated OPENID_AUDIENCE values and deduplicates', () => {
|
||
withEnv(
|
||
{ OPENID_CLIENT_ID: 'librechat', OPENID_AUDIENCE: 'librechat,control-plane-web' },
|
||
() => {
|
||
openIdJwtLogin(mockOpenIdConfig);
|
||
},
|
||
);
|
||
|
||
expect(capturedStrategyOptions.audience).toEqual(['librechat', 'control-plane-web']);
|
||
});
|
||
|
||
it('rejects OpenID JWTs whose issuer does not match the configured issuer', async () => {
|
||
findOpenIDUser.mockResolvedValue({ user: null, error: null, migration: false });
|
||
openIdJwtLogin(mockOpenIdConfig);
|
||
|
||
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
|
||
const { user, info } = await invokeVerify(req, {
|
||
sub: 'oidc-123',
|
||
email: 'test@example.com',
|
||
iss: 'https://other-issuer.example.com',
|
||
exp: 9999999999,
|
||
});
|
||
|
||
expect(user).toBe(false);
|
||
expect(info).toEqual({ message: 'Invalid issuer' });
|
||
expect(findOpenIDUser).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('allows Microsoft Entra tenant issuer values for tenant-independent metadata', async () => {
|
||
const entraConfig = {
|
||
serverMetadata: () => ({
|
||
issuer: 'https://login.microsoftonline.com/{tenantid}/v2.0',
|
||
jwks_uri: 'https://login.microsoftonline.com/common/discovery/v2.0/keys',
|
||
}),
|
||
};
|
||
const user = {
|
||
_id: { toString: () => 'user-abc' },
|
||
role: SystemRoles.USER,
|
||
provider: 'openid',
|
||
};
|
||
findOpenIDUser.mockResolvedValue({ user, error: null, migration: false });
|
||
updateUser.mockResolvedValue({});
|
||
openIdJwtLogin(entraConfig);
|
||
|
||
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
|
||
const { user: result } = await invokeVerify(req, {
|
||
sub: 'oidc-123',
|
||
email: 'test@example.com',
|
||
iss: 'https://login.microsoftonline.com/11111111-2222-3333-4444-555555555555/v2.0',
|
||
exp: 9999999999,
|
||
});
|
||
|
||
expect(result).toBeTruthy();
|
||
expect(findOpenIDUser).toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe('openIdJwtStrategy – token source handling', () => {
|
||
const baseUser = {
|
||
_id: { toString: () => 'user-abc' },
|
||
role: SystemRoles.USER,
|
||
provider: 'openid',
|
||
};
|
||
|
||
const payload = {
|
||
sub: 'oidc-123',
|
||
email: 'test@example.com',
|
||
iss: 'https://issuer.example.com',
|
||
exp: 9999999999,
|
||
};
|
||
|
||
beforeEach(() => {
|
||
jest.clearAllMocks();
|
||
findOpenIDUser.mockResolvedValue({ user: { ...baseUser }, error: null, migration: false });
|
||
updateUser.mockResolvedValue({});
|
||
|
||
// Initialize the strategy so capturedVerifyCallback is set
|
||
openIdJwtLogin(mockOpenIdConfig);
|
||
});
|
||
|
||
it('should read all tokens from session when available', async () => {
|
||
const req = {
|
||
headers: { authorization: 'Bearer raw-bearer-token' },
|
||
session: {
|
||
openidTokens: {
|
||
accessToken: 'session-access',
|
||
idToken: 'session-id',
|
||
refreshToken: 'session-refresh',
|
||
},
|
||
},
|
||
};
|
||
|
||
const { user } = await invokeVerify(req, payload);
|
||
|
||
expect(user.federatedTokens).toEqual({
|
||
access_token: 'session-access',
|
||
id_token: 'session-id',
|
||
refresh_token: 'session-refresh',
|
||
expires_at: payload.exp,
|
||
});
|
||
});
|
||
|
||
it('should fall back to cookies when session is absent', async () => {
|
||
const req = {
|
||
headers: {
|
||
authorization: 'Bearer raw-bearer-token',
|
||
cookie:
|
||
'openid_access_token=cookie-access; openid_id_token=cookie-id; refreshToken=cookie-refresh',
|
||
},
|
||
};
|
||
|
||
const { user } = await invokeVerify(req, payload);
|
||
|
||
expect(user.federatedTokens).toEqual({
|
||
access_token: 'cookie-access',
|
||
id_token: 'cookie-id',
|
||
refresh_token: 'cookie-refresh',
|
||
expires_at: payload.exp,
|
||
});
|
||
});
|
||
|
||
it('should fall back to cookie for idToken only when session lacks it', async () => {
|
||
const req = {
|
||
headers: {
|
||
authorization: 'Bearer raw-bearer-token',
|
||
cookie: 'openid_id_token=cookie-id',
|
||
},
|
||
session: {
|
||
openidTokens: {
|
||
accessToken: 'session-access',
|
||
// idToken intentionally missing
|
||
refreshToken: 'session-refresh',
|
||
},
|
||
},
|
||
};
|
||
|
||
const { user } = await invokeVerify(req, payload);
|
||
|
||
expect(user.federatedTokens).toEqual({
|
||
access_token: 'session-access',
|
||
id_token: 'cookie-id',
|
||
refresh_token: 'session-refresh',
|
||
expires_at: payload.exp,
|
||
});
|
||
});
|
||
|
||
it('should use raw Bearer token as access_token fallback when neither session nor cookie has one', async () => {
|
||
const req = {
|
||
headers: {
|
||
authorization: 'Bearer raw-bearer-token',
|
||
cookie: 'openid_id_token=cookie-id; refreshToken=cookie-refresh',
|
||
},
|
||
};
|
||
|
||
const { user } = await invokeVerify(req, payload);
|
||
|
||
expect(user.federatedTokens.access_token).toBe('raw-bearer-token');
|
||
expect(user.federatedTokens.id_token).toBe('cookie-id');
|
||
expect(user.federatedTokens.refresh_token).toBe('cookie-refresh');
|
||
});
|
||
|
||
it('should set id_token to undefined when not available in session or cookies', async () => {
|
||
const req = {
|
||
headers: {
|
||
authorization: 'Bearer raw-bearer-token',
|
||
cookie: 'openid_access_token=cookie-access; refreshToken=cookie-refresh',
|
||
},
|
||
};
|
||
|
||
const { user } = await invokeVerify(req, payload);
|
||
|
||
expect(user.federatedTokens.access_token).toBe('cookie-access');
|
||
expect(user.federatedTokens.id_token).toBeUndefined();
|
||
expect(user.federatedTokens.refresh_token).toBe('cookie-refresh');
|
||
});
|
||
|
||
it('should keep id_token and access_token as distinct values from cookies', async () => {
|
||
const req = {
|
||
headers: {
|
||
authorization: 'Bearer raw-bearer-token',
|
||
cookie:
|
||
'openid_access_token=the-access-token; openid_id_token=the-id-token; refreshToken=the-refresh',
|
||
},
|
||
};
|
||
|
||
const { user } = await invokeVerify(req, payload);
|
||
|
||
expect(user.federatedTokens.access_token).toBe('the-access-token');
|
||
expect(user.federatedTokens.id_token).toBe('the-id-token');
|
||
expect(user.federatedTokens.access_token).not.toBe(user.federatedTokens.id_token);
|
||
});
|
||
});
|
||
|
||
describe('openIdJwtStrategy – OPENID_EMAIL_CLAIM', () => {
|
||
const payload = {
|
||
sub: 'oidc-123',
|
||
email: 'test@example.com',
|
||
preferred_username: 'testuser',
|
||
upn: 'test@corp.example.com',
|
||
iss: 'https://issuer.example.com',
|
||
exp: 9999999999,
|
||
};
|
||
|
||
beforeEach(() => {
|
||
jest.clearAllMocks();
|
||
delete process.env.OPENID_EMAIL_CLAIM;
|
||
|
||
// Use real findOpenIDUser so it delegates to the findUser mock
|
||
const realFindOpenIDUser = jest.requireActual('@librechat/api').findOpenIDUser;
|
||
findOpenIDUser.mockImplementation(realFindOpenIDUser);
|
||
|
||
findUser.mockResolvedValue(null);
|
||
updateUser.mockResolvedValue({});
|
||
|
||
openIdJwtLogin(mockOpenIdConfig);
|
||
});
|
||
|
||
afterEach(() => {
|
||
delete process.env.OPENID_EMAIL_CLAIM;
|
||
});
|
||
|
||
it('should use the default email when OPENID_EMAIL_CLAIM is not set', async () => {
|
||
const existingUser = {
|
||
_id: 'user-id-1',
|
||
provider: 'openid',
|
||
openidId: payload.sub,
|
||
openidIssuer: 'https://issuer.example.com',
|
||
email: payload.email,
|
||
role: SystemRoles.USER,
|
||
};
|
||
findUser.mockImplementation(async (query) => {
|
||
if (query.openidId === payload.sub && query.openidIssuer === 'https://issuer.example.com') {
|
||
return existingUser;
|
||
}
|
||
return null;
|
||
});
|
||
|
||
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
|
||
await invokeVerify(req, payload);
|
||
|
||
expect(findUser).toHaveBeenCalledWith({
|
||
openidId: payload.sub,
|
||
openidIssuer: 'https://issuer.example.com',
|
||
});
|
||
});
|
||
|
||
it('should use OPENID_EMAIL_CLAIM when set for email lookup', async () => {
|
||
process.env.OPENID_EMAIL_CLAIM = 'upn';
|
||
findUser.mockResolvedValue(null);
|
||
|
||
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
|
||
const { user } = await invokeVerify(req, payload);
|
||
|
||
expect(findUser).toHaveBeenCalledTimes(2);
|
||
expect(findUser.mock.calls[0][0]).toEqual({
|
||
openidId: payload.sub,
|
||
openidIssuer: 'https://issuer.example.com',
|
||
});
|
||
expect(findUser.mock.calls[1][0]).toEqual({
|
||
email: 'test@corp.example.com',
|
||
});
|
||
expect(user).toBe(false);
|
||
});
|
||
|
||
it('should fall back to default chain when OPENID_EMAIL_CLAIM points to missing claim', async () => {
|
||
process.env.OPENID_EMAIL_CLAIM = 'nonexistent_claim';
|
||
findUser.mockResolvedValue(null);
|
||
|
||
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
|
||
const { user } = await invokeVerify(req, payload);
|
||
|
||
expect(findUser).toHaveBeenCalledWith({ email: payload.email });
|
||
expect(user).toBe(false);
|
||
});
|
||
|
||
it('should reject login when email fallback finds user with mismatched openidId', async () => {
|
||
const emailMatchWithDifferentSub = {
|
||
_id: 'user-id-2',
|
||
provider: 'openid',
|
||
openidId: 'different-sub',
|
||
email: payload.email,
|
||
role: SystemRoles.USER,
|
||
};
|
||
|
||
findUser.mockImplementation(async (query) => {
|
||
if (query.$or) {
|
||
return null;
|
||
}
|
||
if (query.email === payload.email) {
|
||
return emailMatchWithDifferentSub;
|
||
}
|
||
return null;
|
||
});
|
||
|
||
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
|
||
const { user, info } = await invokeVerify(req, payload);
|
||
|
||
expect(user).toBe(false);
|
||
expect(info).toEqual({ message: 'auth_failed' });
|
||
});
|
||
|
||
it('should trim whitespace from OPENID_EMAIL_CLAIM', async () => {
|
||
process.env.OPENID_EMAIL_CLAIM = ' upn ';
|
||
findUser.mockResolvedValue(null);
|
||
|
||
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
|
||
await invokeVerify(req, payload);
|
||
|
||
expect(findUser).toHaveBeenCalledWith({ email: 'test@corp.example.com' });
|
||
});
|
||
|
||
it('should ignore empty string OPENID_EMAIL_CLAIM and use default fallback', async () => {
|
||
process.env.OPENID_EMAIL_CLAIM = '';
|
||
findUser.mockResolvedValue(null);
|
||
|
||
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
|
||
await invokeVerify(req, payload);
|
||
|
||
expect(findUser).toHaveBeenCalledWith({ email: payload.email });
|
||
});
|
||
|
||
it('should ignore whitespace-only OPENID_EMAIL_CLAIM and use default fallback', async () => {
|
||
process.env.OPENID_EMAIL_CLAIM = ' ';
|
||
findUser.mockResolvedValue(null);
|
||
|
||
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
|
||
await invokeVerify(req, payload);
|
||
|
||
expect(findUser).toHaveBeenCalledWith({ email: payload.email });
|
||
});
|
||
|
||
it('should resolve undefined email when payload is null', async () => {
|
||
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
|
||
const { user } = await invokeVerify(req, null);
|
||
|
||
expect(user).toBe(false);
|
||
});
|
||
|
||
it('should attempt email lookup via preferred_username fallback when email claim is absent', async () => {
|
||
const payloadNoEmail = {
|
||
sub: 'oidc-new-sub',
|
||
preferred_username: 'legacy@corp.com',
|
||
upn: 'legacy@corp.com',
|
||
iss: 'https://issuer.example.com',
|
||
exp: 9999999999,
|
||
};
|
||
|
||
const legacyUser = {
|
||
_id: 'legacy-db-id',
|
||
email: 'legacy@corp.com',
|
||
openidId: null,
|
||
role: SystemRoles.USER,
|
||
};
|
||
|
||
findUser.mockImplementation(async (query) => {
|
||
if (query.$or) {
|
||
return null;
|
||
}
|
||
if (query.email === 'legacy@corp.com') {
|
||
return legacyUser;
|
||
}
|
||
return null;
|
||
});
|
||
|
||
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
|
||
const { user } = await invokeVerify(req, payloadNoEmail);
|
||
|
||
expect(findUser).toHaveBeenCalledTimes(2);
|
||
expect(findUser.mock.calls[1][0]).toEqual({ email: 'legacy@corp.com' });
|
||
expect(user).toBeTruthy();
|
||
expect(updateUser).toHaveBeenCalledWith(
|
||
'legacy-db-id',
|
||
expect.objectContaining({
|
||
provider: 'openid',
|
||
openidId: payloadNoEmail.sub,
|
||
openidIssuer: 'https://issuer.example.com',
|
||
}),
|
||
);
|
||
});
|
||
});
|