Add start.gg OAuth login flow for local users

This commit is contained in:
Pandipipas
2026-02-16 00:22:54 +01:00
parent 165482a7e0
commit 78dc137679
2 changed files with 381 additions and 2 deletions
+291
View File
@@ -1,7 +1,16 @@
import { createServer, type Server } from 'node:http';
import { randomUUID } from 'node:crypto';
import { getData, type CountryRecord } from 'country-list';
import { nodecg } from './util/nodecg.js';
const STARTGG_ENDPOINT = 'https://api.start.gg/gql/alpha';
const STARTGG_OAUTH_AUTHORIZE_ENDPOINT = 'https://start.gg/oauth/authorize';
const STARTGG_OAUTH_TOKEN_ENDPOINT = 'https://api.start.gg/oauth/token';
const STARTGG_OAUTH_SCOPES = 'user.identity tournament.manager';
const STARTGG_OAUTH_CALLBACK_PATH = '/startgg/callback';
const STARTGG_OAUTH_DEFAULT_PORT = 34920;
const STARTGG_OAUTH_SESSION_TTL_MS = 10 * 60 * 1000;
const RECENT_TOURNAMENTS_LIMIT = 12;
const PARTICIPANTS_PAGE_SIZE = 120;
@@ -26,6 +35,31 @@ interface ImportedPlayer {
twitter: string;
}
interface OAuthConfig {
clientId: string;
clientSecret: string;
callbackPort: number;
}
interface OAuthSession {
sessionId: string;
state: string;
createdAt: number;
expiresAt: number;
status: 'pending' | 'completed' | 'error' | 'expired';
token?: string;
error?: string;
}
interface OAuthTokenResponse {
access_token?: string;
error?: string;
error_description?: string;
}
const oauthSessions = new Map<string, OAuthSession>();
let oauthCallbackServer: Server | null = null;
const requestStartGG = async <T>(query: string, variables: Record<string, unknown>, token: string): Promise<T> => {
const response = await fetch(STARTGG_ENDPOINT, {
method: 'POST',
@@ -76,6 +110,263 @@ const sendAck = (ack: unknown, error: string | null, response?: unknown) => {
ack(error, response);
};
const getOAuthConfig = (): OAuthConfig | null => {
const bundleConfig = nodecg.bundleConfig as unknown as Record<string, unknown>;
const clientId = String(bundleConfig.startggClientId || '').trim();
const clientSecret = String(bundleConfig.startggClientSecret || '').trim();
const rawPort = Number(bundleConfig.startggOAuthPort ?? STARTGG_OAUTH_DEFAULT_PORT);
const callbackPort = Number.isFinite(rawPort) && rawPort > 0 ? rawPort : STARTGG_OAUTH_DEFAULT_PORT;
if (!clientId || !clientSecret) {
return null;
}
return {
clientId,
clientSecret,
callbackPort,
};
};
const getCallbackUrl = (callbackPort: number) => `http://127.0.0.1:${callbackPort}${STARTGG_OAUTH_CALLBACK_PATH}`;
const cleanupExpiredOAuthSessions = () => {
const now = Date.now();
oauthSessions.forEach((session) => {
if (session.expiresAt <= now && session.status === 'pending') {
oauthSessions.set(session.sessionId, {
...session,
status: 'expired',
});
}
});
};
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>Puedes cerrar esta pestaña y volver a Scoreko.</p>
</div>
</body>
</html>`;
const exchangeOAuthCodeForToken = async (
code: string,
redirectUri: string,
oauthConfig: OAuthConfig,
): Promise<string> => {
const response = await fetch(STARTGG_OAUTH_TOKEN_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
grant_type: 'authorization_code',
code,
client_id: oauthConfig.clientId,
client_secret: oauthConfig.clientSecret,
redirect_uri: redirectUri,
}),
});
const payload = (await response.json()) as OAuthTokenResponse;
if (!response.ok) {
throw new Error(payload.error_description || payload.error || `OAuth token request failed (${response.status})`);
}
const token = String(payload.access_token || '').trim();
if (!token) {
throw new Error('OAuth token response did not include an access token');
}
return token;
};
const ensureOAuthCallbackServer = async (oauthConfig: OAuthConfig) => {
if (oauthCallbackServer) {
return;
}
const callbackUrl = getCallbackUrl(oauthConfig.callbackPort);
const server = createServer((req, res) => {
if (!req.url) {
res.statusCode = 400;
res.end('Bad request');
return;
}
const requestUrl = new URL(req.url, callbackUrl);
if (requestUrl.pathname !== STARTGG_OAUTH_CALLBACK_PATH) {
res.statusCode = 404;
res.end('Not found');
return;
}
cleanupExpiredOAuthSessions();
const state = requestUrl.searchParams.get('state') || '';
const code = requestUrl.searchParams.get('code') || '';
const error = requestUrl.searchParams.get('error') || '';
const session = Array.from(oauthSessions.values()).find((candidate) => candidate.state === state);
if (!session) {
res.statusCode = 400;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(renderCallbackHtml('OAuth inválido', 'No se encontró una sesión activa para esta autorización.'));
return;
}
if (session.expiresAt <= Date.now()) {
oauthSessions.set(session.sessionId, {
...session,
status: 'expired',
});
res.statusCode = 400;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(renderCallbackHtml('Sesión expirada', 'La sesión OAuth expiró. Vuelve a iniciar el proceso desde Scoreko.'));
return;
}
if (error) {
oauthSessions.set(session.sessionId, {
...session,
status: 'error',
error,
});
res.statusCode = 400;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(renderCallbackHtml('OAuth cancelado', `start.gg devolvió el error: ${error}`));
return;
}
if (!code) {
oauthSessions.set(session.sessionId, {
...session,
status: 'error',
error: 'Missing authorization code',
});
res.statusCode = 400;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(renderCallbackHtml('OAuth incompleto', 'No se recibió un código de autorización.'));
return;
}
void exchangeOAuthCodeForToken(code, callbackUrl, oauthConfig)
.then((token) => {
oauthSessions.set(session.sessionId, {
...session,
status: 'completed',
token,
error: undefined,
});
})
.catch((exchangeError) => {
const message = exchangeError instanceof Error ? exchangeError.message : 'Failed to exchange authorization code';
oauthSessions.set(session.sessionId, {
...session,
status: 'error',
error: message,
});
});
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(renderCallbackHtml('Autorización recibida', 'Se recibió tu autorización. Finalizando el login en segundo plano...'));
});
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(oauthConfig.callbackPort, '127.0.0.1', () => {
server.off('error', reject);
resolve();
});
});
oauthCallbackServer = server;
};
nodecg.listenFor('startgg:createOAuthSession', async (_payload: unknown, ack) => {
const oauthConfig = getOAuthConfig();
if (!oauthConfig) {
sendAck(ack, 'OAuth no está configurado en esta instalación (faltan startggClientId/startggClientSecret).');
return;
}
try {
await ensureOAuthCallbackServer(oauthConfig);
} catch (serverError) {
const message = serverError instanceof Error ? serverError.message : 'No se pudo iniciar el callback OAuth local';
sendAck(ack, message);
return;
}
cleanupExpiredOAuthSessions();
const sessionId = randomUUID();
const state = randomUUID();
const now = Date.now();
const session: OAuthSession = {
sessionId,
state,
createdAt: now,
expiresAt: now + STARTGG_OAUTH_SESSION_TTL_MS,
status: 'pending',
};
oauthSessions.set(sessionId, session);
const params = new URLSearchParams({
response_type: 'code',
client_id: oauthConfig.clientId,
redirect_uri: getCallbackUrl(oauthConfig.callbackPort),
scope: STARTGG_OAUTH_SCOPES,
state,
});
sendAck(ack, null, {
sessionId,
authUrl: `${STARTGG_OAUTH_AUTHORIZE_ENDPOINT}?${params.toString()}`,
});
});
nodecg.listenFor('startgg:getOAuthSessionStatus', (payload: unknown, ack) => {
cleanupExpiredOAuthSessions();
const sessionId = typeof payload === 'object' && payload !== null && 'sessionId' in payload
? String((payload as { sessionId?: string }).sessionId || '').trim()
: '';
if (!sessionId) {
sendAck(ack, 'Missing OAuth session id');
return;
}
const session = oauthSessions.get(sessionId);
if (!session) {
sendAck(ack, 'OAuth session not found');
return;
}
sendAck(ack, null, {
status: session.status,
token: session.status === 'completed' ? session.token : undefined,
error: session.status === 'error' ? session.error : undefined,
});
});
nodecg.listenFor('startgg:fetchRecentTournaments', async (payload: unknown, ack) => {
const token = typeof payload === 'object' && payload !== null && 'token' in payload
? String((payload as { token?: string }).token || '').trim()