diff --git a/apps/server/src/common/proxy-fetch.spec.ts b/apps/server/src/common/proxy-fetch.spec.ts new file mode 100644 index 000000000..25cbc5f84 --- /dev/null +++ b/apps/server/src/common/proxy-fetch.spec.ts @@ -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; + } + }); +}); diff --git a/apps/server/src/common/proxy-fetch.ts b/apps/server/src/common/proxy-fetch.ts new file mode 100644 index 000000000..ecaa852e9 --- /dev/null +++ b/apps/server/src/common/proxy-fetch.ts @@ -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) 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); diff --git a/apps/server/src/core/integration/oauth/oauth.service.ts b/apps/server/src/core/integration/oauth/oauth.service.ts index 0e0c01413..88b69dcaf 100644 --- a/apps/server/src/core/integration/oauth/oauth.service.ts +++ b/apps/server/src/core/integration/oauth/oauth.service.ts @@ -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 { + 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) { diff --git a/apps/server/src/core/integration/utils/provider-fetch.ts b/apps/server/src/core/integration/utils/provider-fetch.ts new file mode 100644 index 000000000..0ccb08bd0 --- /dev/null +++ b/apps/server/src/core/integration/utils/provider-fetch.ts @@ -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 { + 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 { + 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; +}