mirror of
https://github.com/Pandipipas/scoreko-dev.git
synced 2026-06-06 03:32:06 +00:00
Refactor OAuth handling: Extract OAuth server logic into a separate module, streamline session management, and enhance error handling in startgg.ts
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
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 };
|
||||
};
|
||||
Reference in New Issue
Block a user