Files
scoreko-dev/src/extension/util/oauth-server.ts
T

274 lines
8.5 KiB
TypeScript

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<string>;
}
export interface OAuthServerHandle {
/** Arranca el servidor de callback si aún no está corriendo */
ensureServer(config: OAuthConfig): Promise<void>;
/** 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) => `<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<title>${title}</title>
<style>
body { font-family: Arial, sans-serif; margin: 2rem; background: #121212; color: #fff; }
.box { max-width: 680px; padding: 1rem 1.2rem; border: 1px solid #444; border-radius: 8px; }
.ok { color: #66bb6a; }
.ko { color: #ef5350; }
</style>
</head>
<body>
<div class="box">
<h2>${title}</h2>
<p>${message}</p>
<p>You can close this tab and return to Scoreko.</p>
</div>
</body>
</html>`;
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<string, OAuthSession>();
let server: Server | null = null;
const getCallbackUrl = (port: number) =>
`http://127.0.0.1:${port}${options.callbackPath}`;
const updateSession = (sessionId: string, update: Partial<OAuthSession>) => {
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<void> => {
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<void>((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 };
};