mirror of
https://github.com/Pandipipas/scoreko-dev.git
synced 2026-06-06 03:32:06 +00:00
274 lines
8.5 KiB
TypeScript
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 };
|
|
};
|