src/dashboard/oauth-sessions.js
Rama master · réplica de lectura del repositorio original.
// In-memory OAuth onboarding session store.
//
// Ported from CLIProxyAPI's oauth_sessions.go three-layer decoupling
// (start / callback / poll), collapsed to a single-process in-memory Map — no
// file mailbox or fs-watcher needed here. This is what lets a PUBLIC deploy
// onboard a Devin account without the operator's machine ever receiving a
// localhost OAuth callback: the user finishes login in their own browser and
// pastes the whole redirect URL back; the server extracts the token from it.
//
// Contract (mirrors the workflow-poll shape the dashboard already uses):
// registerSession() -> state (opaque, URL-safe)
// getStatus(state) -> { status: 'wait' | 'error' | 'ok', error? }
// completeSession(state) -> drop (success)
// failSession(state,msg) -> mark error with a message
//
// A missing session reads as 'ok' (already consumed / never existed) — the same
// terminal semantics CLIProxyAPI uses, so a poll after success cleanly ends. The
// one exception: a session that expired while still pending is remembered as an
// 'error' (ERR_SESSION_EXPIRED) so a slow browser login can't masquerade as ok.
import { randomBytes } from 'crypto';
const sessions = new Map(); // state -> { provider, status, error, expiresAt }
const TTL_MS = 10 * 60 * 1000; // 10 minutes to finish a browser login
// Tombstones for sessions that ran out their TTL while still pending. Without
// this, a purged pending session is indistinguishable from a consumed-success
// one (both just "missing"), so getStatus would report a false 'ok' — the
// browser login timed out but the dashboard would claim the account was added.
// We remember the state briefly so a late poll gets a truthful expired-error.
const expired = new Map(); // state -> tombstoneExpiresAt
const TOMBSTONE_MS = 30 * 60 * 1000; // keep the expired verdict long enough for a poll to observe it
// State charset guard (from oauth_sessions.go:208-233): the state is used as a
// map key and as an anti-CSRF token echoed through the OAuth round-trip. Reject
// anything that isn't a short, URL-safe token so it can't smuggle path
// traversal or oversized junk.
const STATE_RE = /^[A-Za-z0-9._-]{1,128}$/;
export function validateState(state) {
return typeof state === 'string' && STATE_RE.test(state);
}
export function registerSession(provider = 'windsurf') {
purgeExpired();
// 24 random bytes -> 32-char base64url, well within STATE_RE.
const state = randomBytes(24).toString('base64url');
sessions.set(state, {
provider: String(provider || 'windsurf'),
status: 'pending',
error: '',
expiresAt: Date.now() + TTL_MS,
});
return state;
}
export function getSession(state) {
purgeExpired();
return sessions.get(state) || null;
}
export function getStatus(state) {
purgeExpired();
const s = sessions.get(state);
if (!s) {
// A pending session that ran out its TTL leaves a tombstone: report the
// timeout instead of a false 'ok'. Anything else missing is consumed/unknown.
if (expired.has(state)) return { status: 'error', error: 'ERR_SESSION_EXPIRED' };
return { status: 'ok' }; // consumed or unknown -> terminal
}
if (s.status === 'error') return { status: 'error', error: s.error || 'unknown_error' };
if (s.status === 'ok') return { status: 'ok' };
return { status: 'wait' };
}
export function completeSession(state) {
sessions.delete(state);
expired.delete(state); // a real success must never read back as expired
}
export function failSession(state, message) {
const s = sessions.get(state);
if (s) {
s.status = 'error';
s.error = String(message || 'unknown_error');
}
}
function purgeExpired() {
const now = Date.now();
for (const [k, v] of sessions) {
if (v.expiresAt < now) {
// Only pending sessions get a tombstone — a completed/failed one already
// carries its own terminal verdict (dropped, or status:'error').
if (v.status === 'pending') expired.set(k, now + TOMBSTONE_MS);
sessions.delete(k);
}
}
for (const [k, deadline] of expired) {
if (deadline < now) expired.delete(k);
}
}
// Periodic sweep so abandoned sessions don't accumulate. unref so it never keeps
// the process alive on its own.
const _sweep = setInterval(purgeExpired, 60 * 1000);
if (typeof _sweep.unref === 'function') _sweep.unref();
// Test seam.
export const __testing = { sessions, expired, TTL_MS, TOMBSTONE_MS, STATE_RE, purgeExpired };