fix: harden CPA auth flow with API callback/state checks

Move CPA oauth url/callback handling to management API first, persist state/origin runtime keys, and fail fast on invalid callback or missing management auth to reduce flaky panel-driven auth failures.
This commit is contained in:
cnxwzy
2026-04-27 19:13:22 +08:00
parent c98cfd7053
commit f9f8df19d3
12 changed files with 911 additions and 2294 deletions
+19 -30
View File
@@ -33,27 +33,16 @@
setStep8TabUpdatedListener,
} = deps;
function getVisibleStep(state, fallback = 9) {
const visibleStep = Math.floor(Number(state?.visibleStep) || 0);
return visibleStep > 0 ? visibleStep : fallback;
}
function getAuthLoginStepForVisibleStep(visibleStep) {
return visibleStep >= 12 ? 10 : 7;
}
async function executeStep9(state) {
const visibleStep = getVisibleStep(state, 9);
if (!state.oauthUrl) {
const authLoginStep = getAuthLoginStepForVisibleStep(visibleStep);
throw new Error(`缺少登录用 OAuth 链接,请先完成步骤 ${authLoginStep}`);
throw new Error('缺少登录用 OAuth 链接,请先完成步骤 7。');
}
await addLog(`步骤 ${visibleStep}:正在监听 localhost 回调地址...`);
await addLog('步骤 9:正在监听 localhost 回调地址...');
const callbackTimeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function'
? await getOAuthFlowStepTimeoutMs(240000, {
step: visibleStep,
step: 9,
actionLabel: 'OAuth localhost 回调',
})
: 240000;
@@ -82,8 +71,8 @@
cleanupListener();
clearTimeout(timeout);
addLog(`步骤 ${visibleStep}:已捕获 localhost 地址:${callbackUrl}`, 'ok').then(() => {
return completeStepFromBackground(visibleStep, { localhostUrl: callbackUrl });
addLog(`步骤 9:已捕获 localhost 地址:${callbackUrl}`, 'ok').then(() => {
return completeStepFromBackground(9, { localhostUrl: callbackUrl });
}).then(() => {
resolve();
}).catch((err) => {
@@ -92,7 +81,7 @@
};
const timeout = setTimeout(() => {
rejectStep9(new Error(`${Math.round(callbackTimeoutMs / 1000)} 秒内未捕获到 localhost 回调跳转,步骤 ${visibleStep} 的点击可能被拦截了。`));
rejectStep9(new Error('120 秒内未捕获到 localhost 回调跳转,步骤 9 的点击可能被拦截了。'));
}, callbackTimeoutMs);
setStep8PendingReject((error) => {
@@ -122,10 +111,10 @@
if (signupTabId && await isTabAlive('signup-page')) {
await chrome.tabs.update(signupTabId, { active: true });
await addLog(`步骤 ${visibleStep}:已切回认证页,正在准备调试器点击...`);
await addLog('步骤 9:已切回认证页,正在准备调试器点击...');
} else {
signupTabId = await reuseOrCreateTab('signup-page', state.oauthUrl);
await addLog(`步骤 ${visibleStep}:已重新打开认证页,正在准备调试器点击...`);
await addLog('步骤 9:已重新打开认证页,正在准备调试器点击...');
}
throwIfStep8SettledOrStopped(resolved);
@@ -135,11 +124,11 @@
await ensureStep8SignupPageReady(signupTabId, {
timeoutMs: typeof getOAuthFlowStepTimeoutMs === 'function'
? await getOAuthFlowStepTimeoutMs(15000, {
step: visibleStep,
step: 9,
actionLabel: '等待 OAuth 同意页内容脚本就绪',
})
: 15000,
logMessage: `步骤 ${visibleStep}:认证页内容脚本尚未就绪,正在等待页面恢复...`,
logMessage: '步骤 9:认证页内容脚本尚未就绪,正在等待页面恢复...',
});
for (let round = 1; round <= STEP8_MAX_ROUNDS && !resolved; round++) {
@@ -148,7 +137,7 @@
signupTabId,
typeof getOAuthFlowStepTimeoutMs === 'function'
? await getOAuthFlowStepTimeoutMs(STEP8_READY_WAIT_TIMEOUT_MS, {
step: visibleStep,
step: 9,
actionLabel: '等待 OAuth 同意页出现',
})
: STEP8_READY_WAIT_TIMEOUT_MS
@@ -160,12 +149,12 @@
const strategy = STEP8_STRATEGIES[Math.min(round - 1, STEP8_STRATEGIES.length - 1)];
await addLog(`步骤 ${visibleStep}:第 ${round}/${STEP8_MAX_ROUNDS} 轮尝试点击“继续”(${strategy.label}...`);
await addLog(`步骤 9:第 ${round}/${STEP8_MAX_ROUNDS} 轮尝试点击“继续”(${strategy.label}...`);
if (strategy.mode === 'debugger') {
const clickActionTimeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function'
? await getOAuthFlowStepTimeoutMs(15000, {
step: visibleStep,
step: 9,
actionLabel: '定位 OAuth 同意页继续按钮',
})
: 15000;
@@ -178,7 +167,7 @@
} else {
const clickActionTimeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function'
? await getOAuthFlowStepTimeoutMs(15000, {
step: visibleStep,
step: 9,
actionLabel: '点击 OAuth 同意页继续按钮',
})
: 15000;
@@ -197,7 +186,7 @@
pageState.url,
typeof getOAuthFlowStepTimeoutMs === 'function'
? await getOAuthFlowStepTimeoutMs(15000, {
step: visibleStep,
step: 9,
actionLabel: '等待 OAuth 同意页点击生效',
})
: 15000
@@ -207,20 +196,20 @@
}
if (effect.progressed) {
await addLog(`步骤 ${visibleStep}:检测到本次点击已生效,${getStep8EffectLabel(effect)},继续等待 localhost 回调...`, 'info');
await addLog(`步骤 9:检测到本次点击已生效,${getStep8EffectLabel(effect)},继续等待 localhost 回调...`, 'info');
break;
}
if (round >= STEP8_MAX_ROUNDS) {
throw new Error(`步骤 ${visibleStep}:连续 ${STEP8_MAX_ROUNDS} 轮点击“继续”后页面仍无反应。`);
throw new Error(`步骤 9:连续 ${STEP8_MAX_ROUNDS} 轮点击“继续”后页面仍无反应。`);
}
await addLog(`步骤 ${visibleStep}${strategy.label} 本轮点击后页面无反应,正在刷新认证页后重试(下一轮 ${round + 1}/${STEP8_MAX_ROUNDS}...`, 'warn');
await addLog(`步骤 9${strategy.label} 本轮点击后页面无反应,正在刷新认证页后重试(下一轮 ${round + 1}/${STEP8_MAX_ROUNDS}...`, 'warn');
await reloadStep8ConsentPage(
signupTabId,
typeof getOAuthFlowStepTimeoutMs === 'function'
? await getOAuthFlowStepTimeoutMs(30000, {
step: visibleStep,
step: 9,
actionLabel: '刷新 OAuth 同意页',
})
: 30000
+11 -25
View File
@@ -40,13 +40,7 @@
return /缺少|未配置|请输入|无效|错误|失败|401|认证失败|未授权|unauthorized|invalid/i.test(message);
}
function getVisibleStep(state, fallback = 7) {
const visibleStep = Math.floor(Number(state?.visibleStep) || 0);
return visibleStep > 0 ? visibleStep : fallback;
}
async function executeStep7(state) {
const visibleStep = getVisibleStep(state, 7);
if (!state.email) {
throw new Error('缺少邮箱地址,请先完成步骤 3。');
}
@@ -60,22 +54,22 @@
try {
const currentState = attempt === 1 ? state : await getState();
const password = currentState.password || currentState.customPassword || '';
const oauthUrl = await refreshOAuthUrlBeforeStep6(currentState, { visibleStep });
const oauthUrl = await refreshOAuthUrlBeforeStep6(currentState);
if (typeof startOAuthFlowTimeoutWindow === 'function') {
await startOAuthFlowTimeoutWindow({ step: visibleStep, oauthUrl });
await startOAuthFlowTimeoutWindow({ step: 7, oauthUrl });
}
const loginTimeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function'
? await getOAuthFlowStepTimeoutMs(180000, {
step: visibleStep,
step: 7,
actionLabel: 'OAuth 登录并进入验证码页',
oauthUrl,
})
: 180000;
if (attempt === 1) {
await addLog(`步骤 ${visibleStep}:正在打开最新 OAuth 链接并登录...`);
await addLog('步骤 7:正在打开最新 OAuth 链接并登录...');
} else {
await addLog(`步骤 ${visibleStep}:上一轮失败后,正在进行第 ${attempt} 次尝试(最多 ${STEP6_MAX_ATTEMPTS} 次)...`, 'warn');
await addLog(`步骤 7:上一轮失败后,正在进行第 ${attempt} 次尝试(最多 ${STEP6_MAX_ATTEMPTS} 次)...`, 'warn');
}
await reuseOrCreateTab('signup-page', oauthUrl);
@@ -89,14 +83,13 @@
payload: {
email: currentState.email,
password,
visibleStep,
},
},
{
timeoutMs: loginTimeoutMs,
responseTimeoutMs: loginTimeoutMs,
retryDelayMs: 700,
logMessage: `步骤 ${visibleStep}:认证页正在切换,等待页面重新就绪后继续登录...`,
logMessage: '步骤 7:认证页正在切换,等待页面重新就绪后继续登录...',
}
);
@@ -105,16 +98,9 @@
}
if (isStep6SuccessResult(result)) {
const completionPayload = {
await completeStepFromBackground(7, {
loginVerificationRequestedAt: result.loginVerificationRequestedAt || null,
};
if (result.skipLoginVerificationStep) {
completionPayload.skipLoginVerificationStep = true;
}
if (result.directOAuthConsentPage) {
completionPayload.directOAuthConsentPage = true;
}
await completeStepFromBackground(visibleStep, completionPayload);
});
return;
}
@@ -132,7 +118,7 @@
}
if (isManagementSecretConfigError(err)) {
await addLog(
`步骤 ${visibleStep}:检测到来源后台管理密钥缺失或错误,不再重试,当前流程停止。原因:${getErrorMessage(err)}`,
`步骤 7:检测到来源后台管理密钥缺失或错误,不再重试,当前流程停止。原因:${getErrorMessage(err)}`,
'error'
);
throw err;
@@ -142,11 +128,11 @@
break;
}
await addLog(`步骤 ${visibleStep}:第 ${attempt} 次尝试失败,原因:${getErrorMessage(err)};准备重试...`, 'warn');
await addLog(`步骤 7:第 ${attempt} 次尝试失败,原因:${getErrorMessage(err)};准备重试...`, 'warn');
}
}
throw new Error(`步骤 ${visibleStep}:判断失败后已重试 ${STEP6_MAX_ATTEMPTS - 1} 次,仍未成功。最后原因:${getErrorMessage(lastError)}`);
throw new Error(`步骤 7:判断失败后已重试 ${STEP6_MAX_ATTEMPTS - 1} 次,仍未成功。最后原因:${getErrorMessage(lastError)}`);
}
return { executeStep7 };
+122 -76
View File
@@ -26,31 +26,18 @@
return String(value || '').trim();
}
function getVisibleStep(state, fallback = 10) {
const visibleStep = Math.floor(Number(state?.visibleStep) || 0);
return visibleStep > 0 ? visibleStep : fallback;
}
function getConfirmStepForVisibleStep(visibleStep) {
return visibleStep >= 13 ? 12 : 9;
}
function getAuthLoginStepForVisibleStep(visibleStep) {
return visibleStep >= 13 ? 10 : 7;
}
function parseLocalhostCallback(rawUrl, visibleStep = 10, confirmStep = 9) {
function parseLocalhostCallback(rawUrl) {
let parsed;
try {
parsed = new URL(rawUrl);
} catch {
throw new Error(`步骤 ${visibleStep} 捕获到的 localhost OAuth 回调地址格式无效,请重新执行步骤 ${confirmStep}`);
throw new Error('步骤 10 捕获到的 localhost OAuth 回调地址格式无效,请重新执行步骤 9。');
}
const code = normalizeString(parsed.searchParams.get('code'));
const state = normalizeString(parsed.searchParams.get('state'));
if (!code || !state) {
throw new Error(`步骤 ${visibleStep} 捕获到的 localhost OAuth 回调地址缺少 code 或 state,请重新执行步骤 ${confirmStep}`);
throw new Error('步骤 10 捕获到的 localhost OAuth 回调地址缺少 code 或 state,请重新执行步骤 9。');
}
return {
@@ -72,6 +59,77 @@
return details || `Codex2API 请求失败(HTTP ${responseStatus})。`;
}
function deriveCpaManagementOrigin(vpsUrl) {
const normalizedUrl = normalizeString(vpsUrl);
if (!normalizedUrl) {
throw new Error('尚未填写 CPA 地址,请先在侧边栏输入。');
}
let parsed;
try {
parsed = new URL(normalizedUrl);
} catch {
throw new Error('CPA 地址格式无效,请先在侧边栏检查。');
}
return parsed.origin;
}
function getCpaApiErrorMessage(payload, responseStatus = 500) {
const details = [
payload?.error,
payload?.message,
payload?.detail,
payload?.reason,
]
.map((value) => normalizeString(value))
.find(Boolean);
return details || `CPA 管理接口请求失败(HTTP ${responseStatus})。`;
}
async function fetchCpaManagementJson(origin, path, options = {}) {
const controller = new AbortController();
const timeoutMs = Math.max(1000, Math.floor(Number(options.timeoutMs) || 20000));
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const managementKey = normalizeString(options.managementKey);
const headers = {
Accept: 'application/json',
'Content-Type': 'application/json',
};
if (managementKey) {
headers.Authorization = `Bearer ${managementKey}`;
headers['X-Management-Key'] = managementKey;
}
const response = await fetch(`${origin}${path}`, {
method: options.method || 'POST',
headers,
body: options.body === undefined ? undefined : JSON.stringify(options.body),
signal: controller.signal,
});
let payload = {};
try {
payload = await response.json();
} catch {
payload = {};
}
if (!response.ok) {
throw new Error(getCpaApiErrorMessage(payload, response.status));
}
return payload;
} catch (error) {
if (error?.name === 'AbortError') {
throw new Error('CPA 管理接口请求超时,请稍后重试。');
}
throw error;
} finally {
clearTimeout(timer);
}
}
async function fetchCodex2ApiJson(origin, path, options = {}) {
const controller = new AbortController();
const timeoutMs = Math.max(1000, Math.floor(Number(options.timeoutMs) || 30000));
@@ -122,95 +180,86 @@
}
async function executeCpaStep10(state) {
const visibleStep = getVisibleStep(state, 10);
const confirmStep = getConfirmStepForVisibleStep(visibleStep);
if (state.localhostUrl && !isLocalhostOAuthCallbackUrl(state.localhostUrl)) {
throw new Error(`步骤 ${confirmStep} 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 ${confirmStep}`);
throw new Error('步骤 9 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 9。');
}
if (!state.localhostUrl) {
throw new Error(`缺少 localhost 回调地址,请先完成步骤 ${confirmStep}`);
throw new Error('缺少 localhost 回调地址,请先完成步骤 9。');
}
if (!state.vpsUrl) {
throw new Error('尚未填写 CPA 地址,请先在侧边栏输入。');
}
if (shouldBypassStep9ForLocalCpa(state)) {
await addLog(`步骤 ${visibleStep}:检测到本地 CPA,且当前策略为“跳过第10步”,本轮不再重复提交回调地址。`, 'info');
await completeStepFromBackground(visibleStep, {
await addLog('步骤 10:检测到本地 CPA,且当前策略为“跳过第10步”,本轮不再重复提交回调地址。', 'info');
await completeStepFromBackground(10, {
localhostUrl: state.localhostUrl,
verifiedStatus: 'local-auto',
});
return;
}
await addLog(`步骤 ${visibleStep}:正在打开 CPA 面板...`);
const injectFiles = ['content/activation-utils.js', 'content/utils.js', 'content/vps-panel.js'];
let tabId = await getTabId('vps-panel');
const alive = tabId && await isTabAlive('vps-panel');
if (!alive) {
tabId = await reuseOrCreateTab('vps-panel', state.vpsUrl, {
inject: injectFiles,
reloadIfSameUrl: true,
});
} else {
await closeConflictingTabsForSource('vps-panel', state.vpsUrl, { excludeTabIds: [tabId] });
await chrome.tabs.update(tabId, { active: true });
await rememberSourceLastUrl('vps-panel', state.vpsUrl);
const callback = parseLocalhostCallback(state.localhostUrl);
const expectedState = normalizeString(state.cpaOAuthState);
if (expectedState && expectedState !== callback.state) {
throw new Error('CPA 回调 state 与当前授权会话不匹配,请重新执行步骤 7。');
}
const managementKey = normalizeString(state.vpsPassword);
if (!managementKey) {
throw new Error('尚未配置 CPA 管理密钥,请先在侧边栏填写。');
}
await ensureContentScriptReadyOnTab('vps-panel', tabId, {
inject: injectFiles,
timeoutMs: 45000,
retryDelayMs: 900,
logMessage: `步骤 ${visibleStep}:CPA 面板仍在加载,正在重试连接...`,
});
await addLog('步骤 10:正在通过 CPA 管理接口提交回调地址...');
try {
const origin = normalizeString(state.cpaManagementOrigin) || deriveCpaManagementOrigin(state.vpsUrl);
const result = await fetchCpaManagementJson(origin, '/v0/management/oauth-callback', {
method: 'POST',
managementKey,
body: {
provider: 'codex',
redirect_url: callback.url,
},
});
await addLog(`步骤 ${visibleStep}:正在填写回调地址...`);
const result = await sendToContentScriptResilient('vps-panel', {
type: 'EXECUTE_STEP',
step: visibleStep,
source: 'background',
payload: { localhostUrl: state.localhostUrl, vpsPassword: state.vpsPassword, visibleStep },
}, {
timeoutMs: 125000,
responseTimeoutMs: 125000,
retryDelayMs: 700,
logMessage: `步骤 ${visibleStep}:CPA 面板通信未就绪,正在等待页面恢复...`,
});
if (result?.error) {
throw new Error(result.error);
const verifiedStatus = normalizeString(result?.message)
|| normalizeString(result?.status_message)
|| 'CPA 已通过接口提交回调';
await addLog(`步骤 10${verifiedStatus}`, 'ok');
await completeStepFromBackground(10, {
localhostUrl: callback.url,
verifiedStatus,
});
} catch (error) {
const reason = normalizeString(error?.message) || 'unknown error';
await addLog(`步骤 10CPA 接口提交失败:${reason}`, 'error');
throw error;
}
}
async function executeCodex2ApiStep10(state) {
const visibleStep = getVisibleStep(state, 10);
const confirmStep = getConfirmStepForVisibleStep(visibleStep);
if (state.localhostUrl && !isLocalhostOAuthCallbackUrl(state.localhostUrl)) {
throw new Error(`步骤 ${confirmStep} 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 ${confirmStep}`);
throw new Error('步骤 9 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 9。');
}
if (!state.localhostUrl) {
throw new Error(`缺少 localhost 回调地址,请先完成步骤 ${confirmStep}`);
throw new Error('缺少 localhost 回调地址,请先完成步骤 9。');
}
if (!state.codex2apiSessionId) {
throw new Error(`缺少 Codex2API 会话信息,请重新执行步骤 ${getAuthLoginStepForVisibleStep(visibleStep)}`);
throw new Error('缺少 Codex2API 会话信息,请重新执行步骤 7。');
}
if (!normalizeString(state.codex2apiAdminKey)) {
throw new Error('尚未配置 Codex2API 管理密钥,请先在侧边栏填写。');
}
const callback = parseLocalhostCallback(state.localhostUrl, visibleStep, confirmStep);
const callback = parseLocalhostCallback(state.localhostUrl);
const expectedState = normalizeString(state.codex2apiOAuthState);
if (expectedState && expectedState !== callback.state) {
throw new Error(`Codex2API 回调 state 与当前授权会话不匹配,请重新执行步骤 ${getAuthLoginStepForVisibleStep(visibleStep)}`);
throw new Error('Codex2API 回调 state 与当前授权会话不匹配,请重新执行步骤 7。');
}
const codex2apiUrl = normalizeCodex2ApiUrl(state.codex2apiUrl);
const origin = new URL(codex2apiUrl).origin;
await addLog(`步骤 ${visibleStep}:正在向 Codex2API 提交回调并创建账号...`);
await addLog('步骤 10:正在向 Codex2API 提交回调并创建账号...');
const result = await fetchCodex2ApiJson(origin, '/api/admin/oauth/exchange-code', {
adminKey: state.codex2apiAdminKey,
method: 'POST',
@@ -222,21 +271,19 @@
});
const verifiedStatus = normalizeString(result?.message) || 'Codex2API OAuth 账号添加成功';
await addLog(`步骤 ${visibleStep}${verifiedStatus}`, 'ok');
await completeStepFromBackground(visibleStep, {
await addLog(`步骤 10${verifiedStatus}`, 'ok');
await completeStepFromBackground(10, {
localhostUrl: callback.url,
verifiedStatus,
});
}
async function executeSub2ApiStep10(state) {
const visibleStep = getVisibleStep(state, 10);
const confirmStep = getConfirmStepForVisibleStep(visibleStep);
if (state.localhostUrl && !isLocalhostOAuthCallbackUrl(state.localhostUrl)) {
throw new Error(`步骤 ${confirmStep} 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 ${confirmStep}`);
throw new Error('步骤 9 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 9。');
}
if (!state.localhostUrl) {
throw new Error(`缺少 localhost 回调地址,请先完成步骤 ${confirmStep}`);
throw new Error('缺少 localhost 回调地址,请先完成步骤 9。');
}
if (!state.sub2apiSessionId) {
throw new Error('缺少 SUB2API 会话信息,请重新执行步骤 1。');
@@ -251,7 +298,7 @@
const sub2apiUrl = normalizeSub2ApiUrl(state.sub2apiUrl);
const injectFiles = ['content/utils.js', 'content/sub2api-panel.js'];
await addLog(`步骤 ${visibleStep}:正在打开 SUB2API 后台...`);
await addLog('步骤 10:正在打开 SUB2API 后台...');
let tabId = await getTabId('sub2api-panel');
const alive = tabId && await isTabAlive('sub2api-panel');
@@ -273,13 +320,12 @@
injectSource: 'sub2api-panel',
});
await addLog(`步骤 ${visibleStep}:正在向 SUB2API 提交回调并创建账号...`);
await addLog('步骤 10:正在向 SUB2API 提交回调并创建账号...');
const result = await sendToContentScript('sub2api-panel', {
type: 'EXECUTE_STEP',
step: visibleStep,
step: 10,
source: 'background',
payload: {
visibleStep,
localhostUrl: state.localhostUrl,
sub2apiUrl,
sub2apiEmail: state.sub2apiEmail,