mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
🔧 fix: Honor NO_PROXY for OpenID requests when PROXY is set (#13716)
* 🔧 fix: Honor NO_PROXY for OpenID requests when PROXY is set openidStrategy routed every OIDC request (issuer discovery, JWKS, token endpoint, Microsoft Graph overage resolution) through undici.ProxyAgent whenever PROXY was set. undici.ProxyAgent does not consult NO_PROXY, so OIDC providers on internal networks that the corporate proxy cannot reach failed at startup with ECONNREFUSED or discovery timeouts, even when the issuer host was listed in NO_PROXY. Replace ProxyAgent with undici.EnvHttpProxyAgent configured to use PROXY for both protocols. EnvHttpProxyAgent applies the standard NO_PROXY/no_proxy exclusion list per request host (suffix matching, leading-dot domains, host:port entries, and *), so excluded hosts are requested directly. The agent is also memoized (keyed on PROXY + NO_PROXY) instead of being constructed per request, so repeated OIDC calls reuse one connection pool. Fixes #13705 * fix: move OpenID proxy helper to api package * chore: import order in openidStrategy.js * chore: import order in openidStrategy.spec.js --------- Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
parent
05eb986097
commit
65e2838038
5 changed files with 147 additions and 10 deletions
|
|
@ -14,14 +14,15 @@ const {
|
|||
getOpenIdEmail,
|
||||
getOpenIdIssuer,
|
||||
getBalanceConfig,
|
||||
selectOpenIdRole,
|
||||
getAvatarSaveParams,
|
||||
isEmailDomainAllowed,
|
||||
getAvatarFileStrategy,
|
||||
getAvatarSaveParams,
|
||||
selectOpenIdRole,
|
||||
resolveAppConfigForUser,
|
||||
getOpenIdProxyDispatcher,
|
||||
getOpenIdRoleSyncOptions,
|
||||
getOpenIdRolesForOpenIdSync,
|
||||
getLibreChatRolesForOpenIdSync,
|
||||
resolveAppConfigForUser,
|
||||
} = require('@librechat/api');
|
||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||
const { resizeAvatar } = require('~/server/services/Files/images/avatar');
|
||||
|
|
@ -61,11 +62,12 @@ async function customFetch(url, options) {
|
|||
try {
|
||||
/** @type {undici.RequestInit} */
|
||||
let fetchOptions = options;
|
||||
if (process.env.PROXY) {
|
||||
const dispatcher = getOpenIdProxyDispatcher();
|
||||
if (dispatcher) {
|
||||
logger.info(`[openidStrategy] proxy agent configured: ${process.env.PROXY}`);
|
||||
fetchOptions = {
|
||||
...options,
|
||||
dispatcher: new undici.ProxyAgent(process.env.PROXY),
|
||||
dispatcher,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -419,9 +421,9 @@ async function resolveGroupsFromOverage(accessToken, sub) {
|
|||
body: JSON.stringify({ securityEnabledOnly: false }),
|
||||
};
|
||||
|
||||
if (process.env.PROXY) {
|
||||
const { ProxyAgent } = undici;
|
||||
fetchOptions.dispatcher = new ProxyAgent(process.env.PROXY);
|
||||
const dispatcher = getOpenIdProxyDispatcher();
|
||||
if (dispatcher) {
|
||||
fetchOptions.dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
const response = await undici.fetch(url, fetchOptions);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,12 @@ const fetch = require('node-fetch');
|
|||
const jwtDecode = require('jsonwebtoken/decode');
|
||||
const { ErrorTypes, FileSources } = require('librechat-data-provider');
|
||||
const { findUser, createUser, updateUser, findRolesByNames } = require('~/models');
|
||||
const { getOpenIdIssuer, resolveAppConfigForUser, isEnabled } = require('@librechat/api');
|
||||
const {
|
||||
getOpenIdProxyDispatcher,
|
||||
resolveAppConfigForUser,
|
||||
getOpenIdIssuer,
|
||||
isEnabled,
|
||||
} = require('@librechat/api');
|
||||
const { resizeAvatar } = require('~/server/services/Files/images/avatar');
|
||||
const { getAppConfig } = require('~/server/services/Config');
|
||||
const { setupOpenId } = require('./openidStrategy');
|
||||
|
|
@ -15,7 +20,6 @@ jest.mock('node-fetch');
|
|||
jest.mock('jsonwebtoken/decode');
|
||||
jest.mock('undici', () => ({
|
||||
fetch: jest.fn(),
|
||||
ProxyAgent: jest.fn(),
|
||||
}));
|
||||
jest.mock('~/server/services/Files/strategies', () => ({
|
||||
getStrategyFunctions: jest.fn(() => ({
|
||||
|
|
@ -74,6 +78,7 @@ jest.mock('@librechat/api', () => {
|
|||
enabled: false,
|
||||
})),
|
||||
getOpenIdIssuer: jest.fn(() => 'https://fake-issuer.com'),
|
||||
getOpenIdProxyDispatcher: jest.fn(() => undefined),
|
||||
getAvatarFileStrategy: jest.fn((config, fallbackStrategy) => {
|
||||
const { FileSources } = jest.requireActual('librechat-data-provider');
|
||||
if (config?.fileStrategies) {
|
||||
|
|
@ -216,6 +221,7 @@ describe('setupOpenId', () => {
|
|||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
}));
|
||||
getOpenIdProxyDispatcher.mockReturnValue(undefined);
|
||||
require('openid-client').genericGrantRequest.mockReset();
|
||||
require('openid-client').genericGrantRequest.mockResolvedValue({
|
||||
access_token: 'exchanged_graph_token',
|
||||
|
|
@ -342,6 +348,32 @@ describe('setupOpenId', () => {
|
|||
expect(metadata.client_secret).toBe('my-secret');
|
||||
expect(metadata.token_endpoint_auth_method).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses the shared OpenID proxy dispatcher for custom fetch requests', async () => {
|
||||
const dispatcher = { dispatch: jest.fn() };
|
||||
const response = { status: 204, statusText: 'No Content', headers: new Headers() };
|
||||
getOpenIdProxyDispatcher.mockReturnValue(dispatcher);
|
||||
undici.fetch.mockResolvedValue(response);
|
||||
|
||||
await setupOpenId();
|
||||
|
||||
const [, , , , options] = openidClient.discovery.mock.calls.at(-1);
|
||||
const openIdFetch = options[openidClient.customFetch];
|
||||
await expect(
|
||||
openIdFetch('https://issuer.example.com/.well-known/openid-configuration', {
|
||||
method: 'GET',
|
||||
}),
|
||||
).resolves.toBe(response);
|
||||
|
||||
expect(getOpenIdProxyDispatcher).toHaveBeenCalled();
|
||||
expect(undici.fetch).toHaveBeenCalledWith(
|
||||
'https://issuer.example.com/.well-known/openid-configuration',
|
||||
{
|
||||
method: 'GET',
|
||||
dispatcher,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('authorizationRequestParams', () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export * from './domain';
|
||||
export * from './openid';
|
||||
export * from './proxy';
|
||||
export * from './exchange';
|
||||
export * from './refresh';
|
||||
export * from './agent';
|
||||
|
|
|
|||
83
packages/api/src/auth/proxy.spec.ts
Normal file
83
packages/api/src/auth/proxy.spec.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { EnvHttpProxyAgent } from 'undici';
|
||||
import { getOpenIdProxyDispatcher } from './proxy';
|
||||
|
||||
jest.mock('undici', () => ({
|
||||
EnvHttpProxyAgent: jest.fn(),
|
||||
}));
|
||||
|
||||
const MockEnvHttpProxyAgent = EnvHttpProxyAgent as jest.MockedClass<typeof EnvHttpProxyAgent>;
|
||||
|
||||
describe('getOpenIdProxyDispatcher', () => {
|
||||
beforeEach(() => {
|
||||
MockEnvHttpProxyAgent.mockClear();
|
||||
delete process.env.PROXY;
|
||||
delete process.env.NO_PROXY;
|
||||
delete process.env.no_proxy;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete process.env.PROXY;
|
||||
delete process.env.NO_PROXY;
|
||||
delete process.env.no_proxy;
|
||||
});
|
||||
|
||||
it('returns undefined when PROXY is not set', () => {
|
||||
expect(getOpenIdProxyDispatcher()).toBeUndefined();
|
||||
expect(MockEnvHttpProxyAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates a NO_PROXY-aware agent for both protocols when PROXY is set', () => {
|
||||
process.env.PROXY = 'http://corporate-proxy-create:8080';
|
||||
|
||||
const dispatcher = getOpenIdProxyDispatcher();
|
||||
|
||||
expect(dispatcher).toBeInstanceOf(EnvHttpProxyAgent);
|
||||
expect(MockEnvHttpProxyAgent).toHaveBeenCalledWith({
|
||||
httpProxy: 'http://corporate-proxy-create:8080',
|
||||
httpsProxy: 'http://corporate-proxy-create:8080',
|
||||
});
|
||||
});
|
||||
|
||||
it('reuses the same agent across calls', () => {
|
||||
process.env.PROXY = 'http://corporate-proxy-reuse:8080';
|
||||
process.env.NO_PROXY = 'localhost,.internal-domain.com';
|
||||
|
||||
const first = getOpenIdProxyDispatcher();
|
||||
const second = getOpenIdProxyDispatcher();
|
||||
|
||||
expect(second).toBe(first);
|
||||
expect(MockEnvHttpProxyAgent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rebuilds the agent when NO_PROXY changes', () => {
|
||||
process.env.PROXY = 'http://corporate-proxy-no-proxy:8080';
|
||||
process.env.NO_PROXY = 'localhost';
|
||||
getOpenIdProxyDispatcher();
|
||||
|
||||
process.env.NO_PROXY = 'localhost,.internal-domain.com';
|
||||
getOpenIdProxyDispatcher();
|
||||
|
||||
expect(MockEnvHttpProxyAgent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('rebuilds the agent when lowercase no_proxy changes', () => {
|
||||
process.env.PROXY = 'http://corporate-proxy-lowercase:8080';
|
||||
process.env.no_proxy = 'localhost';
|
||||
getOpenIdProxyDispatcher();
|
||||
|
||||
process.env.no_proxy = 'localhost,.internal-domain.com';
|
||||
getOpenIdProxyDispatcher();
|
||||
|
||||
expect(MockEnvHttpProxyAgent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('rebuilds the agent when PROXY changes', () => {
|
||||
process.env.PROXY = 'http://corporate-proxy-change:8080';
|
||||
getOpenIdProxyDispatcher();
|
||||
|
||||
process.env.PROXY = 'http://other-proxy:3128';
|
||||
getOpenIdProxyDispatcher();
|
||||
|
||||
expect(MockEnvHttpProxyAgent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
19
packages/api/src/auth/proxy.ts
Normal file
19
packages/api/src/auth/proxy.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { EnvHttpProxyAgent } from 'undici';
|
||||
import type { Dispatcher } from 'undici';
|
||||
|
||||
let proxyDispatcher: EnvHttpProxyAgent | undefined;
|
||||
let proxyDispatcherKey: string | undefined;
|
||||
|
||||
export function getOpenIdProxyDispatcher(): Dispatcher | undefined {
|
||||
const proxy = process.env.PROXY;
|
||||
if (!proxy) return undefined;
|
||||
|
||||
const noProxy = process.env.no_proxy ?? process.env.NO_PROXY ?? '';
|
||||
const key = `${proxy}|${noProxy}`;
|
||||
if (!proxyDispatcher || proxyDispatcherKey !== key) {
|
||||
proxyDispatcher = new EnvHttpProxyAgent({ httpProxy: proxy, httpsProxy: proxy });
|
||||
proxyDispatcherKey = key;
|
||||
}
|
||||
|
||||
return proxyDispatcher;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue