🔧 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:
Anubhav Anand 2026-06-13 21:09:48 +05:30 committed by GitHub
parent 05eb986097
commit 65e2838038
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 147 additions and 10 deletions

View file

@ -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);

View file

@ -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', () => {

View file

@ -1,5 +1,6 @@
export * from './domain';
export * from './openid';
export * from './proxy';
export * from './exchange';
export * from './refresh';
export * from './agent';

View 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);
});
});

View 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;
}