Skip to content

Commit 2ed3d90

Browse files
committed
fix(oauth): make PKCE callback deliver to opener
1 parent 3779acc commit 2ed3d90

8 files changed

Lines changed: 130 additions & 40 deletions

File tree

app/src/config/dataset.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ export interface DatasetConfig {
2323

2424
const DEFAULT_DATASET_CONFIG: DatasetConfig = {
2525
termId: '2025-16',
26-
snapshotPath: '/crawler/data/terms/2025-16.json',
26+
// Leave empty by default; runtime resolver will pick the latest matching term from
27+
// `static/crawler/data/current.json` (bundled into SSG builds).
28+
snapshotPath: '',
2729
parserId: '2025Spring',
2830
seedSelectionIds: []
2931
};

app/src/config/meta.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { base } from '$app/paths';
2+
13
export interface MetaLink {
24
id: string;
35
labelKey: string;
@@ -33,7 +35,7 @@ const DEFAULT_META_CONFIG: AppMetaConfig = {
3335
productBylineKey: 'meta.productByline',
3436
homepage: 'https://xk.shuosc.com',
3537
branding: {
36-
iconSrc: '/icons/shuosc.png',
38+
iconSrc: `${base || ''}/icons/shuosc.png`,
3739
iconAltKey: 'meta.productIconAlt'
3840
},
3941
about: {

app/src/config/userscript.ts

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,7 @@ const DEFAULT_CONFIG: UserscriptConfig = {
1414
import.meta.env?.VITE_USERSCRIPT_HELP_URL ??
1515
`https://github.com/${resolveRepoSlug()}#readme`,
1616
// Prefer `.user.js` suffix to ensure userscript managers trigger install reliably.
17-
//
18-
// On GitHub Pages, some setups (e.g. service worker, COOP/COEP headers, or routing fallbacks)
19-
// can interfere with userscript managers' install interception. A raw.githubusercontent.com
20-
// URL is the most stable install source.
21-
scriptUrl: import.meta.env?.PROD
22-
? import.meta.env?.VITE_USERSCRIPT_INSTALL_URL ??
23-
`https://raw.githubusercontent.com/${resolveRepoSlug()}/main/app/static/backenduserscript/backend.user.js`
24-
: `${base || ''}/backenduserscript/backend.user.js`
17+
scriptUrl: import.meta.env?.VITE_USERSCRIPT_INSTALL_URL ?? `${base || ''}/backenduserscript/backend.user.js`
2518
};
2619

2720
export function getUserscriptConfig(overrides?: Partial<UserscriptConfig>): UserscriptConfig {

app/src/lib/apps/SyncPanel.svelte

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,14 @@
5959
? 'panels.sync.loginUnavailableHint'
6060
: 'errors.githubPkceUnsupported';
6161
62+
const formatMessage = (key: string, values?: Record<string, string>) => {
63+
try {
64+
return t(key, values);
65+
} catch {
66+
return t(key);
67+
}
68+
};
69+
6270
function applyGithubToken(token: string) {
6371
const trimmed = String(token || '').trim();
6472
if (!trimmed) return;
@@ -71,6 +79,9 @@
7179
if (event.data?.type === 'github-token' && event.data.token) {
7280
applyGithubToken(event.data.token);
7381
}
82+
if (event.data?.type === 'github-oauth-error' && event.data.errorKey) {
83+
gistStatus = formatMessage(String(event.data.errorKey), event.data.values);
84+
}
7485
}
7586
7687
function handleStorage(event: StorageEvent) {
@@ -87,6 +98,9 @@
8798
function handleChannelMessage(event: MessageEvent) {
8899
const data = (event as MessageEvent).data as any;
89100
if (data?.type === 'github-token' && data.token) applyGithubToken(data.token);
101+
if (data?.type === 'github-oauth-error' && data.errorKey) {
102+
gistStatus = formatMessage(String(data.errorKey), data.values);
103+
}
90104
}
91105
channel?.addEventListener('message', handleChannelMessage as any);
92106

app/src/lib/i18n/locales/zh-CN.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -952,12 +952,12 @@ export const zhCN = {
952952
loginHint: '使用 OAuth 登录(PKCE,会打开弹窗)。',
953953
loginUnavailableHint: '此部署未配置 GitHub OAuth。请在构建/部署环境中设置 PUBLIC_GITHUB_CLIENT_ID。',
954954
tokenLabel: 'GitHub token(仅本地保存)',
955-
tokenPlaceholder: '粘贴 token(需要 Gist 权限)',
955+
tokenPlaceholder: '仅开发/调试:输入 token(需要 Gist 权限)',
956956
tokenShow: '显示',
957957
tokenHide: '隐藏',
958958
tokenSave: '保存 token',
959959
closeWindow: '关闭窗口',
960-
tokenHint: 'token 仅保存在浏览器 localStorage,不会被打包进同步内容。',
960+
tokenHint: 'token 仅保存在本机浏览器 localStorage,不会被打包进同步内容。',
961961
gistIdLabel: 'Gist ID(可选)',
962962
gistIdPlaceholder: '已存在的 Gist ID,用于增量更新',
963963
noteLabel: '备注 / Note',
@@ -984,7 +984,7 @@ export const zhCN = {
984984
githubAuthorizing: '正在完成 GitHub 登录...',
985985
githubLoginSuccess: 'GitHub 登录成功',
986986
requireLogin: '请先登录 GitHub',
987-
tokenRequired: '请先粘贴 GitHub token',
987+
tokenRequired: '请先登录 GitHub',
988988
tokenSaved: 'token 已保存到本地',
989989
termStateMissing: 'TermState 未加载,请稍后重试',
990990
syncing: '正在生成快照并同步...',

app/src/lib/policies/github/oauthPkce.ts

Lines changed: 62 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,15 @@ export function getGithubPkceAvailability(): GithubPkceAvailability {
5252
// GitHub Pages needs an OAuth proxy (GitHub's token endpoint is not CORS-enabled).
5353
if (isGithubPagesHost() && !getOauthProxyUrl()) return { supported: false, reason: 'unsupportedRuntime' };
5454

55-
// IMPORTANT (GitHub Pages):
56-
// adapter-static writes `auth/callback/index.html`, which requires a trailing slash when accessed directly.
57-
// Without it, GitHub Pages falls back to `404.html`, and the callback runtime won't load reliably.
58-
const redirectUri = new URL(`${base}/auth/callback/`, window.location.origin).toString();
55+
// Use a stable redirectUri (no trailing slash) to match typical GitHub OAuth app settings.
56+
// GitHub Pages will serve `404.html` for `/auth/callback`, and SvelteKit can still route it
57+
// as long as asset paths are base-aware (see `app/src/app.html`).
58+
const redirectUri = new URL(`${base}/auth/callback`, window.location.origin).toString();
5959
return { supported: true, clientId, redirectUri };
6060
}
6161

62+
export type GithubPkceCodePayload = { code: string; state: string };
63+
6264
export function getGithubManualTokenAllowed() {
6365
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
6466
return Boolean(dev) || Boolean(publicEnv.PUBLIC_GITHUB_ALLOW_MANUAL_TOKEN);
@@ -105,9 +107,10 @@ export async function startGithubPkceLoginPopup(): Promise<GithubPkceStartResult
105107
const popup = openCenteredPopup(authorizeUrl.toString(), 'github-login', 520, 640);
106108
if (!popup) return { ok: false, errorKey: 'errors.githubPopupBlocked' };
107109

108-
// Best-effort: close popup from the opener side once the token is delivered.
109-
// (Callback page may be unable to close itself depending on browser policies.)
110-
attachPopupAutoClose(popup);
110+
// Best-effort: complete the OAuth callback from the opener side.
111+
// This is more reliable on GitHub Pages because `/auth/callback` may be served via `404.html` fallback,
112+
// and GitHub's COOP headers can sever `window.opener` in the popup.
113+
attachPopupCallbackCoordinator(popup, availability.redirectUri);
111114
return { ok: true };
112115
}
113116

@@ -232,11 +235,20 @@ function openCenteredPopup(url: string, name: string, width: number, height: num
232235
return window.open(url, name, `width=${width},height=${height},left=${left},top=${top}`);
233236
}
234237

235-
function attachPopupAutoClose(popup: Window) {
238+
function attachPopupCallbackCoordinator(popup: Window, redirectUri: string) {
236239
if (!browser) return;
237240

238241
const channelName = 'neoxk:github-oauth';
239242
const channel = typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel(channelName);
243+
const notifyError = (payload: { errorKey: string; values?: Record<string, string> }) => {
244+
try {
245+
const outbound = new BroadcastChannel(channelName);
246+
outbound.postMessage({ type: 'github-oauth-error', ...payload });
247+
outbound.close();
248+
} catch {
249+
// ignore
250+
}
251+
};
240252

241253
let done = false;
242254
const closePopup = () => {
@@ -251,39 +263,60 @@ function attachPopupAutoClose(popup: Window) {
251263
if (done) return;
252264
done = true;
253265
window.removeEventListener('message', handleMessage);
254-
window.removeEventListener('storage', handleStorage);
255266
channel?.removeEventListener('message', handleChannelMessage as any);
256267
channel?.close();
257268
clearInterval(interval);
258269
clearTimeout(timeout);
259270
};
260271

261-
const onToken = (token: unknown) => {
262-
const value = typeof token === 'string' ? token.trim() : '';
272+
const deliverToken = (token: string) => {
273+
const value = token.trim();
263274
if (!value) return;
275+
try {
276+
const outbound = new BroadcastChannel(channelName);
277+
outbound.postMessage({ type: 'github-token', token: value });
278+
outbound.close();
279+
} catch {
280+
// ignore
281+
}
282+
};
283+
284+
const onCallback = async (payload: { code: string; state: string }) => {
285+
if (done) return;
286+
const code = String(payload.code || '').trim();
287+
const state = String(payload.state || '').trim();
288+
if (!code || !state) return;
289+
290+
const callbackUrl = new URL(redirectUri);
291+
callbackUrl.searchParams.set('code', code);
292+
callbackUrl.searchParams.set('state', state);
293+
294+
const result = await completeGithubPkceCallback(callbackUrl);
295+
if (!result.ok) {
296+
notifyError({ errorKey: result.errorKey, values: result.values });
297+
return;
298+
}
299+
deliverToken(result.token);
264300
closePopup();
265-
// A second close attempt helps on some browsers.
266301
setTimeout(closePopup, 100);
267302
cleanup();
268303
};
269304

270305
const handleMessage = (event: MessageEvent) => {
271306
if (event.origin !== window.location.origin) return;
272-
if (event.data?.type === 'github-token' && event.data.token) onToken(event.data.token);
273-
};
274-
275-
const handleStorage = (event: StorageEvent) => {
276-
if (event.key !== 'githubToken') return;
277-
if (event.newValue) onToken(event.newValue);
307+
if (event.data?.type === 'github-oauth-code' && event.data.code && event.data.state) {
308+
void onCallback({ code: event.data.code, state: event.data.state });
309+
}
278310
};
279311

280312
const handleChannelMessage = (event: MessageEvent) => {
281313
const data = (event as MessageEvent).data as any;
282-
if (data?.type === 'github-token' && data.token) onToken(data.token);
314+
if (data?.type === 'github-oauth-code' && data.code && data.state) {
315+
void onCallback({ code: data.code, state: data.state });
316+
}
283317
};
284318

285319
window.addEventListener('message', handleMessage);
286-
window.addEventListener('storage', handleStorage);
287320
channel?.addEventListener('message', handleChannelMessage as any);
288321

289322
const interval = window.setInterval(() => {
@@ -292,11 +325,17 @@ function attachPopupAutoClose(popup: Window) {
292325
cleanup();
293326
return;
294327
}
328+
329+
// If the popup navigates back to our origin, try to read its URL and complete the callback here.
295330
try {
296-
const token = localStorage.getItem('githubToken');
297-
if (token) onToken(token);
331+
const href = popup.location.href;
332+
if (!href) return;
333+
const url = new URL(href);
334+
const code = url.searchParams.get('code');
335+
const state = url.searchParams.get('state');
336+
if (code && state) void onCallback({ code, state });
298337
} catch {
299-
// ignore
338+
// ignore (cross-origin until it returns to our site)
300339
}
301340
}, 350);
302341

app/src/routes/auth/callback/+page.svelte

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,21 +18,54 @@
1818
onMount(async () => {
1919
status = t('panels.sync.statuses.githubAuthorizing');
2020
21-
const result = await completeGithubPkceCallback(new URL(window.location.href));
21+
// Prefer delivering the code/state to the opener, and let the opener complete the token exchange.
22+
// This avoids relying on `window.opener` (may be severed by COOP) and avoids CORS in the popup.
23+
const url = new URL(window.location.href);
24+
const code = url.searchParams.get('code');
25+
const state = url.searchParams.get('state');
26+
27+
if (code && state) {
28+
deliverCallback({ code, state });
29+
status = t('panels.sync.statuses.githubLoginSuccess');
30+
setTimeout(() => tryCloseOrFallback(), 350);
31+
return;
32+
}
33+
34+
const result = await completeGithubPkceCallback(url);
2235
if (!result.ok) {
2336
status = t(result.errorKey, result.values);
24-
// Even on failure, avoid leaving users stuck in a popup/tab.
2537
setTimeout(() => tryCloseOrFallback(), 700);
2638
return;
2739
}
2840
2941
token = result.token;
3042
status = t('panels.sync.statuses.githubLoginSuccess');
31-
3243
deliverToken(token);
3344
tryCloseOrFallback();
3445
});
3546
47+
function deliverCallback(payload: { code: string; state: string }) {
48+
const code = String(payload.code || '').trim();
49+
const state = String(payload.state || '').trim();
50+
if (!code || !state) return;
51+
52+
try {
53+
window.opener?.postMessage({ type: 'github-oauth-code', code, state }, window.location.origin);
54+
} catch {
55+
// ignore
56+
}
57+
58+
try {
59+
if (typeof BroadcastChannel !== 'undefined') {
60+
const channel = new BroadcastChannel('neoxk:github-oauth');
61+
channel.postMessage({ type: 'github-oauth-code', code, state });
62+
channel.close();
63+
}
64+
} catch {
65+
// ignore
66+
}
67+
}
68+
3669
function deliverToken(tokenValue: string) {
3770
const trimmed = String(tokenValue || '').trim();
3871
if (!trimmed) return;

app/svelte.config.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import adapter from '@sveltejs/adapter-static';
22
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
33

4+
function resolveBasePath() {
5+
const explicit = process.env.BASE_PATH;
6+
if (explicit != null) return explicit;
7+
const repo = process.env.GITHUB_REPOSITORY?.split('/')?.[1];
8+
return repo ? `/${repo}` : '';
9+
}
10+
411
const config = {
512
preprocess: vitePreprocess(),
613

@@ -13,7 +20,7 @@ const config = {
1320
strict: true
1421
}),
1522
paths: {
16-
base: process.env.BASE_PATH ?? '',
23+
base: resolveBasePath(),
1724
relative: false
1825
},
1926
prerender: {

0 commit comments

Comments
 (0)