diff --git a/background.js b/background.js index 05e6e94..8034ad5 100644 --- a/background.js +++ b/background.js @@ -12042,6 +12042,29 @@ function throwIfStep8SettledOrStopped(isSettled = false) { } } +function isStep9AuthCallbackWaitPageUrl(rawUrl) { + if (!rawUrl) return false; + try { + const parsed = new URL(rawUrl); + const hostname = String(parsed.hostname || '').toLowerCase(); + if (!['auth.openai.com', 'auth0.openai.com', 'accounts.openai.com'].includes(hostname)) { + return false; + } + const pathname = String(parsed.pathname || ''); + return /\/api\/oauth\/oauth2\/auth(?:[/?#]|$)/i.test(pathname) + || /\/oauth\/oauth2\/auth(?:[/?#]|$)/i.test(pathname); + } catch { + return false; + } +} + +async function shouldDeferStep9CallbackTimeout(details = {}) { + const tabId = details?.tabId; + if (!Number.isInteger(tabId)) return false; + const tab = await chrome.tabs.get(tabId).catch(() => null); + return isStep9AuthCallbackWaitPageUrl(tab?.url || ''); +} + async function ensureStep8SignupPageReady(tabId, options = {}) { const visibleStep = Math.floor(Number(options.visibleStep || options.logStep || options.step) || 0); await ensureContentScriptReadyOnTab('signup-page', tabId, { @@ -12496,6 +12519,7 @@ const step9Executor = self.MultiPageBackgroundStep9?.createStep9Executor({ setStep8TabUpdatedListener, setWebNavCommittedListener, setWebNavListener, + shouldDeferStep9CallbackTimeout, sleepWithStop, STEP8_CLICK_RETRY_DELAY_MS, STEP8_MAX_ROUNDS, diff --git a/background/steps/confirm-oauth.js b/background/steps/confirm-oauth.js index a2cff0c..52fe3ce 100644 --- a/background/steps/confirm-oauth.js +++ b/background/steps/confirm-oauth.js @@ -33,6 +33,7 @@ setWebNavCommittedListener, setStep8PendingReject, setStep8TabUpdatedListener, + shouldDeferStep9CallbackTimeout, } = deps; const LOCALHOST_CALLBACK_LOCAL_TIMEOUT_MS = 240000; @@ -96,6 +97,7 @@ let signupTabId = null; const callbackWaitStartedAt = Date.now(); let timeoutCheckTimer = null; + let timeoutDeferredLogged = false; const cleanupListener = () => { if (timeoutCheckTimer) { @@ -128,11 +130,46 @@ }); }; + const isCallbackTimeoutDeferred = async (elapsedMs) => { + if (typeof shouldDeferStep9CallbackTimeout !== 'function') { + return false; + } + try { + const deferred = await shouldDeferStep9CallbackTimeout({ + tabId: signupTabId, + visibleStep, + elapsedMs, + oauthUrl: activeState?.oauthUrl || '', + }); + if (deferred && !timeoutDeferredLogged) { + timeoutDeferredLogged = true; + await addStepLog( + visibleStep, + '检测到认证页仍在安全验证/授权跳转中,暂停本地回调超时判定,继续等待 localhost 回调...', + 'info' + ); + } + return Boolean(deferred); + } catch (error) { + await addStepLog( + visibleStep, + `复核认证页跳转状态失败(${error?.message || error}),继续按原超时规则等待回调。`, + 'warn' + ); + return false; + } + }; + const checkCallbackTimeout = async () => { if (resolved) { return; } const elapsedMs = Date.now() - callbackWaitStartedAt; + if (await isCallbackTimeoutDeferred(elapsedMs)) { + timeoutCheckTimer = setTimeout(checkCallbackTimeout, CALLBACK_TIMEOUT_CHECK_INTERVAL_MS); + return; + } + if (elapsedMs >= LOCALHOST_CALLBACK_LOCAL_TIMEOUT_MS) { rejectStep9(new Error(`${Math.round(LOCALHOST_CALLBACK_LOCAL_TIMEOUT_MS / 1000)} 秒内未捕获到 localhost 回调跳转,步骤 ${visibleStep} 的点击可能被拦截了。`)); return; diff --git a/tests/step9-oauth-timeout-toggle.test.js b/tests/step9-oauth-timeout-toggle.test.js index 00803a1..62a4a70 100644 --- a/tests/step9-oauth-timeout-toggle.test.js +++ b/tests/step9-oauth-timeout-toggle.test.js @@ -182,3 +182,175 @@ return { }, }); }); + +test('step9 defers callback timeout while auth security verification is still redirecting', async () => { + const api = new Function('step9ModuleSource', ` +const self = {}; +let webNavListener = null; +let webNavCommittedListener = null; +let step8TabUpdatedListener = null; +let cleanupCalls = 0; +let deferCalls = 0; +let completePayload = null; +const callbackUrl = 'http://localhost:1455/auth/callback?code=abc&state=xyz'; + +const chrome = { + webNavigation: { + onBeforeNavigate: { + addListener(listener) { + webNavListener = listener; + setTimeout(() => { + if (typeof webNavListener === 'function') { + webNavListener({ tabId: 123, url: callbackUrl }); + } + }, 25); + }, + removeListener() {}, + }, + onCommitted: { + addListener(listener) { + webNavCommittedListener = listener; + }, + removeListener() {}, + }, + }, + tabs: { + onUpdated: { + addListener(listener) { + step8TabUpdatedListener = listener; + }, + removeListener() {}, + }, + async update() {}, + }, +}; + +async function addLog() {} +function cleanupStep8NavigationListeners() { + cleanupCalls += 1; + webNavListener = null; + webNavCommittedListener = null; + step8TabUpdatedListener = null; +} +function throwIfStep8SettledOrStopped(resolved) { + if (resolved) throw new Error('already resolved'); +} +async function getTabId() { return 123; } +async function isTabAlive() { return true; } +async function ensureStep8SignupPageReady() {} +async function getOAuthFlowStepTimeoutMs(defaultTimeoutMs, options = {}) { + if (options.actionLabel === 'OAuth localhost 回调') { + return 1; + } + return defaultTimeoutMs; +} +async function shouldDeferStep9CallbackTimeout(details = {}) { + deferCalls += 1; + return Number(details.tabId) === 123; +} +async function waitForStep8Ready() { + return { + consentReady: true, + url: 'https://auth.openai.com/sign-in-with-chatgpt/codex/consent', + }; +} +async function triggerStep8ContentStrategy() {} +async function waitForStep8ClickEffect() { + return { + progressed: true, + reason: 'url_changed', + url: 'https://auth.openai.com/api/oauth/oauth2/auth?client_id=app_test', + }; +} +function getStep8CallbackUrlFromNavigation(details, signupTabId) { + return Number(details?.tabId) === Number(signupTabId) ? details.url : ''; +} +function getStep8CallbackUrlFromTabUpdate() { return ''; } +function getStep8EffectLabel() { return 'URL 已变化'; } +async function prepareStep8DebuggerClick() { return { rect: { centerX: 10, centerY: 10 } }; } +async function clickWithDebugger() {} +async function reloadStep8ConsentPage() {} +async function reuseOrCreateTab() { return 123; } +async function sleepWithStop() {} +function setWebNavListener(listener) { webNavListener = listener; } +function getWebNavListener() { return webNavListener; } +function setWebNavCommittedListener(listener) { webNavCommittedListener = listener; } +function getWebNavCommittedListener() { return webNavCommittedListener; } +function setStep8TabUpdatedListener(listener) { step8TabUpdatedListener = listener; } +function getStep8TabUpdatedListener() { return step8TabUpdatedListener; } +function setStep8PendingReject() {} +async function completeStepFromBackground(step, payload) { + completePayload = { step, payload }; +} + +const STEP8_CLICK_RETRY_DELAY_MS = 1; +const STEP8_READY_WAIT_TIMEOUT_MS = 5; +const STEP8_MAX_ROUNDS = 1; +const STEP8_STRATEGIES = [ + { mode: 'content', strategy: 'requestSubmit', label: 'form.requestSubmit' }, +]; + +${step9ModuleSource} + +const executor = self.MultiPageBackgroundStep9.createStep9Executor({ + addLog, + chrome, + cleanupStep8NavigationListeners, + clickWithDebugger, + completeStepFromBackground, + ensureStep8SignupPageReady, + getOAuthFlowStepTimeoutMs, + getStep8CallbackUrlFromNavigation, + getStep8CallbackUrlFromTabUpdate, + getStep8EffectLabel, + getTabId, + getWebNavCommittedListener, + getWebNavListener, + getStep8TabUpdatedListener, + isTabAlive, + prepareStep8DebuggerClick, + reloadStep8ConsentPage, + reuseOrCreateTab, + setStep8PendingReject, + setStep8TabUpdatedListener, + setWebNavCommittedListener, + setWebNavListener, + shouldDeferStep9CallbackTimeout, + sleepWithStop, + STEP8_CLICK_RETRY_DELAY_MS, + STEP8_MAX_ROUNDS, + STEP8_READY_WAIT_TIMEOUT_MS, + STEP8_STRATEGIES, + throwIfStep8SettledOrStopped, + triggerStep8ContentStrategy, + waitForStep8ClickEffect, + waitForStep8Ready, +}); + +return { + executeStep9: executor.executeStep9, + snapshot() { + return { + cleanupCalls, + deferCalls, + completePayload, + }; + }, +}; +`)(step9ModuleSource); + + await api.executeStep9({ + oauthUrl: 'https://auth.openai.com/original-oauth', + visibleStep: 12, + }); + + const snapshot = api.snapshot(); + assert.equal(snapshot.deferCalls >= 1, true); + assert.equal(snapshot.cleanupCalls >= 1, true); + assert.deepEqual(snapshot.completePayload, { + step: 12, + payload: { + localhostUrl: 'http://localhost:1455/auth/callback?code=abc&state=xyz', + }, + }); +});