From 656b4be8621a935641728d4d45af81dd26860bf1 Mon Sep 17 00:00:00 2001 From: netcon Date: Thu, 6 Aug 2026 11:19:32 +0800 Subject: [PATCH] feat: fit COOP headers (#711) --- functions/api/github-auth-callback.ts | 68 +++++++++--------------- functions/api/gitlab-auth-callback.ts | 63 +++++++--------------- package.json | 1 + public/_headers | 3 ++ src/github-auth.ts | 37 ++----------- src/gitlab-auth.ts | 27 +--------- src/oauth-callback.ts | 58 ++++++++++++++++++++ src/oauth-common.ts | 3 ++ src/oauth-web.ts | 76 +++++++++++++++++++++++++++ webpack.config.js | 1 + 10 files changed, 192 insertions(+), 145 deletions(-) create mode 100644 public/_headers create mode 100644 src/oauth-callback.ts create mode 100644 src/oauth-common.ts create mode 100644 src/oauth-web.ts 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]' },