Ctrl K
ComunidadGuías…

src/dashboard/email-otp-login.js

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * EMAIL-OTP login for Windsurf/Devin upstream (2026-07-07).
 *
 * Architecture (verified 2026-07-06):
 *   1. POST SendEmailVerification {first_name, email, turnstile_token} → {}
 *   2. Poll Gmail IMAP for 6-digit OTP code (pluggable reader interface)
 *   3. POST RegisterUser {email, turnstile_token, otp_code, ...} → {api_key, name, api_server_url}
 *
 * Wire facts:
 *   - Base: https://windsurf.com/_backend/exa.seat_management_pb.SeatManagementService
 *   - Protocol: Connect-RPC (Content-Type: application/json, 'Connect-Protocol-Version: 1')
 *   - Turnstile sitekey: 0x4AAAAAAA447Bur1xJStKg5 (public, browser-generated, ~300s TTL, single-use)
 *   - MANDATORY real turnstile_token (empty/fake → rejected; dev keys rejected)
 *
 * Zero npm deps (Node built-ins only). Reuses windsurf-login.js transport/proxy/fingerprint patterns.
 */

import tls from 'node:tls';
import { config, log } from '../config.js';
import { resolveProxyConnectHost } from '../net-safety.js';
import { safeEmailRef, safeKeyRef, sliceRedactedJson } from '../log-safety.js';
// R-1 (RATE-RETRY audit): bounded response-body reads, shared with the
// devin-connect readers — one ceiling, one truncation contract.
import { createBoundedBodyAccumulator, MAX_ERROR_BODY_BYTES } from '../devin-connect.js';

// Test-only transport seam (same pattern as windsurf-login.js __setLoginTransportForTests)
let _transportOverride = null;
export function __setOtpTransportForTests(fn) { _transportOverride = fn; }

// Import reusable helpers from windsurf-login.js
// (these are NOT exported currently, so we'll replicate minimally below)

const SEAT_SERVICE_BASE = 'https://windsurf.com/_backend/exa.seat_management_pb.SeatManagementService';
const SEND_EMAIL_VERIFICATION_URL = `${SEAT_SERVICE_BASE}/SendEmailVerification`;
const REGISTER_USER_URL = `${SEAT_SERVICE_BASE}/RegisterUser`;

// ─── Fingerprint randomization (minimal replica from windsurf-login.js) ───

const OS_VERSIONS = [
  'Windows NT 10.0; Win64; x64',
  'Macintosh; Intel Mac OS X 10_15_7',
  'Macintosh; Intel Mac OS X 13_4_1',
  'X11; Ubuntu; Linux x86_64',
];

const CHROME_VERSIONS = [
  '128.0.0.0', '129.0.0.0', '130.0.0.0', '131.0.0.0', '132.0.0.0',
];

const ACCEPT_LANGUAGES = [
  'en-US,en;q=0.9', 'zh-CN,zh;q=0.9,en;q=0.8', 'ja,en-US;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': 'same-origin',
    '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,
  };
}

// ─── HTTPS request with proxy support (minimal replica) ───

import https from 'node:https';
import http from 'node:http';
import { isSocks, createSocksTunnel } from '../socks.js';

async function createProxyTunnel(proxy, targetHost, targetPort) {
  if (isSocks(proxy)) return createSocksTunnel(proxy, targetHost, targetPort);
  // #11: pin the proxy host to a vetted IP literal (no second rebindable lookup).
  const rawHost = proxy.host.replace(/:\d+$/, '');
  const proxyHost = await resolveProxyConnectHost(rawHost, { allowPrivate: config.allowPrivateProxyHosts });
  return new Promise((resolve, reject) => {
    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();
  });
}

function httpsRequest(url, opts, postData, proxy) {
  if (_transportOverride) return _transportOverride(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) => {
      // R-1 (RATE-RETRY audit): bounded read — see createBoundedBodyAccumulator
      // (shared ceiling). Over the cap → reject; never parse a truncated prefix.
      const buffer = createBoundedBodyAccumulator();
      res.on('data', buffer.onData);
      res.on('end', () => {
        const { raw: rawBuffer, truncated } = buffer.finish();
        if (truncated) {
          reject(new Error(`Response body exceeded ${MAX_ERROR_BODY_BYTES} bytes; truncated`));
          return;
        }
        const raw = rawBuffer.toString('utf8');
        try {
          resolve({ status: res.statusCode, data: JSON.parse(raw) });
        } catch {
          resolve({ status: res.statusCode, data: raw });
        }
      });
      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);
    }
  });
}

