src/dashboard/windsurf-login.js
Rama master · réplica de lectura del repositorio original.
/**
* Windsurf direct login — Auth1/Firebase auth + Codeium registration.
* Supports proxy tunneling and fingerprint randomization.
*/
import http from 'http';
import https from 'https';
import { randomBytes } from 'crypto';
import { config, log } from '../config.js';
import { resolveProxyConnectHost } from '../net-safety.js';
import { safeEmailRef, safeKeyRef, logHash, sliceRedactedJson } from '../log-safety.js';
import { isSocks, createSocksTunnel } from '../socks.js';
import { getEmailLockThreshold, getEmailLockMs, getBackendSwitch } from '../runtime-config.js';
import { parseFields, getField, getAllFields } from '../proto.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';
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';
const AUTH1_EMAIL_START_URL = 'https://windsurf.com/_devin-auth/email/start';
const AUTH1_EMAIL_COMPLETE_URL = 'https://windsurf.com/_devin-auth/email/complete';
// 2026-04-26: Windsurf added a Connect-RPC email-method probe under
// `_backend/...SeatManagementService/CheckUserLoginMethod` (`{userExists,
// hasPassword}`). It is NOT reliable: for a real, existing account it returns
// an EMPTY `{}` (live recon 2026-10-22), and treating that as "no password"
// or as "password-capable" both misclassify the account. `/_devin-auth/
// connections` is the authoritative probe — its `auth_method.has_password` is
// what windsurf.com's own login page reads. CheckUserLoginMethod is kept only
// as enrichment for the case where connections 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`;
// ─── Second-host fallback: app.devin.ai (opt-in, OFF by default) ──────────
// The OAuth feasibility study (docs-internal/workflow-results/oauth-relogin-study/
// FEASIBILITY-BLUEPRINT.md §6 + tool-github-oauth-totp.md §1.1) found the
// leaked Windsurf account-switcher drives app.devin.ai/api/auth1/{connections,
// password/login} as the SAME Auth1 mechanism we hit on windsurf.com/_devin-auth,
// differing only by host. windsurf.com runs on Vercel functions that 504/503
// intermittently and has migrated endpoints under us before; pointing a fallback
// at a second host (app.devin.ai) spreads that single-host availability risk
// without touching GitHub OAuth / TOTP / form-scraping.
//
// Gated behind DEVIN_CONNECT_LOGIN_HOST_FALLBACK=1. When the env var is unset
// (default), windsurfLogin behaves EXACTLY as before — only the windsurf.com
// primary path runs and this code is never reached.
const DEVIN_HOST_BASE = 'https://app.devin.ai';
const DEVIN_AUTH1_CONNECTIONS_URL = `${DEVIN_HOST_BASE}/api/auth1/connections`;
const DEVIN_AUTH1_PASSWORD_LOGIN_URL = `${DEVIN_HOST_BASE}/api/auth1/password/login`;
// Same Auth1 email-code flow, same two payloads — only the host and the
// optional `product` field differ (Devin's own bundle sends `{email, mode}`).
const DEVIN_AUTH1_EMAIL_START_URL = `${DEVIN_HOST_BASE}/api/auth1/email/start`;
const DEVIN_AUTH1_EMAIL_COMPLETE_URL = `${DEVIN_HOST_BASE}/api/auth1/email/complete`;
function isLoginHostFallbackEnabled() {
return getBackendSwitch('loginHostFallback');
}
// Test-only transport seam: when set, every httpsRequest in this module is
// routed through the injected fn (same (url, opts, postData, proxy) signature,
// must resolve { status, data } — data is a Buffer when opts.raw is set). Lets
// the host-fallback tests drive the full login flow deterministically with zero
// real network. Mirrors the __set*ForTests convention used elsewhere (auth.js
// __setReloginDeps, _resetEmailLockoutForTests below).
let _transportOverride = null;
export function __setLoginTransportForTests(fn) { _transportOverride = fn; }
export 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 {}
// The wire response is application/proto — decode it directly (binary-safe)
// instead of scanning the bytes as text: session_token = #1, orgs = #2,
// auth1_token = #3 (optional, possibly ROTATED by this exchange),
// account_id = #4, primary_org_id = #5.
if (Buffer.isBuffer(payload)) {
try {
const fields = parseFields(payload);
const sessionToken = getField(fields, 1, 2)?.value?.toString('utf8');
if (sessionToken) {
return {
sessionToken,
accountId: getField(fields, 4, 2)?.value?.toString('utf8'),
primaryOrgId: getField(fields, 5, 2)?.value?.toString('utf8'),
// Field #3 exists in WindsurfPostAuthResponse and the decoder used to
// skip it. Absent → undefined (no behaviour change); present → the
// token that can mint the NEXT session, so callers can re-store it.
auth1Token: getField(fields, 3, 2)?.value?.toString('utf8'),
orgs: getAllFields(fields, 2).filter(f => f.wireType === 2).map(f => decodePostAuthOrg(f.value)),
};
}
} catch {}
}
// Regex fallback for non-protobuf responses (plain-text bodies etc.).
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' };
}
/** Decode one WindsurfPostAuthOrg sub-message (#2 of WindsurfPostAuthResponse). */
function decodePostAuthOrg(buf) {
const f = parseFields(buf);
const str = (n) => getField(f, n, 2)?.value?.toString('utf8');
const bool = (n) => { const v = getField(f, n, 0); return v ? v.value !== 0 : undefined; };
return {
id: str(1), name: str(2), isAdmin: bool(3), canUseCli: bool(4),
canUseCascade: bool(5), planSlug: str(6), subscriptionStatus: str(7),
webappHost: str(8), ssoLoginRequired: bool(9),
};
}
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}: ${sliceRedactedJson(res.data)}`);
} 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}: ${sliceRedactedJson(res.data)}`);
continue;
}
lastErr = new Error(`OneTimeToken ${label} HTTP ${res.status}: ${sliceRedactedJson(res.data)}`);
} 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',
];
// Same version the official IDE reports (src/windsurf.js DEFAULT_CLIENT_VERSION),
// sent as x-client-version on the Auth1/check-login-method hops. Mirror the
// upstream browser/desktop client header set so referrer-based firewall rules
// see the same fingerprint as a real Windsurf client.
const CLIENT_VERSION = process.env.WINDSURF_CLIENT_VERSION || '2.0.67';
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`;
// Auth1/PostAuth routes are gated on a desktop-browser referrer; a desktop
// User-Agent + Origin/Referer pair keeps this proxy indistinguishable from
// the real Windsurf website client (mirrors cubezhao/ai-tools-mng auth.rs).
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),
'x-client-version': CLIENT_VERSION,
...extra,
};
}
// ─── Proxy tunnel (HTTP CONNECT or SOCKS5) ───────────────
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();
});
}
// ─── HTTPS request with optional proxy ────────────────────
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;
}
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',
// Firebase API-key referrer restriction (403). Not a credential failure —
// our egress is what upstream rejected, so it must read as a deployment
// problem, not "wrong password", and must never feed the email lockout.
'API_KEY_HTTP_REFERRER_BLOCKED': 'ERR_HTTP_REFERRER_BLOCKED',
};
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}: ${sliceRedactedJson(res.data || '')}`);
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 serves two shapes on /_devin-auth/connections:
// { auth_method: { method, has_password, enterprise_sso_domain }, connections: [...] }
// { auth_method: { method: 'auth1', has_password: bool } } (older)
//
// `auth_method.has_password` is the ONLY authoritative password signal — it is
// what windsurf.com's own login page branches on (`if (has_password) password
// step; else send email code`).
//
// `connections[type=email].enabled` does NOT mean "has a password": the live
// OAuth-only account returns `email.enabled:true` alongside
// `has_password:false`, because the email connection is what carries the
// email CODE. Reading `enabled` as "password-capable" (what this function used
// to do) sent that account to password/login, and the resulting 401 was
// reported as ERR_INVALID_CREDENTIALS instead of "no password". Never infer a
// password from the connection list; when `auth_method` is absent the answer
// is `null` (unknown), not `true`.
function interpretConnections(data) {
if (!data || typeof data !== 'object' || Array.isArray(data)) {
return { method: null, hasPassword: null, userExists: null, emailCode: false, oauth: [], raw: {} };
}
const authMethod = data.auth_method && typeof data.auth_method === 'object' ? data.auth_method : null;
const conns = Array.isArray(data.connections)
? data.connections.filter(c => c && typeof c === 'object')
: null;
const emailConn = conns ? conns.find(c => c.type === 'email') : null;
const emailOffered = !!(emailConn && emailConn.enabled !== false);
const oauth = conns
? conns.filter(c => c.enabled !== false && (c.type === 'google' || c.type === 'github')).map(c => c.type)
: [];
const hasPassword = authMethod && typeof authMethod.has_password === 'boolean'
? authMethod.has_password
: null;
// `method:null` on a present auth_method is upstream's "no such account", and
// so is the LITERAL string "not_found": measured live 2026-10-22, an unknown
// address returns
// auth_method: { method: "not_found", has_password: false, enterprise_sso_domain: false }
// and windsurf.com's own login bundle branches on exactly that value
// (`method === 'not_found'` → "No account found. Sign up?"). Reading it as a
// method made `userExists` true and offered an email code for a typo (F1).
// It is a sentinel, never a login method — do not return it to callers.
const rawMethod = authMethod && typeof authMethod.method === 'string'
? authMethod.method.trim()
: '';
const method = rawMethod && rawMethod !== 'not_found' ? rawMethod : null;
// With `auth_method` present, `method:null`/`not_found` is upstream's "no
// such account". WITHOUT it, an empty `connections: []` is not evidence of
// anything — the body may simply be partial — so the answer is "unknown"
// (null), never "no account". Returning false there made a probe reply of
// `{"connections":[]}` (a real shape from app.devin.ai) abort the login as
// ERR_EMAIL_NOT_FOUND.
const userExists = authMethod
? method !== null
: (conns && conns.length > 0 ? true : null);
// Email-code login is the alternative whenever there is no password. With
// `hasPassword:true` the email connection is the password form, not a code.
// A missing account gets neither: upstream's has_password:false describes a
// method with no account behind it, so it must not become an offer to send
// mail (F1).
const emailCode = userExists === false
? false
: hasPassword === false
? (conns ? emailOffered : true)
: (hasPassword === null ? emailOffered : false);
// The connection list is global, not account-scoped: upstream returns the
// same email/google/github entries for an address that does not exist. Do
// not hand a caller a provider list for a missing account.
return { method, hasPassword, userExists, emailCode, oauth: userExists === false ? [] : oauth, raw: data };
}
// Which host answered the method probe for an address. The email-code flow
// starts on the SAME host: a probe that only app.devin.ai could answer is
// evidence that windsurf.com is not reachable from this deployment, and
// guessing again would send the operator's code request into the outage.
const PROBE_HOST_TTL_MS = 5 * 60 * 1000;
const _probeHost = new Map();
export function _resetProbeHostForTests() { _probeHost.clear(); }
function rememberProbeHost(email, host) {
const key = String(email || '').toLowerCase();
if (!key || (host !== 'windsurf' && host !== 'devin')) return;
_probeHost.set(key, { host, at: Date.now() });
if (_probeHost.size <= 200) return;
const oldest = [..._probeHost.entries()].sort((a, b) => a[1].at - b[1].at)
.slice(0, _probeHost.size - 200);
for (const [k] of oldest) _probeHost.delete(k);
}
function preferredEmailHost(email) {
const key = String(email || '').toLowerCase();
const entry = _probeHost.get(key);
if (!entry) return null;
if (Date.now() - entry.at > PROBE_HOST_TTL_MS) {
_probeHost.delete(key);
return null;
}
return entry.host;
}
async function fetchAuth1Connections(email, fingerprint, proxy, host = 'windsurf') {
const body = JSON.stringify({ product: 'windsurf', email });
const headers = buildJsonHeaders(fingerprint, body);
const url = host === 'devin' ? DEVIN_AUTH1_CONNECTIONS_URL : AUTH1_CONNECTIONS_URL;
const res = await httpsRequestRetrying(
url, { method: 'POST', headers }, body, proxy, `Auth1 connections (${host})`
);
// 400/422 is upstream answering about the REQUEST (measured: a malformed
// address returns `value is not a valid email address`). Retrying it on the
// other host or on the secondary probe cannot change that answer, so it is
// terminal instead of "unknown".
if (res.status === 400 || res.status === 422) {
const detail = upstreamDetail(res.data);
const invalidEmail = /email/i.test(detail);
const err = new Error(invalidEmail ? 'ERR_INVALID_EMAIL' : 'ERR_LOGIN_PROBE_FAILED');
err.code = err.message;
err.statusCode = 400;
err.detail = detail || `upstream ${res.status}`;
throw err;
}
if (res.status >= 400) {
const err = new Error(`connections ${res.status}`);
err.statusCode = res.status;
throw err;
}
return res.data || {};
}
// Secondary probe (Windsurf 2026-04-26 Connect-RPC surface). Only consulted
// when /_devin-auth/connections is unreachable — it returns `{}` for real
// accounts, so an empty body is "unknown" (null), never "no password" and
// never "password-capable".
// Returns the uniform { method, hasPassword, userExists, emailCode, oauth, raw }
// shape, or null when it learned nothing.
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}): ${sliceRedactedJson(res.data || '')}`);
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, the answer is "unknown".
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 ${safeEmailRef(email)}; login method unknown`);
return null;
}
if (res.data.userExists === false) {
return { method: null, hasPassword: null, userExists: false, emailCode: false, oauth: [], raw: res.data };
}
const hasPassword = hasPwField ? !!res.data.hasPassword : null;
return {
method: 'auth1',
hasPassword,
userExists: true,
// This endpoint says nothing about connections, so email-code availability
// is only implied when it positively reports "no password".
emailCode: hasPassword === false,
oauth: [],
raw: res.data,
};
} catch (e) {
log.warn(`CheckUserLoginMethod unreachable: ${e.message}`);
return null;
}
}
// Authoritative login-method probe.
//
// Order matters: /_devin-auth/connections FIRST (its auth_method.has_password
// is what the website itself branches on), CheckUserLoginMethod only as
// enrichment when connections cannot answer. A probe that learns nothing
// yields hasPassword:null — callers must treat that as "unknown", not as
// either "password-capable" or "no password".
//
// When the login-host fallback is enabled, the SAME read-only probe is asked
// on app.devin.ai before giving up: both hosts were measured to return the
// identical shape (2026-10-22), and a probe outage on the Vercel host is the
// one input that used to turn "this account has no password" into
// "wrong password" (see the recheck in windsurfLoginPrimaryHost). With the
// fallback off, only windsurf.com is contacted — the default contract.
export async function probeLoginMethod(email, proxy = null, fingerprint = null) {
const fp = fingerprint || generateFingerprint();
const hosts = isLoginHostFallbackEnabled() ? ['windsurf', 'devin'] : ['windsurf'];
let lastRaw = null;
for (const host of hosts) {
try {
const connections = await fetchAuth1Connections(email, fp, proxy, host);
lastRaw = connections;
const interpreted = interpretConnections(connections);
// A known non-auth1 method (firebase/oauth) is a routing answer, not noise:
// returning it lets windsurfLoginPrimaryHost reach the legacy Firebase path
// instead of guessing Auth1. Only a bare/empty body stays "unknown".
if (interpreted.hasPassword !== null || interpreted.userExists === false
|| (interpreted.method && interpreted.method !== 'auth1')) {
rememberProbeHost(email, host);
return { ...interpreted, source: 'connections', host };
}
} catch (err) {
// A definitive answer about the address is not an availability failure.
if (err?.code === 'ERR_INVALID_EMAIL') throw err;
log.warn(`Auth1 connections probe failed on ${host} for ${safeEmailRef(email)}: ${err.message}`);
}
}
const check = await fetchCheckUserLoginMethod(email, fp, proxy);
if (check) return { ...check, source: 'check_user_login_method', host: null };
return {
method: null,
hasPassword: null,
userExists: null,
emailCode: false,
oauth: [],
raw: lastRaw || {},
source: 'unknown',
host: 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:${sliceRedactedJson(loginRes.data, 200)}`);
}
log.info(`Auth1 login OK: ${safeEmailRef(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:${sliceRedactedJson(br.data, 200)}`);
}
const sessionToken = br.data.sessionToken;
const accountId = br.data.accountId || 'unknown';
log.info(`Windsurf PostAuth OK (${bl}): ${safeEmailRef(email)} accountHash=${logHash(accountId)} → using sessionToken as apiKey`);
return {
apiKey: sessionToken,
name: email,
email,
// V-24: the address upstream just authenticated. A bound re-auth compares
// this with the account row before the token may replace anything.
identityEmail: normalizeLoginIdentity(email),
apiServerUrl: '',
sessionToken,
auth1Token,
};
}
// ─── Second-host fallback chain: app.devin.ai ─────────────────────────────
//
// A COMPLETE, INDEPENDENT login attempt against app.devin.ai instead of
// windsurf.com. Only reached when DEVIN_CONNECT_LOGIN_HOST_FALLBACK=1 AND the
// windsurf.com primary path already failed. Same email+password, no GitHub,
// no TOTP, no form-scraping.
//
// Chain: app.devin.ai/api/auth1/connections (probe) →
// app.devin.ai/api/auth1/password/login ({email,password}) → {token} →
// postAuthDualPath(token) [REUSED from the primary path] → sessionToken.
//
// TODO(unverified): the final hop — feeding an Auth1 token minted on
// app.devin.ai into OUR WindsurfPostAuth (windsurf.com/_backend or
// server.self-serve.windsurf.com) and getting a usable `devin-session-token$`
// back — has NOT been exercised against a live account. The study
// (docs-internal/workflow-results/oauth-relogin-study/FEASIBILITY-BLUEPRINT.md §1.2/§5.3,
// tool-github-oauth-totp.md §1.1) establishes only that both hosts share the
// SAME Auth1 family statically; "same family" does NOT prove our PostAuth
// exchanger accepts a token sourced from the other host. This must be
// confirmed with a real account before relying on the fallback as live-effective.
async function windsurfLoginViaDevinHost(email, password, fingerprint, proxy) {
// Step 1 — connections probe (app.devin.ai uses product:"devin").
// Best-effort: a probe FAILURE must NOT abort — we still try the login,
// exactly like the primary path tolerates a flaky connections endpoint.
// A probe that SUCCEEDS and reports `has_password:false` is different: that
// is the same terminal wrong-method answer as on windsurf.com, so the
// password login is skipped instead of burning a guaranteed 401.
try {
const connBody = JSON.stringify({ product: 'devin', email });
const connHeaders = buildJsonHeaders(fingerprint, connBody);
const connRes = await httpsRequestRetrying(
DEVIN_AUTH1_CONNECTIONS_URL, { method: 'POST', headers: connHeaders }, connBody, proxy,
'Devin-host Auth1 connections'
);
const probed = interpretConnections(connRes.data);
if (probed.hasPassword === false) {
const err = new Error('ERR_NO_PASSWORD_OAUTH_ACCOUNT');
err.code = 'ERR_NO_PASSWORD_OAUTH_ACCOUNT';
err.method = probed.emailCode ? 'email_code' : 'oauth';
err.oauthProviders = probed.oauth || [];
err.detail = 'app.devin.ai reports this account has no password.';
err.isAuthFail = false;
throw err;
}
} catch (err) {
if (err?.code === 'ERR_NO_PASSWORD_OAUTH_ACCOUNT') throw err;
log.warn(`Devin-host connections probe failed for ${safeEmailRef(email)}: ${err.message}`);
}
// Step 2 — Auth1 password/login on app.devin.ai → auth1 bearer token.
const loginBody = JSON.stringify({ email, password });
const loginHeaders = buildJsonHeaders(fingerprint, loginBody);
const loginRes = await httpsRequestRetrying(
DEVIN_AUTH1_PASSWORD_LOGIN_URL, { method: 'POST', headers: loginHeaders }, loginBody, proxy,
'Devin-host Auth1 password/login'
);
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('DevinHost', detailMsg, 'ERR_LOGIN_FAILED');
}
const auth1Token = loginRes.data?.token;
if (!auth1Token) {
throw new Error(`ERR_AUTH1_TOKEN_MISSING:${sliceRedactedJson(loginRes.data, 200)}`);
}
log.info(`Devin-host Auth1 login OK: ${safeEmailRef(email)}`);
// Step 3 — REUSE the existing PostAuth dual-path exchanger. See the
// TODO(unverified) above: this is the hop whose cross-host acceptance is
// not yet live-confirmed.
const { res: br, label: bl } = await postAuthDualPath(auth1Token, fingerprint, proxy);
if (br.status >= 400 || !br.data?.sessionToken) {
throw new Error(`ERR_POSTAUTH_FAILED:${sliceRedactedJson(br.data, 200)}`);
}
const sessionToken = br.data.sessionToken;
const accountId = br.data.accountId || 'unknown';
log.info(`Devin-host PostAuth OK (${bl}): ${safeEmailRef(email)} accountHash=${logHash(accountId)} → using sessionToken as apiKey`);
return {
apiKey: sessionToken,
name: email,
email,
identityEmail: normalizeLoginIdentity(email),
apiServerUrl: '',
sessionToken,
auth1Token,
viaHost: 'app.devin.ai',
};
}
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) {
// 403s from identitytoolkit carry just `{error:{message}}`; a Referer/
// API-key restriction arrives as API_KEY_HTTP_REFERRER_BLOCKED, which the
// friendly-error mapper (createFriendlyAuthError) resolves to a clear
// ERR_HTTP_REFERRER_BLOCKED instead of the raw code.
// The referrer restriction does NOT arrive as `error.message`. Google's real
// envelope is `{error:{code:403, status:'PERMISSION_DENIED', message:'Requests
// from referer <x> are blocked.', details:[{reason:'API_KEY_HTTP_REFERRER_BLOCKED'}]}}`
// — a numeric code and a prose message, with the machine-readable reason buried in
// details[]. Reading only message/code (as this did) means the mapping fires only
// for a shape Google never sends, and the operator still gets the prose string as
// their error "code". So prefer the structured reason, then fall back.
const err = fbRes.data.error;
const reason = Array.isArray(err.details)
? err.details.find((d) => d && typeof d.reason === 'string')?.reason
: null;
const msg = reason || err.message || err.code || 'Unknown Firebase error';
// Keep the prose as the human detail when the reason carried the code, so the log
// still says WHICH referer was rejected.
throw createFriendlyAuthError('Firebase', msg, err.message || msg);
}
const idToken = fbRes.data.idToken;
if (!idToken) throw new Error('ERR_FIREBASE_TOKEN_MISSING');
log.info(`Firebase login OK: ${safeEmailRef(email)}, uidHash=${logHash(fbRes.data.localId)}`);
const reg = await registerWithCodeium(idToken, fingerprint, proxy);
log.info(`Codeium register OK: ${safeEmailRef(email)} → ${safeKeyRef(reg.api_key, 'apiKey')}`);
return {
apiKey: reg.api_key,
name: reg.name || email,
email,
identityEmail: normalizeLoginIdentity(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_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();
// Operator disabled the email lockout (threshold 0): release any existing
// lock immediately so "0 = off" takes effect now, not at natural expiry.
if (getEmailLockThreshold() <= 0) {
_emailFailures.delete(k);
return null;
}
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;
// Operator can disable the email lockout entirely (threshold 0) from Settings.
const threshold = getEmailLockThreshold();
if (threshold <= 0) 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 >= threshold) {
const durMs = getEmailLockMs();
e.lockedUntil = now + durMs;
e.count = 0;
log.warn(`Email lockout: ${safeEmailRef(k)} banned for ${Math.round(durMs / 60000)}min after ${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} 因连续 ${getEmailLockThreshold()} 次登录失败被本地锁定,请 ${minutes} 分钟后再试。`);
err.code = 'ERR_EMAIL_LOCKED';
err.retryAfterMs = lockMs;
err.isAuthFail = false;
throw err;
}
const fingerprint = generateFingerprint();
log.info(`Windsurf login: ${safeEmailRef(email)} fpHash=${logHash(fingerprint['User-Agent'])} proxy=${proxy?.host || 'none'}`);
try {
return await windsurfLoginPrimaryHost(email, password, fingerprint, proxy);
} catch (primaryErr) {
// A terminal login-method answer is not an availability failure: the
// account has no password, so the second host would be asked to run the
// exact same impossible password login. Never fall back for these.
if (primaryErr?.code === 'ERR_NO_PASSWORD_OAUTH_ACCOUNT' || primaryErr?.code === 'ERR_EMAIL_NOT_FOUND') {
throw primaryErr;
}
// Default (flag unset): preserve the exact pre-existing behavior — only
// windsurf.com runs, so re-throw and never touch app.devin.ai.
if (!isLoginHostFallbackEnabled()) throw primaryErr;
// Second-host fallback (app.devin.ai), opt-in. Same email+password, no
// GitHub/TOTP. Spreads windsurf.com (Vercel) availability / endpoint-
// migration risk across a second host.
log.warn(`Primary windsurf.com login failed for ${safeEmailRef(email)} (${primaryErr.message}); attempting app.devin.ai host fallback`);
try {
const result = await windsurfLoginViaDevinHost(email, password, fingerprint, proxy);
// Genuine success on the second host clears any failure the primary
// path recorded — the credential is provably valid.
recordEmailSuccess(email);
log.info(`Host fallback OK via app.devin.ai for ${safeEmailRef(email)}`);
return result;
} catch (fallbackErr) {
// Both hosts failed. Surface the ORIGINAL primary error so the caller
// (reLoginAccount) sees the real primary signal and runs its existing
// failure path → failover. We NEVER fake success, and we NEVER
// reclassify the account as dead from here — a failed re-login just
// means "re-login failed → false → failover", per the transient-first
// error model. The upstream error classifier and the re-login flow in
// auth.js stay untouched.
log.warn(`Host fallback via app.devin.ai also failed for ${safeEmailRef(email)}: ${fallbackErr.message}`);
throw primaryErr;
}
}
}
// One terminal "no password" answer, used both by the probe's first pass and
// by the post-rejection recheck. `method` lets the dashboard switch the form
// (email code vs OAuth-only) without a second probe, and `isAuthFail:false`
// keeps a wrong-method answer out of the per-email lockout.
function noPasswordError(probe) {
const err = new Error('ERR_NO_PASSWORD_OAUTH_ACCOUNT');
err.code = 'ERR_NO_PASSWORD_OAUTH_ACCOUNT';
err.method = probe.emailCode ? 'email_code' : 'oauth';
err.oauthProviders = probe.oauth || [];
err.detail = probe.emailCode
? 'This account has no password. Log in with the emailed verification code, or with Google/GitHub, or paste an Auth Token from windsurf.com/show-auth-token.'
: 'This account has no password and email-code login is disabled. Log in with Google/GitHub, or paste an Auth Token from windsurf.com/show-auth-token.';
err.isAuthFail = false;
err.statusCode = 400;
return err;
}
// The original windsurf.com login path (login-method probe →
// Auth1 password/login → PostAuth, or legacy Firebase). Extracted from
// windsurfLogin so the public entry can wrap it with the opt-in
// app.devin.ai host fallback without altering this logic.
async function windsurfLoginPrimaryHost(email, password, fingerprint, proxy) {
// Probe: /_devin-auth/connections is authoritative; CheckUserLoginMethod
// only enriches when connections is unreachable. `hasPassword:null` means
// both probes were silent — NOT "password-capable".
const probe = await probeLoginMethod(email, proxy, fingerprint);
if (probe.userExists === false) {
const err = createFriendlyAuthError('Auth1', 'EMAIL_NOT_FOUND');
// A nonexistent address is not a wrong credential; locking it would let a
// typo (or an enumeration probe) ban a real operator for 15 minutes.
throw err;
}
if (probe.hasPassword === false) {
// Terminal wrong-method signal: this account has no password (registered
// via Google/GitHub OAuth, or email-code only). It is NOT a brute-force
// guess, so it must not count toward the email lockout, and it must never
// reach password/login — the upstream 401 for such an account is not
// evidence of a wrong password, only of a wrong method.
throw noPasswordError(probe);
}
if (probe.hasPassword === true) {
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);
}
// The method WAS confirmed here, so the caller must not offer the
// "this account may have no password" alternatives.
if (e && typeof e === 'object') e.probeUnknown = false;
throw e;
}
}
// A known non-auth1 method (legacy firebase/oauth) is an explicit routing
// answer: go straight to the legacy path instead of guessing Auth1.
if (probe.method && probe.method !== 'auth1') {
const result = await windsurfLoginViaFirebase(email, password, fingerprint, proxy);
recordEmailSuccess(email);
return result;
}
// Probe unknown (both hosts and the secondary probe silent): we have no
// evidence the account lacks a password, so the operator-supplied password
// still gets one attempt. Auth1 FIRST — the legacy Firebase
// signInWithPassword endpoint is App-Check-gated upstream (measured
// 2026-10-22: 401 "Firebase App Check token is invalid" for every
// server-side caller), so leading with it only delays the real attempt.
// Firebase stays as the last resort when Auth1 itself is unavailable.
try {
const result = await windsurfLoginViaAuth1(email, password, fingerprint, proxy);
recordEmailSuccess(email);
return result;
} catch (auth1Err) {
if (!auth1Err?.isAuthFail) {
// Auth1 never judged the credential (network / 5xx / PostAuth). Try the
// legacy path once; if it also fails, surface the Auth1 error, which is
// the meaningful one.
try {
const result = await windsurfLoginViaFirebase(email, password, fingerprint, proxy);
recordEmailSuccess(email);
return result;
} catch {
throw auth1Err;
}
}
// Auth1 positively rejected the credential. Before reporting "wrong
// password", re-ask the method ONCE: a probe outage on the first call is
// exactly how an email-code-only account was reported as
// ERR_INVALID_CREDENTIALS (live 2026-10-21, 3.9.42). This recheck only
// runs after a rejected password, so it costs nothing on the happy path.
let recheck = null;
try {
recheck = await probeLoginMethod(email, proxy, fingerprint);
} catch { /* stays unknown */ }
if (recheck && recheck.hasPassword === false) {
throw noPasswordError(recheck);
}
// One credential guess, counted once (the old Firebase→Auth1 chain could
// have counted two).
recordEmailFailure(email, auth1Err?.message);
// Tell the caller the method was never confirmed: the password really was
// rejected, but if the address is OAuth/email-code-only the operator
// needs the alternative offered, not "wrong password" alone.
auth1Err.probeUnknown = recheck ? recheck.hasPassword === null : true;
auth1Err.alternatives = ['email_code', 'oauth', 'token'];
throw auth1Err;
}
}
// ─── Email-code login (accounts without a password) ───────────────────────
//
// For accounts whose probe says `has_password:false`, the only non-OAuth path
// is the emailed 6-digit code (recon .agent/loginrecon-20261022/LoginRecon.md §4.3):
//
// POST /_devin-auth/email/start {email, mode:'login', product:'Windsurf'}
// → {email_verification_token}
// POST /_devin-auth/email/complete {email_verification_token, code, mode:'login'}
// → {token} (an Auth1 token, same kind password/login mints)
// → postAuthDualPath(token) → session_token, used as the pool API key.
//
// The verification token is a bearer secret for one account. It stays in this
// module's memory keyed by an opaque flowId, never travels to the browser, and
// never appears in a log line. The code is equally secret: it is forwarded
// upstream and dropped.
//
// DESIGN GAP (Q2 F2, deliberately NOT implemented here): the resulting
// `devin-session-token$…` has no refresh path. refreshAllFirebaseTokens skips
// accounts without a refreshToken, reLoginAccount needs a stored password this
// flow never has (storeCredential:false), and the Auth1 token's TTL is
// unmeasured — so a retired session is only recoverable by re-adding the
// account. The design work that remains is an explicit "needs reauth" account
// state (or encrypted Auth1 storage once its TTL is known), not a retry here.
const EMAIL_FLOW_TTL_MS = 10 * 60 * 1000;
const EMAIL_FLOW_MAX_ATTEMPTS = 5;
const EMAIL_FLOW_MAX_ENTRIES = 200;
// Devin's own UI enforces a 30s resend cooldown; mirror it so a double-click
// loop cannot burn the upstream send quota (and the account's sender
// reputation) without an operator decision.
const EMAIL_CODE_RESEND_COOLDOWN_MS = 30 * 1000;
// A successful email/complete mints an Auth1 token that is single-use upstream,
// so it is the only thing that can be replayed when the PostAuth exchange
// fails. Keep it briefly: long enough to ride out a PostAuth blip, short enough
// that a forgotten flow cannot be used as a session minter (F6).
const EMAIL_FLOW_POSTAUTH_RETRY_MS = 2 * 60 * 1000;
const EMAIL_FLOW_POSTAUTH_MAX_ATTEMPTS = 3;
// Upstream `email/complete` failures are NOT all credential guesses (F5).
// Only a positive "the code is wrong" detail may feed the per-email lockout:
// - an expired/consumed verification token (measured: 401
// {"detail":"Invalid verification token"}, 6/6) says nothing about the
// digits the user typed, and the token cannot be retried anyway;
// - a 5xx / transport failure says nothing at all.
// Counting either as a wrong code locked PASSWORD login for 15 minutes after a
// code simply expired.
const EMAIL_CODE_MISMATCH_RE = /invalid verification code|invalid code|incorrect code|wrong code/i;
const EMAIL_FLOW_DEAD_RE = /invalid verification token|verification token|expired|already used|already consumed/i;
// 'expired' → the verification token is dead upstream: drop the flow, retry
// is impossible, no lockout.
// 'code' → upstream positively says the digits are wrong: count the flow
// attempt AND the email lockout.
// 'rejected' → upstream 400/422 without a reason: count the flow attempt
// (one flow must not be an unlimited oracle) but not the lockout.
// 'unavailable' → 403/404/405/429/5xx: availability, retry the same code.
function classifyEmailCompleteFailure(res, detail) {
const text = String(detail || '');
if (res.status === 401 || res.status === 410 || EMAIL_FLOW_DEAD_RE.test(text)) return 'expired';
if (EMAIL_CODE_MISMATCH_RE.test(text)) return 'code';
if (res.status === 400 || res.status === 422) return 'rejected';
return 'unavailable';
}
const _emailFlows = new Map();
const _emailCodeSentAt = new Map();
export function _resetEmailFlowsForTests() {
_emailFlows.clear();
_emailCodeSentAt.clear();
}
function emailFlowError(code, detail, statusCode, extra = null) {
const err = new Error(code);
err.code = code;
err.detail = detail;
err.statusCode = statusCode;
err.isAuthFail = false;
if (extra) Object.assign(err, extra);
return err;
}
// Same per-email lockout the password path uses, so a code-guessing loop is
// bounded by the operator's configured strike count. 400, not 429: the
// dashboard UI renders a 429 as an IP-wide ban overlay, and this lock is
// per-email.
function emailLockedError(lockMs) {
return emailFlowError(
'ERR_EMAIL_LOCKED',
`Email temporarily locked after repeated failed logins; retry in ${Math.ceil(lockMs / 60000)} min.`,
400,
{ retryAfterMs: lockMs },
);
}
// ─── Login identity (V-24 / audit A23) ────────────────────────────────────
//
// A login result proves ONE thing about identity: the address that upstream
// authenticated. A bound re-auth (`accountId`) replaces the token on an
// existing row, so a result for a different address would install person B's
// session on person A's row and clear A's error/ban state on the way. The
// identity is therefore part of the login result and is compared before any
// token/status mutation; case and surrounding whitespace are not identity.
//
// Upstream reports `accountId` too, but the pool rows do not carry it, so it
// is not a comparable key here — the authenticated address is.
/** Case/whitespace-insensitive identity key. '' means "no identity proof". */
function normalizeLoginIdentity(email) {
return String(email ?? '').trim().toLowerCase();
}
/** The identity a login result proves, or '' when it proves none. */
function loginResultIdentity(result) {
return normalizeLoginIdentity(result?.identityEmail || result?.email);
}
/** Operator-facing masked address; never echoes the full identity. */
function maskIdentityEmail(email) {
const s = String(email ?? '').trim();
const at = s.indexOf('@');
if (at <= 0) return s ? `${s[0]}***` : '';
return `${s[0]}***${s.slice(at)}`;
}
/**
* Throw unless `result` proves it belongs to `expectedEmail`.
*
* Called with the bound account's address BEFORE markAccountReauthenticated()
* (api.js) and, for the email-code flow, before the single-use code is spent.
* A result that carries no identity at all is refused on a bound path: the
* token would replace a live credential on nothing but hope.
*/
export function assertLoginIdentity(result, expectedEmail) {
const expected = normalizeLoginIdentity(expectedEmail);
if (!expected) return;
const actual = loginResultIdentity(result);
if (actual && actual === expected) return;
const err = new Error('ERR_LOGIN_IDENTITY_MISMATCH');
err.code = 'ERR_LOGIN_IDENTITY_MISMATCH';
err.statusCode = 409;
err.detail = actual
? `This login belongs to ${maskIdentityEmail(actual)}, not to the selected account (${maskIdentityEmail(expected)}).`
: `The login did not report which account it belongs to, so it cannot be bound to ${maskIdentityEmail(expected)}.`;
throw err;
}
function pruneEmailFlows(now) {
for (const [id, flow] of _emailFlows) {
if (now > flow.expiresAt) _emailFlows.delete(id);
}
// The resend-cooldown map is keyed by email and would otherwise keep one
// entry per address forever on a long-lived process.
for (const [email, sentAt] of _emailCodeSentAt) {
if (now - sentAt > EMAIL_FLOW_TTL_MS) _emailCodeSentAt.delete(email);
}
if (_emailFlows.size < EMAIL_FLOW_MAX_ENTRIES) return;
const oldest = [..._emailFlows.entries()]
.sort((a, b) => a[1].createdAt - b[1].createdAt)
.slice(0, _emailFlows.size - EMAIL_FLOW_MAX_ENTRIES + 1);
for (const [id] of oldest) _emailFlows.delete(id);
}
setInterval(() => pruneEmailFlows(Date.now()), 10 * 60 * 1000).unref?.();
function emailEndpointUrl(kind, host) {
if (kind === 'start') return host === 'devin' ? DEVIN_AUTH1_EMAIL_START_URL : AUTH1_EMAIL_START_URL;
return host === 'devin' ? DEVIN_AUTH1_EMAIL_COMPLETE_URL : AUTH1_EMAIL_COMPLETE_URL;
}
function emailEndpointBody(kind, host, { email, token, code }) {
if (kind === 'start') {
// Devin's bundle sends `{email, mode}`; windsurf.com adds `product`.
return host === 'devin' ? { email, mode: 'login' } : { email, mode: 'login', product: 'Windsurf' };
}
return { email_verification_token: token, code, mode: 'login' };
}
function upstreamDetail(data) {
const raw = data?.detail ?? data?.error ?? data?.message;
if (Array.isArray(raw)) return raw.map(d => d?.msg || d?.type || JSON.stringify(d)).join('; ');
if (typeof raw === 'string') return raw;
if (raw && typeof raw === 'object') return String(raw.message || raw.code || '');
return '';
}
// POST one email endpoint, moving to the other login host only on an
// AVAILABILITY failure (network / 5xx / endpoint gone) and only when the
// existing DEVIN_CONNECT_LOGIN_HOST_FALLBACK switch is on. A 4xx is upstream
// answering (wrong code, expired token, rate limit) — retrying it on a second
// host would just double-spend the attempt, so it is returned as-is.
//
// `kind === 'start'` sends mail, so it is EXACTLY ONCE (F8): no 5xx backoff and
// no second host after a transport error. A 504 can arrive after the message
// was queued and a transport error leaves delivery unknown, so either retry
// sends a second code (measured: 3 sends / 7.0 s for one request). Only an
// explicit 404/405 — proof that host has no such endpoint — falls through.
async function requestEmailEndpoint(kind, preferredHost, payload, fingerprint, proxy) {
const first = preferredHost || 'windsurf';
const second = first === 'windsurf' ? 'devin' : 'windsurf';
const hosts = isLoginHostFallbackEnabled() ? [first, second] : [first];
const exactlyOnce = kind === 'start';
let lastErr = null;
for (let i = 0; i < hosts.length; i++) {
const host = hosts[i];
const url = emailEndpointUrl(kind, host);
const body = JSON.stringify(emailEndpointBody(kind, host, payload));
const headers = buildJsonHeaders(fingerprint, body);
const label = `Auth1 email/${kind} (${host})`;
try {
const res = exactlyOnce
? await httpsRequest(url, { method: 'POST', headers }, body, proxy)
: await httpsRequestRetrying(url, { method: 'POST', headers }, body, proxy, label);
const endpointGone = res.status === 404 || res.status === 405;
if (endpointGone && i < hosts.length - 1) {
log.warn(`${label} unavailable (${res.status}); trying the other login host`);
lastErr = new Error(`${label} upstream ${res.status}`);
continue;
}
return { res, host };
} catch (err) {
lastErr = err;
if (exactlyOnce || i === hosts.length - 1) throw err;
log.warn(`${label} failed (${err.message}); trying the other login host`);
continue;
}
}
throw lastErr || new Error('ERR_EMAIL_ENDPOINT_UNREACHABLE');
}
/**
* Ask Windsurf to email a login code.
*
* The upstream `email_verification_token` is stored server-side under a fresh
* opaque flowId (TTL 10 min, single successful use) and is NOT part of the
* returned object — only `{ flowId, sent: true }` reaches the browser.
*
* @param {string} email
* @param {object} [proxy]
* @returns {Promise<{flowId: string, sent: true, expiresInMs: number}>}
*/
export async function emailLoginStart(email, proxy = null, opts = {}) {
const normalizedEmail = String(email || '').trim();
if (!normalizedEmail) throw emailFlowError('ERR_EMAIL_REQUIRED', 'email is required', 400);
const lockMs = checkEmailLocked(normalizedEmail);
if (lockMs != null) throw emailLockedError(lockMs);
// Send the code on the host that answered the method probe (or the one the
// operator explicitly picked). A probe that only app.devin.ai could answer
// is evidence windsurf.com is unreachable from this deployment; re-guessing
// would push the request straight back into the outage. `opts.host` is an
// explicit operator choice from the dashboard, not a blind fallback.
const explicitHost = opts?.host === 'devin' ? 'devin' : (opts?.host === 'windsurf' ? 'windsurf' : null);
const preferredHost = explicitHost || preferredEmailHost(normalizedEmail) || 'windsurf';
const now = Date.now();
const sendKey = normalizedEmail.toLowerCase();
const sentAt = _emailCodeSentAt.get(sendKey);
if (sentAt != null && now - sentAt < EMAIL_CODE_RESEND_COOLDOWN_MS) {
throw emailFlowError(
'ERR_EMAIL_CODE_COOLDOWN',
'A verification code was just sent; wait before requesting another.',
400,
{ retryAfterMs: EMAIL_CODE_RESEND_COOLDOWN_MS - (now - sentAt) },
);
}
// Stamp the ATTEMPT, before the upstream call. A send that fails upstream
// (throw / 4xx / 5xx) must not reset the window, otherwise a failing sender
// can be hammered in a loop — every retry would reach an upstream that is
// already refusing. The success path re-stamps on completion so the 30 s
// window still measures from the send that actually went out.
_emailCodeSentAt.set(sendKey, now);
const fingerprint = generateFingerprint();
let started;
try {
started = await requestEmailEndpoint('start', preferredHost, { email: normalizedEmail }, fingerprint, proxy);
} catch (err) {
// The transport layer rejects with a bare Error (no code), which used to
// reach the route as HTTP 400 + a raw English message, breaking the
// stable-code contract (F8). One send was attempted; whether the mail went
// out is unknowable, so say so instead of inviting an immediate resend.
if (err && typeof err.code === 'string' && err.code.startsWith('ERR_EMAIL_') && err.statusCode) throw err;
log.warn(`Auth1 email/start transport failure: ${err?.message || err}`);
throw emailFlowError(
'ERR_EMAIL_START_FAILED',
'The email endpoint failed before confirming the send — check the inbox before retrying.',
502,
);
}
const { res, host } = started;
const detail = upstreamDetail(res.data);
if (res.status >= 400 || detail) {
throw emailFlowError(
'ERR_EMAIL_START_FAILED',
detail || `upstream ${res.status}`,
res.status >= 500 ? 502 : 400,
);
}
const token = res.data?.email_verification_token;
if (typeof token !== 'string' || !token) {
throw emailFlowError('ERR_EMAIL_START_NO_TOKEN', 'upstream did not return a verification token', 502);
}
pruneEmailFlows(now);
const flowId = randomBytes(16).toString('hex');
_emailFlows.set(flowId, {
email: normalizedEmail,
// V-24: the identity the code was sent to, bound at start so the exchange
// can be refused before the single-use code is spent.
identityEmail: normalizeLoginIdentity(normalizedEmail),
token,
host,
createdAt: now,
expiresAt: now + EMAIL_FLOW_TTL_MS,
attempts: 0,
used: false,
inFlight: false,
// Set only after upstream accepts the code; the sole retryable state when
// the PostAuth exchange fails (F6). Never the verification token.
auth1Token: null,
verifiedAt: 0,
postAuthAttempts: 0,
});
_emailCodeSentAt.set(sendKey, Date.now());
log.info(`Email login code requested: ${safeEmailRef(normalizedEmail)} host=${host} flowHash=${logHash(flowId)}`);
return { flowId, sent: true, expiresInMs: EMAIL_FLOW_TTL_MS, host };
}
/**
* Verify the emailed code and finish the login.
*
* The code is single-use upstream, but the *exchange* is not the code: a
* successful email/complete mints an Auth1 token that is kept on the flow for
* EMAIL_FLOW_POSTAUTH_RETRY_MS, so a PostAuth failure can be retried without
* asking the user for another email (F6). The flow is marked used only once the
* session token exists.
*
* @param {string} flowId - opaque id returned by emailLoginStart
* @param {string} code - the 6-digit code from the inbox
* @param {object} [proxy]
* @param {object} [opts]
* @param {string} [opts.expectedIdentityEmail] - address of the account the
* caller is about to install this session on. A mismatch is refused HERE,
* before the upstream exchange, because the code cannot be replayed.
*/
export async function emailLoginComplete(flowId, code, proxy = null, opts = {}) {
const id = String(flowId || '');
const flow = _emailFlows.get(id);
if (!flow) {
throw emailFlowError('ERR_EMAIL_FLOW_NOT_FOUND', 'Verification flow not found — request a new code.', 404);
}
if (flow.used) {
throw emailFlowError('ERR_EMAIL_FLOW_USED', 'This verification code was already used — request a new one.', 409);
}
const now = Date.now();
if (now > flow.expiresAt) {
_emailFlows.delete(id);
throw emailFlowError('ERR_EMAIL_FLOW_EXPIRED', 'The verification code expired — request a new one.', 410);
}
if (flow.inFlight) {
throw emailFlowError('ERR_EMAIL_FLOW_IN_USE', 'This verification code is already being checked.', 409);
}
// V-24: the flow's identity was bound at start. A caller binding the
// exchange to a different account is refused before the code is spent —
// after the exchange the code is gone, so a late check would cost the
// operator another email for nothing.
const expectedIdentity = normalizeLoginIdentity(opts?.expectedIdentityEmail);
if (expectedIdentity && expectedIdentity !== (flow.identityEmail || normalizeLoginIdentity(flow.email))) {
throw emailFlowError(
'ERR_LOGIN_IDENTITY_MISMATCH',
`This verification code was sent to ${maskIdentityEmail(flow.email)}, not to the selected account (${maskIdentityEmail(expectedIdentity)}). Request a code for the account you are re-authenticating.`,
409,
);
}
const normalizedCode = String(code ?? '').replace(/\s+/g, '');
// 6 digits upstream; the bounds are wide on purpose so an upstream policy
// change does not reject a valid code here.
if (!/^\d{4,10}$/.test(normalizedCode)) {
throw emailFlowError('ERR_EMAIL_CODE_INVALID', 'Enter the numeric verification code from the email.', 400);
}
const lockMs = checkEmailLocked(flow.email);
if (lockMs != null) throw emailLockedError(lockMs);
const fingerprint = generateFingerprint();
let auth1Token = null;
let completedHost = flow.host;
flow.inFlight = true;
try {
// A code that upstream already accepted is spent: replaying its verification
// token would only earn a 401. Retry the exchange with the stored Auth1
// token instead, and only inside the short window.
const reusable = typeof flow.auth1Token === 'string' && flow.auth1Token
&& flow.verifiedAt && now - flow.verifiedAt <= EMAIL_FLOW_POSTAUTH_RETRY_MS;
if (reusable) {
auth1Token = flow.auth1Token;
log.info(`Email code already verified; retrying the session exchange: ${safeEmailRef(flow.email)} flowHash=${logHash(id)}`);
} else {
if (!flow.token) {
// Spent verification token, retry window closed: nothing left to send.
_emailFlows.delete(id);
throw emailFlowError('ERR_EMAIL_FLOW_EXPIRED', 'The verification code was already used — request a new one.', 410);
}
let exchange;
try {
exchange = await requestEmailEndpoint(
'complete', flow.host, { email: flow.email, token: flow.token, code: normalizedCode }, fingerprint, proxy,
);
} catch (err) {
// Transport layer gave up (plain Error, no code). Upstream never judged
// the digits: keep the flow and its attempt count, and say so with a
// stable code (F5) instead of leaking a raw message as HTTP 400.
if (err && typeof err.code === 'string' && err.code.startsWith('ERR_EMAIL_') && err.statusCode) throw err;
log.warn(`Auth1 email/complete transport failure for ${safeEmailRef(flow.email)}: ${err?.message || err}`);
throw emailFlowError(
'ERR_EMAIL_VERIFY_UNAVAILABLE',
'The verification service did not answer — the code was not checked. Try again.',
502,
{ retryable: true },
);
}
const { res, host } = exchange;
completedHost = host;
const detail = upstreamDetail(res.data);
if (res.status >= 400 || detail) {
const kind = classifyEmailCompleteFailure(res, detail);
if (kind === 'expired') {
// The verification token is dead upstream — a retry can only 401
// again. Drop the flow and count NOTHING: this is not a code guess
// and must never feed the 900 s lockout (F5).
_emailFlows.delete(id);
throw emailFlowError(
'ERR_EMAIL_FLOW_EXPIRED',
'The verification code expired or was already used — request a new one.',
410,
);
}
if (kind === 'unavailable') {
// 5xx / transport / 429 / 403: upstream never judged the digits.
// Keep the flow and its attempt count exactly as they were.
log.warn(`Auth1 email/complete unavailable (${res.status}) for ${safeEmailRef(flow.email)}`);
throw emailFlowError(
'ERR_EMAIL_VERIFY_UNAVAILABLE',
'The verification service did not answer — the code was not checked. Try again.',
502,
{ retryable: true },
);
}
flow.attempts += 1;
// Only a positive "wrong code" answer is a credential guess and feeds
// the per-email lockout the password path uses. An ambiguous 400/422
// still burns a flow attempt so one flow cannot be an unlimited oracle.
if (kind === 'code') recordEmailFailure(flow.email, 'email_code_rejected');
if (flow.attempts >= EMAIL_FLOW_MAX_ATTEMPTS) {
flow.used = true;
flow.token = null;
}
throw emailFlowError(
'ERR_EMAIL_CODE_REJECTED',
detail || `upstream ${res.status}`,
400,
{ isAuthFail: kind === 'code', attemptsRemaining: Math.max(0, EMAIL_FLOW_MAX_ATTEMPTS - flow.attempts) },
);
}
auth1Token = res.data?.token;
if (typeof auth1Token !== 'string' || !auth1Token) {
throw emailFlowError(
'ERR_EMAIL_VERIFY_UNAVAILABLE',
'The verification service accepted the code but returned no session token. Try again.',
502,
{ retryable: true },
);
}
// The verification token is spent; the Auth1 token is the only thing a
// retry may replay, and only for the exchange window.
flow.token = null;
flow.auth1Token = auth1Token;
flow.verifiedAt = Date.now();
flow.host = host;
log.info(`Email code accepted: ${safeEmailRef(flow.email)} host=${host} flowHash=${logHash(id)}`);
}
flow.postAuthAttempts += 1;
const postAuthAttempt = flow.postAuthAttempts;
const remaining = Math.max(0, EMAIL_FLOW_POSTAUTH_MAX_ATTEMPTS - postAuthAttempt);
const giveUp = () => {
flow.used = true;
flow.auth1Token = null;
};
let br;
let label;
try {
({ res: br, label } = await postAuthDualPath(auth1Token, fingerprint, proxy));
} catch (e) {
// Both PostAuth hosts failed at the transport/5xx layer. The code stays
// valid for the retry window (F6).
log.warn(`PostAuth unavailable after an accepted code (${safeEmailRef(flow.email)}): ${e.message}`);
if (remaining === 0) giveUp();
throw emailFlowError(
'ERR_POSTAUTH_FAILED',
'Windsurf accepted the code but the session exchange did not answer — retry the same code.',
502,
{ retryable: remaining > 0, attemptsRemaining: remaining },
);
}
if (br.status >= 400 || !br.data?.sessionToken) {
log.warn(`PostAuth refused after an accepted code (${safeEmailRef(flow.email)}): HTTP ${br.status}`);
if (remaining === 0) giveUp();
throw emailFlowError(
'ERR_POSTAUTH_FAILED',
'Windsurf accepted the code but the session exchange was refused — retry the same code.',
502,
{ retryable: remaining > 0, attemptsRemaining: remaining },
);
}
// Only a real session token consumes the flow.
flow.used = true;
flow.auth1Token = null;
recordEmailSuccess(flow.email);
const accountId = br.data.accountId || 'unknown';
log.info(`Email-code login OK (${label}): ${safeEmailRef(flow.email)} accountHash=${logHash(accountId)} → using sessionToken as apiKey`);
return {
apiKey: br.data.sessionToken,
name: flow.email,
email: flow.email,
// V-24: the identity bound to this flow at start — the address the code
// was mailed to, not whatever the caller claims at completion.
identityEmail: flow.identityEmail || normalizeLoginIdentity(flow.email),
apiServerUrl: '',
sessionToken: br.data.sessionToken,
auth1Token,
viaEmailCode: true,
...(completedHost === 'devin' ? { viaHost: 'app.devin.ai' } : {}),
};
} finally {
flow.inFlight = false;
}
}
/**
* 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: ${sliceRedactedJson(res.data, 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 || '',
};
}