Add OAuth flow timeout toggle

This commit is contained in:
initiatione
2026-05-01 11:41:12 +08:00
parent be23c53269
commit 6ae4e0f5a0
13 changed files with 476 additions and 9 deletions
+96
View File
@@ -296,3 +296,99 @@ return {
assert.equal(timeoutMs, 15000);
});
test('oauth timeout budget clamps local timeout when enabled by default', async () => {
const api = new Function(`
const LOG_PREFIX = '[test]';
const OAUTH_FLOW_TIMEOUT_MS = 5 * 60 * 1000;
${extractFunction('buildOAuthFlowTimeoutError')}
${extractFunction('normalizeOAuthFlowDeadlineAt')}
${extractFunction('normalizeOAuthFlowSourceUrl')}
${extractFunction('getOAuthFlowRemainingMs')}
${extractFunction('getOAuthFlowStepTimeoutMs')}
return {
getOAuthFlowStepTimeoutMs,
};
`)();
const timeoutMs = await api.getOAuthFlowStepTimeoutMs(15000, {
step: 8,
actionLabel: '登录验证码流程',
state: {
oauthUrl: 'https://oauth.example/current',
oauthFlowDeadlineAt: Date.now() + 1200,
oauthFlowDeadlineSourceUrl: 'https://oauth.example/current',
},
});
assert(timeoutMs <= 1200);
assert(timeoutMs >= 1000);
});
test('oauth timeout budget disabled mode ignores active deadlines', async () => {
const api = new Function(`
const LOG_PREFIX = '[test]';
const OAUTH_FLOW_TIMEOUT_MS = 5 * 60 * 1000;
${extractFunction('buildOAuthFlowTimeoutError')}
${extractFunction('normalizeOAuthFlowDeadlineAt')}
${extractFunction('normalizeOAuthFlowSourceUrl')}
${extractFunction('getOAuthFlowRemainingMs')}
${extractFunction('getOAuthFlowStepTimeoutMs')}
return {
getOAuthFlowStepTimeoutMs,
};
`)();
const timeoutMs = await api.getOAuthFlowStepTimeoutMs(15000, {
step: 9,
actionLabel: 'OAuth localhost 回调',
state: {
oauthFlowTimeoutEnabled: false,
oauthUrl: 'https://oauth.example/current',
oauthFlowDeadlineAt: Date.now() - 1000,
oauthFlowDeadlineSourceUrl: 'https://oauth.example/current',
},
});
assert.equal(timeoutMs, 15000);
});
test('startOAuthFlowTimeoutWindow clears stale deadline when timeout is disabled', async () => {
const events = {
stateUpdates: [],
logs: [],
};
const api = new Function('events', `
const OAUTH_FLOW_TIMEOUT_MS = 5 * 60 * 1000;
async function getState() {
return {
oauthFlowTimeoutEnabled: false,
oauthFlowDeadlineAt: Date.now() - 1000,
oauthFlowDeadlineSourceUrl: 'https://oauth.example/old',
};
}
async function setState(update) {
events.stateUpdates.push(update);
}
async function addLog(message, level) {
events.logs.push({ message, level });
}
${extractFunction('normalizeOAuthFlowSourceUrl')}
${extractFunction('startOAuthFlowTimeoutWindow')}
return {
startOAuthFlowTimeoutWindow,
};
`)(events);
const result = await api.startOAuthFlowTimeoutWindow({
step: 7,
oauthUrl: 'https://oauth.example/current',
});
assert.equal(result, null);
assert.deepStrictEqual(events.stateUpdates, [{
oauthFlowDeadlineAt: null,
oauthFlowDeadlineSourceUrl: null,
}]);
assert.match(events.logs[0].message, /授权后链总超时已关闭/);
});
@@ -143,3 +143,81 @@ return {
domains: ['mail.example.com'],
});
});
test('oauth flow timeout setting is persisted and normalized as boolean', () => {
const api = new Function(`
const DEFAULT_VERIFICATION_RESEND_COUNT = 4;
const PERSISTED_SETTING_DEFAULTS = {
panelMode: 'cpa',
oauthFlowTimeoutEnabled: true,
autoStepDelaySeconds: null,
verificationResendCount: DEFAULT_VERIFICATION_RESEND_COUNT,
mailProvider: '163',
mail2925Mode: 'provide',
emailGenerator: 'duck',
autoDeleteUsedIcloudAlias: false,
accountRunHistoryTextEnabled: false,
cloudflareTempEmailUseRandomSubdomain: false,
cloudflareTempEmailDomain: '',
cloudflareTempEmailDomains: [],
};
const PERSISTED_SETTING_KEYS = Object.keys(PERSISTED_SETTING_DEFAULTS);
function normalizePanelMode(value) { return value === 'sub2api' ? 'sub2api' : 'cpa'; }
function normalizeLocalCpaStep9Mode(value) { return value === 'bypass' ? 'bypass' : 'submit'; }
function normalizeAutoRunFallbackThreadIntervalMinutes(value) { return Number(value) || 0; }
function normalizeAutoRunDelayMinutes(value) { return Number(value) || 30; }
function normalizeAutoStepDelaySeconds(value, fallback = null) { return value == null || value === '' ? fallback : Number(value); }
function normalizeVerificationResendCount(value, fallback) { return Number.isFinite(Number(value)) ? Number(value) : fallback; }
function normalizeMailProvider(value) { return String(value || '').trim().toLowerCase() || '163'; }
function normalizeMail2925Mode(value) { return String(value || '').trim().toLowerCase() === 'receive' ? 'receive' : 'provide'; }
function normalizeEmailGenerator(value) { return String(value || '').trim().toLowerCase() || 'duck'; }
function normalizeIcloudHost(value) { return ''; }
function normalizeIcloudTargetMailboxType(value) { return String(value || '').trim() || 'icloud-inbox'; }
function normalizeIcloudForwardMailProvider(value) { return String(value || '').trim() || 'qq'; }
function normalizeIcloudFetchMode(value) { return String(value || '').trim() === 'always_new' ? 'always_new' : 'reuse_existing'; }
function normalizeAccountRunHistoryHelperBaseUrl(value) { return String(value || '').trim(); }
function normalizeHotmailServiceMode(value) { return String(value || '').trim().toLowerCase() === 'remote' ? 'remote' : 'local'; }
function normalizeHotmailRemoteBaseUrl(value) { return String(value || '').trim(); }
function normalizeHotmailLocalBaseUrl(value) { return String(value || '').trim(); }
function normalizeLuckmailBaseUrl(value) { return String(value || '').trim(); }
function normalizeLuckmailEmailType(value) { return String(value || '').trim(); }
function normalizeCloudflareDomain(value) { return String(value || '').trim().toLowerCase(); }
function normalizeCloudflareDomains(value) { return Array.isArray(value) ? value.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean) : []; }
function normalizeCustomEmailPool(value) { return Array.isArray(value) ? value : []; }
function normalizeCloudflareTempEmailBaseUrl(value) { return String(value || '').trim(); }
function normalizeCloudflareTempEmailAddress(value = '') { return String(value || '').trim().toLowerCase(); }
function normalizeCloudflareTempEmailReceiveMailbox(value = '') { return String(value || '').trim().toLowerCase(); }
function normalizeCloudflareTempEmailDomain(value) { return String(value || '').trim().toLowerCase(); }
function normalizeCloudflareTempEmailDomains(value) { return Array.isArray(value) ? value.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean) : []; }
function normalizeHotmailAccounts(value) { return Array.isArray(value) ? value : []; }
function normalizeMail2925Accounts(value) { return Array.isArray(value) ? value : []; }
function normalizePayPalAccounts(value) { return Array.isArray(value) ? value : []; }
function normalizeHeroSmsAcquirePriority(value) { return String(value || '').trim() === 'price' ? 'price' : 'country'; }
function normalizeHeroSmsMaxPrice(value) { return String(value || '').trim(); }
function normalizeHeroSmsCountryFallback(value) { return Array.isArray(value) ? value : []; }
function normalizePhoneVerificationReplacementLimit(value) { return Number(value) || 3; }
function normalizePhoneCodeWaitSeconds(value) { return Number(value) || 60; }
function normalizePhoneCodeTimeoutWindows(value) { return Number(value) || 2; }
function normalizePhoneCodePollIntervalSeconds(value) { return Number(value) || 5; }
function normalizePhoneCodePollMaxRounds(value) { return Number(value) || 4; }
function resolveLegacyAutoStepDelaySeconds() { return undefined; }
${extractFunction('normalizePersistentSettingValue')}
${extractFunction('buildPersistentSettingsPayload')}
return {
buildPersistentSettingsPayload,
normalizePersistentSettingValue,
};
`)();
assert.equal(api.normalizePersistentSettingValue('oauthFlowTimeoutEnabled', 0), false);
assert.equal(api.normalizePersistentSettingValue('oauthFlowTimeoutEnabled', 1), true);
assert.deepEqual(api.buildPersistentSettingsPayload({
oauthFlowTimeoutEnabled: false,
}), {
oauthFlowTimeoutEnabled: false,
});
const defaults = api.buildPersistentSettingsPayload({}, { fillDefaults: true });
assert.equal(defaults.oauthFlowTimeoutEnabled, true);
});
@@ -220,6 +220,7 @@ const inputAutoSkipFailuresThreadIntervalMinutes = { value: '5' };
const inputAutoDelayEnabled = { checked: true };
const inputAutoDelayMinutes = { value: '30' };
const inputAutoStepDelaySeconds = { value: '10' };
const inputOAuthFlowTimeoutEnabled = { checked: true };
const inputVerificationResendCount = { value: '6' };
const DEFAULT_VERIFICATION_RESEND_COUNT = 4;
const DEFAULT_PHONE_VERIFICATION_REPLACEMENT_LIMIT = 3;
+2
View File
@@ -128,6 +128,7 @@ const inputAutoSkipFailuresThreadIntervalMinutes = { value: '0' };
const inputAutoDelayEnabled = { checked: false };
const inputAutoDelayMinutes = { value: '30' };
const inputAutoStepDelaySeconds = { value: '' };
const inputOAuthFlowTimeoutEnabled = { checked: true };
const inputVerificationResendCount = { value: '4' };
const DEFAULT_VERIFICATION_RESEND_COUNT = 4;
const DEFAULT_PHONE_VERIFICATION_REPLACEMENT_LIMIT = 3;
@@ -375,6 +376,7 @@ const inputAutoSkipFailuresThreadIntervalMinutes = { value: '' };
const inputAutoDelayEnabled = { checked: false };
const inputAutoDelayMinutes = { value: '' };
const inputAutoStepDelaySeconds = { value: '' };
const inputOAuthFlowTimeoutEnabled = { checked: true };
const inputVerificationResendCount = { value: '' };
const inputPhoneVerificationEnabled = { checked: false };
const DEFAULT_PHONE_VERIFICATION_ENABLED = false;
@@ -199,6 +199,7 @@ const inputAutoSkipFailuresThreadIntervalMinutes = { value: '0' };
const inputAutoDelayEnabled = { checked: false };
const inputAutoDelayMinutes = { value: '30' };
const inputAutoStepDelaySeconds = { value: '' };
const inputOAuthFlowTimeoutEnabled = { checked: true };
const inputVerificationResendCount = { value: '4' };
const DEFAULT_VERIFICATION_RESEND_COUNT = 4;
const DEFAULT_PHONE_VERIFICATION_REPLACEMENT_LIMIT = 3;
@@ -77,6 +77,9 @@ test('sidepanel html exposes phone verification toggle and dedicated HeroSMS row
assert.match(html, /id="row-phone-code-timeout-windows"/);
assert.match(html, /id="row-phone-code-poll-interval-seconds"/);
assert.match(html, /id="row-phone-code-poll-max-rounds"/);
assert.match(html, /id="row-oauth-flow-timeout"/);
assert.match(html, /id="input-oauth-flow-timeout-enabled"/);
assert.match(html, /只取消 Step 7 后链总预算/);
assert.doesNotMatch(html, /id="input-account-run-history-text-enabled"/);
});
@@ -222,6 +225,7 @@ const inputAutoSkipFailuresThreadIntervalMinutes = { value: '0' };
const inputAutoDelayEnabled = { checked: false };
const inputAutoDelayMinutes = { value: '30' };
const inputAutoStepDelaySeconds = { value: '' };
const inputOAuthFlowTimeoutEnabled = { checked: false };
const inputPhoneVerificationEnabled = { checked: true };
const inputVerificationResendCount = { value: '4' };
const inputHeroSmsApiKey = { value: 'demo-key' };
@@ -299,6 +303,7 @@ return { collectSettingsPayload };
const payload = api.collectSettingsPayload();
assert.equal(payload.phoneVerificationEnabled, true);
assert.equal(payload.oauthFlowTimeoutEnabled, false);
assert.equal(payload.accountRunHistoryTextEnabled, true);
assert.equal(payload.accountRunHistoryHelperBaseUrl, 'http://127.0.0.1:17373');
assert.equal(payload.heroSmsApiKey, 'demo-key');
+184
View File
@@ -0,0 +1,184 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const step9ModuleSource = fs.readFileSync('background/steps/confirm-oauth.js', 'utf8');
test('step9 observes disabled oauth timeout while waiting for localhost callback', async () => {
const api = new Function('step9ModuleSource', `
const self = {};
let webNavListener = null;
let webNavCommittedListener = null;
let step8TabUpdatedListener = null;
let step8PendingReject = null;
let cleanupCalls = 0;
let remainingCalls = 0;
let recoveryCalls = 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 getOAuthFlowRemainingMs() {
remainingCalls += 1;
return null;
}
async function recoverOAuthLocalhostTimeout() {
recoveryCalls += 1;
throw new Error('should not recover when timeout has been disabled');
}
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://chatgpt.com/' };
}
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(handler) { step8PendingReject = handler; }
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,
getOAuthFlowRemainingMs,
getOAuthFlowStepTimeoutMs,
getStep8CallbackUrlFromNavigation,
getStep8CallbackUrlFromTabUpdate,
getStep8EffectLabel,
getTabId,
getWebNavCommittedListener,
getWebNavListener,
getStep8TabUpdatedListener,
isTabAlive,
prepareStep8DebuggerClick,
recoverOAuthLocalhostTimeout,
reloadStep8ConsentPage,
reuseOrCreateTab,
setStep8PendingReject,
setStep8TabUpdatedListener,
setWebNavCommittedListener,
setWebNavListener,
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,
remainingCalls,
recoveryCalls,
completePayload,
hasPendingReject: Boolean(step8PendingReject),
};
},
};
`)(step9ModuleSource);
await api.executeStep9({
oauthUrl: 'https://auth.openai.com/original-oauth',
visibleStep: 9,
});
const snapshot = api.snapshot();
assert.equal(snapshot.recoveryCalls, 0);
assert.equal(snapshot.remainingCalls >= 1, true);
assert.equal(snapshot.cleanupCalls >= 1, true);
assert.equal(snapshot.hasPendingReject, false);
assert.deepEqual(snapshot.completePayload, {
step: 9,
payload: {
localhostUrl: 'http://localhost:1455/auth/callback?code=abc&state=xyz',
},
});
});
+9 -1
View File
@@ -11,6 +11,7 @@ let step8TabUpdatedListener = null;
let step8PendingReject = null;
let cleanupCalls = 0;
let timeoutCalls = 0;
let remainingCalls = 0;
let recoveryCalls = 0;
let completePayload = null;
const logs = [];
@@ -25,7 +26,7 @@ const chrome = {
if (typeof webNavListener === 'function') {
webNavListener({ tabId: 123, url: callbackUrl });
}
}, 0);
}, 25);
},
removeListener() {},
},
@@ -107,6 +108,11 @@ async function recoverOAuthLocalhostTimeout(details = {}) {
};
}
async function getOAuthFlowRemainingMs() {
remainingCalls += 1;
return null;
}
function getStep8CallbackUrlFromNavigation(details, signupTabId) {
if (
Number(signupTabId) === Number(details?.tabId)
@@ -163,6 +169,7 @@ const executor = self.MultiPageBackgroundStep9.createStep9Executor({
clickWithDebugger,
completeStepFromBackground,
ensureStep8SignupPageReady,
getOAuthFlowRemainingMs,
getOAuthFlowStepTimeoutMs,
getStep8CallbackUrlFromNavigation,
getStep8CallbackUrlFromTabUpdate,
@@ -197,6 +204,7 @@ return {
return {
cleanupCalls,
timeoutCalls,
remainingCalls,
recoveryCalls,
completePayload,
hasPendingReject: Boolean(step8PendingReject),