// ─── Connect-RPC calls ───

/**
 * Send email verification code via windsurf.com Connect-RPC.
 * @param {string} email - target email address
 * @param {string} firstName - display name for the email
 * @param {string} turnstileToken - real Cloudflare Turnstile response token (~300s TTL, single-use)
 * @param {object} [proxy] - { host, port, username?, password?, type }
 * @returns {Promise<{success: boolean}>}
 * @throws {Error} ERR_TURNSTILE_INVALID | ERR_EMAIL_VERIFICATION_FAILED
 */
export async function sendEmailVerification(email, firstName, turnstileToken, proxy = null) {
  const fingerprint = generateFingerprint();
  const body = JSON.stringify({
    first_name: firstName || '',
    email,
    turnstile_token: turnstileToken,
  });
  const headers = buildJsonHeaders(fingerprint, body, { 'Connect-Protocol-Version': '1' });

  log.info(`SendEmailVerification: ${safeEmailRef(email)}, firstName="${firstName}"`);

  const res = await httpsRequest(SEND_EMAIL_VERIFICATION_URL, { method: 'POST', headers }, body, proxy);

  if (res.status >= 400) {
    const errMsg = typeof res.data === 'string' ? res.data : JSON.stringify(res.data);
    if (/turnstile/i.test(errMsg)) {
      throw new Error('ERR_TURNSTILE_INVALID');
    }
    throw new Error(`ERR_EMAIL_VERIFICATION_FAILED: HTTP ${res.status}: ${sliceRedactedJson(res.data, 200)}`);
  }

  log.info(`SendEmailVerification OK: ${safeEmailRef(email)}`);
  return { success: true };
}

/**
 * Complete registration with OTP code via RegisterUser Connect-RPC.
 * @param {string} email
 * @param {string} otpCode - 6-digit code from pollGmailForOTP
 * @param {string} turnstileToken - SAME token used in sendEmailVerification (must be fresh)
 * @param {string} [firstName] - optional
 * @param {string} [lastName] - optional
 * @param {object} [proxy] - proxy config
 * @returns {Promise<{apiKey: string, name: string, apiServerUrl: string}>}
 * @throws {Error} ERR_OTP_INVALID | ERR_REGISTRATION_FAILED
 */
export async function registerUserWithOtp(email, otpCode, turnstileToken, firstName = '', lastName = '', proxy = null) {
  const fingerprint = generateFingerprint();
  const body = JSON.stringify({
    email,
    turnstile_token: turnstileToken,
    otp_code: otpCode,
    first_name: firstName || '',
    last_name: lastName || '',
  });
  const headers = buildJsonHeaders(fingerprint, body, { 'Connect-Protocol-Version': '1' });

  // SD-1: never log any fragment of the OTP — the whole 6-digit space must
  // stay unguessable from logs (10^6, not 10^4). See test/otp-log-redaction.test.js.
  log.info(`RegisterUser (OTP): ${safeEmailRef(email)}, otpCode=***`);

  const res = await httpsRequest(REGISTER_USER_URL, { method: 'POST', headers }, body, proxy);

  if (res.status >= 400) {
    const errMsg = typeof res.data === 'string' ? res.data : JSON.stringify(res.data);
    if (/otp|code|verification/i.test(errMsg)) {
      throw new Error('ERR_OTP_INVALID');
    }
    throw new Error(`ERR_REGISTRATION_FAILED: HTTP ${res.status}: ${sliceRedactedJson(res.data, 200)}`);
  }

  const apiKey = res.data?.api_key || res.data?.apiKey;
  const name = res.data?.name || email;
  const apiServerUrl = res.data?.api_server_url || res.data?.apiServerUrl || '';

  if (!apiKey) {
    throw new Error(`ERR_REGISTRATION_FAILED: no api_key in response: ${sliceRedactedJson(res.data, 200)}`);
  }

  log.info(`RegisterUser OK: ${safeEmailRef(email)} → ${safeKeyRef(apiKey, 'apiKey')}`);

  return { apiKey, name, apiServerUrl };
}

