Fix bound email relogin callback recovery

This commit is contained in:
QLHazyCoder
2026-05-20 03:26:08 +08:00
parent fbb66fb5a2
commit 25249e229a
6 changed files with 373 additions and 1 deletions
+15 -1
View File
@@ -15261,9 +15261,17 @@ async function recoverOAuthLocalhostTimeout(details = {}) {
return null;
}
const authLoginStep = typeof getAuthChainStartStepId === 'function'
const defaultAuthLoginStep = typeof getAuthChainStartStepId === 'function'
? getAuthChainStartStepId(state || {})
: FINAL_OAUTH_CHAIN_START_STEP;
const reloginBoundEmailStep = typeof getStepIdByKeyForState === 'function'
? Number(getStepIdByKeyForState('relogin-bound-email', state || {}))
: 0;
const authLoginStep = Number.isFinite(reloginBoundEmailStep)
&& reloginBoundEmailStep > 0
&& reloginBoundEmailStep < Number(visibleStep)
? reloginBoundEmailStep
: defaultAuthLoginStep;
const authLoginNodeId = String(getNodeIdByStepForState(authLoginStep, state || {}) || 'oauth-login').trim();
const confirmNodeId = String(getNodeIdByStepForState(visibleStep, state || {}) || 'confirm-oauth').trim();
@@ -15336,6 +15344,12 @@ async function recoverOAuthLocalhostTimeout(details = {}) {
return step8Executor.executeBindEmail(payload);
case 'fetch-bind-email-code':
return step8Executor.executeFetchBindEmailCode(payload);
case 'relogin-bound-email':
return executeReloginBoundEmail(payload);
case 'fetch-bound-email-login-code':
return step8Executor.executeBoundEmailLoginCode(payload);
case 'post-bound-email-phone-verification':
return step8Executor.executeBoundEmailPostLoginPhoneVerification(payload);
default:
throw new Error(`OAuth localhost 恢复不支持节点 ${nodeId}`);
}
+15
View File
@@ -89,6 +89,21 @@ function resolveCommandNodeId(message = {}) {
}
const visibleStep = Number(message.payload?.visibleStep || message.step) || 0;
if (visibleStep === 4) return 'fetch-signup-code';
if (message.type === 'FILL_CODE' && (visibleStep === 12 || visibleStep === 15)) {
return 'fetch-bound-email-login-code';
}
if (
(
message.type === 'SUBMIT_PHONE_NUMBER'
|| message.type === 'SUBMIT_PHONE_VERIFICATION_CODE'
|| message.type === 'RESEND_PHONE_VERIFICATION_CODE'
|| message.type === 'CHECK_PHONE_RESEND_ERROR'
|| message.type === 'RETURN_TO_ADD_PHONE'
)
&& (visibleStep === 13 || visibleStep === 16)
) {
return 'post-bound-email-phone-verification';
}
if (visibleStep === 8 || visibleStep === 11) return 'fetch-login-code';
if (visibleStep === 9 || visibleStep === 12) return 'post-login-phone-verification';
if (visibleStep === 10 || visibleStep === 13) return 'confirm-oauth';
+140
View File
@@ -470,6 +470,146 @@ return {
assert.match(events.logs[0].message, /授权后链总超时已关闭/);
});
test('oauth localhost timeout recovery resumes from bound-email relogin tail when present', async () => {
const events = {
logs: [],
recoveryRuns: [],
stateUpdates: [],
timeoutWindows: [],
};
const api = new Function('events', `
const FINAL_OAUTH_CHAIN_START_STEP = 7;
const state = {
oauthUrl: 'https://oauth.example/current',
phoneSignupReloginAfterBindEmailEnabled: true,
};
const STEP_NODE_IDS = {
7: 'oauth-login',
8: 'fetch-login-code',
9: 'bind-email',
10: 'fetch-bind-email-code',
11: 'relogin-bound-email',
12: 'fetch-bound-email-login-code',
13: 'post-bound-email-phone-verification',
14: 'confirm-oauth',
15: 'platform-verify',
};
const NODE_STEP_IDS = Object.fromEntries(Object.entries(STEP_NODE_IDS).map(([step, nodeId]) => [nodeId, Number(step)]));
function getErrorMessage(error) {
return error?.message || String(error || '');
}
function getAuthChainStartStepId() {
return 7;
}
function getStepIdByKeyForState(stepKey) {
return NODE_STEP_IDS[String(stepKey || '').trim()] || null;
}
function getNodeIdByStepForState(step) {
return STEP_NODE_IDS[Number(step)] || '';
}
function getStepIdByNodeIdForState(nodeId) {
return NODE_STEP_IDS[String(nodeId || '').trim()] || null;
}
function getAutoRunWorkflowNodeIds() {
return [
'oauth-login',
'fetch-login-code',
'bind-email',
'fetch-bind-email-code',
'relogin-bound-email',
'fetch-bound-email-login-code',
'post-bound-email-phone-verification',
'confirm-oauth',
'platform-verify',
];
}
async function addLog(message, level = 'info', options = {}) {
events.logs.push({ message, level, options });
}
async function getLoginAuthStateFromContent() {
return { state: 'oauth_consent_page' };
}
function isAddPhoneAuthState() {
return false;
}
function getLoginAuthStateLabel(value) {
return value || 'unknown';
}
async function getState() {
return { ...state };
}
async function setState(update) {
events.stateUpdates.push(update);
Object.assign(state, update);
}
async function startOAuthFlowTimeoutWindow(payload) {
events.timeoutWindows.push(payload);
}
const step7Executor = {
async executeStep7(payload) {
events.recoveryRuns.push({ nodeId: 'oauth-login', visibleStep: payload.visibleStep });
},
};
const step8Executor = {
async executeStep8(payload) {
events.recoveryRuns.push({ nodeId: 'fetch-login-code', visibleStep: payload.visibleStep });
},
async executePostLoginPhoneVerification(payload) {
events.recoveryRuns.push({ nodeId: 'post-login-phone-verification', visibleStep: payload.visibleStep });
},
async executeBindEmail(payload) {
events.recoveryRuns.push({ nodeId: 'bind-email', visibleStep: payload.visibleStep });
},
async executeFetchBindEmailCode(payload) {
events.recoveryRuns.push({ nodeId: 'fetch-bind-email-code', visibleStep: payload.visibleStep });
},
async executeBoundEmailLoginCode(payload) {
events.recoveryRuns.push({ nodeId: 'fetch-bound-email-login-code', visibleStep: payload.visibleStep });
},
async executeBoundEmailPostLoginPhoneVerification(payload) {
events.recoveryRuns.push({ nodeId: 'post-bound-email-phone-verification', visibleStep: payload.visibleStep });
},
};
async function executeReloginBoundEmail(payload) {
events.recoveryRuns.push({ nodeId: 'relogin-bound-email', visibleStep: payload.visibleStep });
}
${extractFunction('isStep9OAuthLocalhostTimeoutError')}
${extractFunction('recoverOAuthLocalhostTimeout')}
return {
recoverOAuthLocalhostTimeout,
};
`)(events);
const recoveredState = await api.recoverOAuthLocalhostTimeout({
error: new Error('步骤 14:从拿到 OAuth 登录地址开始,5 分钟内未完成 OAuth localhost 回调,结束当前链路。'),
state: {
oauthUrl: 'https://oauth.example/current',
phoneSignupReloginAfterBindEmailEnabled: true,
},
visibleStep: 14,
});
assert.deepStrictEqual(events.recoveryRuns, [
{ nodeId: 'relogin-bound-email', visibleStep: 11 },
{ nodeId: 'fetch-bound-email-login-code', visibleStep: 12 },
{ nodeId: 'post-bound-email-phone-verification', visibleStep: 13 },
]);
assert.deepStrictEqual(events.stateUpdates, [{ localhostUrl: null }]);
assert.deepStrictEqual(events.timeoutWindows, [
{
step: 14,
oauthUrl: 'https://oauth.example/current',
},
]);
assert.equal(recoveredState.localhostUrl, null);
assert.ok(events.logs.some(({ message }) => /步骤 11/.test(message)));
assert.equal(events.recoveryRuns.some(({ nodeId }) => nodeId === 'oauth-login'), false);
});
test('executeNode retries fetch-network errors for fetch-signup-code with cooldown and bounded attempts', async () => {
const api = new Function(`
const LOG_PREFIX = '[test]';
@@ -588,6 +588,72 @@ test('step 7 keeps Plus email login even when phone sms runtime exists', async (
assert.equal(events.payloads[0].accountIdentifier, 'plus.user@example.com');
});
test('step 7 keeps relogin-bound-email as the active node id', async () => {
const source = fs.readFileSync('background/steps/oauth-login.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep7;`)(globalScope);
const events = {
completions: [],
messages: [],
};
const executor = api.createStep7Executor({
addLog: async () => {},
completeNodeFromBackground: async (step, payload) => {
events.completions.push({ step, payload });
},
getErrorMessage: (error) => error?.message || String(error || ''),
getLoginAuthStateLabel: (state) => state || 'unknown',
getState: async () => ({
email: 'bound.user@example.com',
password: 'secret',
plusModeEnabled: true,
signupMethod: 'phone',
phoneSignupReloginAfterBindEmailEnabled: true,
}),
isStep6RecoverableResult: (result) => result?.step6Outcome === 'recoverable',
isStep6SuccessResult: (result) => result?.step6Outcome === 'success',
refreshOAuthUrlBeforeStep6: async () => 'https://oauth.example/relogin',
reuseOrCreateTab: async () => {},
sendToContentScriptResilient: async (_sourceName, message) => {
events.messages.push(message);
return {
step6Outcome: 'success',
state: 'verification_page',
loginVerificationRequestedAt: 223344,
};
},
STEP6_MAX_ATTEMPTS: 3,
throwIfStopped: () => {},
});
await executor.executeStep7({
nodeId: 'relogin-bound-email',
visibleStep: 14,
forceLoginIdentifierType: 'email',
forceEmailLogin: true,
signupMethod: 'email',
resolvedSignupMethod: 'email',
accountIdentifierType: 'email',
accountIdentifier: 'bound.user@example.com',
email: 'bound.user@example.com',
password: 'secret',
});
assert.equal(events.messages.length, 1);
assert.equal(events.messages[0].nodeId, 'relogin-bound-email');
assert.equal(events.messages[0].payload.visibleStep, 14);
assert.deepStrictEqual(events.completions, [
{
step: 'relogin-bound-email',
payload: {
loginVerificationRequestedAt: 223344,
},
},
]);
});
test('step 7 keeps phone login after step 8 stores an unbound email for phone signup', async () => {
const source = fs.readFileSync('background/steps/oauth-login.js', 'utf8');
const globalScope = {};
+86
View File
@@ -0,0 +1,86 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('content/signup-page.js', 'utf8');
function extractFunction(name) {
const markers = [`async function ${name}(`, `function ${name}(`];
const start = markers
.map((marker) => source.indexOf(marker))
.find((index) => index >= 0);
if (start < 0) {
throw new Error(`missing function ${name}`);
}
let parenDepth = 0;
let signatureEnded = false;
let braceStart = -1;
for (let i = start; i < source.length; i += 1) {
const ch = source[i];
if (ch === '(') {
parenDepth += 1;
} else if (ch === ')') {
parenDepth -= 1;
if (parenDepth === 0) {
signatureEnded = true;
}
} else if (ch === '{' && signatureEnded) {
braceStart = i;
break;
}
}
if (braceStart < 0) {
throw new Error(`missing body for function ${name}`);
}
let depth = 0;
let end = braceStart;
for (; end < source.length; end += 1) {
const ch = source[end];
if (ch === '{') depth += 1;
if (ch === '}') {
depth -= 1;
if (depth === 0) {
end += 1;
break;
}
}
}
return source.slice(start, end);
}
const api = new Function(`
${extractFunction('resolveCommandNodeId')}
return {
resolveCommandNodeId,
};
`)();
test('signup page resolves bound-email relogin verification nodes from dynamic visible steps', () => {
assert.equal(
api.resolveCommandNodeId({
type: 'FILL_CODE',
step: 8,
payload: { visibleStep: 15 },
}),
'fetch-bound-email-login-code'
);
assert.equal(
api.resolveCommandNodeId({
type: 'SUBMIT_PHONE_VERIFICATION_CODE',
step: 16,
payload: { visibleStep: 16 },
}),
'post-bound-email-phone-verification'
);
assert.equal(
api.resolveCommandNodeId({
type: 'FILL_CODE',
step: 8,
payload: { nodeId: 'fetch-bound-email-login-code', visibleStep: 15 },
}),
'fetch-bound-email-login-code'
);
});
+51
View File
@@ -1682,6 +1682,57 @@ test('verification flow does not replay step 8 code submit after transient auth-
assert.deepStrictEqual(resilientMessages, ['GET_LOGIN_AUTH_STATE']);
});
test('verification flow forwards dynamic completion node id when submitting bound-email login code', async () => {
const directCalls = [];
const helpers = api.createVerificationFlowHelpers({
addLog: async () => {},
chrome: {
tabs: {
update: async () => {},
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeNodeFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0,
getNodeIdByStepForState: (step) => ({
14: 'fetch-bound-email-login-code',
})[Number(step)] || '',
getState: async () => ({}),
getTabId: async () => 1,
HOTMAIL_PROVIDER: 'hotmail-api',
isStopError: () => false,
LUCKMAIL_PROVIDER: 'luckmail-api',
MAIL_2925_VERIFICATION_INTERVAL_MS: 15000,
MAIL_2925_VERIFICATION_MAX_ATTEMPTS: 15,
pollCloudflareTempEmailVerificationCode: async () => ({}),
pollHotmailVerificationCode: async () => ({}),
pollLuckmailVerificationCode: async () => ({}),
sendToContentScript: async (_source, message) => {
directCalls.push(message);
return { success: true };
},
sendToContentScriptResilient: async () => {
throw new Error('step 8 code submit should use the direct channel once');
},
sendToMailContentScriptResilient: async () => ({}),
setState: async () => {},
setStepStatus: async () => {},
sleepWithStop: async () => {},
throwIfStopped: () => {},
VERIFICATION_POLL_MAX_ROUNDS: 5,
});
await helpers.submitVerificationCode(8, '246810', { completionStep: 14 });
assert.equal(directCalls.length, 1);
assert.equal(directCalls[0].type, 'FILL_CODE');
assert.equal(directCalls[0].payload.code, '246810');
assert.equal(directCalls[0].payload.nodeId, 'fetch-bound-email-login-code');
});
test('verification flow keeps step 8 code submit response timeout above local floor when flow budget is nearly exhausted', async () => {
const directCalls = [];