mirror of
https://github.com/docmost/docmost.git
synced 2026-08-20 04:51:36 +10:00
fix(integration): send provider and oauth requests through configured proxy
This commit is contained in:
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user