// ─── Gmail IMAP OTP reader (hand-rolled, zero npm deps) ───

/**
 * Minimal IMAP-over-TLS client for Gmail OTP reading.
 * Connects to imap.gmail.com:993, logs in, searches recent messages for 6-digit code.
 * @param {string} imapUser - GMAIL_IMAP_USER (full email)
 * @param {string} imapPassword - GMAIL_IMAP_PASSWORD (app password)
 * @param {number} [timeoutMs=120000] - max wait time (polling loop)
 * @returns {Promise<string>} - 6-digit code
 * @throws {Error} ERR_OTP_TIMEOUT | ERR_IMAP_* | ERR_GMAIL_CREDS_MISSING
 */
export async function pollGmailForOtp(imapUser, imapPassword, timeoutMs = 120000) {
  if (!imapUser || !imapPassword) {
    throw new Error('ERR_GMAIL_CREDS_MISSING: GMAIL_IMAP_USER and GMAIL_IMAP_PASSWORD env vars required');
  }

  log.info(`Polling Gmail IMAP for OTP: ${safeEmailRef(imapUser)}, timeout=${timeoutMs}ms`);

  const startTime = Date.now();
  const pollIntervalMs = 5000; // 5s between IMAP queries

  while (Date.now() - startTime < timeoutMs) {
    try {
      const code = await readOtpFromImapOnce(imapUser, imapPassword);
      if (code) {
        log.info(`OTP code found: ***`);
        return code;
      }
    } catch (err) {
      log.warn(`IMAP poll attempt failed: ${err.message}`);
    }

    // Wait before next poll
    await new Promise(r => setTimeout(r, pollIntervalMs));
  }

  throw new Error('ERR_OTP_TIMEOUT');
}

/**
 * Single IMAP query: connect, login, search recent messages from Windsurf/Devin, extract 6-digit code.
 * Returns null if no code found (caller polls).
 */
