import { createServer, type Server, type ServerResponse } from 'node:http'; import { randomUUID } from 'node:crypto'; // ─── Tipos públicos ──────────────────────────────────────────────────────────── export interface OAuthConfig { clientId: string; clientSecret: string; callbackPort: number; } export interface OAuthSessionStatus { status: 'pending' | 'completed' | 'error' | 'expired'; token?: string; error?: string; } export interface CreateSessionResult { sessionId: string; authUrl: string; } export interface OAuthServerOptions { /** Nombre legible del proveedor, usado en mensajes y HTML del callback */ provider: string; /** Ruta del callback OAuth, p.ej. '/startgg/callback' */ callbackPath: string; /** URL del endpoint de autorización del proveedor */ authorizeEndpoint: string; /** Scopes separados por espacio */ scope: string; /** Milisegundos antes de que una sesión pendiente expire */ sessionTtlMs: number; /** * Intercambia un código de autorización por un access token. * Lanza un error si el intercambio falla. */ exchangeToken: (code: string, redirectUri: string, config: OAuthConfig) => Promise; } export interface OAuthServerHandle { /** Arranca el servidor de callback si aún no está corriendo */ ensureServer(config: OAuthConfig): Promise; /** Crea una nueva sesión OAuth y devuelve sessionId + URL de autorización */ createSession(config: OAuthConfig): CreateSessionResult; /** Devuelve el estado actual de una sesión, o null si no existe */ getSessionStatus(sessionId: string): OAuthSessionStatus | null; } // ─── Tipos internos ──────────────────────────────────────────────────────────── interface OAuthSession { sessionId: string; state: string; expiresAt: number; status: 'pending' | 'completed' | 'error' | 'expired'; token?: string; error?: string; } // ─── HTML de callback ────────────────────────────────────────────────────────── const renderCallbackHtml = (title: string, message: string) => ` ${title}

${title}

${message}

You can close this tab and return to Scoreko.

`; const respondWithCallbackHtml = ( res: ServerResponse, statusCode: number, title: string, message: string, ) => { res.statusCode = statusCode; res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.end(renderCallbackHtml(title, message)); }; // ─── Factory principal ───────────────────────────────────────────────────────── export const createOAuthServer = (options: OAuthServerOptions): OAuthServerHandle => { const sessions = new Map(); let server: Server | null = null; const getCallbackUrl = (port: number) => `http://127.0.0.1:${port}${options.callbackPath}`; const updateSession = (sessionId: string, update: Partial) => { const session = sessions.get(sessionId); if (!session) return; sessions.set(sessionId, { ...session, ...update }); }; /** * Marca como expiradas las sesiones pendientes que han superado su TTL, * y elimina del Map las sesiones ya terminadas (completed / error / expired) * que también hayan superado su TTL. */ const cleanupSessions = () => { const now = Date.now(); sessions.forEach((session, sessionId) => { if (session.expiresAt > now) return; if (session.status === 'pending') { updateSession(sessionId, { status: 'expired' }); } // Eliminar sesiones terminadas que ya hayan expirado para no crecer sin límite if (session.status !== 'pending') { sessions.delete(sessionId); } }); }; const ensureServer = async (config: OAuthConfig): Promise => { if (server) return; const callbackUrl = getCallbackUrl(config.callbackPort); const newServer = createServer((req, res) => { if (!req.url) { res.statusCode = 400; res.end('Bad request'); return; } const requestUrl = new URL(req.url, callbackUrl); if (requestUrl.pathname !== options.callbackPath) { res.statusCode = 404; res.end('Not found'); return; } cleanupSessions(); const state = requestUrl.searchParams.get('state') ?? ''; const code = requestUrl.searchParams.get('code') ?? ''; const error = requestUrl.searchParams.get('error') ?? ''; const session = Array.from(sessions.values()).find((s) => s.state === state); if (!session) { respondWithCallbackHtml( res, 400, 'Invalid OAuth', 'No active session was found for this authorization.', ); return; } if (session.expiresAt <= Date.now()) { updateSession(session.sessionId, { status: 'expired' }); respondWithCallbackHtml( res, 400, 'Session expired', 'The OAuth session expired. Start the process again from Scoreko.', ); return; } if (error) { updateSession(session.sessionId, { status: 'error', error }); respondWithCallbackHtml( res, 400, 'OAuth canceled', `${options.provider} returned this error: ${error}`, ); return; } if (!code) { updateSession(session.sessionId, { status: 'error', error: 'Missing authorization code' }); respondWithCallbackHtml( res, 400, 'Incomplete OAuth', 'No authorization code was received.', ); return; } void options .exchangeToken(code, callbackUrl, config) .then((token) => { updateSession(session.sessionId, { status: 'completed', token, error: undefined }); }) .catch((err: unknown) => { const message = err instanceof Error ? err.message : 'Failed to exchange authorization code'; updateSession(session.sessionId, { status: 'error', error: message }); }); respondWithCallbackHtml( res, 200, 'Authorization received', 'Your authorization was received. Finishing sign-in in the background...', ); }); // Si el servidor sufre un error tras arrancar, resetear la referencia // para que la próxima llamada a ensureServer() pueda reiniciarlo. newServer.on('error', (err) => { console.error(`[${options.provider}] OAuth callback server error:`, err); server = null; }); await new Promise((resolve, reject) => { newServer.once('error', reject); newServer.listen(config.callbackPort, '127.0.0.1', () => { newServer.off('error', reject); resolve(); }); }); server = newServer; }; const createSession = (config: OAuthConfig): CreateSessionResult => { cleanupSessions(); const sessionId = randomUUID(); const state = randomUUID(); sessions.set(sessionId, { sessionId, state, expiresAt: Date.now() + options.sessionTtlMs, status: 'pending', }); const params = new URLSearchParams({ response_type: 'code', client_id: config.clientId, redirect_uri: getCallbackUrl(config.callbackPort), scope: options.scope, state, }); return { sessionId, authUrl: `${options.authorizeEndpoint}?${params.toString()}`, }; }; const getSessionStatus = (sessionId: string): OAuthSessionStatus | null => { cleanupSessions(); const session = sessions.get(sessionId); if (!session) return null; return { status: session.status, token: session.status === 'completed' ? session.token : undefined, error: session.status === 'error' ? session.error : undefined, }; }; return { ensureServer, createSession, getSessionStatus }; };