src/dashboard/email-otp-login.js
Rama master · réplica de lectura del repositorio original.
/**
* 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,
};
}