async function readOtpFromImapOnce(imapUser, imapPassword) {
  return new Promise((resolve, reject) => {
    const socket = tls.connect({ host: 'imap.gmail.com', port: 993 }, () => {
      // Connected
    });

    let tagCounter = 1;
    const nextTag = () => `A${tagCounter++}`;
    let buffer = '';
    let currentPhase = 'greeting';
    let settled = false;
    let messageIds = [];
    let fetchingBodyContent = false;
    let bodyBuffer = '';

    const done = (result) => {
      if (settled) return;
      settled = true;
      socket.destroy();
      if (result instanceof Error) reject(result);
      else resolve(result);
    };

    const sendCmd = (cmd) => {
      socket.write(cmd + '\r\n');
    };

    socket.on('data', (chunk) => {
      buffer += chunk.toString('utf8');

      const lines = buffer.split('\r\n');
      buffer = lines.pop(); // keep incomplete line

      for (const line of lines) {
        // Handle multi-line FETCH responses (body content may span lines)
        if (fetchingBodyContent) {
          bodyBuffer += line + '\n';
          // End of body: look for closing parenthesis or next tag
          if (line.includes(')') || /^A\d+/.test(line)) {
            fetchingBodyContent = false;
            // Extract 6-digit code from accumulated body
            const codeMatch = bodyBuffer.match(/\b(\d{6})\b/);
            if (codeMatch) {
              done(codeMatch[1]);
              return;
            }
            bodyBuffer = '';
            if (/^A\d+ OK/i.test(line)) {
              done(null); // No code found
            }
          }
          continue;
        }

        if (currentPhase === 'greeting') {
          // Wait for server greeting: "* OK ..."
          if (line.startsWith('* OK')) {
            currentPhase = 'login';
            sendCmd(`${nextTag()} LOGIN "${imapUser}" "${imapPassword}"`);
          }
        } else if (currentPhase === 'login') {
          // Wait for login success: "A1 OK ..."
          if (/^A\d+ OK/i.test(line)) {
            currentPhase = 'select';
            sendCmd(`${nextTag()} SELECT INBOX`);
          } else if (/^A\d+ NO|BAD/i.test(line)) {
            done(new Error(`ERR_IMAP_LOGIN_FAILED: ${line}`));
          }
        } else if (currentPhase === 'select') {
          // Wait for SELECT success: "A2 OK ..."
          if (/^A\d+ OK/i.test(line)) {
            currentPhase = 'search';
            // Search for recent messages from Windsurf/Devin sender
            // Windsurf uses "noreply@windsurf.com" or similar — adjust if needed
            sendCmd(`${nextTag()} SEARCH FROM "windsurf" UNSEEN`);
          } else if (/^A\d+ NO|BAD/i.test(line)) {
            done(new Error(`ERR_IMAP_SELECT_FAILED: ${line}`));
          }
        } else if (currentPhase === 'search') {
          // Parse SEARCH response: "* SEARCH 1234 5678"
          if (line.startsWith('* SEARCH')) {
            // `* SEARCH` itself carries no digits — every match is a message id.
            // (The old `.slice(1)` meant to skip the token but actually dropped
            // the first id, so a single-result SEARCH read as "no mail".)
            const msgIds = line.match(/\d+/g) || [];
            if (msgIds.length === 0) {
              // No new messages found, return null (caller will poll again)
              done(null);
              return;
            }
            messageIds = msgIds;
            // Fetch the most recent message body
            currentPhase = 'fetch';
            const lastId = msgIds[msgIds.length - 1];
            sendCmd(`${nextTag()} FETCH ${lastId} BODY[TEXT]`);
          } else if (/^A\d+ OK/i.test(line)) {
            // SEARCH completed with no results
            done(null);
          }
        } else if (currentPhase === 'fetch') {
          // Start of BODY[TEXT] response: "* 1234 FETCH (BODY[TEXT] {123}"
          if (/BODY\[TEXT\]/i.test(line) && line.includes('{')) {
            fetchingBodyContent = true;
            bodyBuffer = '';
            continue;
          }
          // Single-line BODY match (rare, but handle it)
          const codeMatch = line.match(/\b(\d{6})\b/);
          if (codeMatch) {
            done(codeMatch[1]);
            return;
          }
          // FETCH complete: "A4 OK ..."
          if (/^A\d+ OK/i.test(line)) {
            done(null); // No code found in this message
          }
        }
      }
    });

    socket.on('error', (err) => done(new Error(`ERR_IMAP_CONNECTION: ${err.message}`)));
    socket.on('end', () => { if (!settled) done(new Error('ERR_IMAP_DISCONNECT')); });
    socket.setTimeout(30000, () => done(new Error('ERR_IMAP_TIMEOUT')));
  });
}

// ─── Full OTP login orchestrator ───

/**
 * Full OTP login flow: sendEmailVerification → pollGmailForOtp → registerUserWithOtp.
 * Returns pool-compatible account shape.
 * @param {string} email
 * @param {string} firstName
 * @param {string} turnstileToken - real browser-generated Cloudflare Turnstile token
 * @param {string} [lastName]
 * @param {object} [proxy]
 * @param {object} [deps] - injectable dependencies (readOtp, env) for testing
 * @returns {Promise<{apiKey: string, name: string, email: string, apiServerUrl: string}>}
 */
export async function windsurfLoginViaEmailOtp(email, firstName, turnstileToken, lastName = '', proxy = null, deps = {}) {
  const env = deps.env || process.env;
  const readOtp = deps.readOtp || pollGmailForOtp;

  log.info(`Email-OTP login START: ${safeEmailRef(email)}`);

  // Step 1: Send verification email
  await sendEmailVerification(email, firstName, turnstileToken, proxy);

  // Step 2: Poll Gmail for OTP code
  const imapUser = env.GMAIL_IMAP_USER;
  const imapPassword = env.GMAIL_IMAP_PASSWORD;
  if (!imapUser || !imapPassword) {
    throw new Error('ERR_GMAIL_CREDS_MISSING: Set GMAIL_IMAP_USER and GMAIL_IMAP_PASSWORD env vars');
  }
  const otpCode = await readOtp(imapUser, imapPassword, 120000);

  // Step 3: Register user with OTP
  const result = await registerUserWithOtp(email, otpCode, turnstileToken, firstName, lastName, proxy);

  log.info(`Email-OTP login OK: ${safeEmailRef(email)}`);

  return {
    apiKey: result.apiKey,
    name: result.name,
    email,
    apiServerUrl: result.apiServerUrl,
  };
}