Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/clean-bears-train.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@gitbook/integration-github': minor
---

Add support for gitbook proxy
4 changes: 2 additions & 2 deletions bun.lock
Original file line number Diff line number Diff line change
Expand Up @@ -592,7 +592,7 @@
},
"integrations/slack": {
"name": "@gitbook/integration-slack",
"version": "2.5.3",
"version": "2.6.0",
"dependencies": {
"@ai-sdk/openai": "^2.0.62",
"@gitbook/api": "*",
Expand Down Expand Up @@ -729,7 +729,7 @@
},
"packages/api": {
"name": "@gitbook/api",
"version": "0.147.0",
"version": "0.151.0",
"dependencies": {
"event-iterator": "^2.0.0",
"eventsource-parser": "^3.0.0",
Expand Down
2 changes: 2 additions & 0 deletions integrations/github/gitbook-manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,6 @@ secrets:
CLIENT_ID: ${{ env.GITHUB_CLIENT_ID }}
CLIENT_SECRET: ${{ env.GITHUB_CLIENT_SECRET }}
WEBHOOK_SECRET: ${{ env.GITHUB_WEBHOOK_SECRET }}
PROXY_URL: ${{ env.GITBOOK_PROXY_URL }}
PROXY_SECRET: ${{ env.GITBOOK_PROXY_SECRET }}
target: space
23 changes: 9 additions & 14 deletions integrations/github/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import LinkHeader from 'http-link-header';
import { Logger, ExposableError } from '@gitbook/runtime';

import type { GithubRuntimeContext, GitHubSpaceConfiguration } from './types';
import { assertIsDefined, getSpaceConfigOrThrow } from './utils';
import { assertIsDefined, getSpaceConfigOrThrow, signResponse } from './utils';

export type OAuthTokenCredentials = NonNullable<GitHubSpaceConfiguration['oauth_credentials']>;

Expand Down Expand Up @@ -293,8 +293,8 @@ async function requestGitHubAPI(
retriesLeft = 1,
): Promise<Response> {
const { access_token } = credentials;
logger.debug(`GitHub API -> [${options.method}] ${url.toString()}`);
const response = await fetch(url.toString(), {
logger.debug(`GitHub API -> [${options.method}] ${url.toString()}, using proxy: ${context.environment.proxied}`);
const response = await context.fetchWithProxy(url.toString(), {
...options,
headers: {
...options.headers,
Expand All @@ -313,11 +313,7 @@ async function requestGitHubAPI(

logger.debug(`refreshing OAuth credentials for space ${spaceInstallation.space}`);

const refreshed = await refreshCredentials(
context.environment.secrets.CLIENT_ID,
context.environment.secrets.CLIENT_SECRET,
credentials.refresh_token,
);
const refreshed = await refreshCredentials(context, credentials.refresh_token);

await context.api.integrations.updateIntegrationSpaceInstallation(
spaceInstallation.integration,
Expand Down Expand Up @@ -349,18 +345,17 @@ async function requestGitHubAPI(
}

async function refreshCredentials(
clientId: string,
clientSecret: string,
context: GithubRuntimeContext,
refreshToken: string,
): Promise<OAuthTokenCredentials> {
const url = new URL('https://github.com/login/oauth/access_token');

url.searchParams.set('client_id', clientId);
url.searchParams.set('client_secret', clientSecret);
url.searchParams.set('client_id', context.environment.secrets.CLIENT_ID);
url.searchParams.set('client_secret', context.environment.secrets.CLIENT_SECRET);
url.searchParams.set('grant_type', 'refresh_token');
url.searchParams.set('refresh_token', refreshToken);

const resp = await fetch(url.toString(), {
const resp = await context.fetchWithProxy(url.toString(), {
method: 'POST',
headers: {
'User-Agent': 'GitHub-Integration-Worker',
Expand Down Expand Up @@ -401,4 +396,4 @@ export function extractTokenCredentialsOrThrow(
}

return oAuthCredentials;
}
}
52 changes: 52 additions & 0 deletions integrations/github/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,58 @@ export function arrayToHex(arr: ArrayBuffer) {
return [...new Uint8Array(arr)].map((x) => x.toString(16).padStart(2, '0')).join('');
}

/**
* Convert a hex string to an array buffer
*/
function hexToArray(input: string) {
if (input.length % 2 !== 0) {
throw new RangeError('Expected string to be an even number of characters');
}

const view = new Uint8Array(input.length / 2);

for (let i = 0; i < input.length; i += 2) {
view[i / 2] = parseInt(input.substring(i, i + 2), 16);
}

return view.buffer;
}

/**
* Import a secret CryptoKey to use for signing.
*/
async function importKey(secret: string): Promise<CryptoKey> {
return await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign', 'verify'],
);
}

/**
* Sign a message with a secret key by using HMAC-SHA256 algorithm.
*/
export async function signResponse(message: string, secret: string): Promise<string> {
const key = await importKey(secret);
const signed = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(message));
return arrayToHex(signed);
}

/**
* Verify that a message matches a signature by using HMAC-SHA256 algorithm.
*/
export async function verifySignature(
message: string,
signature: string,
secret: string,
): Promise<boolean> {
const key = await importKey(secret);
const sigBuf = hexToArray(signature);
return await crypto.subtle.verify('HMAC', key, sigBuf, new TextEncoder().encode(message));
}

/**
* Constant-time string comparison. Equivalent of `crypto.timingSafeEqual`.
**/
Expand Down
1 change: 0 additions & 1 deletion integrations/gitlab/gitbook-manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,4 @@ configurations:
secrets:
PROXY_URL: ${{ env.GITBOOK_PROXY_URL }}
PROXY_SECRET: ${{ env.GITBOOK_PROXY_SECRET }}
REFLAG_SECRET_KEY: ${{ env.REFLAG_SECRET_KEY }}
target: space
82 changes: 11 additions & 71 deletions integrations/gitlab/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,30 +276,17 @@ async function requestGitLab(
url: URL,
options: RequestInit = {},
): Promise<Response> {
const useProxy = await shouldUseProxy(context);
logger.debug(`GitLab API -> [${options.method}] ${url.toString()}, using proxy: ${useProxy}`);
const response = useProxy
? await proxyRequest(context, url.toString(), {
...options,
headers: {
...options.headers,
...(options.body ? { 'Content-Type': 'application/json' } : {}),
Accept: 'application/json',
Authorization: `Bearer ${token}`,
'User-Agent': 'GitLab-Integration-Worker',
},
})
: await fetch(url.toString(), {
...options,
headers: {
...options.headers,
...(options.body ? { 'Content-Type': 'application/json' } : {}),
Accept: 'application/json',
Authorization: `Bearer ${token}`,
'User-Agent': 'GitLab-Integration-Worker',
},
});

logger.debug(`GitLab API -> [${options.method}] ${url.toString()}, using proxy: ${context.environment.proxied}`);
const response = await context.fetchWithProxy(url.toString(), {
...options,
headers: {
...options.headers,
...(options.body ? { 'Content-Type': 'application/json' } : {}),
Accept: 'application/json',
Authorization: `Bearer ${token}`,
'User-Agent': 'GitLab-Integration-Worker',
},
});
if (!response.ok) {
const text = await response.text();

Expand Down Expand Up @@ -335,50 +322,3 @@ export function getAccessTokenOrThrow(config: GitLabSpaceConfiguration): string
return accessToken;
}

export async function proxyRequest(
context: GitLabRuntimeContext,
url: string,
options: RequestInit = {},
): Promise<Response> {
const signature = await signResponse(url, context.environment.secrets.PROXY_SECRET);
const proxyUrl = new URL(context.environment.secrets.PROXY_URL);

proxyUrl.searchParams.set('target', url);
logger.info(`Proxying request to ${proxyUrl.toString()}, original target: ${url}`);

return fetch(proxyUrl.toString(), {
...options,
headers: {
...options.headers,
'X-Gitbook-Proxy-Signature': signature,
},
});
}

export async function shouldUseProxy(context: GitLabRuntimeContext): Promise<boolean> {
const companyId = context.environment.installation?.target.organization;
if (!companyId) {
return false;
}
try {
const response = await fetch(
`https://front.reflag.com/features/enabled?context.company.id=${companyId}&key=GIT_SYNC_STATIC_IP`,
{
method: 'GET',
headers: {
Authorization: `Bearer ${context.environment.secrets.REFLAG_SECRET_KEY}`,
'Content-Type': 'application/json',
},
},
);

const json = (await response.json()) as {
features: { GIT_SYNC_STATIC_IP: { isEnabled: boolean } };
};
const flag = json.features.GIT_SYNC_STATIC_IP;

return flag.isEnabled;
} catch (e) {
return false;
}
}
7 changes: 7 additions & 0 deletions packages/runtime/src/context.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { GitBookAPI, IntegrationEnvironment } from '@gitbook/api';
import { fetchWithProxy } from 'proxy';

export interface RuntimeEnvironment<
InstallationConfiguration = {},
Expand Down Expand Up @@ -32,6 +33,11 @@ export interface RuntimeContext<Environment extends RuntimeEnvironment = Integra
* the execution context is closed.
*/
waitUntil: FetchEvent['waitUntil'];

/**
* Fetch function that will proxy requests if the integration installation is configured to use a proxy.
*/
fetchWithProxy: (input: RequestInfo, init?: RequestInit) => Promise<Response>;
}

/**
Expand Down Expand Up @@ -59,5 +65,6 @@ export function createContext(
}),

waitUntil,
fetchWithProxy: fetchWithProxy(environment),
};
}
66 changes: 66 additions & 0 deletions packages/runtime/src/proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { IntegrationEnvironment } from "@gitbook/api";

const isUsingProxy = (env: IntegrationEnvironment) => {
return env.proxied || false;
}

/**
* Import a secret CryptoKey to use for signing.
*/
async function importKey(secret: string): Promise<CryptoKey> {
return await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign', 'verify'],
);
}


/**
* Convert an array buffer to a hex string
*/
export function arrayToHex(arr: ArrayBuffer) {
return [...new Uint8Array(arr)].map((x) => x.toString(16).padStart(2, '0')).join('');
}

/**
* Sign a message with a secret key by using HMAC-SHA256 algorithm.
*/
async function signResponse(message: string, secret: string): Promise<string> {
const key = await importKey(secret);
const signed = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(message));
return arrayToHex(signed);
}


async function proxyRequest(url: string, init: RequestInit | undefined, env: IntegrationEnvironment): Promise<Response> {
if(!env.secrets?.PROXY_URL || !env.secrets?.PROXY_SECRET) {
throw new Error('Proxy is not properly configured for this integration.');
}
const signature = await signResponse(url, env.secrets.PROXY_SECRET);
const proxyUrl = new URL(env.secrets.PROXY_URL);

proxyUrl.searchParams.set('target', url);

return fetch(proxyUrl.toString(), {
...init,
headers: {
...init?.headers,
'X-Gitbook-Proxy-Signature': signature,
},
});
}

export function fetchWithProxy(env: IntegrationEnvironment) {
return async (input: RequestInfo, init?: RequestInit): Promise<Response> => {
if (isUsingProxy(env)) {
const url = typeof input === 'string' ? input : input.url;
return proxyRequest(url, init, env);
} else {
return fetch(input, init);
}
};
}

Loading