From ec61b2ece5a81ab3cbcded24f3d82de3764c526f Mon Sep 17 00:00:00 2001 From: ranger Date: Thu, 28 May 2026 17:00:12 +0800 Subject: [PATCH 01/22] =?UTF-8?q?feat(openai-reauth):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E7=8B=AC=E7=AB=8B=20OAuth=20=E9=87=8D=E6=96=B0=E6=8E=88?= =?UTF-8?q?=E6=9D=83=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - flows/openai-reauth/: 独立 flow definition + workflow + mail-rules - background/oauth-client.js: PKCE 生成 + URL 构造 + Token 交换 (fetchImpl 注入) - background/cookie-cleanup.js: 清 OpenAI/ChatGPT cookies (复用 step1 模式) - background/steps/: 4 个 step executor (prepare → submit-email → fetch-code → capture-callback) - 注册到 flows/index.js + manifest.json + background.js (importScripts/mailRuleRegistry/stepExecutorsByKey) - 复用 oauth-login content handler、pollFlowVerificationCode、FILL_CODE - 测试: oauth-client (14) / mail-rules (10) / cookie-cleanup (6) / capture-callback (8) = 38 个新测试 - 全量回归 1307/1307 通过 注: sidepanel UI 留待下一期 (需要 message-router 与 sidepanel 主 UI 配合升级) --- background.js | 61 ++++ flows/index.js | 34 ++- .../background/cookie-cleanup.js | 124 ++++++++ .../openai-reauth/background/oauth-client.js | 247 ++++++++++++++++ .../steps/capture-reauth-callback.js | 194 ++++++++++++ .../background/steps/fetch-reauth-code.js | 131 +++++++++ .../background/steps/prepare-reauth.js | 130 +++++++++ .../background/steps/submit-reauth-email.js | 110 +++++++ flows/openai-reauth/index.js | 86 ++++++ flows/openai-reauth/mail-rules.js | 133 +++++++++ flows/openai-reauth/workflow.js | 88 ++++++ manifest.json | 5 + tests/openai-reauth-capture-callback.test.js | 276 ++++++++++++++++++ tests/openai-reauth-cookie-cleanup.test.js | 116 ++++++++ tests/openai-reauth-mail-rules.test.js | 128 ++++++++ tests/openai-reauth-oauth-client.test.js | 230 +++++++++++++++ 16 files changed, 2083 insertions(+), 10 deletions(-) create mode 100644 flows/openai-reauth/background/cookie-cleanup.js create mode 100644 flows/openai-reauth/background/oauth-client.js create mode 100644 flows/openai-reauth/background/steps/capture-reauth-callback.js create mode 100644 flows/openai-reauth/background/steps/fetch-reauth-code.js create mode 100644 flows/openai-reauth/background/steps/prepare-reauth.js create mode 100644 flows/openai-reauth/background/steps/submit-reauth-email.js create mode 100644 flows/openai-reauth/index.js create mode 100644 flows/openai-reauth/mail-rules.js create mode 100644 flows/openai-reauth/workflow.js create mode 100644 tests/openai-reauth-capture-callback.test.js create mode 100644 tests/openai-reauth-cookie-cleanup.test.js create mode 100644 tests/openai-reauth-mail-rules.test.js create mode 100644 tests/openai-reauth-oauth-client.test.js diff --git a/background.js b/background.js index cceb2073..4fb90315 100644 --- a/background.js +++ b/background.js @@ -3,6 +3,8 @@ importScripts( 'flows/openai/index.js', 'flows/openai/workflow.js', + 'flows/openai-reauth/index.js', + 'flows/openai-reauth/workflow.js', 'flows/kiro/index.js', 'flows/kiro/workflow.js', 'flows/grok/index.js', @@ -52,6 +54,7 @@ importScripts( 'background/signup-flow-helpers.js', 'background/mail-rule-registry.js', 'flows/openai/mail-rules.js', + 'flows/openai-reauth/mail-rules.js', 'flows/kiro/mail-rules.js', 'flows/grok/mail-rules.js', 'background/flow-mail-polling.js', @@ -82,6 +85,12 @@ importScripts( 'flows/openai/background/steps/fetch-login-code.js', 'flows/openai/background/steps/confirm-oauth.js', 'flows/openai/background/steps/platform-verify.js', + 'flows/openai-reauth/background/oauth-client.js', + 'flows/openai-reauth/background/cookie-cleanup.js', + 'flows/openai-reauth/background/steps/prepare-reauth.js', + 'flows/openai-reauth/background/steps/submit-reauth-email.js', + 'flows/openai-reauth/background/steps/fetch-reauth-code.js', + 'flows/openai-reauth/background/steps/capture-reauth-callback.js', 'data/names.js', 'hotmail-utils.js', 'microsoft-email.js', @@ -13743,6 +13752,11 @@ const openAiMailRules = self.MultiPageOpenAiMailRules?.createOpenAiMailRules({ MAIL_2925_VERIFICATION_INTERVAL_MS, MAIL_2925_VERIFICATION_MAX_ATTEMPTS, }); +const openAiReauthMailRules = self.MultiPageOpenAiReauthMailRules?.createOpenAiReauthMailRules({ + getHotmailVerificationRequestTimestamp, + MAIL_2925_VERIFICATION_INTERVAL_MS, + MAIL_2925_VERIFICATION_MAX_ATTEMPTS, +}); const kiroMailRules = self.MultiPageKiroMailRules?.createKiroMailRules({ LUCKMAIL_PROVIDER, MAIL_2925_VERIFICATION_INTERVAL_MS, @@ -13757,6 +13771,7 @@ const mailRuleRegistry = self.MultiPageBackgroundMailRuleRegistry?.createMailRul defaultFlowId: DEFAULT_ACTIVE_FLOW_ID, flowBuilders: { openai: openAiMailRules, + 'openai-reauth': openAiReauthMailRules, kiro: kiroMailRules, grok: grokMailRules, }, @@ -14351,6 +14366,10 @@ const stepExecutorsByKey = { 'post-bound-email-phone-verification': (state) => step8Executor.executeBoundEmailPostLoginPhoneVerification(state), 'confirm-oauth': (state) => step9Executor.executeStep9(state), 'platform-verify': (state) => executeStep10(state), + 'prepare-reauth': (state) => prepareReauthExecutor.executePrepareReauth(state), + 'submit-reauth-email': (state) => submitReauthEmailExecutor.executeSubmitReauthEmail(state), + 'fetch-reauth-code': (state) => fetchReauthCodeExecutor.executeFetchReauthCode(state), + 'capture-reauth-callback': (state) => captureReauthCallbackExecutor.executeCaptureReauthCallback(state), 'kiro-open-register-page': (state) => kiroRegisterRunner.executeKiroOpenRegisterPage(state), 'kiro-submit-email': (state) => kiroRegisterRunner.executeKiroSubmitEmail(state), 'kiro-submit-name': (state) => kiroRegisterRunner.executeKiroSubmitName(state), @@ -16294,6 +16313,48 @@ const step9Executor = self.MultiPageBackgroundStep9?.createStep9Executor({ waitForStep8Ready, }); +const openAiReauthOAuthClient = self.MultiPageOpenAiReauthOAuthClient || null; +const openAiReauthCookieCleanup = self.MultiPageOpenAiReauthCookieCleanup || null; + +const prepareReauthExecutor = self.MultiPageOpenAiReauthPrepareStep?.createPrepareReauthExecutor({ + addLog, + chrome, + clearOpenAiAuthCookies: openAiReauthCookieCleanup?.clearOpenAiAuthCookies, + completeNodeFromBackground, + generatePkcePair: openAiReauthOAuthClient?.generatePkcePair, + generateState: openAiReauthOAuthClient?.generateState, + buildAuthorizeUrl: openAiReauthOAuthClient?.buildAuthorizeUrl, + registerTab, + reuseOrCreateTab, + setState, +}); + +const submitReauthEmailExecutor = self.MultiPageOpenAiReauthSubmitEmailStep?.createSubmitReauthEmailExecutor({ + addLog, + completeNodeFromBackground, + reuseOrCreateTab, + sendToContentScriptResilient, + throwIfStopped, +}); + +const fetchReauthCodeExecutor = self.MultiPageOpenAiReauthFetchCodeStep?.createFetchReauthCodeExecutor({ + addLog, + completeNodeFromBackground, + pollFlowVerificationCode: flowMailPollingService?.pollFlowVerificationCode, + sendToContentScriptResilient, + throwIfStopped, +}); + +const captureReauthCallbackExecutor = self.MultiPageOpenAiReauthCaptureCallbackStep?.createCaptureReauthCallbackExecutor({ + addLog, + chrome, + completeNodeFromBackground, + exchangeAuthorizationCode: openAiReauthOAuthClient?.exchangeAuthorizationCode, + parseCallbackUrl: openAiReauthOAuthClient?.parseCallbackUrl, + buildUpdatedAccount: openAiReauthOAuthClient?.buildUpdatedAccount, + setState, +}); + async function executeStep9(state) { return step9Executor.executeStep9(state); } diff --git a/flows/index.js b/flows/index.js index 3fa9b963..b9014a71 100644 --- a/flows/index.js +++ b/flows/index.js @@ -8,6 +8,10 @@ id: 'openai', path: 'flows/openai/', }, + 'openai-reauth': { + id: 'openai-reauth', + path: 'flows/openai-reauth/', + }, kiro: { id: 'kiro', path: 'flows/kiro/', @@ -32,18 +36,28 @@ if (!baseEntry) { return null; } + function pickDefinition() { + switch (normalized) { + case 'openai': return rootScope.MultiPageOpenAiFlowDefinition || null; + case 'openai-reauth': return rootScope.MultiPageOpenAiReauthFlowDefinition || null; + case 'kiro': return rootScope.MultiPageKiroFlowDefinition || null; + case 'grok': return rootScope.MultiPageGrokFlowDefinition || null; + default: return null; + } + } + function pickWorkflow() { + switch (normalized) { + case 'openai': return rootScope.MultiPageOpenAiWorkflow || null; + case 'openai-reauth': return rootScope.MultiPageOpenAiReauthWorkflow || null; + case 'kiro': return rootScope.MultiPageKiroWorkflow || null; + case 'grok': return rootScope.MultiPageGrokWorkflow || null; + default: return null; + } + } return { ...baseEntry, - definition: normalized === 'openai' - ? (rootScope.MultiPageOpenAiFlowDefinition || null) - : (normalized === 'kiro' - ? (rootScope.MultiPageKiroFlowDefinition || null) - : (rootScope.MultiPageGrokFlowDefinition || null)), - workflow: normalized === 'openai' - ? (rootScope.MultiPageOpenAiWorkflow || null) - : (normalized === 'kiro' - ? (rootScope.MultiPageKiroWorkflow || null) - : (rootScope.MultiPageGrokWorkflow || null)), + definition: pickDefinition(), + workflow: pickWorkflow(), }; } diff --git a/flows/openai-reauth/background/cookie-cleanup.js b/flows/openai-reauth/background/cookie-cleanup.js new file mode 100644 index 00000000..cd145bcf --- /dev/null +++ b/flows/openai-reauth/background/cookie-cleanup.js @@ -0,0 +1,124 @@ +(function attachOpenAiReauthCookieCleanup(root, factory) { + root.MultiPageOpenAiReauthCookieCleanup = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createOpenAiReauthCookieCleanupModule() { + const REAUTH_COOKIE_CLEAR_DOMAINS = Object.freeze([ + 'chatgpt.com', + 'chat.openai.com', + 'openai.com', + 'auth.openai.com', + 'auth0.openai.com', + 'accounts.openai.com', + ]); + + function normalizeCookieDomain(domain) { + return String(domain || '').trim().replace(/^\.+/, '').toLowerCase(); + } + + function shouldClearCookie(cookie) { + const domain = normalizeCookieDomain(cookie?.domain); + if (!domain) return false; + return REAUTH_COOKIE_CLEAR_DOMAINS.some((target) => ( + domain === target || domain.endsWith(`.${target}`) + )); + } + + function buildCookieKey(cookie, fallbackStoreId = '') { + return [ + cookie?.storeId || fallbackStoreId || '', + cookie?.domain || '', + cookie?.path || '', + cookie?.name || '', + cookie?.partitionKey ? JSON.stringify(cookie.partitionKey) : '', + ].join('|'); + } + + function buildCookieRemovalUrl(cookie) { + const host = normalizeCookieDomain(cookie?.domain); + const rawPath = String(cookie?.path || '/'); + const path = rawPath.startsWith('/') ? rawPath : `/${rawPath}`; + return `https://${host}${path}`; + } + + function getErrorMessage(error) { + return error?.message || String(error || '未知错误'); + } + + async function collectCookies(chromeApi) { + if (!chromeApi?.cookies?.getAll) { + return []; + } + const stores = chromeApi.cookies.getAllCookieStores + ? await chromeApi.cookies.getAllCookieStores() + : [{ id: undefined }]; + const cookies = []; + const seen = new Set(); + const queryDomains = Array.from( + new Set(REAUTH_COOKIE_CLEAR_DOMAINS.map(normalizeCookieDomain).filter(Boolean)) + ); + + for (const store of stores) { + const storeId = store?.id; + for (const domain of queryDomains) { + let batch = []; + try { + batch = await chromeApi.cookies.getAll( + storeId ? { storeId, domain } : { domain } + ); + } catch (error) { + console.warn('[MultiPage:reauth-cookie-cleanup] query cookies failed', { + storeId: storeId || '', + domain, + message: getErrorMessage(error), + }); + continue; + } + for (const cookie of batch || []) { + if (!shouldClearCookie(cookie)) continue; + const key = buildCookieKey(cookie, storeId); + if (seen.has(key)) continue; + seen.add(key); + cookies.push(cookie); + } + } + } + return cookies; + } + + async function removeCookie(chromeApi, cookie) { + const details = { + url: buildCookieRemovalUrl(cookie), + name: cookie.name, + }; + if (cookie.storeId) details.storeId = cookie.storeId; + if (cookie.partitionKey) details.partitionKey = cookie.partitionKey; + + try { + const result = await chromeApi.cookies.remove(details); + return Boolean(result); + } catch (error) { + console.warn('[MultiPage:reauth-cookie-cleanup] remove cookie failed', { + domain: cookie?.domain, + name: cookie?.name, + message: getErrorMessage(error), + }); + return false; + } + } + + async function clearOpenAiAuthCookies({ chromeApi } = {}) { + if (!chromeApi?.cookies) { + return { collected: 0, removed: 0 }; + } + const cookies = await collectCookies(chromeApi); + let removed = 0; + for (const cookie of cookies) { + if (await removeCookie(chromeApi, cookie)) removed += 1; + } + return { collected: cookies.length, removed }; + } + + return { + REAUTH_COOKIE_CLEAR_DOMAINS, + clearOpenAiAuthCookies, + }; +}); diff --git a/flows/openai-reauth/background/oauth-client.js b/flows/openai-reauth/background/oauth-client.js new file mode 100644 index 00000000..f7db6bbd --- /dev/null +++ b/flows/openai-reauth/background/oauth-client.js @@ -0,0 +1,247 @@ +(function attachOpenAiReauthOAuthClient(root, factory) { + root.MultiPageOpenAiReauthOAuthClient = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createOpenAiReauthOAuthClientModule() { + const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'; + const ISSUER = 'https://auth.openai.com'; + const AUTHORIZE_ENDPOINT = `${ISSUER}/oauth/authorize`; + const TOKEN_ENDPOINT = `${ISSUER}/oauth/token`; + const REDIRECT_PORT = 1455; + const REDIRECT_PATH = '/auth/callback'; + const REDIRECT_URI = `http://localhost:${REDIRECT_PORT}${REDIRECT_PATH}`; + const SCOPE = 'openid profile email offline_access'; + + function cleanString(value = '') { + return String(value ?? '').trim(); + } + + function base64UrlEncode(bytes) { + let binary = ''; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); + } + + async function sha256Bytes(input) { + const encoder = new TextEncoder(); + return new Uint8Array(await crypto.subtle.digest('SHA-256', encoder.encode(String(input || '')))); + } + + function randomUrlSafeString(length = 64) { + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; + const size = Math.max(43, Math.min(128, Math.floor(Number(length) || 64))); + const bytes = new Uint8Array(size); + crypto.getRandomValues(bytes); + let output = ''; + for (let index = 0; index < size; index += 1) { + output += alphabet[bytes[index] % alphabet.length]; + } + return output; + } + + async function generatePkcePair() { + const codeVerifier = randomUrlSafeString(64); + const codeChallenge = base64UrlEncode(await sha256Bytes(codeVerifier)); + return { codeVerifier, codeChallenge }; + } + + function generateState() { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); + } + + function buildAuthorizeUrl(params = {}) { + const codeChallenge = cleanString(params.codeChallenge); + const stateToken = cleanString(params.state); + if (!codeChallenge) { + throw new Error('buildAuthorizeUrl 缺少 codeChallenge。'); + } + if (!stateToken) { + throw new Error('buildAuthorizeUrl 缺少 state。'); + } + const search = new URLSearchParams(); + search.set('client_id', CLIENT_ID); + search.set('code_challenge', codeChallenge); + search.set('code_challenge_method', 'S256'); + search.set('codex_cli_simplified_flow', 'true'); + search.set('id_token_add_organizations', 'true'); + search.set('redirect_uri', REDIRECT_URI); + search.set('response_type', 'code'); + search.set('scope', SCOPE); + search.set('state', stateToken); + return `${AUTHORIZE_ENDPOINT}?${search.toString()}`; + } + + function parseCallbackUrl(rawUrl, expectedState = '') { + const normalizedUrl = cleanString(rawUrl); + if (!normalizedUrl) { + return null; + } + let parsed; + try { + parsed = new URL(normalizedUrl); + } catch (_error) { + return null; + } + if (!/^https?:$/.test(parsed.protocol)) { + return null; + } + if (!['127.0.0.1', 'localhost'].includes(parsed.hostname)) { + return null; + } + if (Number(parsed.port || 0) !== REDIRECT_PORT) { + return null; + } + if (parsed.pathname !== REDIRECT_PATH) { + return null; + } + const stateValue = cleanString(parsed.searchParams.get('state')); + const errorText = cleanString( + parsed.searchParams.get('error_description') || parsed.searchParams.get('error') + ); + const code = cleanString(parsed.searchParams.get('code')); + if (expectedState && stateValue && stateValue !== cleanString(expectedState)) { + return { + url: normalizedUrl, + state: stateValue, + error: `回调 state 不匹配:expected=${cleanString(expectedState)} actual=${stateValue}`, + }; + } + if (errorText) { + return { url: normalizedUrl, state: stateValue, error: errorText }; + } + if (!code) { + return null; + } + return { url: normalizedUrl, state: stateValue, code }; + } + + function decodeJwtPayload(jwt) { + const parts = String(jwt || '').split('.'); + if (parts.length < 2) { + return null; + } + try { + const segment = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + const padded = segment + '='.repeat((4 - segment.length % 4) % 4); + const decoded = typeof atob === 'function' + ? atob(padded) + : Buffer.from(padded, 'base64').toString('binary'); + const bytes = new Uint8Array(decoded.length); + for (let index = 0; index < decoded.length; index += 1) { + bytes[index] = decoded.charCodeAt(index); + } + const text = new TextDecoder().decode(bytes); + return JSON.parse(text); + } catch (_error) { + return null; + } + } + + async function exchangeAuthorizationCode(params = {}) { + const fetchImpl = typeof params.fetchImpl === 'function' + ? params.fetchImpl + : (typeof fetch === 'function' ? fetch.bind(globalThis) : null); + if (typeof fetchImpl !== 'function') { + throw new Error('exchangeAuthorizationCode 需要 fetch 支持。'); + } + const code = cleanString(params.code); + const codeVerifier = cleanString(params.codeVerifier); + if (!code) throw new Error('exchangeAuthorizationCode 缺少 code。'); + if (!codeVerifier) throw new Error('exchangeAuthorizationCode 缺少 codeVerifier。'); + + const body = new URLSearchParams({ + grant_type: 'authorization_code', + client_id: CLIENT_ID, + code, + redirect_uri: REDIRECT_URI, + code_verifier: codeVerifier, + }); + const response = await fetchImpl(TOKEN_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }); + const text = await response.text(); + let json = null; + try { + json = text ? JSON.parse(text) : null; + } catch (_error) { + json = null; + } + if (!response.ok) { + const reason = json?.error_description || json?.error || text || `${response.status}`; + throw new Error(`换取 Token 失败:${cleanString(reason).slice(0, 400) || response.status}`); + } + const accessToken = cleanString(json?.access_token); + const refreshToken = cleanString(json?.refresh_token); + const idToken = cleanString(json?.id_token); + const expiresIn = Number(json?.expires_in) || 0; + if (!accessToken || !refreshToken) { + throw new Error('Token 响应缺少 access_token 或 refresh_token。'); + } + return { + accessToken, + refreshToken, + idToken, + expiresIn, + tokenType: cleanString(json?.token_type), + }; + } + + function buildUpdatedAccount(originalAccount = {}, tokens = {}) { + const idPayload = decodeJwtPayload(tokens.idToken) || {}; + const accessPayload = decodeJwtPayload(tokens.accessToken) || {}; + const authClaims = idPayload['https://api.openai.com/auth'] || {}; + const profileClaims = idPayload['https://api.openai.com/profile'] || {}; + const expiresAt = tokens.expiresIn + ? Math.floor(Date.now() / 1000) + Number(tokens.expiresIn) + : Number(accessPayload.exp || 0) || 0; + const defaultOrgId = (Array.isArray(authClaims.organizations) + ? authClaims.organizations.find((org) => org?.is_default)?.id + : '') || ''; + + const baseCredentials = (originalAccount && typeof originalAccount.credentials === 'object') + ? originalAccount.credentials + : {}; + const nextCredentials = { + ...baseCredentials, + access_token: tokens.accessToken, + refresh_token: tokens.refreshToken, + id_token: tokens.idToken || baseCredentials.id_token || '', + client_id: CLIENT_ID, + expires_at: expiresAt, + email: cleanString(profileClaims.email || idPayload.email || baseCredentials.email), + chatgpt_account_id: cleanString(authClaims.chatgpt_account_id || baseCredentials.chatgpt_account_id), + chatgpt_user_id: cleanString(authClaims.chatgpt_user_id || baseCredentials.chatgpt_user_id), + organization_id: cleanString(defaultOrgId || baseCredentials.organization_id), + plan_type: cleanString(authClaims.chatgpt_plan_type || baseCredentials.plan_type) || 'free', + }; + return { + ...originalAccount, + credentials: nextCredentials, + }; + } + + return { + CLIENT_ID, + ISSUER, + AUTHORIZE_ENDPOINT, + TOKEN_ENDPOINT, + REDIRECT_PORT, + REDIRECT_PATH, + REDIRECT_URI, + SCOPE, + base64UrlEncode, + buildAuthorizeUrl, + buildUpdatedAccount, + decodeJwtPayload, + exchangeAuthorizationCode, + generatePkcePair, + generateState, + parseCallbackUrl, + randomUrlSafeString, + sha256Bytes, + }; +}); diff --git a/flows/openai-reauth/background/steps/capture-reauth-callback.js b/flows/openai-reauth/background/steps/capture-reauth-callback.js new file mode 100644 index 00000000..0fd6ad2f --- /dev/null +++ b/flows/openai-reauth/background/steps/capture-reauth-callback.js @@ -0,0 +1,194 @@ +(function attachOpenAiReauthCaptureCallbackStep(root, factory) { + root.MultiPageOpenAiReauthCaptureCallbackStep = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createCaptureCallbackStepModule() { + const NODE_ID = 'capture-reauth-callback'; + const VISIBLE_STEP = 4; + const STEP_KEY = NODE_ID; + const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000; + const CALLBACK_CHECK_INTERVAL_MS = 1000; + + function getErrorMessage(error) { + return error instanceof Error ? error.message : String(error ?? '未知错误'); + } + + function createCaptureReauthCallbackExecutor(deps = {}) { + const { + addLog = async () => {}, + chrome: chromeApi = (typeof globalThis !== 'undefined' ? globalThis.chrome : null), + completeNodeFromBackground, + exchangeAuthorizationCode, + parseCallbackUrl, + buildUpdatedAccount, + fetchImpl = (typeof fetch === 'function' ? fetch.bind(globalThis) : null), + setState, + } = deps; + + if (typeof completeNodeFromBackground !== 'function') { + throw new Error('capture-reauth-callback executor 缺少 completeNodeFromBackground。'); + } + if (typeof exchangeAuthorizationCode !== 'function') { + throw new Error('capture-reauth-callback executor 缺少 exchangeAuthorizationCode。'); + } + if (typeof parseCallbackUrl !== 'function') { + throw new Error('capture-reauth-callback executor 缺少 parseCallbackUrl。'); + } + if (typeof buildUpdatedAccount !== 'function') { + throw new Error('capture-reauth-callback executor 缺少 buildUpdatedAccount。'); + } + if (typeof setState !== 'function') { + throw new Error('capture-reauth-callback executor 缺少 setState。'); + } + if (!chromeApi?.webNavigation || !chromeApi?.tabs) { + throw new Error('capture-reauth-callback executor 需要 chrome.webNavigation / chrome.tabs。'); + } + + function logStep(message, level = 'info') { + return addLog(message, level, { step: VISIBLE_STEP, stepKey: STEP_KEY }); + } + + function executeCaptureReauthCallback(state = {}) { + const nodeId = String(state?.nodeId || NODE_ID).trim(); + const expectedState = String(state?.reauthState || '').trim(); + const codeVerifier = String(state?.reauthCodeVerifier || '').trim(); + const originalAccount = state?.reauthInputAccount; + + return new Promise((resolve, reject) => { + if (!expectedState) { + reject(new Error('缺少 OAuth state,请先执行步骤 1。')); + return; + } + if (!codeVerifier) { + reject(new Error('缺少 PKCE code_verifier,请先执行步骤 1。')); + return; + } + if (!originalAccount || typeof originalAccount !== 'object') { + reject(new Error('缺少待重新授权的账号 JSON。')); + return; + } + + let resolved = false; + const startedAt = Date.now(); + let timeoutTimer = null; + let onBeforeNavigate = null; + let onCommitted = null; + let onTabUpdated = null; + + function cleanup() { + if (timeoutTimer) { + clearTimeout(timeoutTimer); + timeoutTimer = null; + } + if (onBeforeNavigate) { + chromeApi.webNavigation.onBeforeNavigate.removeListener?.(onBeforeNavigate); + onBeforeNavigate = null; + } + if (onCommitted) { + chromeApi.webNavigation.onCommitted.removeListener?.(onCommitted); + onCommitted = null; + } + if (onTabUpdated) { + chromeApi.tabs.onUpdated.removeListener?.(onTabUpdated); + onTabUpdated = null; + } + } + + function rejectStep(error) { + if (resolved) return; + resolved = true; + cleanup(); + reject(error); + } + + async function finalize(parsed) { + if (resolved || !parsed) return; + if (parsed.error) { + rejectStep(new Error(`OAuth 回调错误:${parsed.error}`)); + return; + } + const code = String(parsed.code || '').trim(); + if (!code) return; + + resolved = true; + cleanup(); + + try { + await logStep(`已捕获 localhost 回调,正在向 OAuth 服务端换取新 Token...`); + const tokens = await exchangeAuthorizationCode({ + code, + codeVerifier, + fetchImpl, + }); + const updatedAccount = buildUpdatedAccount(originalAccount, tokens); + await setState({ + reauthResultAccount: updatedAccount, + reauthCodeVerifier: '', + reauthState: '', + reauthLastError: '', + }); + await logStep('Token 换取成功,新 access_token / refresh_token / id_token 已写入会话状态。', 'ok'); + await completeNodeFromBackground(nodeId, { reauthResultAccount: updatedAccount }); + resolve(); + } catch (error) { + const message = getErrorMessage(error); + await setState({ reauthLastError: message }).catch(() => {}); + await logStep(`步骤 4 失败:${message}`, 'error'); + reject(error); + } + } + + function handleNavigation(details = {}) { + const url = String(details?.url || '').trim(); + if (!url) return; + const parsed = parseCallbackUrl(url, expectedState); + if (parsed) { + finalize(parsed); + const tabId = Number(details?.tabId); + if (Number.isInteger(tabId) && chromeApi.tabs?.remove) { + chromeApi.tabs.remove(tabId).catch(() => {}); + } + } + } + + function handleTabUpdated(_tabId, _changeInfo, tab) { + const url = String(tab?.url || _changeInfo?.url || '').trim(); + if (!url) return; + const parsed = parseCallbackUrl(url, expectedState); + if (parsed) { + finalize(parsed); + const tabIdToClose = Number(_tabId); + if (Number.isInteger(tabIdToClose) && chromeApi.tabs?.remove) { + chromeApi.tabs.remove(tabIdToClose).catch(() => {}); + } + } + } + + onBeforeNavigate = handleNavigation; + onCommitted = handleNavigation; + onTabUpdated = handleTabUpdated; + chromeApi.webNavigation.onBeforeNavigate.addListener(onBeforeNavigate); + chromeApi.webNavigation.onCommitted.addListener(onCommitted); + chromeApi.tabs.onUpdated.addListener(onTabUpdated); + + function checkTimeout() { + if (resolved) return; + if (Date.now() - startedAt >= CALLBACK_TIMEOUT_MS) { + rejectStep(new Error(`${Math.round(CALLBACK_TIMEOUT_MS / 1000)} 秒内未捕获到 localhost 回调,OAuth 同意点击可能被拦截。`)); + return; + } + timeoutTimer = setTimeout(checkTimeout, CALLBACK_CHECK_INTERVAL_MS); + } + timeoutTimer = setTimeout(checkTimeout, CALLBACK_CHECK_INTERVAL_MS); + + logStep('正在监听 localhost:1455 回调...').catch(() => {}); + }); + } + + return { executeCaptureReauthCallback }; + } + + return { + NODE_ID, + VISIBLE_STEP, + createCaptureReauthCallbackExecutor, + }; +}); diff --git a/flows/openai-reauth/background/steps/fetch-reauth-code.js b/flows/openai-reauth/background/steps/fetch-reauth-code.js new file mode 100644 index 00000000..cc1483a7 --- /dev/null +++ b/flows/openai-reauth/background/steps/fetch-reauth-code.js @@ -0,0 +1,131 @@ +(function attachOpenAiReauthFetchCodeStep(root, factory) { + root.MultiPageOpenAiReauthFetchCodeStep = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createFetchCodeStepModule() { + const NODE_ID = 'fetch-reauth-code'; + const VISIBLE_STEP = 3; + const STEP_KEY = NODE_ID; + const FILL_CODE_TIMEOUT_MS = 60000; + const MAIL_2925_FILTER_LOOKBACK_MS = 10 * 60 * 1000; + + function getErrorMessage(error) { + return error instanceof Error ? error.message : String(error ?? '未知错误'); + } + + function createFetchReauthCodeExecutor(deps = {}) { + const { + addLog = async () => {}, + completeNodeFromBackground, + pollFlowVerificationCode, + sendToContentScriptResilient, + throwIfStopped = () => {}, + } = deps; + + if (typeof completeNodeFromBackground !== 'function') { + throw new Error('fetch-reauth-code executor 缺少 completeNodeFromBackground。'); + } + if (typeof pollFlowVerificationCode !== 'function') { + throw new Error('fetch-reauth-code executor 缺少 pollFlowVerificationCode。'); + } + if (typeof sendToContentScriptResilient !== 'function') { + throw new Error('fetch-reauth-code executor 缺少 sendToContentScriptResilient。'); + } + + function logStep(message, level = 'info') { + return addLog(message, level, { step: VISIBLE_STEP, stepKey: STEP_KEY }); + } + + function resolveFilterAfterTimestamp(state = {}) { + const requestedAt = Math.max( + 0, + Number(state?.loginVerificationRequestedAt) || Number(state?.reauthStartedAt) || Date.now() + ); + const provider = String(state?.mailProvider || '').trim().toLowerCase(); + if (provider === '2925') { + return Math.max(0, requestedAt - MAIL_2925_FILTER_LOOKBACK_MS); + } + return requestedAt; + } + + async function executeFetchReauthCode(state = {}) { + const nodeId = String(state?.nodeId || NODE_ID).trim(); + const email = String(state?.reauthEmail || state?.email || '').trim(); + if (!email) { + throw new Error('缺少邮箱地址,请先执行步骤 1。'); + } + + if (state?.skipReauthVerificationStep) { + await logStep('OAuth 授权页未要求验证码,跳过本步骤。', 'ok'); + await completeNodeFromBackground(nodeId, { skipReauthVerificationStep: true }); + return; + } + + try { + throwIfStopped(); + await logStep(`正在轮询邮箱 ${email} 的 OAuth 验证码...`); + + const codeResult = await pollFlowVerificationCode({ + actionLabel: 'OAuth 重新授权验证码', + flowId: 'openai-reauth', + logStep: VISIBLE_STEP, + logStepKey: STEP_KEY, + missingCapabilityMessage: '当前重新授权步骤缺少邮件轮询能力,无法继续执行。', + nodeId: NODE_ID, + notFoundMessage: `步骤 ${VISIBLE_STEP}:邮箱轮询结束,但未获取到 OAuth 验证码。`, + state: { + ...state, + activeFlowId: 'openai-reauth', + flowId: 'openai-reauth', + visibleStep: VISIBLE_STEP, + }, + step: VISIBLE_STEP, + filterAfterTimestamp: resolveFilterAfterTimestamp(state), + }); + + const code = String(codeResult?.code || '').trim(); + if (!code) { + throw new Error('邮箱轮询完成,但未取到 OAuth 验证码。'); + } + + await logStep(`已收到验证码 ${code},正在填回 OAuth 授权页...`); + + throwIfStopped(); + const fillResult = await sendToContentScriptResilient( + 'openai-auth', + { + type: 'FILL_CODE', + step: VISIBLE_STEP, + source: 'background', + payload: { code, visibleStep: VISIBLE_STEP }, + }, + { + timeoutMs: FILL_CODE_TIMEOUT_MS, + responseTimeoutMs: FILL_CODE_TIMEOUT_MS, + retryDelayMs: 700, + logMessage: '认证页正在切换,等待页面重新就绪后继续填写验证码...', + logStep: VISIBLE_STEP, + logStepKey: STEP_KEY, + } + ); + + if (fillResult?.error) { + throw new Error(fillResult.error); + } + + await logStep('验证码已填回,等待 OAuth 服务端跳转 localhost 回调。', 'ok'); + await completeNodeFromBackground(nodeId, { reauthVerificationCode: code }); + } catch (error) { + const message = getErrorMessage(error); + await logStep(`步骤 3 失败:${message}`, 'error'); + throw error; + } + } + + return { executeFetchReauthCode }; + } + + return { + NODE_ID, + VISIBLE_STEP, + createFetchReauthCodeExecutor, + }; +}); diff --git a/flows/openai-reauth/background/steps/prepare-reauth.js b/flows/openai-reauth/background/steps/prepare-reauth.js new file mode 100644 index 00000000..cd28292c --- /dev/null +++ b/flows/openai-reauth/background/steps/prepare-reauth.js @@ -0,0 +1,130 @@ +(function attachOpenAiReauthPrepareStep(root, factory) { + root.MultiPageOpenAiReauthPrepareStep = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createPrepareStepModule() { + const NODE_ID = 'prepare-reauth'; + const VISIBLE_STEP = 1; + const STEP_KEY = NODE_ID; + + function cleanString(value = '') { + return String(value ?? '').trim(); + } + + function getErrorMessage(error) { + return error instanceof Error ? error.message : String(error ?? '未知错误'); + } + + function createPrepareReauthExecutor(deps = {}) { + const { + addLog = async () => {}, + chrome: chromeApi = (typeof globalThis !== 'undefined' ? globalThis.chrome : null), + clearOpenAiAuthCookies, + completeNodeFromBackground, + generatePkcePair, + generateState, + buildAuthorizeUrl, + reuseOrCreateTab, + registerTab, + setState, + } = deps; + + if (typeof completeNodeFromBackground !== 'function') { + throw new Error('prepare-reauth executor 缺少 completeNodeFromBackground。'); + } + if (typeof clearOpenAiAuthCookies !== 'function') { + throw new Error('prepare-reauth executor 缺少 clearOpenAiAuthCookies。'); + } + if (typeof generatePkcePair !== 'function' || typeof generateState !== 'function' || typeof buildAuthorizeUrl !== 'function') { + throw new Error('prepare-reauth executor 缺少 oauth-client 依赖。'); + } + if (typeof reuseOrCreateTab !== 'function') { + throw new Error('prepare-reauth executor 缺少 reuseOrCreateTab。'); + } + if (typeof setState !== 'function') { + throw new Error('prepare-reauth executor 缺少 setState。'); + } + + function logStep(message, level = 'info') { + return addLog(message, level, { step: VISIBLE_STEP, stepKey: STEP_KEY }); + } + + function readReauthInputAccount(state = {}) { + const account = state?.reauthInputAccount; + if (!account || typeof account !== 'object') { + throw new Error('缺少待重新授权的账号 JSON,请在 sidepanel 粘贴账号对象后再启动。'); + } + const credentials = account.credentials && typeof account.credentials === 'object' + ? account.credentials + : {}; + const email = cleanString(credentials.email || account.email || account.name); + if (!email) { + throw new Error('账号 JSON 中缺少 email 字段。'); + } + const mailProvider = cleanString(account.mailProvider || credentials.mailProvider); + if (!mailProvider) { + throw new Error('账号 JSON 中缺少 mailProvider 字段(必须显式声明邮箱来源)。'); + } + return { email, mailProvider }; + } + + async function executePrepareReauth(state = {}) { + const nodeId = cleanString(state?.nodeId) || NODE_ID; + try { + const { email, mailProvider } = readReauthInputAccount(state); + + await logStep(`正在为 ${email} 准备重新授权...`); + + if (chromeApi?.cookies) { + const result = await clearOpenAiAuthCookies({ chromeApi }); + await logStep(`已清理 ${result.removed}/${result.collected} 个 OpenAI/ChatGPT cookies。`, 'ok'); + } else { + await logStep('当前环境无 chrome.cookies API,跳过 cookie 清理。', 'warn'); + } + + const pkce = await generatePkcePair(); + const stateToken = generateState(); + const oauthUrl = buildAuthorizeUrl({ + codeChallenge: pkce.codeChallenge, + state: stateToken, + }); + + await setState({ + reauthEmail: email, + email, + reauthMailProvider: mailProvider, + mailProvider, + reauthCodeVerifier: pkce.codeVerifier, + reauthState: stateToken, + reauthAuthorizeUrl: oauthUrl, + oauthUrl, + reauthStartedAt: Date.now(), + reauthResultAccount: null, + reauthLastError: '', + }); + + const tabId = await reuseOrCreateTab('openai-auth', oauthUrl, { forceNew: true }); + if (typeof registerTab === 'function' && Number.isInteger(tabId)) { + await registerTab('openai-auth', tabId); + } + await logStep('已打开 OAuth 授权页,准备进入下一步。', 'ok'); + + await completeNodeFromBackground(nodeId, { + reauthEmail: email, + reauthMailProvider: mailProvider, + }); + } catch (error) { + const message = getErrorMessage(error); + await setState({ reauthLastError: message }); + await logStep(`步骤 1 失败:${message}`, 'error'); + throw error; + } + } + + return { executePrepareReauth }; + } + + return { + NODE_ID, + VISIBLE_STEP, + createPrepareReauthExecutor, + }; +}); diff --git a/flows/openai-reauth/background/steps/submit-reauth-email.js b/flows/openai-reauth/background/steps/submit-reauth-email.js new file mode 100644 index 00000000..2b529d2e --- /dev/null +++ b/flows/openai-reauth/background/steps/submit-reauth-email.js @@ -0,0 +1,110 @@ +(function attachOpenAiReauthSubmitEmailStep(root, factory) { + root.MultiPageOpenAiReauthSubmitEmailStep = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createSubmitEmailStepModule() { + const NODE_ID = 'submit-reauth-email'; + const VISIBLE_STEP = 2; + const STEP_KEY = NODE_ID; + const SUBMIT_EMAIL_TIMEOUT_MS = 90000; + + function getErrorMessage(error) { + return error instanceof Error ? error.message : String(error ?? '未知错误'); + } + + function createSubmitReauthEmailExecutor(deps = {}) { + const { + addLog = async () => {}, + completeNodeFromBackground, + reuseOrCreateTab, + sendToContentScriptResilient, + throwIfStopped = () => {}, + } = deps; + + if (typeof completeNodeFromBackground !== 'function') { + throw new Error('submit-reauth-email executor 缺少 completeNodeFromBackground。'); + } + if (typeof sendToContentScriptResilient !== 'function') { + throw new Error('submit-reauth-email executor 缺少 sendToContentScriptResilient。'); + } + if (typeof reuseOrCreateTab !== 'function') { + throw new Error('submit-reauth-email executor 缺少 reuseOrCreateTab。'); + } + + function logStep(message, level = 'info') { + return addLog(message, level, { step: VISIBLE_STEP, stepKey: STEP_KEY }); + } + + async function executeSubmitReauthEmail(state = {}) { + const nodeId = String(state?.nodeId || NODE_ID).trim(); + const email = String(state?.reauthEmail || state?.email || '').trim(); + const oauthUrl = String(state?.reauthAuthorizeUrl || state?.oauthUrl || '').trim(); + if (!email) { + throw new Error('缺少邮箱地址,请先执行步骤 1。'); + } + if (!oauthUrl) { + throw new Error('缺少 OAuth 授权 URL,请先执行步骤 1。'); + } + + try { + throwIfStopped(); + await logStep(`正在向 OAuth 授权页提交邮箱 ${email}...`); + + await reuseOrCreateTab('openai-auth', oauthUrl); + + const result = await sendToContentScriptResilient( + 'openai-auth', + { + type: 'EXECUTE_NODE', + nodeId: 'oauth-login', + step: VISIBLE_STEP, + source: 'background', + payload: { + email, + accountIdentifier: email, + loginIdentifierType: 'email', + password: '', + visibleStep: VISIBLE_STEP, + }, + }, + { + timeoutMs: SUBMIT_EMAIL_TIMEOUT_MS, + responseTimeoutMs: SUBMIT_EMAIL_TIMEOUT_MS, + retryDelayMs: 700, + logMessage: '认证页正在切换,等待页面重新就绪后继续提交邮箱...', + logStep: VISIBLE_STEP, + logStepKey: STEP_KEY, + } + ); + + if (result?.error) { + throw new Error(result.error); + } + + if (result?.directOAuthConsentPage || result?.skipLoginVerificationStep) { + await logStep('OAuth 授权页未要求验证码,直接进入回调阶段。', 'ok'); + await completeNodeFromBackground(nodeId, { + skipReauthVerificationStep: true, + loginVerificationRequestedAt: result?.loginVerificationRequestedAt || null, + }); + return; + } + + await logStep('已提交邮箱,等待邮箱验证码到达。', 'ok'); + await completeNodeFromBackground(nodeId, { + loginVerificationRequestedAt: result?.loginVerificationRequestedAt || Date.now(), + }); + } catch (error) { + const message = getErrorMessage(error); + await logStep(`步骤 2 失败:${message}`, 'error'); + throw error; + } + } + + return { executeSubmitReauthEmail }; + } + + return { + NODE_ID, + VISIBLE_STEP, + createSubmitReauthEmailExecutor, + }; +}); diff --git a/flows/openai-reauth/index.js b/flows/openai-reauth/index.js new file mode 100644 index 00000000..8321737e --- /dev/null +++ b/flows/openai-reauth/index.js @@ -0,0 +1,86 @@ +(function attachMultiPageOpenAiReauthFlowDefinition(root, factory) { + root.MultiPageOpenAiReauthFlowDefinition = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createMultiPageOpenAiReauthFlowDefinition() { + function freezeDeep(entry) { + if (!entry || typeof entry !== 'object' || Object.isFrozen(entry)) { + return entry; + } + Object.getOwnPropertyNames(entry).forEach((key) => { + freezeDeep(entry[key]); + }); + return Object.freeze(entry); + } + + const VALUE = freezeDeep({ + id: 'openai-reauth', + label: 'OpenAI 重新授权', + services: ['account', 'email'], + capabilities: { + stepDefinitionMode: 'openai-reauth-static', + canSwitchFlow: false, + supportsEmailSignup: false, + supportsPhoneSignup: false, + supportsPlusMode: false, + supportsContributionMode: false, + supportsAccountContribution: false, + supportedTargetIds: [], + }, + baseGroups: ['openai-oauth'], + targets: {}, + defaultTargetId: null, + settingsDefaults: {}, + settingsGroups: {}, + targetCapabilities: {}, + runtimeSources: { + 'openai-auth': { + flowId: 'openai-reauth', + kind: 'flow-page', + label: '认证页', + readyPolicy: 'allow-child-frame', + family: 'openai-auth-family', + driverId: 'flows/openai/content/openai-auth', + cleanupScopes: ['oauth-localhost-callback'], + detectionMatchers: [ + { + hostnames: ['auth0.openai.com', 'auth.openai.com', 'accounts.openai.com'], + }, + ], + familyMatchers: [ + { + hostnames: ['auth0.openai.com', 'auth.openai.com', 'accounts.openai.com'], + }, + ], + }, + }, + driverDefinitions: { + 'flows/openai/content/openai-auth': { + sourceId: 'openai-auth', + commands: ['oauth-login', 'submit-verification-code', 'detect-auth-state'], + }, + }, + nodes: [ + { + id: 'prepare-reauth', + step: 1, + label: '准备授权(清 cookie / 生成 PKCE / 打开认证页)', + }, + { + id: 'submit-reauth-email', + step: 2, + label: '提交邮箱并等待验证码页', + }, + { + id: 'fetch-reauth-code', + step: 3, + label: '收取邮箱验证码并填回', + }, + { + id: 'capture-reauth-callback', + step: 4, + label: '抓取 localhost 回调并换取新 Token', + }, + ], + }); + + return VALUE; +}); diff --git a/flows/openai-reauth/mail-rules.js b/flows/openai-reauth/mail-rules.js new file mode 100644 index 00000000..06326aa6 --- /dev/null +++ b/flows/openai-reauth/mail-rules.js @@ -0,0 +1,133 @@ +(function attachOpenAiReauthMailRules(root, factory) { + root.MultiPageOpenAiReauthMailRules = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createOpenAiReauthMailRulesModule() { + const REAUTH_CODE_RULE_ID = 'openai-reauth-code'; + const REAUTH_CODE_NODE_ID = 'fetch-reauth-code'; + const REAUTH_VISIBLE_STEP = 3; + + const OPENAI_CODE_PATTERNS = Object.freeze([ + Object.freeze({ + source: '(?:chatgpt\\s+log-?in\\s+code|enter\\s+this\\s+code)[^0-9]{0,24}(\\d{6})', + flags: 'i', + }), + Object.freeze({ + source: 'your\\s+chatgpt\\s+code\\s+is\\s+(\\d{6})', + flags: 'i', + }), + Object.freeze({ + source: '(?:verification\\s+code|temporary\\s+verification\\s+code|your\\s+chatgpt\\s+code|code(?:\\s+is)?)[^0-9]{0,16}(\\d{6})', + flags: 'i', + }), + ]); + const OPENAI_REQUIRED_KEYWORDS = Object.freeze([ + 'openai', + 'chatgpt', + 'verify', + 'verification', + 'confirm', + 'login', + '验证码', + '代码', + ]); + const OPENAI_SENDER_FILTERS = Object.freeze([ + 'openai', 'noreply', 'verify', 'auth', 'chatgpt', 'duckduckgo', 'forward', + ]); + const OPENAI_SUBJECT_FILTERS = Object.freeze([ + 'verify', 'verification', 'code', '验证码', 'confirm', 'login', + ]); + + function buildTargetEmailHints(targetEmail = '') { + const normalized = String(targetEmail || '').trim().toLowerCase(); + if (!normalized) return []; + const hints = [normalized]; + const atIndex = normalized.indexOf('@'); + if (atIndex > 0) { + hints.push(`${normalized.slice(0, atIndex)}=${normalized.slice(atIndex + 1)}`); + } + return [...new Set(hints)]; + } + + function createOpenAiReauthMailRules(deps = {}) { + const { + getHotmailVerificationRequestTimestamp = () => 0, + MAIL_2925_VERIFICATION_INTERVAL_MS = 15000, + MAIL_2925_VERIFICATION_MAX_ATTEMPTS = 15, + } = deps; + + function isMail2925Provider(state = {}) { + return String(state?.mailProvider || '').trim().toLowerCase() === '2925'; + } + + function shouldMatchMail2925TargetEmail(state = {}) { + return isMail2925Provider(state) + && String(state?.mail2925Mode || '').trim().toLowerCase() === 'receive'; + } + + function resolveTargetEmail(state = {}) { + return String(state?.reauthEmail || state?.email || '').trim(); + } + + function getVisibleStepForNode(_nodeId, _state = {}) { + return REAUTH_VISIBLE_STEP; + } + + function getRuleDefinition(_input, state = {}) { + const mail2925Provider = isMail2925Provider(state); + const targetEmail = resolveTargetEmail(state); + return { + flowId: 'openai-reauth', + ruleId: REAUTH_CODE_RULE_ID, + nodeId: REAUTH_CODE_NODE_ID, + step: REAUTH_VISIBLE_STEP, + artifactType: 'code', + codePatterns: OPENAI_CODE_PATTERNS, + filterAfterTimestamp: mail2925Provider + ? 0 + : getHotmailVerificationRequestTimestamp(REAUTH_VISIBLE_STEP, state), + requiredKeywords: OPENAI_REQUIRED_KEYWORDS, + senderFilters: OPENAI_SENDER_FILTERS, + subjectFilters: OPENAI_SUBJECT_FILTERS, + targetEmail, + targetEmailHints: buildTargetEmailHints(targetEmail), + mail2925MatchTargetEmail: shouldMatchMail2925TargetEmail(state), + maxAttempts: mail2925Provider ? MAIL_2925_VERIFICATION_MAX_ATTEMPTS : 5, + intervalMs: mail2925Provider ? MAIL_2925_VERIFICATION_INTERVAL_MS : 3000, + }; + } + + function getRuleDefinitionForNode(nodeId, state = {}) { + if (String(nodeId || '').trim() !== REAUTH_CODE_NODE_ID) { + return null; + } + return getRuleDefinition({ nodeId }, state); + } + + function buildVerificationPollPayload(input, state = {}, overrides = {}) { + return { + ...getRuleDefinition(input, state), + ...(overrides || {}), + }; + } + + function buildVerificationPollPayloadForNode(nodeId, state = {}, overrides = {}) { + const rule = getRuleDefinitionForNode(nodeId, state); + if (!rule) return null; + return { ...rule, ...(overrides || {}) }; + } + + return { + buildVerificationPollPayload, + buildVerificationPollPayloadForNode, + getRuleDefinition, + getRuleDefinitionForNode, + getVisibleStepForNode, + }; + } + + return { + REAUTH_CODE_RULE_ID, + REAUTH_CODE_NODE_ID, + REAUTH_VISIBLE_STEP, + createOpenAiReauthMailRules, + }; +}); diff --git a/flows/openai-reauth/workflow.js b/flows/openai-reauth/workflow.js new file mode 100644 index 00000000..99786e30 --- /dev/null +++ b/flows/openai-reauth/workflow.js @@ -0,0 +1,88 @@ +(function attachMultiPageOpenAiReauthWorkflow(root, factory) { + root.MultiPageOpenAiReauthWorkflow = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createMultiPageOpenAiReauthWorkflow() { + function freezeDeep(entry) { + if (!entry || typeof entry !== 'object' || Object.isFrozen(entry)) { + return entry; + } + Object.getOwnPropertyNames(entry).forEach((key) => { + freezeDeep(entry[key]); + }); + return Object.freeze(entry); + } + + const STEP_VARIANTS = freezeDeep({ + default: [ + { + id: 1, + order: 10, + key: 'prepare-reauth', + title: '准备授权(清 cookie / 生成 PKCE / 打开认证页)', + sourceId: 'openai-auth', + driverId: null, + command: 'prepare-reauth', + flowId: 'openai-reauth', + }, + { + id: 2, + order: 20, + key: 'submit-reauth-email', + title: '提交邮箱并等待验证码页', + sourceId: 'openai-auth', + driverId: 'flows/openai/content/openai-auth', + command: 'oauth-login', + flowId: 'openai-reauth', + }, + { + id: 3, + order: 30, + key: 'fetch-reauth-code', + title: '收取邮箱验证码并填回', + sourceId: 'openai-auth', + driverId: 'flows/openai/content/openai-auth', + command: 'submit-verification-code', + mailRuleId: 'openai-reauth-code', + flowId: 'openai-reauth', + }, + { + id: 4, + order: 40, + key: 'capture-reauth-callback', + title: '抓取 localhost 回调并换取新 Token', + sourceId: 'openai-auth', + driverId: null, + command: 'capture-reauth-callback', + flowId: 'openai-reauth', + }, + ], + }); + + function getVariantStepDefinitions(variantKey = 'default') { + return Array.isArray(STEP_VARIANTS[variantKey]) ? STEP_VARIANTS[variantKey] : STEP_VARIANTS.default; + } + + function getModeStepDefinitions() { + return getVariantStepDefinitions('default'); + } + + function getAllSteps() { + return getVariantStepDefinitions('default'); + } + + function getPlusPaymentStepTitle() { + return ''; + } + + function resolveStepTitle(step = {}) { + return step?.title || ''; + } + + return { + flowId: 'openai-reauth', + getAllSteps, + getModeStepDefinitions, + getPlusPaymentStepTitle, + getVariantStepDefinitions, + resolveStepTitle, + }; +}); diff --git a/manifest.json b/manifest.json index dd77c232..bbe21710 100644 --- a/manifest.json +++ b/manifest.json @@ -50,6 +50,7 @@ "js": [ "content/activation-utils.js", "flows/openai/index.js", + "flows/openai-reauth/index.js", "flows/kiro/index.js", "flows/grok/index.js", "flows/index.js", @@ -72,6 +73,7 @@ "js": [ "content/activation-utils.js", "flows/openai/index.js", + "flows/openai-reauth/index.js", "flows/kiro/index.js", "flows/grok/index.js", "flows/index.js", @@ -94,6 +96,7 @@ "js": [ "content/activation-utils.js", "flows/openai/index.js", + "flows/openai-reauth/index.js", "flows/kiro/index.js", "flows/grok/index.js", "flows/index.js", @@ -113,6 +116,7 @@ "js": [ "content/activation-utils.js", "flows/openai/index.js", + "flows/openai-reauth/index.js", "flows/kiro/index.js", "flows/grok/index.js", "flows/index.js", @@ -131,6 +135,7 @@ "js": [ "content/activation-utils.js", "flows/openai/index.js", + "flows/openai-reauth/index.js", "flows/kiro/index.js", "flows/grok/index.js", "flows/index.js", diff --git a/tests/openai-reauth-capture-callback.test.js b/tests/openai-reauth-capture-callback.test.js new file mode 100644 index 00000000..8cdfd5dc --- /dev/null +++ b/tests/openai-reauth-capture-callback.test.js @@ -0,0 +1,276 @@ +'use strict'; + +const assert = require('node:assert'); +const test = require('node:test'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const STEP_PATH = path.join(__dirname, '..', 'flows', 'openai-reauth', 'background', 'steps', 'capture-reauth-callback.js'); + +function loadStepModule() { + const source = fs.readFileSync(STEP_PATH, 'utf-8'); + const sandbox = { self: {}, globalThis: {}, console, setTimeout, clearTimeout }; + vm.createContext(sandbox); + vm.runInContext(source, sandbox); + return sandbox.self.MultiPageOpenAiReauthCaptureCallbackStep; +} + +function buildMockChromeApi() { + const navListeners = []; + const committedListeners = []; + const tabUpdatedListeners = []; + const removedTabs = []; + return { + navListeners, + committedListeners, + tabUpdatedListeners, + removedTabs, + api: { + webNavigation: { + onBeforeNavigate: { + addListener: (fn) => navListeners.push(fn), + removeListener: (fn) => { + const idx = navListeners.indexOf(fn); + if (idx >= 0) navListeners.splice(idx, 1); + }, + }, + onCommitted: { + addListener: (fn) => committedListeners.push(fn), + removeListener: (fn) => { + const idx = committedListeners.indexOf(fn); + if (idx >= 0) committedListeners.splice(idx, 1); + }, + }, + }, + tabs: { + onUpdated: { + addListener: (fn) => tabUpdatedListeners.push(fn), + removeListener: (fn) => { + const idx = tabUpdatedListeners.indexOf(fn); + if (idx >= 0) tabUpdatedListeners.splice(idx, 1); + }, + }, + remove: async (tabId) => { removedTabs.push(tabId); }, + }, + }, + }; +} + +function buildBaseDeps(overrides = {}) { + const completeCalls = []; + const setStateCalls = []; + const logCalls = []; + return { + completeCalls, + setStateCalls, + logCalls, + deps: { + addLog: async (message, level, options) => { logCalls.push({ message, level, options }); }, + completeNodeFromBackground: async (nodeId, payload) => { completeCalls.push({ nodeId, payload }); }, + exchangeAuthorizationCode: async ({ code, codeVerifier }) => ({ + accessToken: `access_for_${code}_${codeVerifier}`, + refreshToken: 'refresh_x', + idToken: 'id_x', + expiresIn: 3600, + }), + parseCallbackUrl: (url, expected) => { + if (!url.includes('localhost:1455/auth/callback')) return null; + const parsed = new URL(url); + const code = parsed.searchParams.get('code'); + const stateParam = parsed.searchParams.get('state'); + if (expected && stateParam !== expected) return { error: 'state mismatch' }; + return code ? { code, state: stateParam } : null; + }, + buildUpdatedAccount: (original, tokens) => ({ + ...original, + credentials: { + ...(original.credentials || {}), + access_token: tokens.accessToken, + refresh_token: tokens.refreshToken, + }, + }), + setState: async (patch) => { setStateCalls.push(patch); }, + ...overrides, + }, + }; +} + +test('收到合法 localhost 回调时换 token、更新 account 并 complete', async () => { + const mod = loadStepModule(); + const chromeMock = buildMockChromeApi(); + const harness = buildBaseDeps(); + const { executeCaptureReauthCallback } = mod.createCaptureReauthCallbackExecutor({ + ...harness.deps, + chrome: chromeMock.api, + }); + + const promise = executeCaptureReauthCallback({ + nodeId: 'capture-reauth-callback', + reauthState: 'STATE_TOKEN', + reauthCodeVerifier: 'VERIFIER_TOKEN', + reauthInputAccount: { name: 'a@b.com', credentials: { email: 'a@b.com' } }, + }); + + assert.equal(chromeMock.navListeners.length, 1); + assert.equal(chromeMock.committedListeners.length, 1); + assert.equal(chromeMock.tabUpdatedListeners.length, 1); + + chromeMock.navListeners[0]({ + url: 'http://localhost:1455/auth/callback?code=ABC&state=STATE_TOKEN', + tabId: 99, + }); + + await promise; + + assert.equal(chromeMock.navListeners.length, 0, '应已清理监听器'); + assert.equal(chromeMock.removedTabs[0], 99, '应关闭回调 tab'); + assert.equal(harness.completeCalls.length, 1); + assert.equal(harness.completeCalls[0].payload.reauthResultAccount.credentials.access_token, + 'access_for_ABC_VERIFIER_TOKEN'); + const lastSetState = harness.setStateCalls[harness.setStateCalls.length - 1]; + assert.ok(lastSetState.reauthResultAccount); + assert.equal(lastSetState.reauthCodeVerifier, ''); + assert.equal(lastSetState.reauthState, ''); +}); + +test('state 不匹配时拒绝并清理监听器', async () => { + const mod = loadStepModule(); + const chromeMock = buildMockChromeApi(); + const harness = buildBaseDeps(); + const { executeCaptureReauthCallback } = mod.createCaptureReauthCallbackExecutor({ + ...harness.deps, + chrome: chromeMock.api, + }); + + const promise = executeCaptureReauthCallback({ + reauthState: 'STATE_GOOD', + reauthCodeVerifier: 'VERIFIER', + reauthInputAccount: { name: 'x@y.com' }, + }); + + chromeMock.navListeners[0]({ + url: 'http://localhost:1455/auth/callback?code=Z&state=BAD', + tabId: 1, + }); + + await assert.rejects(promise, /state mismatch/); + assert.equal(chromeMock.navListeners.length, 0); +}); + +test('缺少 reauthState 时立刻拒绝', async () => { + const mod = loadStepModule(); + const chromeMock = buildMockChromeApi(); + const harness = buildBaseDeps(); + const { executeCaptureReauthCallback } = mod.createCaptureReauthCallbackExecutor({ + ...harness.deps, + chrome: chromeMock.api, + }); + + await assert.rejects( + executeCaptureReauthCallback({ + reauthCodeVerifier: 'V', + reauthInputAccount: { name: 'a' }, + }), + /OAuth state/ + ); +}); + +test('缺少 codeVerifier 时立刻拒绝', async () => { + const mod = loadStepModule(); + const chromeMock = buildMockChromeApi(); + const harness = buildBaseDeps(); + const { executeCaptureReauthCallback } = mod.createCaptureReauthCallbackExecutor({ + ...harness.deps, + chrome: chromeMock.api, + }); + + await assert.rejects( + executeCaptureReauthCallback({ + reauthState: 'S', + reauthInputAccount: { name: 'a' }, + }), + /code_verifier/ + ); +}); + +test('缺少 reauthInputAccount 时立刻拒绝', async () => { + const mod = loadStepModule(); + const chromeMock = buildMockChromeApi(); + const harness = buildBaseDeps(); + const { executeCaptureReauthCallback } = mod.createCaptureReauthCallbackExecutor({ + ...harness.deps, + chrome: chromeMock.api, + }); + + await assert.rejects( + executeCaptureReauthCallback({ + reauthState: 'S', + reauthCodeVerifier: 'V', + }), + /账号 JSON/ + ); +}); + +test('Token 交换失败时拒绝并写入 reauthLastError', async () => { + const mod = loadStepModule(); + const chromeMock = buildMockChromeApi(); + const harness = buildBaseDeps({ + exchangeAuthorizationCode: async () => { throw new Error('upstream rejected'); }, + }); + const { executeCaptureReauthCallback } = mod.createCaptureReauthCallbackExecutor({ + ...harness.deps, + chrome: chromeMock.api, + }); + + const promise = executeCaptureReauthCallback({ + reauthState: 'STATE', + reauthCodeVerifier: 'V', + reauthInputAccount: { name: 'a' }, + }); + + chromeMock.navListeners[0]({ + url: 'http://localhost:1455/auth/callback?code=C&state=STATE', + tabId: 7, + }); + + await assert.rejects(promise, /upstream rejected/); + const errorPatch = harness.setStateCalls.find((p) => p.reauthLastError); + assert.ok(errorPatch, '失败时应写入 reauthLastError'); + assert.match(errorPatch.reauthLastError, /upstream rejected/); +}); + +test('tabUpdated 路径同样能捕获回调', async () => { + const mod = loadStepModule(); + const chromeMock = buildMockChromeApi(); + const harness = buildBaseDeps(); + const { executeCaptureReauthCallback } = mod.createCaptureReauthCallbackExecutor({ + ...harness.deps, + chrome: chromeMock.api, + }); + + const promise = executeCaptureReauthCallback({ + reauthState: 'S', + reauthCodeVerifier: 'V', + reauthInputAccount: { name: 'x' }, + }); + + chromeMock.tabUpdatedListeners[0]( + 42, + {}, + { url: 'http://localhost:1455/auth/callback?code=K&state=S' } + ); + + await promise; + assert.equal(harness.completeCalls.length, 1); + assert.equal(chromeMock.removedTabs[0], 42); +}); + +test('createExecutor 在 deps 缺失时直接抛错(不允许半成品)', () => { + const mod = loadStepModule(); + const chromeMock = buildMockChromeApi(); + assert.throws( + () => mod.createCaptureReauthCallbackExecutor({ chrome: chromeMock.api }), + /completeNodeFromBackground/ + ); +}); diff --git a/tests/openai-reauth-cookie-cleanup.test.js b/tests/openai-reauth-cookie-cleanup.test.js new file mode 100644 index 00000000..0f292949 --- /dev/null +++ b/tests/openai-reauth-cookie-cleanup.test.js @@ -0,0 +1,116 @@ +'use strict'; + +const assert = require('node:assert'); +const test = require('node:test'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const COOKIE_CLEANUP_PATH = path.join(__dirname, '..', 'flows', 'openai-reauth', 'background', 'cookie-cleanup.js'); + +function loadCookieCleanupModule() { + const source = fs.readFileSync(COOKIE_CLEANUP_PATH, 'utf-8'); + const sandbox = { self: {}, globalThis: {}, console }; + vm.createContext(sandbox); + vm.runInContext(source, sandbox); + return sandbox.self.MultiPageOpenAiReauthCookieCleanup; +} + +function buildFakeChromeApi(cookies, options = {}) { + const removeCalls = []; + return { + removeCalls, + chromeApi: { + cookies: { + getAllCookieStores: async () => options.stores || [{ id: '0' }], + getAll: async (query) => cookies.filter((c) => + (!query.storeId || c.storeId === query.storeId) + && (!query.domain || c.domain === query.domain || c.domain === `.${query.domain}`) + ), + remove: async (details) => { + removeCalls.push(details); + if (options.removeFails && options.removeFails.has(details.name)) { + throw new Error(`mock remove failure for ${details.name}`); + } + return { name: details.name, url: details.url }; + }, + }, + }, + }; +} + +test('REAUTH_COOKIE_CLEAR_DOMAINS 包含 6 个目标 domain', () => { + const mod = loadCookieCleanupModule(); + assert.deepEqual( + [...mod.REAUTH_COOKIE_CLEAR_DOMAINS].sort(), + [ + 'accounts.openai.com', + 'auth.openai.com', + 'auth0.openai.com', + 'chat.openai.com', + 'chatgpt.com', + 'openai.com', + ] + ); +}); + +test('clearOpenAiAuthCookies 收集并删除目标 domain 的 cookie', async () => { + const mod = loadCookieCleanupModule(); + const fake = buildFakeChromeApi([ + { name: 'session', domain: '.openai.com', path: '/', storeId: '0' }, + { name: 'auth_token', domain: 'auth.openai.com', path: '/', storeId: '0' }, + { name: 'cf_token', domain: 'chatgpt.com', path: '/', storeId: '0' }, + { name: 'unrelated', domain: 'google.com', path: '/', storeId: '0' }, + ]); + const result = await mod.clearOpenAiAuthCookies({ chromeApi: fake.chromeApi }); + assert.equal(result.collected, 3); + assert.equal(result.removed, 3); + assert.equal(fake.removeCalls.length, 3); + for (const call of fake.removeCalls) { + assert.match(call.url, /^https:\/\/(chatgpt\.com|auth\.openai\.com|openai\.com)\/$/); + } + assert.equal(fake.removeCalls.find((c) => c.name === 'unrelated'), undefined); +}); + +test('clearOpenAiAuthCookies 跨 storeId 不重复', async () => { + const mod = loadCookieCleanupModule(); + const fake = buildFakeChromeApi( + [ + { name: 'a', domain: 'auth.openai.com', path: '/', storeId: '0' }, + { name: 'a', domain: 'auth.openai.com', path: '/', storeId: '1' }, + ], + { stores: [{ id: '0' }, { id: '1' }] } + ); + const result = await mod.clearOpenAiAuthCookies({ chromeApi: fake.chromeApi }); + assert.equal(result.collected, 2); + assert.equal(result.removed, 2); +}); + +test('clearOpenAiAuthCookies 部分失败时不影响其他 cookie', async () => { + const mod = loadCookieCleanupModule(); + const fake = buildFakeChromeApi( + [ + { name: 'good', domain: 'auth.openai.com', path: '/', storeId: '0' }, + { name: 'bad', domain: 'auth.openai.com', path: '/', storeId: '0' }, + ], + { removeFails: new Set(['bad']) } + ); + const result = await mod.clearOpenAiAuthCookies({ chromeApi: fake.chromeApi }); + assert.equal(result.collected, 2); + assert.equal(result.removed, 1); +}); + +test('clearOpenAiAuthCookies 在 chromeApi 缺失时安全返回', async () => { + const mod = loadCookieCleanupModule(); + const result = await mod.clearOpenAiAuthCookies({}); + assert.deepEqual(result, { collected: 0, removed: 0 }); +}); + +test('clearOpenAiAuthCookies 保留 partitionKey', async () => { + const mod = loadCookieCleanupModule(); + const fake = buildFakeChromeApi([ + { name: 'p', domain: 'auth.openai.com', path: '/', storeId: '0', partitionKey: { topLevelSite: 'https://chatgpt.com' } }, + ]); + await mod.clearOpenAiAuthCookies({ chromeApi: fake.chromeApi }); + assert.deepEqual(fake.removeCalls[0].partitionKey, { topLevelSite: 'https://chatgpt.com' }); +}); diff --git a/tests/openai-reauth-mail-rules.test.js b/tests/openai-reauth-mail-rules.test.js new file mode 100644 index 00000000..9e11dd90 --- /dev/null +++ b/tests/openai-reauth-mail-rules.test.js @@ -0,0 +1,128 @@ +'use strict'; + +const assert = require('node:assert'); +const test = require('node:test'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const MAIL_RULES_PATH = path.join(__dirname, '..', 'flows', 'openai-reauth', 'mail-rules.js'); + +function loadMailRulesModule() { + const source = fs.readFileSync(MAIL_RULES_PATH, 'utf-8'); + const sandbox = { self: {}, globalThis: {} }; + vm.createContext(sandbox); + vm.runInContext(source, sandbox); + return sandbox.self.MultiPageOpenAiReauthMailRules; +} + +test('createOpenAiReauthMailRules 暴露统一接口', () => { + const mod = loadMailRulesModule(); + const rules = mod.createOpenAiReauthMailRules({}); + assert.equal(typeof rules.buildVerificationPollPayload, 'function'); + assert.equal(typeof rules.buildVerificationPollPayloadForNode, 'function'); + assert.equal(typeof rules.getRuleDefinition, 'function'); + assert.equal(typeof rules.getRuleDefinitionForNode, 'function'); +}); + +test('getRuleDefinitionForNode 仅识别 fetch-reauth-code', () => { + const mod = loadMailRulesModule(); + const rules = mod.createOpenAiReauthMailRules({}); + const ok = rules.getRuleDefinitionForNode('fetch-reauth-code', { reauthEmail: 'a@b.com' }); + assert.ok(ok); + assert.equal(ok.flowId, 'openai-reauth'); + assert.equal(ok.ruleId, 'openai-reauth-code'); + assert.equal(ok.nodeId, 'fetch-reauth-code'); + assert.equal(ok.step, 3); + assert.equal(ok.targetEmail, 'a@b.com'); + + assert.equal(rules.getRuleDefinitionForNode('fetch-signup-code', { email: 'x@y.com' }), null); + assert.equal(rules.getRuleDefinitionForNode('fetch-login-code', { email: 'x@y.com' }), null); + assert.equal(rules.getRuleDefinitionForNode('', { email: 'x@y.com' }), null); +}); + +test('targetEmail 优先 reauthEmail,缺失时 fallback 到 email', () => { + const mod = loadMailRulesModule(); + const rules = mod.createOpenAiReauthMailRules({}); + const a = rules.getRuleDefinitionForNode('fetch-reauth-code', { reauthEmail: 'r@x.com', email: 'fallback@x.com' }); + assert.equal(a.targetEmail, 'r@x.com'); + const b = rules.getRuleDefinitionForNode('fetch-reauth-code', { email: 'only@x.com' }); + assert.equal(b.targetEmail, 'only@x.com'); +}); + +test('targetEmailHints 包含原始邮箱与 username=domain 形式', () => { + const mod = loadMailRulesModule(); + const rules = mod.createOpenAiReauthMailRules({}); + const result = rules.getRuleDefinitionForNode('fetch-reauth-code', { reauthEmail: 'foo@2925.com' }); + assert.deepEqual(result.targetEmailHints, ['foo@2925.com', 'foo=2925.com']); +}); + +test('mail2925 provider 切换到长间隔与多次重试', () => { + const mod = loadMailRulesModule(); + const rules = mod.createOpenAiReauthMailRules({ + MAIL_2925_VERIFICATION_INTERVAL_MS: 15000, + MAIL_2925_VERIFICATION_MAX_ATTEMPTS: 15, + }); + const result = rules.getRuleDefinitionForNode('fetch-reauth-code', { + reauthEmail: 'r@2925.com', + mailProvider: '2925', + mail2925Mode: 'receive', + }); + assert.equal(result.maxAttempts, 15); + assert.equal(result.intervalMs, 15000); + assert.equal(result.mail2925MatchTargetEmail, true); + assert.equal(result.filterAfterTimestamp, 0); +}); + +test('非 2925 provider 使用默认短间隔与 5 次重试', () => { + const mod = loadMailRulesModule(); + const rules = mod.createOpenAiReauthMailRules({}); + const result = rules.getRuleDefinitionForNode('fetch-reauth-code', { + reauthEmail: 'r@hotmail.com', + mailProvider: 'hotmail-api', + }); + assert.equal(result.maxAttempts, 5); + assert.equal(result.intervalMs, 3000); + assert.equal(result.mail2925MatchTargetEmail, false); +}); + +test('hotmail provider 使用 timestamp dep 提供的 filter', () => { + const mod = loadMailRulesModule(); + let receivedStep = null; + let receivedState = null; + const rules = mod.createOpenAiReauthMailRules({ + getHotmailVerificationRequestTimestamp: (step, state) => { + receivedStep = step; + receivedState = state; + return 1779999999999; + }, + }); + const result = rules.getRuleDefinitionForNode('fetch-reauth-code', { + reauthEmail: 'r@hotmail.com', + mailProvider: 'hotmail-api', + }); + assert.equal(receivedStep, 3); + assert.equal(receivedState.reauthEmail, 'r@hotmail.com'); + assert.equal(result.filterAfterTimestamp, 1779999999999); +}); + +test('buildVerificationPollPayloadForNode 合并 overrides', () => { + const mod = loadMailRulesModule(); + const rules = mod.createOpenAiReauthMailRules({}); + const result = rules.buildVerificationPollPayloadForNode( + 'fetch-reauth-code', + { reauthEmail: 'a@b.com' }, + { filterAfterTimestamp: 12345 } + ); + assert.equal(result.filterAfterTimestamp, 12345); + assert.equal(result.targetEmail, 'a@b.com'); +}); + +test('buildVerificationPollPayloadForNode 拒绝未知 nodeId', () => { + const mod = loadMailRulesModule(); + const rules = mod.createOpenAiReauthMailRules({}); + assert.equal( + rules.buildVerificationPollPayloadForNode('fetch-login-code', { email: 'x@y.com' }), + null + ); +}); diff --git a/tests/openai-reauth-oauth-client.test.js b/tests/openai-reauth-oauth-client.test.js new file mode 100644 index 00000000..b97f136f --- /dev/null +++ b/tests/openai-reauth-oauth-client.test.js @@ -0,0 +1,230 @@ +'use strict'; + +const assert = require('node:assert'); +const test = require('node:test'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const OAUTH_CLIENT_PATH = path.join(__dirname, '..', 'flows', 'openai-reauth', 'background', 'oauth-client.js'); + +function loadOAuthClientModule() { + const source = fs.readFileSync(OAUTH_CLIENT_PATH, 'utf-8'); + const sandbox = { + self: {}, + globalThis: {}, + crypto: globalThis.crypto, + TextEncoder, + TextDecoder, + fetch: globalThis.fetch, + URL, + URLSearchParams, + Uint8Array, + Buffer, + btoa: (str) => Buffer.from(str, 'binary').toString('base64'), + atob: (str) => Buffer.from(str, 'base64').toString('binary'), + console, + }; + sandbox.self.crypto = globalThis.crypto; + vm.createContext(sandbox); + vm.runInContext(source, sandbox); + return sandbox.self.MultiPageOpenAiReauthOAuthClient; +} + +test('PKCE RFC 7636 已知向量验证', async () => { + const mod = loadOAuthClientModule(); + const knownVerifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'; + const expected = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'; + const bytes = await mod.sha256Bytes(knownVerifier); + const actual = mod.base64UrlEncode(bytes); + assert.equal(actual, expected, '已知向量应通过 RFC 7636 验证'); +}); + +test('generatePkcePair 返回合规的 verifier 和 challenge', async () => { + const mod = loadOAuthClientModule(); + const { codeVerifier, codeChallenge } = await mod.generatePkcePair(); + assert.match(codeVerifier, /^[A-Za-z0-9\-._~]{43,128}$/, 'verifier 字符集和长度合规'); + assert.match(codeChallenge, /^[A-Za-z0-9_-]{43}$/, 'challenge 是 43 字符 base64url 无填充'); +}); + +test('generateState 返回 64 字符 hex', () => { + const mod = loadOAuthClientModule(); + const s = mod.generateState(); + assert.match(s, /^[0-9a-f]{64}$/); +}); + +test('buildAuthorizeUrl 重建用户给的示例链接', () => { + const mod = loadOAuthClientModule(); + const url = mod.buildAuthorizeUrl({ + codeChallenge: 'xu7USQwZr4TDQWbZPOmqmkzwB5bbuTzHK0Z3AToSF9Y', + state: '17cd69f25d8fc9253b6850031c465cc57dc6badd7943f6306444b37c3ce565b7', + }); + const expected = 'https://auth.openai.com/oauth/authorize?client_id=app_EMoamEEZ73f0CkXaXp7hrann&code_challenge=xu7USQwZr4TDQWbZPOmqmkzwB5bbuTzHK0Z3AToSF9Y&code_challenge_method=S256&codex_cli_simplified_flow=true&id_token_add_organizations=true&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback&response_type=code&scope=openid+profile+email+offline_access&state=17cd69f25d8fc9253b6850031c465cc57dc6badd7943f6306444b37c3ce565b7'; + assert.equal(url, expected); +}); + +test('buildAuthorizeUrl 缺少参数时抛错', () => { + const mod = loadOAuthClientModule(); + assert.throws(() => mod.buildAuthorizeUrl({ codeChallenge: 'x' }), /state/); + assert.throws(() => mod.buildAuthorizeUrl({ state: 'y' }), /codeChallenge/); +}); + +test('parseCallbackUrl 成功路径', () => { + const mod = loadOAuthClientModule(); + const result = mod.parseCallbackUrl( + 'http://localhost:1455/auth/callback?code=abc123&state=xyz', + 'xyz' + ); + assert.equal(result.code, 'abc123'); + assert.equal(result.state, 'xyz'); + assert.equal(result.error, undefined); +}); + +test('parseCallbackUrl state 不匹配时返回 error', () => { + const mod = loadOAuthClientModule(); + const result = mod.parseCallbackUrl( + 'http://localhost:1455/auth/callback?code=abc&state=actual', + 'expected' + ); + assert.ok(result.error.includes('state')); +}); + +test('parseCallbackUrl 拒绝非法 URL 路径与端口', () => { + const mod = loadOAuthClientModule(); + assert.equal(mod.parseCallbackUrl('http://localhost:1455/other?code=x&state=y', 'y'), null); + assert.equal(mod.parseCallbackUrl('http://localhost:9999/auth/callback?code=x&state=y', 'y'), null); + assert.equal(mod.parseCallbackUrl('http://evil.com/auth/callback?code=x&state=y', 'y'), null); +}); + +test('parseCallbackUrl error_description 优先', () => { + const mod = loadOAuthClientModule(); + const result = mod.parseCallbackUrl( + 'http://localhost:1455/auth/callback?error=access_denied&error_description=user_canceled&state=y', + 'y' + ); + assert.equal(result.error, 'user_canceled'); + assert.equal(result.code, undefined); +}); + +test('exchangeAuthorizationCode 用 fake fetch 验证请求体与字段抽取', async () => { + const mod = loadOAuthClientModule(); + let capturedUrl = ''; + let capturedBody = ''; + let capturedHeaders = null; + const fakeFetch = async (url, options) => { + capturedUrl = url; + capturedBody = options.body; + capturedHeaders = options.headers; + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ + access_token: 'tok_access', + refresh_token: 'tok_refresh', + id_token: 'tok_id', + expires_in: 3600, + token_type: 'Bearer', + }), + }; + }; + const result = await mod.exchangeAuthorizationCode({ + code: 'code_value', + codeVerifier: 'verifier_value', + fetchImpl: fakeFetch, + }); + assert.equal(capturedUrl, 'https://auth.openai.com/oauth/token'); + assert.equal(capturedHeaders['Content-Type'], 'application/x-www-form-urlencoded'); + const params = new URLSearchParams(capturedBody); + assert.equal(params.get('grant_type'), 'authorization_code'); + assert.equal(params.get('client_id'), 'app_EMoamEEZ73f0CkXaXp7hrann'); + assert.equal(params.get('code'), 'code_value'); + assert.equal(params.get('code_verifier'), 'verifier_value'); + assert.equal(params.get('redirect_uri'), 'http://localhost:1455/auth/callback'); + assert.equal(result.accessToken, 'tok_access'); + assert.equal(result.refreshToken, 'tok_refresh'); + assert.equal(result.idToken, 'tok_id'); + assert.equal(result.expiresIn, 3600); +}); + +test('exchangeAuthorizationCode 失败响应抛出详细错误', async () => { + const mod = loadOAuthClientModule(); + const fakeFetch = async () => ({ + ok: false, + status: 400, + text: async () => JSON.stringify({ error: 'invalid_grant', error_description: 'code expired' }), + }); + await assert.rejects( + () => mod.exchangeAuthorizationCode({ code: 'c', codeVerifier: 'v', fetchImpl: fakeFetch }), + /code expired|invalid_grant/ + ); +}); + +test('exchangeAuthorizationCode 缺少 access_token 时抛错', async () => { + const mod = loadOAuthClientModule(); + const fakeFetch = async () => ({ + ok: true, + status: 200, + text: async () => JSON.stringify({ refresh_token: 'r' }), + }); + await assert.rejects( + () => mod.exchangeAuthorizationCode({ code: 'c', codeVerifier: 'v', fetchImpl: fakeFetch }), + /access_token|refresh_token/ + ); +}); + +test('decodeJwtPayload 能解码示例 id_token', () => { + const mod = loadOAuthClientModule(); + const samplePayload = { email: 'foo@bar.com', exp: 1779903020 }; + const headerB64 = Buffer.from(JSON.stringify({ alg: 'none' }), 'utf-8').toString('base64url'); + const payloadB64 = Buffer.from(JSON.stringify(samplePayload), 'utf-8').toString('base64url'); + const jwt = `${headerB64}.${payloadB64}.sig`; + const decoded = mod.decodeJwtPayload(jwt); + assert.deepEqual(decoded, samplePayload); +}); + +test('buildUpdatedAccount 保留原账号外层字段,仅替换 credentials', () => { + const mod = loadOAuthClientModule(); + const idPayload = { + email: 'ranger@2925.com', + 'https://api.openai.com/auth': { + chatgpt_account_id: 'acct_123', + chatgpt_user_id: 'user_456', + chatgpt_plan_type: 'plus', + organizations: [ + { id: 'org-x', is_default: false }, + { id: 'org-default', is_default: true }, + ], + }, + 'https://api.openai.com/profile': { email: 'ranger@2925.com', email_verified: true }, + }; + const idTokenJwt = [ + Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url'), + Buffer.from(JSON.stringify(idPayload)).toString('base64url'), + 'sig', + ].join('.'); + const original = { + name: 'ranger@2925.com', + platform: 'openai', + type: 'oauth', + credentials: { email: 'ranger@2925.com', client_id: 'old', refresh_token: 'old-refresh' }, + extra: { email: 'ranger@2925.com' }, + concurrency: 3, + priority: 3, + }; + const updated = mod.buildUpdatedAccount(original, { + accessToken: 'new-access', + refreshToken: 'new-refresh', + idToken: idTokenJwt, + expiresIn: 3600, + }); + assert.equal(updated.name, 'ranger@2925.com'); + assert.equal(updated.concurrency, 3); + assert.equal(updated.credentials.access_token, 'new-access'); + assert.equal(updated.credentials.refresh_token, 'new-refresh'); + assert.equal(updated.credentials.id_token, idTokenJwt); + assert.equal(updated.credentials.chatgpt_account_id, 'acct_123'); + assert.equal(updated.credentials.organization_id, 'org-default'); + assert.equal(updated.credentials.plan_type, 'plus'); + assert.equal(updated.credentials.client_id, 'app_EMoamEEZ73f0CkXaXp7hrann'); + assert.ok(updated.credentials.expires_at > Math.floor(Date.now() / 1000)); +}); From fe3f96ba17cf79d9dc87febbfb2ece10a6b706d2 Mon Sep 17 00:00:00 2001 From: ranger Date: Thu, 28 May 2026 17:02:37 +0800 Subject: [PATCH 02/22] =?UTF-8?q?chore:=20gitignore=20=E5=8A=A0=E5=85=A5?= =?UTF-8?q?=20.omc/=20=E5=92=8C=20/tmp/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .omc/: oh-my-claudecode 状态目录 - /tmp/: 本地实验脚本与含真实 token 的账号 JSON 临时存放区,不入库 --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 9ed4eab3..c03f9f95 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ /data/account-run-history.json .npm-test.log .omx/ +.omc/ +/tmp/ /node_modules /.runtime /docs/新步骤顺序 From b2908c4e02425f5a2a3c1f15a78523deef10c04e Mon Sep 17 00:00:00 2001 From: ranger Date: Thu, 28 May 2026 17:44:12 +0800 Subject: [PATCH 03/22] =?UTF-8?q?feat(openai-reauth):=20sidepanel=20UI=20?= =?UTF-8?q?=E9=9B=86=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - flows/openai-reauth/index.js: settingsGroups 加 reauth-input + baseGroups 追加 - flows/openai-reauth/reauth-account-validator.js: 独立校验函数模块(新建,可单测) - sidepanel/sidepanel.html: 新增 row-reauth-account-json 和 row-reauth-result(含 textarea/pre/复制按钮) - sidepanel/sidepanel.css: textarea / readonly pre 样式 - sidepanel/sidepanel.js: DOM 常量、ensurePendingReauthAccount、renderReauthResultAccount、step 按钮 payload 注入、复制按钮绑定 - background/message-router.js: EXECUTE_NODE case 内提取 reauthInputAccount 写入 state - tests/openai-reauth-oauth-client.test.js: 用 node:crypto webcrypto 替代 globalThis.crypto,修复 Node 18 sandbox - tests/openai-reauth-validate-account-json.test.js: 13 个校验函数边界测试(新建) reauth 测试 50/50 通过;全量回归 1316/1320,4 个失败是 master 原本的 kiro Node 18 sandbox 问题,与 P2 无关 --- background/message-router.js | 3 + flows/openai-reauth/index.js | 10 +- .../openai-reauth/reauth-account-validator.js | 57 +++++++ sidepanel/sidepanel.css | 31 ++++ sidepanel/sidepanel.html | 30 ++++ sidepanel/sidepanel.js | 88 ++++++++++- tests/openai-reauth-oauth-client.test.js | 5 +- ...penai-reauth-validate-account-json.test.js | 142 ++++++++++++++++++ 8 files changed, 361 insertions(+), 5 deletions(-) create mode 100644 flows/openai-reauth/reauth-account-validator.js create mode 100644 tests/openai-reauth-validate-account-json.test.js diff --git a/background/message-router.js b/background/message-router.js index 3fa9d935..1f10eb43 100644 --- a/background/message-router.js +++ b/background/message-router.js @@ -1375,6 +1375,9 @@ await setPersistentSettings({ emailPrefix: message.payload.emailPrefix }); await setState({ emailPrefix: message.payload.emailPrefix }); } + if (message.payload.reauthInputAccount !== undefined) { + await setState({ reauthInputAccount: message.payload.reauthInputAccount }); + } const executionState = await getState(); if (doesNodeUseCompletionSignal(nodeId, executionState)) { const completionPayload = await executeNodeViaCompletionSignal(nodeId); diff --git a/flows/openai-reauth/index.js b/flows/openai-reauth/index.js index 8321737e..785c556a 100644 --- a/flows/openai-reauth/index.js +++ b/flows/openai-reauth/index.js @@ -25,11 +25,17 @@ supportsAccountContribution: false, supportedTargetIds: [], }, - baseGroups: ['openai-oauth'], + baseGroups: ['openai-oauth', 'reauth-input'], targets: {}, defaultTargetId: null, settingsDefaults: {}, - settingsGroups: {}, + settingsGroups: { + 'reauth-input': { + id: 'reauth-input', + label: 'OAuth 重新授权', + rowIds: ['row-reauth-account-json', 'row-reauth-result'], + }, + }, targetCapabilities: {}, runtimeSources: { 'openai-auth': { diff --git a/flows/openai-reauth/reauth-account-validator.js b/flows/openai-reauth/reauth-account-validator.js new file mode 100644 index 00000000..f9f8bfdd --- /dev/null +++ b/flows/openai-reauth/reauth-account-validator.js @@ -0,0 +1,57 @@ +(function attachOpenAiReauthAccountValidator(root, factory) { + root.MultiPageOpenAiReauthAccountValidator = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createOpenAiReauthAccountValidatorModule() { + function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); + } + + function cleanString(value) { + return String(value ?? '').trim(); + } + + function validateReauthAccountJson(rawText) { + const trimmed = cleanString(rawText); + if (!trimmed) { + return { ok: false, error: '请粘贴 account 对象 JSON。' }; + } + + let parsed; + try { + parsed = JSON.parse(trimmed); + } catch (error) { + return { ok: false, error: `JSON 解析失败:${error.message}` }; + } + + if (!isPlainObject(parsed)) { + return { ok: false, error: 'JSON 必须是对象(不是数组或基本类型)。' }; + } + + const credentialsObject = isPlainObject(parsed.credentials) ? parsed.credentials : {}; + const email = cleanString(credentialsObject.email) + || cleanString(parsed.email) + || cleanString(parsed.name); + if (!email) { + return { ok: false, error: 'JSON 缺少 email:请检查 credentials.email / email / name 任一字段。' }; + } + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + return { ok: false, error: `识别到的 email 不是合法地址:${email}` }; + } + + const mailProvider = cleanString(parsed.mailProvider); + if (!mailProvider) { + return { + ok: false, + error: '缺少顶层 mailProvider 字段(必须显式声明邮箱来源,如 "2925" / "hotmail-api" / "icloud" / "luckmail-api" / "cloudmail" / "yyds-mail" / "cloudflare-temp-email")。', + }; + } + + return { + ok: true, + email, + mailProvider, + account: parsed, + }; + } + + return { validateReauthAccountJson }; +}); diff --git a/sidepanel/sidepanel.css b/sidepanel/sidepanel.css index 613dd007..7570cfe5 100644 --- a/sidepanel/sidepanel.css +++ b/sidepanel/sidepanel.css @@ -3705,3 +3705,34 @@ header { transition-duration: 0.01ms !important; } } + +.data-row-stack { + flex-direction: column; + align-items: stretch; + gap: 6px; + width: 100%; +} +.data-textarea-reauth { + width: 100%; + min-height: 140px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; + resize: vertical; +} +.data-readonly-reauth { + width: 100%; + min-height: 160px; + max-height: 360px; + margin: 0; + padding: 8px; + overflow: auto; + white-space: pre-wrap; + word-break: break-all; + background: rgba(0, 0, 0, 0.04); + border-radius: 4px; + font-size: 12px; +} +#reauth-json-status.ok, +#reauth-copy-status.ok { color: #198754; } +#reauth-json-status.error, +#reauth-copy-status.error { color: #dc3545; } diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index 2876411d..5d88a356 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -668,6 +668,33 @@ 等待中... + + +
+ 设置 +
+ +
+
-