Skip to content
Merged
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
68 changes: 24 additions & 44 deletions functions/api/github-auth-callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,72 +3,52 @@
* @author netcon
*/

const createResponseHtml = (text: string, script: string) => `
<!DOCTYPE html>
<html lang="en">
<head>
<title>Connect to GitHub</title>
</head>
<body>
<h1>${text}</h1>
<script>${script}</script>
</body>
</html>
`;

// return the data to the opener window by postMessage API,
// and close current window if successfully connected
const createAuthorizeResultHtml = (data: Record<any, any>, 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(/</g, '\\u003c');
const script = `
'${origins}'.split(',').forEach(function(allowedOrigin) {
window.opener.postMessage(${resultStr}, allowedOrigin);
});
${data.error ? '' : 'setTimeout(() => 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<string, unknown>) => {
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' } });
};

if (!code) {
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<string, unknown>;
return createResponse(response.status, result);
} catch (e) {
return createResponse(500, UNKNOWN_ERROR);
}
Expand Down
63 changes: 19 additions & 44 deletions functions/api/gitlab-auth-callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,65 +3,39 @@
* @author netcon
*/

const createResponseHtml = (text: string, script: string) => `
<!DOCTYPE html>
<html lang="en">
<head>
<title>Connect to GitLab</title>
</head>
<body>
<h1>${text}</h1>
<script>${script}</script>
</body>
</html>
`;

// return the data to the opener window by postMessage API,
// and close current window if successfully connected
const createAuthorizeResultHtml = (data: Record<any, any>, 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(/</g, '\\u003c');
const script = `
'${origins}'.split(',').forEach(function(allowedOrigin) {
window.opener.postMessage(${resultStr}, allowedOrigin);
});
${data.error ? '' : 'setTimeout(() => 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;
GITLAB_OAUTH_SECRET: string;
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<string, unknown>) => {
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' } });
};

if (!code) {
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', {
Expand All @@ -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<string, unknown>;
return createResponse(response.status, result);
} catch (e) {
return createResponse(500, UNKNOWN_ERROR);
}
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions public/_headers
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/*
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: credentialless
37 changes: 5 additions & 32 deletions src/github-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
};
27 changes: 2 additions & 25 deletions src/gitlab-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand All @@ -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);
};
58 changes: 58 additions & 0 deletions src/oauth-callback.ts
Original file line number Diff line number Diff line change
@@ -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) => `
<!DOCTYPE html>
<html lang="en">
<head>
<title>${title}</title>
</head>
<body>
<h1>${text}</h1>
<script>${script}</script>
</body>
</html>
`;

export const createAuthorizeResultHtml = (
title: string,
data: Record<string, unknown>,
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(/</g, '\\u003c');
const channelName = JSON.stringify(getOAuthBroadcastChannelName(sanitizedState));
const script = `
const result = ${resultStr};
if (typeof BroadcastChannel !== 'undefined') {
const channel = new BroadcastChannel(${channelName});
channel.postMessage(result);
channel.close();
}
if (window.opener) {
window.opener.postMessage(result, ${JSON.stringify(origin)});
}
${data.error ? '' : 'setTimeout(function() { window.close(); }, 50);'}`;
const text = data.error
? 'Failed! You can close this window and retry.'
: 'Connected! You can close this window now.';
return createResponseHtml(title, text, script);
};
3 changes: 3 additions & 0 deletions src/oauth-common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const OAUTH_BROADCAST_CHANNEL_PREFIX = 'github1s:oauth:';

export const getOAuthBroadcastChannelName = (state: string) => `${OAUTH_BROADCAST_CHANNEL_PREFIX}${state}`;
Loading
Loading