diff --git a/functions/api/github-auth-callback.ts b/functions/api/github-auth-callback.ts
index 90f910f0f..408dba46f 100644
--- a/functions/api/github-auth-callback.ts
+++ b/functions/api/github-auth-callback.ts
@@ -3,57 +3,27 @@
* @author netcon
*/
-const createResponseHtml = (text: string, script: string) => `
-
-
-
- Connect to GitHub
-
-
- ${text}
-
-
-
-`;
-
-// return the data to the opener window by postMessage API,
-// and close current window if successfully connected
-const createAuthorizeResultHtml = (data: Record, state: string, origins: string) => {
- const errorText = 'Failed! You can close this window and retry.';
- const successText = 'Connected! You can now close this window.';
- const resultStr = JSON.stringify({
- type: 'authorizing',
- payload: data,
- state: state.replace(/[^a-zA-Z0-9]/g, ''),
- }).replace(/ window.close(), 50);'}`;
- return createResponseHtml(data.error ? errorText : successText, script);
-};
-
-const MISSING_CODE_ERROR = {
- error: 'request_invalid',
- error_description: 'Missing code',
-};
-const UNKNOWN_ERROR = {
- error: 'internal_error',
- error_description: 'Unknown error',
-};
+import {
+ createAuthorizeResultHtml,
+ INVALID_ORIGIN_ERROR,
+ MISSING_CODE_ERROR,
+ UNKNOWN_ERROR,
+} from '../../src/oauth-callback';
export const onRequest: PagesFunction<{
GITHUB_OAUTH_ID: string;
GITHUB_OAUTH_SECRET: string;
GITHUB1S_ALLOWED_ORIGINS: string;
}> = async ({ request, env }) => {
- const searchParams = new URL(request.url).searchParams;
+ const { searchParams, origin } = new URL(request.url);
const code = searchParams.get('code');
+ const allowedOrigins = env.GITHUB1S_ALLOWED_ORIGINS.split(',')
+ .map((item) => item.trim())
+ .filter(Boolean);
- const createResponse = (status, data) => {
+ const createResponse = (status: number, data: Record) => {
const state = searchParams.get('state') || '';
- const body = createAuthorizeResultHtml(data, state, env.GITHUB1S_ALLOWED_ORIGINS);
+ const body = createAuthorizeResultHtml('Connect to GitHub', data, state, origin);
return new Response(body, { status, headers: { 'content-type': 'text/html' } });
};
@@ -61,14 +31,24 @@ export const onRequest: PagesFunction<{
return createResponse(401, MISSING_CODE_ERROR);
}
+ if (!allowedOrigins.includes(origin)) {
+ return createResponse(401, INVALID_ORIGIN_ERROR);
+ }
+
try {
// https://docs.github.com/en/developers/apps/authorizing-oauth-apps#2-users-are-redirected-back-to-your-site-by-github
const response = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
- body: JSON.stringify({ client_id: env.GITHUB_OAUTH_ID, client_secret: env.GITHUB_OAUTH_SECRET, code }),
+ body: JSON.stringify({
+ code,
+ client_id: env.GITHUB_OAUTH_ID,
+ client_secret: env.GITHUB_OAUTH_SECRET,
+ redirect_uri: `${origin}/api/github-auth-callback`,
+ }),
headers: { accept: 'application/json', 'content-type': 'application/json' },
});
- return response.json().then((result) => createResponse(response.status, result));
+ const result = (await response.json()) as Record;
+ return createResponse(response.status, result);
} catch (e) {
return createResponse(500, UNKNOWN_ERROR);
}
diff --git a/functions/api/gitlab-auth-callback.ts b/functions/api/gitlab-auth-callback.ts
index e8c5bba5d..f20159252 100644
--- a/functions/api/gitlab-auth-callback.ts
+++ b/functions/api/gitlab-auth-callback.ts
@@ -3,45 +3,12 @@
* @author netcon
*/
-const createResponseHtml = (text: string, script: string) => `
-
-
-
- Connect to GitLab
-
-
- ${text}
-
-
-
-`;
-
-// return the data to the opener window by postMessage API,
-// and close current window if successfully connected
-const createAuthorizeResultHtml = (data: Record, state: string, origins: string) => {
- const errorText = 'Failed! You can close this window and retry.';
- const successText = 'Connected! You can now close this window.';
- const resultStr = JSON.stringify({
- type: 'authorizing',
- payload: data,
- state: state.replace(/[^a-zA-Z0-9]/g, ''),
- }).replace(/ window.close(), 50);'}`;
- return createResponseHtml(data.error ? errorText : successText, script);
-};
-
-const MISSING_CODE_ERROR = {
- error: 'request_invalid',
- error_description: 'Missing code',
-};
-const UNKNOWN_ERROR = {
- error: 'internal_error',
- error_description: 'Unknown error',
-};
+import {
+ createAuthorizeResultHtml,
+ INVALID_ORIGIN_ERROR,
+ MISSING_CODE_ERROR,
+ UNKNOWN_ERROR,
+} from '../../src/oauth-callback';
export const onRequest: PagesFunction<{
GITLAB_OAUTH_ID: string;
@@ -49,12 +16,15 @@ export const onRequest: PagesFunction<{
GITLAB1S_ALLOWED_ORIGINS: string;
GITLAB_OAUTH_REDIRECT_URI: string;
}> = async ({ request, env }) => {
- const searchParams = new URL(request.url).searchParams;
+ const { searchParams, origin } = new URL(request.url);
const code = searchParams.get('code');
+ const allowedOrigins = env.GITLAB1S_ALLOWED_ORIGINS.split(',')
+ .map((item) => item.trim())
+ .filter(Boolean);
- const createResponse = (status, data) => {
+ const createResponse = (status: number, data: Record) => {
const state = searchParams.get('state') || '';
- const body = createAuthorizeResultHtml(data, state, env.GITLAB1S_ALLOWED_ORIGINS);
+ const body = createAuthorizeResultHtml('Connect to GitLab', data, state, origin);
return new Response(body, { status, headers: { 'content-type': 'text/html' } });
};
@@ -62,6 +32,10 @@ export const onRequest: PagesFunction<{
return createResponse(401, MISSING_CODE_ERROR);
}
+ if (!allowedOrigins.includes(origin)) {
+ return createResponse(401, INVALID_ORIGIN_ERROR);
+ }
+
try {
// https://docs.gitlab.com/ee/api/oauth2.html#authorization-code-flow
const response = await fetch('https://gitlab.com/oauth/token', {
@@ -70,12 +44,13 @@ export const onRequest: PagesFunction<{
code,
client_id: env.GITLAB_OAUTH_ID,
client_secret: env.GITLAB_OAUTH_SECRET,
- redirect_uri: env.GITLAB_OAUTH_REDIRECT_URI,
+ redirect_uri: `${origin}/api/gitlab-auth-callback`,
grant_type: 'authorization_code',
}),
headers: { accept: 'application/json', 'content-type': 'application/json' },
});
- return response.json().then((result) => createResponse(response.status, result));
+ const result = (await response.json()) as Record;
+ return createResponse(response.status, result);
} catch (e) {
return createResponse(500, UNKNOWN_ERROR);
}
diff --git a/package.json b/package.json
index 264949427..b5a1e6605 100644
--- a/package.json
+++ b/package.json
@@ -12,6 +12,7 @@
"link": "node scripts/link.js",
"format": "prettier --write .",
"eslint": "eslint --fix",
+ "typecheck": "tsc --noEmit && tsc --noEmit -p functions/tsconfig.json",
"test:ci": "start-test watch:dev-server 8080 test",
"test": "cd tests && npm install && npx playwright install && npm run test",
"postinstall": "husky install && node scripts/postinstall.js"
diff --git a/public/_headers b/public/_headers
new file mode 100644
index 000000000..3a270de9d
--- /dev/null
+++ b/public/_headers
@@ -0,0 +1,3 @@
+/*
+ Cross-Origin-Opener-Policy: same-origin
+ Cross-Origin-Embedder-Policy: credentialless
diff --git a/src/github-auth.ts b/src/github-auth.ts
index 86acf6daf..c5956dbf9 100644
--- a/src/github-auth.ts
+++ b/src/github-auth.ts
@@ -3,17 +3,15 @@
* @author netcon
*/
+import { createOAuthState, waitForOAuthResult } from './oauth-web';
+
+export { createOAuthState } from './oauth-web';
+
const GITHUB_ORIGIN = 'https://github.com';
const OAUTH_REDIRECT_URI = `${location.origin}/api/github-auth-callback`;
const OPEN_WINDOW_FEATURES =
'directories=no,titlebar=no,toolbar=no,location=no,status=no,menubar=no,scrollbars=no,resizable=no,width=800,height=520,top=150,left=150';
-export const createOAuthState = () => {
- const bytes = new Uint8Array(16);
- window.crypto.getRandomValues(bytes);
- return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
-};
-
const createAuthorizeUrl = (state: string) => {
const parameters = Object.entries({
state,
@@ -24,33 +22,8 @@ const createAuthorizeUrl = (state: string) => {
return `${GITHUB_ORIGIN}/login/oauth/authorize?${parameters.join('&')}`;
};
-export const timeout = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
-
export const ConnectToGitHub = () => {
const STATE = createOAuthState();
const opener = window.open(createAuthorizeUrl(STATE), '_blank', OPEN_WINDOW_FEATURES);
-
- return new Promise((resolve) => {
- const handleAuthMessage = (event: MessageEvent) => {
- // Note that though the browser block opening window and popup a tip,
- // the user can be still open it from the tip. In this case, the `opener`
- // is null, and we should still process the authorizing message
- const isValidOpener = !!(opener && event.source === opener);
- const isValidOrigin = event.origin === location.origin;
- const isValidResponse = event.data ? event.data.type === 'authorizing' : false;
- const isValidState = event.data ? event.data.state === STATE : false;
- if (!isValidOpener || !isValidOrigin || !isValidResponse || !isValidState) {
- return;
- }
- window.removeEventListener('message', handleAuthMessage);
- resolve(event.data?.payload);
- };
-
- window.addEventListener('message', handleAuthMessage);
- // if there isn't any message from opener window in 300s, remove the message handler
- timeout(300 * 1000).then(() => {
- window.removeEventListener('message', handleAuthMessage);
- resolve({ error: 'authorizing_timeout', error_description: 'Authorizing timeout' });
- });
- });
+ return waitForOAuthResult(STATE, opener);
};
diff --git a/src/gitlab-auth.ts b/src/gitlab-auth.ts
index 661579031..e612e36c2 100644
--- a/src/gitlab-auth.ts
+++ b/src/gitlab-auth.ts
@@ -3,7 +3,7 @@
* @author netcon
*/
-import { timeout, createOAuthState } from './github-auth';
+import { createOAuthState, waitForOAuthResult } from './oauth-web';
const GITLAB_ORIGIN = 'https://gitlab.com';
const OAUTH_REDIRECT_URI = `${location.origin}/api/gitlab-auth-callback`;
@@ -25,28 +25,5 @@ const createAuthorizeUrl = (state: string) => {
export const ConnectToGitLab = async () => {
const STATE = createOAuthState();
const opener = window.open(createAuthorizeUrl(STATE), '_blank', OPEN_WINDOW_FEATURES);
-
- return new Promise((resolve) => {
- const handleAuthMessage = (event: MessageEvent) => {
- // Note that though the browser block opening window and popup a tip,
- // the user can be still open it from the tip. In this case, the `opener`
- // is null, and we should still process the authorizing message
- const isValidOpener = !!(opener && event.source === opener);
- const isValidOrigin = event.origin === location.origin;
- const isValidResponse = event.data ? event.data.type === 'authorizing' : false;
- const isValidState = event.data ? event.data.state === STATE : false;
- if (!isValidOpener || !isValidOrigin || !isValidResponse || !isValidState) {
- return;
- }
- window.removeEventListener('message', handleAuthMessage);
- resolve(event.data?.payload);
- };
-
- window.addEventListener('message', handleAuthMessage);
- // if there isn't any message from opener window in 300s, remove the message handler
- timeout(300 * 1000).then(() => {
- window.removeEventListener('message', handleAuthMessage);
- resolve({ error: 'authorizing_timeout', error_description: 'Authorizing timeout' });
- });
- });
+ return waitForOAuthResult(STATE, opener);
};
diff --git a/src/oauth-callback.ts b/src/oauth-callback.ts
new file mode 100644
index 000000000..0cc3db979
--- /dev/null
+++ b/src/oauth-callback.ts
@@ -0,0 +1,58 @@
+import { getOAuthBroadcastChannelName } from './oauth-common';
+
+export const MISSING_CODE_ERROR = {
+ error: 'request_invalid',
+ error_description: 'Missing code',
+};
+export const INVALID_ORIGIN_ERROR = {
+ error: 'request_invalid',
+ error_description: 'Invalid origin',
+};
+export const UNKNOWN_ERROR = {
+ error: 'internal_error',
+ error_description: 'Unknown error',
+};
+
+const createResponseHtml = (title: string, text: string, script: string) => `
+
+
+
+ ${title}
+
+
+ ${text}
+
+
+
+`;
+
+export const createAuthorizeResultHtml = (
+ title: string,
+ data: Record,
+ state: string,
+ origin: string,
+) => {
+ const sanitizedState = state.replace(/[^a-zA-Z0-9]/g, '');
+ const result = {
+ type: 'authorizing',
+ payload: data,
+ state: sanitizedState,
+ };
+ const resultStr = JSON.stringify(result).replace(/ `${OAUTH_BROADCAST_CHANNEL_PREFIX}${state}`;
diff --git a/src/oauth-web.ts b/src/oauth-web.ts
new file mode 100644
index 000000000..c00588e04
--- /dev/null
+++ b/src/oauth-web.ts
@@ -0,0 +1,76 @@
+import { getOAuthBroadcastChannelName } from './oauth-common';
+
+export { getOAuthBroadcastChannelName } from './oauth-common';
+
+interface OAuthResultMessage {
+ type: 'authorizing';
+ payload: Record;
+ state: string;
+}
+
+export const createOAuthState = () => {
+ const bytes = new Uint8Array(16);
+ window.crypto.getRandomValues(bytes);
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
+};
+
+const isOAuthResultMessage = (data: unknown, state: string): data is OAuthResultMessage => {
+ if (!data || typeof data !== 'object') {
+ return false;
+ }
+ const message = data as Partial;
+ return (
+ message.type === 'authorizing' &&
+ message.state === state &&
+ message.payload !== null &&
+ typeof message.payload === 'object'
+ );
+};
+
+export const waitForOAuthResult = (
+ state: string,
+ opener: Window | null,
+ timeoutMs = 300 * 1000,
+): Promise> => {
+ const channel =
+ typeof BroadcastChannel === 'undefined' ? undefined : new BroadcastChannel(getOAuthBroadcastChannelName(state));
+
+ return new Promise((resolve) => {
+ let settled = false;
+
+ const cleanup = () => {
+ window.removeEventListener('message', handleWindowMessage);
+ channel?.removeEventListener('message', handleBroadcastMessage);
+ channel?.close();
+ window.clearTimeout(timeoutId);
+ };
+
+ const finish = (data: unknown) => {
+ if (settled || !isOAuthResultMessage(data, state)) {
+ return;
+ }
+ settled = true;
+ cleanup();
+ resolve(data.payload);
+ };
+
+ const handleBroadcastMessage = (event: MessageEvent) => finish(event.data);
+ const handleWindowMessage = (event: MessageEvent) => {
+ if (!opener || event.source !== opener || event.origin !== location.origin) {
+ return;
+ }
+ finish(event.data);
+ };
+
+ channel?.addEventListener('message', handleBroadcastMessage);
+ window.addEventListener('message', handleWindowMessage);
+ const timeoutId = window.setTimeout(() => {
+ if (settled) {
+ return;
+ }
+ settled = true;
+ cleanup();
+ resolve({ error: 'authorizing_timeout', error_description: 'Authorizing timeout' });
+ }, timeoutMs);
+ });
+};
diff --git a/webpack.config.js b/webpack.config.js
index 1df4501a5..e5704108c 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -65,6 +65,7 @@ export default (env, argv) => {
plugins: [
new CopyPlugin({
patterns: [
+ { from: 'public/_headers', to: '_headers', toType: 'file' },
{ from: 'public/favicon*', to: '[name][ext]' },
{ from: 'public/manifest.json', to: '[name][ext]' },
{ from: 'public/robots.txt', to: '[name][ext]' },