fix(integration): send provider and oauth requests through configured proxy

This commit is contained in:
Philipinho
2026-08-10 03:24:19 +01:00
parent bf56b7c2c2
commit 311b511afb
4 changed files with 188 additions and 3 deletions
@@ -0,0 +1,64 @@
import { getProxyAwareFetch, proxyFetch } from './proxy-fetch';
describe('getProxyAwareFetch', () => {
const originalEnv = { ...process.env };
afterEach(() => {
process.env = { ...originalEnv };
});
it('returns undefined when no proxy env vars are set', () => {
delete process.env.HTTP_PROXY;
delete process.env.HTTPS_PROXY;
delete process.env.http_proxy;
delete process.env.https_proxy;
expect(getProxyAwareFetch()).toBeUndefined();
});
it('returns a fetch function when HTTP_PROXY is set', () => {
delete process.env.HTTPS_PROXY;
delete process.env.http_proxy;
delete process.env.https_proxy;
process.env.HTTP_PROXY = 'http://proxy.example.com:8080';
expect(typeof getProxyAwareFetch()).toBe('function');
});
it('returns a fetch function when HTTPS_PROXY is set', () => {
delete process.env.HTTP_PROXY;
delete process.env.http_proxy;
delete process.env.https_proxy;
process.env.HTTPS_PROXY = 'http://proxy.example.com:8080';
expect(typeof getProxyAwareFetch()).toBe('function');
});
it('returns a fetch function when lowercase http_proxy is set', () => {
delete process.env.HTTP_PROXY;
delete process.env.HTTPS_PROXY;
delete process.env.https_proxy;
process.env.http_proxy = 'http://proxy.example.com:8080';
expect(typeof getProxyAwareFetch()).toBe('function');
});
it('proxyFetch delegates to the platform fetch when no proxy is configured', async () => {
delete process.env.HTTP_PROXY;
delete process.env.HTTPS_PROXY;
delete process.env.http_proxy;
delete process.env.https_proxy;
const original = globalThis.fetch;
const response = new Response('ok');
const spy = jest.fn().mockResolvedValue(response);
globalThis.fetch = spy as unknown as typeof fetch;
try {
await expect(proxyFetch('https://example.com')).resolves.toBe(response);
expect(spy).toHaveBeenCalledWith('https://example.com', undefined);
} finally {
globalThis.fetch = original;
}
});
});
+38
View File
@@ -0,0 +1,38 @@
import { EnvHttpProxyAgent, fetch as undiciFetch } from 'undici';
const LOOPBACK_BYPASS = ['localhost', '127.0.0.1', '::1'];
let cachedAgent: EnvHttpProxyAgent | undefined;
function hasProxyEnv(): boolean {
return Boolean(
process.env.HTTP_PROXY ||
process.env.HTTPS_PROXY ||
process.env.http_proxy ||
process.env.https_proxy,
);
}
function buildAgent(): EnvHttpProxyAgent {
const existing = process.env.NO_PROXY || process.env.no_proxy || '';
const merged = [existing, ...LOOPBACK_BYPASS]
.map((s) => s.trim())
.filter(Boolean)
.join(',');
return new EnvHttpProxyAgent({ noProxy: merged });
}
export function getProxyAwareFetch(): typeof fetch | undefined {
if (!hasProxyEnv()) return undefined;
cachedAgent ??= buildAgent();
const agent = cachedAgent;
return ((input, init) =>
undiciFetch(input as any, {
...(init as any),
dispatcher: agent,
}) as unknown as Promise<Response>) as typeof fetch;
}
// Drop-in replacement for direct fetch calls: proxies when configured, platform fetch otherwise.
export const proxyFetch: typeof fetch = (input, init) =>
(getProxyAwareFetch() ?? fetch)(input, init);
@@ -12,9 +12,15 @@ import { IntegrationConnectionRepo } from '../repos/integration-connection.repo'
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { encryptToken, decryptToken } from '../crypto/token-crypto';
import { IntegrationConnection } from '@docmost/db/types/entity.types';
import { OAuthConfig } from '../registry/integration-provider.interface';
import {
OAuthConfig,
TokenInvalidError,
} from '../registry/integration-provider.interface';
import { proxyFetch } from '../../../common/proxy-fetch';
import * as crypto from 'crypto';
const OAUTH_HTTP_TIMEOUT_MS = 10_000;
type OAuthTokenResponse = {
access_token: string;
refresh_token?: string;
@@ -309,6 +315,9 @@ export class OAuthService {
async getValidAccessToken(
connection: IntegrationConnection,
): Promise<string> {
if (connection.invalidatedAt) {
throw new TokenInvalidError();
}
const appSecret = this.environmentService.getAppSecret();
const accessToken = decryptToken(connection.accessToken, appSecret);
@@ -354,16 +363,24 @@ export class OAuthService {
});
try {
const response = await fetch(oauthConfig.tokenUrl, {
const response = await proxyFetch(oauthConfig.tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
body: params.toString(),
signal: AbortSignal.timeout(OAUTH_HTTP_TIMEOUT_MS),
});
if (!response.ok) {
this.logger.error(
`Token refresh failed for ${integration.type}: ${response.status}`,
);
// 400/401 from the token endpoint means invalid_grant/invalid_client:
// the refresh token is dead, not a transient failure.
if (response.status === 400 || response.status === 401) {
throw new TokenInvalidError(
`Refresh token rejected for ${integration.type}`,
);
}
throw new BadRequestException('Token refresh failed');
}
@@ -380,10 +397,14 @@ export class OAuthService {
accessToken: encryptedAccessToken,
refreshToken: encryptedRefreshToken,
tokenExpiresAt,
invalidatedAt: null,
});
return data.access_token;
} catch (err) {
if (err instanceof TokenInvalidError) {
throw err;
}
this.logger.error(`Token refresh error: ${(err as Error).message}`);
throw new BadRequestException('Failed to refresh token');
}
@@ -402,10 +423,11 @@ export class OAuthService {
redirect_uri: this.buildCallbackUrl(type),
});
const response = await fetch(oauthConfig.tokenUrl, {
const response = await proxyFetch(oauthConfig.tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
body: params.toString(),
signal: AbortSignal.timeout(OAUTH_HTTP_TIMEOUT_MS),
});
if (!response.ok) {
@@ -0,0 +1,61 @@
import {
ProviderApiError,
TokenInvalidError,
UnfurlForbiddenError,
} from '../registry/integration-provider.interface';
import { proxyFetch } from '../../../common/proxy-fetch';
export const INTEGRATION_HTTP_TIMEOUT_MS = 10_000;
// Providers explain refusals in the response body ("insufficient scope",
// "not allowed", ...); without it a 403 is undiagnosable from the logs.
const MAX_ERROR_BODY_CHARS = 300;
async function readErrorBody(response: Response): Promise<string> {
try {
const text = await response.text();
return text.replace(/\s+/g, ' ').trim().slice(0, MAX_ERROR_BODY_CHARS);
} catch {
return '';
}
}
// Bounds every provider call and maps 401 to TokenInvalidError so callers can retire the connection.
export async function providerApiFetch(
providerName: string,
url: string,
init: RequestInit = {},
): Promise<Response> {
const response = await proxyFetch(url, {
...init,
signal: AbortSignal.timeout(INTEGRATION_HTTP_TIMEOUT_MS),
});
if (response.status === 401) {
throw new TokenInvalidError(
`${providerName} API error: 401 Unauthorized ${await readErrorBody(response)}`.trimEnd(),
);
}
if (!response.ok) {
const body = await readErrorBody(response);
// 403 normally means the viewer simply can't reach that resource, which is
// an expected "no card" outcome. GitHub also spends 403 on secondary rate
// limits, so quota signals stay a real error an operator can see.
const rateLimited =
response.headers.get('retry-after') !== null ||
response.headers.get('x-ratelimit-remaining') === '0';
if (response.status === 403 && !rateLimited) {
throw new UnfurlForbiddenError(
`${providerName} API error: 403 ${body}`.trimEnd(),
);
}
throw new ProviderApiError(
providerName,
response.status,
`${response.statusText} ${body}`.trim(),
);
}
return response;
}