diff --git a/background.js b/background.js index 7b7cb7f..7b71a1d 100644 --- a/background.js +++ b/background.js @@ -1056,6 +1056,74 @@ function getAuthChainStartStepId(state = {}) { return isPlusModeState(state) ? 10 : FINAL_OAUTH_CHAIN_START_STEP; } +function normalizeAuthRecoveryIdentifierType(value = '') { + const normalized = String(value || '').trim().toLowerCase(); + return normalized === 'phone' || normalized === 'email' ? normalized : ''; +} + +function isPhoneSignupAuthRecoveryState(state = {}) { + const signupMethod = String(state?.resolvedSignupMethod || state?.signupMethod || '').trim().toLowerCase(); + return signupMethod === SIGNUP_METHOD_PHONE || signupMethod === 'phone'; +} + +function getPhoneSignupAuthRecoveryIdentity(state = {}) { + const accountIdentifierType = normalizeAuthRecoveryIdentifierType(state?.accountIdentifierType); + const phoneNumber = String( + state?.signupPhoneNumber + || state?.signupPhoneCompletedActivation?.phoneNumber + || state?.signupPhoneActivation?.phoneNumber + || (accountIdentifierType === 'phone' ? state?.accountIdentifier : '') + || '' + ).trim(); + if (!phoneNumber) { + return null; + } + return { + accountIdentifierType: 'phone', + accountIdentifier: phoneNumber, + signupPhoneNumber: phoneNumber, + signupPhoneCompletedActivation: state?.signupPhoneCompletedActivation || null, + signupPhoneActivation: state?.signupPhoneActivation || null, + }; +} + +function isBoundEmailReloginAuthRecoveryNode(nodeId = '') { + return [ + 'relogin-bound-email', + 'fetch-bound-email-login-code', + 'post-bound-email-phone-verification', + ].includes(String(nodeId || '').trim()); +} + +function buildAuthLoginRecoveryState(initialState = {}, authLoginNodeId = 'oauth-login') { + const nodeId = String(authLoginNodeId || '').trim() || 'oauth-login'; + const isBoundEmailRelogin = isBoundEmailReloginAuthRecoveryNode(nodeId); + if (isBoundEmailRelogin) { + return { + ...initialState, + authLoginPhase: 'bound-email-relogin', + }; + } + + const phoneIdentity = isPhoneSignupAuthRecoveryState(initialState) + ? getPhoneSignupAuthRecoveryIdentity(initialState) + : null; + if (!phoneIdentity) { + return { + ...initialState, + authLoginPhase: 'primary-login', + }; + } + + return { + ...initialState, + ...phoneIdentity, + authLoginPhase: 'primary-login', + forceLoginIdentifierType: 'phone', + forceEmailLogin: false, + }; +} + function getStepDefinitionForState(step, state = {}) { const numericStep = Number(step); return getStepDefinitionsForState(state).find((definition) => Number(definition.id) === numericStep) || null; @@ -15140,6 +15208,7 @@ async function rerunStep7ForStep8Recovery(options = {}) { ? getAuthChainStartStepId(initialState) : FINAL_OAUTH_CHAIN_START_STEP; const authLoginNodeId = getNodeIdByStepForState(authLoginStep, initialState) || 'oauth-login'; + const recoveryState = buildAuthLoginRecoveryState(initialState, authLoginNodeId); await addLog(logMessage, 'warn', { step: logStep, stepKey: logStepKey, @@ -15149,8 +15218,9 @@ async function rerunStep7ForStep8Recovery(options = {}) { try { await step7Executor.executeStep7({ - ...initialState, + ...recoveryState, visibleStep: authLoginStep, + nodeId: authLoginNodeId, }); } catch (err) { const latestState = await getState(); diff --git a/flows/openai/background/steps/oauth-login.js b/flows/openai/background/steps/oauth-login.js index 63098dd..b627b9d 100644 --- a/flows/openai/background/steps/oauth-login.js +++ b/flows/openai/background/steps/oauth-login.js @@ -50,13 +50,42 @@ return String(value || '').trim().toLowerCase() === 'phone' ? 'phone' : 'email'; } + function isStep7BoundEmailReloginContext(state = {}) { + const nodeId = String( + state?.nodeId + || state?.stepKey + || state?.nodeDefinition?.key + || state?.stepDefinition?.key + || '' + ).trim(); + const phase = String(state?.authLoginPhase || '').trim(); + return nodeId === 'relogin-bound-email' || phase === 'bound-email-relogin'; + } + + function resolveForcedStep7IdentifierType(state = {}) { + const forcedIdentifierType = normalizeStep7IdentifierType(state?.forceLoginIdentifierType); + if (forcedIdentifierType === 'phone') { + return 'phone'; + } + if (isStep7BoundEmailReloginContext(state)) { + if (forcedIdentifierType === 'email' || Boolean(state?.forceEmailLogin)) { + return 'email'; + } + } + return ''; + } + function shouldForceStep7EmailLogin(state = {}) { - return normalizeStep7IdentifierType(state?.forceLoginIdentifierType) === 'email' - || Boolean(state?.forceEmailLogin); + return resolveForcedStep7IdentifierType(state) === 'email'; + } + + function isPhoneSignupMethodForStep7(state = {}) { + return normalizeStep7SignupMethod(state?.signupMethod) === 'phone' + || normalizeStep7SignupMethod(state?.resolvedSignupMethod) === 'phone'; } function canUseConfiguredPhoneSignup(state = {}) { - return normalizeStep7SignupMethod(state?.signupMethod) === 'phone' + return isPhoneSignupMethodForStep7(state) && Boolean(state?.phoneVerificationEnabled) && !Boolean(state?.plusModeEnabled) && !Boolean(state?.accountContributionEnabled); @@ -80,8 +109,9 @@ } function resolveStep7LoginIdentifierType(state = {}, fallbackType = '') { - if (shouldForceStep7EmailLogin(state)) { - return 'email'; + const forcedIdentifierType = resolveForcedStep7IdentifierType(state); + if (forcedIdentifierType) { + return forcedIdentifierType; } if (shouldPreferStep7PhoneSignupIdentity(state)) { @@ -236,6 +266,8 @@ throw new Error('缺少登录账号:请先完成步骤 2,或在侧栏“注册邮箱/注册手机号”中手动填写账号后再执行当前步骤。'); } + const forceEmailLoginForThisRun = shouldForceStep7EmailLogin(state); + let attempt = 0; let lastError = null; @@ -243,8 +275,17 @@ throwIfStopped(); attempt += 1; try { - const rawCurrentState = attempt === 1 ? state : await getState(); - const currentState = shouldForceStep7EmailLogin(state) + const rawCurrentState = { + ...(attempt === 1 ? state : await getState()), + ...(resolvedIdentifierType === 'phone' ? { + forceLoginIdentifierType: 'phone', + forceEmailLogin: false, + accountIdentifierType: 'phone', + accountIdentifier: phoneNumber, + signupPhoneNumber: phoneNumber, + } : {}), + }; + const currentState = forceEmailLoginForThisRun ? { ...rawCurrentState, forceLoginIdentifierType: 'email', diff --git a/tests/background-auth-chain-guard.test.js b/tests/background-auth-chain-guard.test.js index bcf38d9..5af333e 100644 --- a/tests/background-auth-chain-guard.test.js +++ b/tests/background-auth-chain-guard.test.js @@ -58,6 +58,85 @@ test('background auth chain set does not include Plus session import nodes', () assert.doesNotMatch(authChainBlock, /cpa-session-import/); }); +test('step 8 recovery rebuilds primary phone login identity before rerunning oauth-login', async () => { + const events = { + executePayloads: [], + logs: [], + statuses: [], + }; + let state = { + signupMethod: 'phone', + resolvedSignupMethod: 'phone', + phoneVerificationEnabled: true, + email: 'bound.step8@example.com', + forceLoginIdentifierType: 'email', + forceEmailLogin: true, + accountIdentifierType: 'email', + accountIdentifier: 'bound.step8@example.com', + signupPhoneNumber: '+447780579093', + signupPhoneCompletedActivation: { + activationId: 'signup-done', + phoneNumber: '+447780579093', + }, + }; + + const api = new Function('events', 'state', ` +const SIGNUP_METHOD_PHONE = 'phone'; +const FINAL_OAUTH_CHAIN_START_STEP = 7; +function getNodeIdByStepForState(step) { + return Number(step) === 7 ? 'oauth-login' : ''; +} +function getErrorMessage(error) { + return error?.message || String(error || ''); +} +function isStopError() { + return false; +} +function isTerminalSecurityBlockedError() { + return false; +} +async function getState() { + return { ...state }; +} +async function addLog(message, level, options) { + events.logs.push({ message, level, options }); +} +async function setNodeStatus(nodeId, status) { + events.statuses.push({ nodeId, status }); +} +async function appendManualAccountRunRecordIfNeeded() {} +async function sleepWithStop() {} +function throwIfStopped() {} +const step7Executor = { + async executeStep7(payload) { + events.executePayloads.push(payload); + }, +}; +${extractFunction('normalizeAuthRecoveryIdentifierType')} +${extractFunction('isPhoneSignupAuthRecoveryState')} +${extractFunction('getPhoneSignupAuthRecoveryIdentity')} +${extractFunction('isBoundEmailReloginAuthRecoveryNode')} +${extractFunction('buildAuthLoginRecoveryState')} +${extractFunction('rerunStep7ForStep8Recovery')} +return { rerunStep7ForStep8Recovery }; +`)(events, state); + + await api.rerunStep7ForStep8Recovery({ + logMessage: 'retry primary login', + logStep: 8, + }); + + assert.equal(events.executePayloads.length, 1); + assert.equal(events.executePayloads[0].nodeId, 'oauth-login'); + assert.equal(events.executePayloads[0].visibleStep, 7); + assert.equal(events.executePayloads[0].authLoginPhase, 'primary-login'); + assert.equal(events.executePayloads[0].forceLoginIdentifierType, 'phone'); + assert.equal(events.executePayloads[0].forceEmailLogin, false); + assert.equal(events.executePayloads[0].accountIdentifierType, 'phone'); + assert.equal(events.executePayloads[0].accountIdentifier, '+447780579093'); + assert.equal(events.executePayloads[0].signupPhoneNumber, '+447780579093'); +}); + const NODE_EXECUTE_COMPAT_HELPERS = ` const AUTH_CHAIN_NODE_IDS = new Set(['oauth-login', 'fetch-login-code', 'confirm-oauth', 'platform-verify']); const STEP_NODE_IDS = { diff --git a/tests/background-step6-retry-limit.test.js b/tests/background-step6-retry-limit.test.js index c4d4913..d3b8032 100644 --- a/tests/background-step6-retry-limit.test.js +++ b/tests/background-step6-retry-limit.test.js @@ -710,6 +710,66 @@ test('step 7 keeps phone login after step 8 stores an unbound email for phone si assert.equal(events.payloads[0].accountIdentifier, '66959916439'); }); +test('step 7 ignores stale email force flags outside bound-email relogin', async () => { + const source = fs.readFileSync('flows/openai/background/steps/oauth-login.js', 'utf8'); + const globalScope = {}; + const api = new Function('self', `${source}; return self.MultiPageBackgroundStep7;`)(globalScope); + + const events = { + payloads: [], + }; + + const pollutedPhoneSignupState = { + nodeId: 'oauth-login', + authLoginPhase: 'primary-login', + phoneVerificationEnabled: true, + signupMethod: 'phone', + resolvedSignupMethod: 'phone', + forceLoginIdentifierType: 'email', + forceEmailLogin: true, + email: 'bound.step8@example.com', + accountIdentifierType: 'phone', + accountIdentifier: '66959916439', + signupPhoneNumber: '66959916439', + signupPhoneCompletedActivation: { + activationId: 'signup-done', + phoneNumber: '66959916439', + countryId: 52, + countryLabel: 'Thailand', + }, + password: 'secret', + }; + + const executor = api.createStep7Executor({ + addLog: async () => {}, + completeNodeFromBackground: async () => {}, + getErrorMessage: (error) => error?.message || String(error || ''), + getLoginAuthStateLabel: (state) => state || 'unknown', + getState: async () => ({ ...pollutedPhoneSignupState }), + isStep6RecoverableResult: (result) => result?.step6Outcome === 'recoverable', + isStep6SuccessResult: (result) => result?.step6Outcome === 'success', + refreshOAuthUrlBeforeStep6: async () => 'https://oauth.example/latest', + reuseOrCreateTab: async () => {}, + sendToContentScriptResilient: async (_sourceName, message) => { + events.payloads.push(message.payload); + return { + step6Outcome: 'success', + state: 'phone_verification_page', + loginVerificationRequestedAt: 123456, + }; + }, + STEP6_MAX_ATTEMPTS: 3, + throwIfStopped: () => {}, + }); + + await executor.executeStep7(pollutedPhoneSignupState); + + assert.equal(events.payloads[0].loginIdentifierType, 'phone'); + assert.equal(events.payloads[0].phoneNumber, '66959916439'); + assert.equal(events.payloads[0].email, ''); + assert.equal(events.payloads[0].accountIdentifier, '66959916439'); +}); + test('step 7 can infer phone login from an available phone signup configuration before step 2 finishes', async () => { const source = fs.readFileSync('flows/openai/background/steps/oauth-login.js', 'utf8'); const globalScope = {};