/** * Windsurf direct login — Auth1/Firebase auth + Codeium registration. * Supports proxy tunneling and fingerprint randomization. */ import http from 'http'; import https from 'https'; import { log } from '../config.js'; import { isSocks, createSocksTunnel } from '../socks.js'; const FIREBASE_API_KEY = 'AIzaSyDsOl-1XpT5err0Tcnx8FFod1H8gVGIycY'; const FIREBASE_AUTH_URL = `https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=${FIREBASE_API_KEY}`; const FIREBASE_REFRESH_URL = `https://securetoken.googleapis.com/v1/token?key=${FIREBASE_API_KEY}`; const CODEIUM_REGISTER_URL = 'https://api.codeium.com/register_user/'; const AUTH1_CONNECTIONS_URL = 'https://windsurf.com/_devin-auth/connections'; const AUTH1_PASSWORD_LOGIN_URL = 'https://windsurf.com/_devin-auth/password/login'; // 2026-04-26: Windsurf moved the primary email-method probe to a Connect-RPC // path under `_backend/...SeatManagementService/CheckUserLoginMethod`. The // response is fast and clean (`{userExists,hasPassword}`); the old // `/_devin-auth/connections` path is still wired in their bundle but // runs on Vercel functions that 504 every few seconds. We use the new // endpoint as the primary probe and fall back to the old one only if // the new one is unreachable. const WINDSURF_CHECK_LOGIN_METHOD_URL = 'https://windsurf.com/_backend/exa.seat_management_pb.SeatManagementService/CheckUserLoginMethod'; const WINDSURF_SEAT_SERVICE_BASE = 'https://server.self-serve.windsurf.com/exa.seat_management_pb.SeatManagementService'; const WINDSURF_POST_AUTH_URL = `${WINDSURF_SEAT_SERVICE_BASE}/WindsurfPostAuth`; const WINDSURF_ONE_TIME_TOKEN_URL = `${WINDSURF_SEAT_SERVICE_BASE}/GetOneTimeAuthToken`; // v2.0.57 (Fix 2): Windsurf migrated PostAuth into the website _backend // path. Wam-bundle and the official 2.0.67 IDE talk to the new host; // keep the old self-serve endpoint as fallback so a regional outage on // either side doesn't break login. Same for GetOneTimeAuthToken which // shares the SeatManagementService surface. const WINDSURF_BACKEND_SEAT_BASE = 'https://windsurf.com/_backend/exa.seat_management_pb.SeatManagementService'; const WINDSURF_POST_AUTH_URL_NEW = `${WINDSURF_BACKEND_SEAT_BASE}/WindsurfPostAuth`; const WINDSURF_ONE_TIME_TOKEN_URL_NEW = `${WINDSURF_BACKEND_SEAT_BASE}/GetOneTimeAuthToken`; function parsePostAuthResponseData(payload) { const raw = Buffer.isBuffer(payload) ? payload.toString('utf8') : String(payload || ''); try { const parsed = JSON.parse(raw); if (parsed && typeof parsed === 'object') return parsed; } catch {} const sessionToken = raw.match(/devin-session-token\$[a-zA-Z0-9._-]+/)?.[0]; const accountId = raw.match(/account-[a-f0-9]+/)?.[0]; const primaryOrgId = raw.match(/org-[a-f0-9]+/)?.[0]; if (sessionToken) return { sessionToken, accountId, primaryOrgId }; return { error: raw.slice(0, 200) || 'empty response' }; } async function postAuthDualPath(auth1Token, fingerprint, proxy, preferredHost = null) { // v2.0.91 (#144 by @Await-d): upstream PostAuth now expects: // - empty application/proto body (not the old JSON bridge) // - X-Devin-Auth1-Token header (not in body) // - Referer from windsurf.com // - Raw response that may be binary proto or JSON const body = Buffer.alloc(0); const headers = { ...fingerprint, 'Content-Type': 'application/proto', 'Content-Length': 0, 'Connect-Protocol-Version': '1', 'X-Devin-Auth1-Token': auth1Token, 'Referer': 'https://windsurf.com/account/login', }; const orderedHosts = preferredHost === 'legacy' ? [[WINDSURF_POST_AUTH_URL, 'legacy'], [WINDSURF_POST_AUTH_URL_NEW, 'new']] : [[WINDSURF_POST_AUTH_URL_NEW, 'new'], [WINDSURF_POST_AUTH_URL, 'legacy']]; let lastErr; for (const [url, label] of orderedHosts) { try { const rawRes = await httpsRequest(url, { method: 'POST', headers, raw: true }, body, proxy); const res = { ...rawRes, data: parsePostAuthResponseData(rawRes.data) }; if (res.status >= 400 && res.status < 500) return { res, label }; if (res.status >= 200 && res.status < 300 && res.data?.sessionToken) { return { res, label }; } lastErr = new Error(`PostAuth ${label} HTTP ${res.status}: ${JSON.stringify(res.data).slice(0, 120)}`); } catch (e) { lastErr = new Error(`PostAuth ${label}: ${e.message}`); } } throw lastErr || new Error('PostAuth: both endpoints failed'); } async function oneTimeTokenDualPath(body, fingerprint, proxy, preferredHost = null) { // v2.0.61 (#114): pin OneTimeAuthToken to whichever host PostAuth used. // The session token gateways aren't symmetric — a token minted by // windsurf.com/_backend may be rejected as "invalid token" on // server.self-serve.windsurf.com (and vice versa). When we know which // host PostAuth just talked to, retry order is forced to put it first // so we don't accidentally replay the token across host boundaries. const headers = buildJsonHeaders(fingerprint, body, { 'Connect-Protocol-Version': '1' }); const orderedHosts = preferredHost === 'legacy' ? [[WINDSURF_ONE_TIME_TOKEN_URL, 'legacy'], [WINDSURF_ONE_TIME_TOKEN_URL_NEW, 'new']] : [[WINDSURF_ONE_TIME_TOKEN_URL_NEW, 'new'], [WINDSURF_ONE_TIME_TOKEN_URL, 'legacy']]; let lastErr; let firstRes = null; let firstLabel = null; for (const [url, label] of orderedHosts) { try { const res = await httpsRequest(url, { method: 'POST', headers }, body, proxy); if (res.status >= 200 && res.status < 300 && res.data?.authToken) { return { res, label }; } // v2.0.61: 4xx from the preferred host is meaningful — used to // return immediately so caller saw the real auth error. // // v2.0.79 (audit M-3): widened to keep trying the other host // ONLY when the preferred host returned an "invalid token" // 401 — that signal is exactly the cross-host symmetry failure // we want to fall through. Other 4xx codes (400 bad request, // 403 forbidden, 410 gone) still short-circuit because they're // genuine permanent errors and trying the other host won't help. if (res.status >= 400 && res.status < 500) { const blob = JSON.stringify(res.data || '').toLowerCase(); const isInvalidToken = res.status === 401 && /invalid\s*token|unauthenticated/i.test(blob); if (label === orderedHosts[0][1] && !isInvalidToken) { return { res, label }; } // Either non-preferred 4xx OR preferred-but-invalid-token: keep // the response around in case the other host also fails — we // surface the FIRST 4xx (preferred host) so the caller sees the // primary auth error not whatever the fallback produced. if (firstRes === null) { firstRes = res; firstLabel = label; } lastErr = new Error(`OneTimeToken ${label} HTTP ${res.status}: ${JSON.stringify(res.data).slice(0, 120)}`); continue; } lastErr = new Error(`OneTimeToken ${label} HTTP ${res.status}: ${JSON.stringify(res.data).slice(0, 120)}`); } catch (e) { lastErr = new Error(`OneTimeToken ${label}: ${e.message}`); } } // Both hosts failed — return the preferred-host 4xx if we have one // (more useful to the caller than the fallback's error). if (firstRes) return { res: firstRes, label: firstLabel }; throw lastErr || new Error('OneTimeToken: both endpoints failed'); } // ─── Fingerprint randomization ──────────────────────────── const OS_VERSIONS = [ 'Windows NT 10.0; Win64; x64', 'Windows NT 10.0; WOW64', 'Macintosh; Intel Mac OS X 10_15_7', 'Macintosh; Intel Mac OS X 11_6_0', 'Macintosh; Intel Mac OS X 12_3_1', 'Macintosh; Intel Mac OS X 13_4_1', 'Macintosh; Intel Mac OS X 14_2_1', 'X11; Linux x86_64', 'X11; Ubuntu; Linux x86_64', ]; const CHROME_VERSIONS = [ '120.0.0.0', '121.0.0.0', '122.0.0.0', '123.0.0.0', '124.0.0.0', '125.0.0.0', '126.0.0.0', '127.0.0.0', '128.0.0.0', '129.0.0.0', '130.0.0.0', '131.0.0.0', '132.0.0.0', '133.0.0.0', '134.0.0.0', ]; const ACCEPT_LANGUAGES = [ 'en-US,en;q=0.9', 'en-GB,en;q=0.9', 'zh-TW,zh;q=0.9,en;q=0.8', 'zh-CN,zh;q=0.9,en;q=0.8', 'ja,en-US;q=0.9,en;q=0.8', 'ko,en-US;q=0.9,en;q=0.8', 'de,en-US;q=0.9,en;q=0.8', 'fr,en-US;q=0.9,en;q=0.8', 'es,en-US;q=0.9,en;q=0.8', 'pt-BR,pt;q=0.9,en;q=0.8', ]; function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; } function generateFingerprint() { const os = pick(OS_VERSIONS); const chromeVer = pick(CHROME_VERSIONS); const major = chromeVer.split('.')[0]; const ua = `Mozilla/5.0 (${os}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeVer} Safari/537.36`; return { 'User-Agent': ua, 'Accept-Language': pick(ACCEPT_LANGUAGES), 'Accept': 'application/json, text/plain, */*', 'Accept-Encoding': 'identity', 'sec-ch-ua': `"Chromium";v="${major}", "Google Chrome";v="${major}", "Not-A.Brand";v="99"`, 'sec-ch-ua-mobile': '?0', 'sec-ch-ua-platform': os.includes('Windows') ? '"Windows"' : os.includes('Mac') ? '"macOS"' : '"Linux"', 'Sec-Fetch-Dest': 'empty', 'Sec-Fetch-Mode': 'cors', 'Sec-Fetch-Site': 'cross-site', 'Origin': 'https://windsurf.com', 'Referer': 'https://windsurf.com/', }; } function buildJsonHeaders(fingerprint, body, extra = {}) { return { ...fingerprint, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), ...extra, }; } // ─── Proxy tunnel (HTTP CONNECT or SOCKS5) ─────────────── function createProxyTunnel(proxy, targetHost, targetPort) { if (isSocks(proxy)) return createSocksTunnel(proxy, targetHost, targetPort); return new Promise((resolve, reject) => { const proxyHost = proxy.host.replace(/:\d+$/, ''); const proxyPort = proxy.port || 8080; const connectReq = http.request({ host: proxyHost, port: proxyPort, method: 'CONNECT', path: `${targetHost}:${targetPort}`, headers: { Host: `${targetHost}:${targetPort}`, ...(proxy.username ? { 'Proxy-Authorization': `Basic ${Buffer.from(`${proxy.username}:${proxy.password || ''}`).toString('base64')}` } : {}), }, }); connectReq.on('connect', (res, socket) => { if (res.statusCode === 200) { resolve(socket); } else { socket.destroy(); reject(new Error(`Proxy CONNECT failed: ${res.statusCode}`)); } }); connectReq.on('error', (err) => reject(new Error(`Proxy connection error: ${err.message}`))); connectReq.setTimeout(15000, () => { connectReq.destroy(); reject(new Error('Proxy connection timeout')); }); connectReq.end(); }); } // ─── HTTPS request with optional proxy ──────────────────── function httpsRequest(url, opts, postData, proxy) { return new Promise(async (resolve, reject) => { const parsed = new URL(url); const requestOpts = { hostname: parsed.hostname, port: 443, path: parsed.pathname + parsed.search, method: opts.method || 'POST', headers: opts.headers || {}, }; const handleResponse = (res) => { const bufs = []; res.on('data', d => bufs.push(d)); res.on('end', () => { const rawBuffer = Buffer.concat(bufs); if (opts.raw) { resolve({ status: res.statusCode, data: rawBuffer }); return; } const raw = rawBuffer.toString('utf8'); try { resolve({ status: res.statusCode, data: JSON.parse(raw) }); } catch { reject(new Error(`Parse error (status ${res.statusCode}, encoding ${res.headers['content-encoding'] || 'identity'}): ${raw.slice(0, 200)}`)); } }); res.on('error', reject); }; try { let req; if (proxy && proxy.host) { const socket = await createProxyTunnel(proxy, parsed.hostname, 443); requestOpts.socket = socket; requestOpts.agent = false; req = https.request(requestOpts, handleResponse); } else { req = https.request(requestOpts, handleResponse); } req.on('error', (err) => reject(new Error(`Request error: ${err.message}`))); req.setTimeout(30000, () => { req.destroy(); reject(new Error('Request timeout')); }); if (postData) req.write(postData); req.end(); } catch (err) { reject(err); } }); } // ─── Login flow ─────────────────────────────────────────── function createFriendlyAuthError(prefix, detail, fallback = 'ERR_LOGIN_FAILED') { const normalized = String(detail || '').trim(); // Map Firebase/Auth1 error codes to our error codes const errorCodeMap = { 'EMAIL_NOT_FOUND': 'ERR_EMAIL_NOT_FOUND', 'INVALID_PASSWORD': 'ERR_INVALID_PASSWORD', 'INVALID_LOGIN_CREDENTIALS': 'ERR_INVALID_CREDENTIALS', 'Invalid email or password': 'ERR_INVALID_CREDENTIALS', 'No password set. Please log in with Google or GitHub.': 'ERR_NO_PASSWORD_SET', 'No password set': 'ERR_NO_PASSWORD_SET', 'USER_DISABLED': 'ERR_USER_DISABLED', 'TOO_MANY_ATTEMPTS_TRY_LATER': 'ERR_TOO_MANY_ATTEMPTS', 'INVALID_EMAIL': 'ERR_INVALID_EMAIL', }; const errorCode = errorCodeMap[normalized] || normalized || fallback; const err = new Error(errorCode); err.isAuthFail = [ 'EMAIL_NOT_FOUND', 'INVALID_PASSWORD', 'INVALID_LOGIN_CREDENTIALS', 'Invalid email or password', 'No password set. Please log in with Google or GitHub.', 'No password set', ].includes(normalized); err.firebaseCode = normalized || undefined; err.code = errorCode; return err; } // 5xx retry helper: Windsurf 的 _devin-auth/* 端点跑在 Vercel functions // 上,时不时 504 / 503 (FUNCTION_INVOCATION_TIMEOUT)。一次 dispatch 的 // 暂时失败不该让用户看到 "登录失败",所以对 5xx 加退避重试 (max 3 次)。 // 4xx 和 200 直接返回不重试。 async function httpsRequestRetrying(url, opts, postData, proxy, label = 'request') { let lastErr = null; const delays = [0, 2000, 5000]; for (let i = 0; i < delays.length; i++) { if (delays[i]) await new Promise(r => setTimeout(r, delays[i])); try { const res = await httpsRequest(url, opts, postData, proxy); if (res.status >= 500 && res.status < 600) { log.warn(`${label} upstream ${res.status} (attempt ${i + 1}/${delays.length})`); lastErr = new Error(`Windsurf upstream ${res.status}: ${JSON.stringify(res.data || '').slice(0, 120)}`); continue; } return res; } catch (e) { log.warn(`${label} threw: ${e.message} (attempt ${i + 1}/${delays.length})`); lastErr = e; } } throw lastErr || new Error(`${label} failed after retries`); } // Windsurf 在 2026-04-26 把 /_devin-auth/connections 的响应从 // { auth_method: { method: 'auth1', has_password: bool } } // 换成 // { connections: [{ id, type, enabled, client_id }, ...] } // 其中 type:'email' + enabled:true = 该账号支持邮箱密码登录。 // 这个函数兼容新旧两种形态,返回统一的 { method, hasPassword, raw }。 function interpretConnections(data) { if (data && Array.isArray(data.connections)) { const email = data.connections.find(c => c && c.type === 'email'); return { method: 'auth1', hasPassword: !!(email && email.enabled), raw: data, }; } if (data && data.auth_method) { return { method: data.auth_method.method || null, hasPassword: data.auth_method.has_password !== false, raw: data, }; } return { method: null, hasPassword: false, raw: data || {} }; } async function fetchAuth1Connections(email, fingerprint, proxy) { const body = JSON.stringify({ product: 'windsurf', email }); const headers = buildJsonHeaders(fingerprint, body); const res = await httpsRequestRetrying( AUTH1_CONNECTIONS_URL, { method: 'POST', headers }, body, proxy, 'Auth1 connections' ); return res.data || {}; } // New primary email-method probe (Windsurf 2026-04-26 migration). // Returns the same { method, hasPassword, raw } shape as // interpretConnections so call sites are uniform. On reachability failure // returns null (caller falls back to /_devin-auth/connections). async function fetchCheckUserLoginMethod(email, fingerprint, proxy) { const body = JSON.stringify({ email }); const headers = buildJsonHeaders(fingerprint, body, { 'Connect-Protocol-Version': '1' }); try { const res = await httpsRequest( WINDSURF_CHECK_LOGIN_METHOD_URL, { method: 'POST', headers }, body, proxy ); if (res.status !== 200 || !res.data || typeof res.data !== 'object') { log.warn(`CheckUserLoginMethod non-200 (${res.status}): ${JSON.stringify(res.data || '').slice(0, 120)}`); return null; } // Empirically (2026-04-29) the Vercel function will sometimes serve // an empty `{}` for valid emails — likely a cache miss / cold-start // edge or geo-routing fallback. Treating `userExists`/`hasPassword` // as false in that case wrongly funnels every account into the // "no password set" branch and aborts before any login attempt. // When neither field is present, defer to the legacy connections // endpoint instead of guessing. const hasUserField = Object.prototype.hasOwnProperty.call(res.data, 'userExists'); const hasPwField = Object.prototype.hasOwnProperty.call(res.data, 'hasPassword'); if (!hasUserField && !hasPwField) { log.warn(`CheckUserLoginMethod empty body for ${email}, falling back to /_devin-auth/connections`); return null; } if (res.data.userExists === false) { // Caller maps this to "user not found" via interpretConnections{method:null}. return { method: null, hasPassword: false, raw: res.data }; } return { method: 'auth1', hasPassword: !!res.data.hasPassword, raw: res.data, }; } catch (e) { log.warn(`CheckUserLoginMethod unreachable: ${e.message}`); return null; } } async function registerWithCodeium(token, fingerprint, proxy) { // v2.0.57 (Fix 1): try register.windsurf.com first, fall back to // api.codeium.com. Both go through our fingerprint+proxy-aware // httpsRequest so the egress IP / UA stays consistent with the rest of // this login flow. const { registerWithFirebaseToken } = await import('../windsurf-api.js'); const requestFn = async (url, opts, body) => { // buildJsonHeaders adds fingerprint headers; preserve any explicit // Connect-Protocol-Version / Accept the helper provided. const merged = buildJsonHeaders(fingerprint, body, { 'Connect-Protocol-Version': '1', 'Accept': 'application/json', }); const r = await httpsRequest(url, { method: opts.method || 'POST', headers: merged }, body, proxy); return { status: r.status, data: r.data, raw: typeof r.data === 'string' ? r.data : JSON.stringify(r.data || {}) }; }; try { const r = await registerWithFirebaseToken(token, { requestFn, proxy }); // Preserve the snake_case shape downstream callers consume. return { api_key: r.apiKey, name: r.name, api_server_url: r.apiServerUrl, }; } catch (e) { throw new Error(`ERR_CODEIUM_REGISTER_FAILED:${e.message}`); } } async function windsurfLoginViaAuth1(email, password, fingerprint, proxy) { const loginBody = JSON.stringify({ email, password }); const loginHeaders = buildJsonHeaders(fingerprint, loginBody); const loginRes = await httpsRequestRetrying( AUTH1_PASSWORD_LOGIN_URL, { method: 'POST', headers: loginHeaders }, loginBody, proxy, 'Auth1 password/login' ); // Pydantic v2 returns `detail: [...]` for validation errors; the older // shape was `detail: 'message'`. Normalize both for the friendly-error // mapper so we don't blow up trying to .toLowerCase an array. const rawDetail = loginRes.data?.detail; const detailMsg = Array.isArray(rawDetail) ? rawDetail.map(d => d?.msg || d?.type || JSON.stringify(d)).join('; ') : (typeof rawDetail === 'string' ? rawDetail : ''); if (loginRes.status >= 400 || detailMsg) { throw createFriendlyAuthError('Auth1', detailMsg, 'ERR_LOGIN_FAILED'); } const auth1Token = loginRes.data?.token; if (!auth1Token) { throw new Error(`ERR_AUTH1_TOKEN_MISSING:${JSON.stringify(loginRes.data).slice(0, 200)}`); } log.info(`Auth1 login OK: ${email}`); // v2.0.90 (#114 lnqdev / CharwinYAO): drop OneTimeAuthToken + Codeium // register_user step entirely. Upstream GetOneTimeAuthToken started // returning 401 invalid_token for ALL sessionTokens — matrix probe // (scripts/probes/v2089-ott-host-matrix.mjs) confirmed 12/12 fail // across 3 accounts × {sToken_new, sToken_legacy} × {OTT_new, // OTT_legacy}. Cross-host retry can't save it; the OTT endpoint is // gone for good (Cognition migrated to the Devin auth flow). // // Reverse-engineering windsurf-assistant v17.42.20 (2026-04-27, the // upstream-tracked Windsurf account-switcher) confirms its production // path is Devin-only: // Auth1 password/login → WindsurfPostAuth → sessionToken // and uses sessionToken directly as the IDE auth credential. No OTT, // no RegisterUser, no codeium register_user. // // Probe scripts/probes/v2089-sessiontoken-as-apikey.mjs verified the // Cascade gRPC backend (server.codeium.com / // server.self-serve.windsurf.com) accepts the raw sessionToken // (devin-session-token$xxx) as metadata.apiKey on GetUserStatus // → 4/4 200 OK with valid planName. The downstream protocol treats // it identically to the codeium register_user-issued sk-ws-01-... key. // // So the chain collapses from // Auth1 → PostAuth → OTT → registerWithCodeium → apiKey // to // Auth1 → PostAuth → apiKey = sessionToken. // RegisterUser only accepts firebase_id_token (won't take sessionToken) // and Firebase signInWithPassword now demands App Check tokens that // server-side callers can't produce — so the firebase path is dead // too. Devin path is the only one that works post-2026-05-04. const { res: br, label: bl } = await postAuthDualPath(auth1Token, fingerprint, proxy); if (br.status >= 400 || !br.data?.sessionToken) { throw new Error(`ERR_POSTAUTH_FAILED:${JSON.stringify(br.data).slice(0, 200)}`); } const sessionToken = br.data.sessionToken; const accountId = br.data.accountId || 'unknown'; log.info(`Windsurf PostAuth OK (${bl}): ${email} account=${accountId} → using sessionToken as apiKey`); return { apiKey: sessionToken, name: email, email, apiServerUrl: '', sessionToken, auth1Token, }; } async function windsurfLoginViaFirebase(email, password, fingerprint, proxy) { const firebaseBody = JSON.stringify({ email, password, returnSecureToken: true, }); const fbHeaders = buildJsonHeaders(fingerprint, firebaseBody); const fbRes = await httpsRequest(FIREBASE_AUTH_URL, { method: 'POST', headers: fbHeaders }, firebaseBody, proxy); if (fbRes.data.error) { const msg = fbRes.data.error.message || 'Unknown Firebase error'; throw createFriendlyAuthError('Firebase', msg, msg); } const idToken = fbRes.data.idToken; if (!idToken) throw new Error('ERR_FIREBASE_TOKEN_MISSING'); log.info(`Firebase login OK: ${email}, UID=${fbRes.data.localId}`); const reg = await registerWithCodeium(idToken, fingerprint, proxy); log.info(`Codeium register OK: ${email} → key=${reg.api_key.slice(0, 20)}...`); return { apiKey: reg.api_key, name: reg.name || email, email, idToken, refreshToken: fbRes.data.refreshToken || '', apiServerUrl: reg.api_server_url || '', }; } /** * Full Windsurf login: * - Auth1 password login → bridge session → one-time auth token → Codeium register * - or legacy Firebase auth → Codeium register * @param {string} email * @param {string} password * @param {object} [proxy] - { host, port, username, password } * @returns {{ apiKey, name, email, idToken }} */ // v2.0.57 Fix 6 — per-email brute-force lockout. Inspiration: // windsurf-assistant-pub `_bumpFailure` (3 strikes / 15 min ban). Without // this, a dashboard-authenticated operator hammering bad credentials // against /auth/login burns through Firebase/Windsurf upstream rate // limits per account and risks getting the *real* email flagged. Lock // the email locally so we never forward more than 3 fresh attempts in // any 15-minute window. const EMAIL_LOCK_THRESHOLD = 3; const EMAIL_LOCK_DURATION_MS = 15 * 60 * 1000; const EMAIL_LOCK_IDLE_TTL_MS = 2 * 60 * 60 * 1000; const _emailFailures = new Map(); export function _resetEmailLockoutForTests() { _emailFailures.clear(); } export function checkEmailLocked(email) { if (!email || typeof email !== 'string') return null; const k = email.toLowerCase(); const e = _emailFailures.get(k); if (!e) return null; const now = Date.now(); if (e.lockedUntil > now) return e.lockedUntil - now; if (e.lockedUntil > 0 && e.lockedUntil <= now) { e.count = 0; e.lockedUntil = 0; } return null; } function recordEmailFailure(email, reason) { if (!email) return; const k = email.toLowerCase(); const now = Date.now(); let e = _emailFailures.get(k); if (!e) { e = { count: 0, lockedUntil: 0, lastActivity: now }; _emailFailures.set(k, e); } e.count += 1; e.lastActivity = now; e.lastReason = reason ? String(reason).slice(0, 80) : ''; if (e.count >= EMAIL_LOCK_THRESHOLD) { e.lockedUntil = now + EMAIL_LOCK_DURATION_MS; e.count = 0; log.warn(`Email lockout: ${k} banned for ${EMAIL_LOCK_DURATION_MS / 60000}min after ${EMAIL_LOCK_THRESHOLD} failed Windsurf logins (last="${e.lastReason}")`); } } function recordEmailSuccess(email) { if (!email) return; _emailFailures.delete(email.toLowerCase()); } setInterval(() => { const now = Date.now(); for (const [k, e] of _emailFailures) { if (e.lockedUntil > now) continue; if (now - (e.lastActivity || 0) > EMAIL_LOCK_IDLE_TTL_MS) _emailFailures.delete(k); } }, 60 * 60 * 1000).unref?.(); export async function windsurfLogin(email, password, proxy = null) { const lockMs = checkEmailLocked(email); if (lockMs != null) { const minutes = Math.ceil(lockMs / 60000); const err = new Error(`Email ${email} 因连续 ${EMAIL_LOCK_THRESHOLD} 次登录失败被本地锁定,请 ${minutes} 分钟后再试。`); err.code = 'ERR_EMAIL_LOCKED'; err.retryAfterMs = lockMs; err.isAuthFail = false; throw err; } const fingerprint = generateFingerprint(); log.info(`Windsurf login: ${email} fp=${fingerprint['User-Agent'].slice(0, 40)}... proxy=${proxy?.host || 'none'}`); // Probe sequence (per Windsurf 2026-04-26 half-migration): // 1. CheckUserLoginMethod (new Connect-RPC, fast + clean shape) // 2. _devin-auth/connections (old path, slow/flaky but still wired) // 3. fall through to Firebase legacy path let conn = await fetchCheckUserLoginMethod(email, fingerprint, proxy); if (!conn || conn.method === null) { let auth1Connections = null; try { auth1Connections = await fetchAuth1Connections(email, fingerprint, proxy); } catch (err) { log.warn(`Auth1 connections probe failed for ${email}: ${err.message}`); } // interpretConnections handles BOTH the old `{auth_method:{...}}` // and the post-2026-04-26 `{connections:[...]}` shape — Windsurf is // currently serving both depending on which CDN edge you hit. conn = interpretConnections(auth1Connections); } if (conn.method === 'auth1') { if (!conn.hasPassword) { const err = createFriendlyAuthError('Auth1', 'No password set. Please log in with Google or GitHub.'); recordEmailFailure(email, 'no_password'); throw err; } try { const result = await windsurfLoginViaAuth1(email, password, fingerprint, proxy); recordEmailSuccess(email); return result; } catch (e) { // Auth-shaped failures count toward the lockout. Network / 5xx // upstream errors don't (those aren't the operator's fault). if (e?.isAuthFail || /ERR_LOGIN_FAILED|ERR_AUTH1|EMAIL|PASSWORD/i.test(e?.message || '')) { recordEmailFailure(email, e?.message); } throw e; } } try { const result = await windsurfLoginViaFirebase(email, password, fingerprint, proxy); recordEmailSuccess(email); return result; } catch (firebaseErr) { if (!firebaseErr?.isAuthFail) { // Network / Firebase 5xx — don't count, just bubble up. throw firebaseErr; } try { const result = await windsurfLoginViaAuth1(email, password, fingerprint, proxy); recordEmailSuccess(email); return result; } catch (auth1Err) { if (auth1Err?.isAuthFail) { // Both paths confirmed the credential is wrong — count as one // failure (not two) so 3 distinct attempts truly = ban. recordEmailFailure(email, firebaseErr?.message || auth1Err?.message); throw firebaseErr; } throw auth1Err; } } } /** * Refresh a Firebase ID token using a stored refresh token. * Returns a new { idToken, refreshToken, expiresIn } or throws. * * @param {string} refreshToken * @param {object} [proxy] * @returns {Promise<{idToken: string, refreshToken: string, expiresIn: number}>} */ export async function refreshFirebaseToken(refreshToken, proxy = null) { if (!refreshToken) throw new Error('No refresh token available'); const postBody = `grant_type=refresh_token&refresh_token=${encodeURIComponent(refreshToken)}`; const headers = { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(postBody), 'Referer': 'https://windsurf.com/', 'Origin': 'https://windsurf.com', 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/130.0.0.0 Safari/537.36', }; const res = await httpsRequest(FIREBASE_REFRESH_URL, { method: 'POST', headers }, postBody, proxy); if (res.data?.error) { const msg = res.data.error.message || res.data.error.code || 'Unknown error'; throw new Error(`Firebase token refresh failed: ${msg}`); } const newIdToken = res.data?.id_token || res.data?.idToken; const newRefreshToken = res.data?.refresh_token || res.data?.refreshToken || refreshToken; const expiresIn = parseInt(res.data?.expires_in || res.data?.expiresIn || '3600', 10); if (!newIdToken) { throw new Error(`Firebase token refresh: no idToken in response: ${JSON.stringify(res.data).slice(0, 200)}`); } log.info(`Firebase token refreshed, expires in ${expiresIn}s`); return { idToken: newIdToken, refreshToken: newRefreshToken, expiresIn }; } /** * Re-register with Codeium using a refreshed Firebase token. * Returns a fresh API key (may be the same key if unchanged). * * @param {string} idToken - fresh Firebase ID token * @param {object} [proxy] * @returns {Promise<{apiKey: string, name: string}>} */ export async function reRegisterWithCodeium(idToken, proxy = null) { const fingerprint = generateFingerprint(); const regRes = await registerWithCodeium(idToken, fingerprint, proxy); return { apiKey: regRes.api_key, name: regRes.name || '', }; }