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:
2026-05-17 22:07:10 +02:00
parent 0857472ad4
commit aea381ea35
5 changed files with 1334 additions and 1655 deletions
+142 -324
View File
@@ -1,8 +1,9 @@
import { createServer, type Server, type ServerResponse } from 'node:http';
import { randomUUID } from 'node:crypto';
import { getData, type CountryRecord } from 'country-list';
import { createOAuthServer, type OAuthConfig } from './util/oauth-server.js';
import { nodecg } from './util/nodecg.js';
// ─── Constantes ────────────────────────────────────────────────────────────────
const STARTGG_ENDPOINT = 'https://api.start.gg/gql/alpha';
const STARTGG_OAUTH_AUTHORIZE_ENDPOINT = 'https://www.start.gg/api/-/rest/oauth/authorize';
const STARTGG_OAUTH_TOKEN_ENDPOINTS = [
@@ -17,6 +18,8 @@ const STARTGG_OAUTH_SESSION_TTL_MS = 10 * 60 * 1000;
const RECENT_TOURNAMENTS_LIMIT = 12;
const PARTICIPANTS_PAGE_SIZE = 120;
// ─── Tipos ─────────────────────────────────────────────────────────────────────
interface StartGGGraphQLResponse<T> {
data?: T;
errors?: Array<{ message?: string }>;
@@ -39,21 +42,6 @@ interface ImportedPlayer {
twitter: string;
}
interface OAuthConfig {
clientId: string;
clientSecret: string;
callbackPort: number;
}
interface OAuthSession {
sessionId: string;
state: string;
expiresAt: number;
status: 'pending' | 'completed' | 'error' | 'expired';
token?: string;
error?: string;
}
interface OAuthTokenResponse {
access_token?: string;
error?: string;
@@ -61,31 +49,98 @@ interface OAuthTokenResponse {
message?: string;
}
const oauthSessions = new Map<string, OAuthSession>();
let oauthCallbackServer: Server | null = null;
// ─── Config OAuth ──────────────────────────────────────────────────────────────
const getStringProp = (payload: unknown, key: string): string => {
if (typeof payload !== 'object' || payload === null || !(key in payload)) {
return '';
}
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;
const value = (payload as Record<string, unknown>)[key];
return typeof value === 'string' ? value.trim() : String(value || '').trim();
if (!clientId || !clientSecret) return null;
return { clientId, clientSecret, callbackPort };
};
const updateOAuthSession = (sessionId: string, update: Partial<OAuthSession>) => {
const session = oauthSessions.get(sessionId);
if (!session) {
return;
}
// ─── Intercambio de token (multi-endpoint) ─────────────────────────────────────
oauthSessions.set(sessionId, {
...session,
...update,
const parseOAuthTokenPayload = async (response: Response): Promise<OAuthTokenResponse> => {
const rawBody = await response.text();
try {
return JSON.parse(rawBody) as OAuthTokenResponse;
} catch {
return { message: rawBody };
}
};
const exchangeOAuthCodeForToken = async (
code: string,
redirectUri: string,
config: OAuthConfig,
): Promise<string> => {
const params = new URLSearchParams({
grant_type: 'authorization_code',
code,
client_id: config.clientId,
client_secret: config.clientSecret,
redirect_uri: redirectUri,
});
let lastError = 'Unknown OAuth token exchange error';
for (const tokenEndpoint of STARTGG_OAUTH_TOKEN_ENDPOINTS) {
const response = await fetch(tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
});
const payload = await parseOAuthTokenPayload(response);
if (response.ok) {
const token = String(payload.access_token ?? '').trim();
if (token) return token;
lastError =
payload.error_description ??
payload.error ??
payload.message ??
'OAuth token response did not include an access token';
continue;
}
lastError =
payload.error_description ??
payload.error ??
payload.message ??
`OAuth token request failed (${response.status})`;
// Solo 404 justifica probar el siguiente endpoint
if (response.status !== 404) break;
}
throw new Error(lastError);
};
const requestStartGG = async <T>(query: string, variables: Record<string, unknown>, token: string): Promise<T> => {
// ─── Servidor OAuth ────────────────────────────────────────────────────────────
const oauthServer = createOAuthServer({
provider: 'start.gg',
callbackPath: STARTGG_OAUTH_CALLBACK_PATH,
authorizeEndpoint: STARTGG_OAUTH_AUTHORIZE_ENDPOINT,
scope: STARTGG_OAUTH_SCOPES,
sessionTtlMs: STARTGG_OAUTH_SESSION_TTL_MS,
exchangeToken: exchangeOAuthCodeForToken,
});
// ─── GraphQL ───────────────────────────────────────────────────────────────────
const requestStartGG = async <T>(
query: string,
variables: Record<string, unknown>,
token: string,
): Promise<T> => {
const response = await fetch(STARTGG_ENDPOINT, {
method: 'POST',
headers: {
@@ -107,7 +162,7 @@ const requestStartGG = async <T>(query: string, variables: Record<string, unknow
}
if (payload.errors?.length) {
throw new Error(payload.errors[0]?.message || 'Unknown start.gg error');
throw new Error(payload.errors[0]?.message ?? 'Unknown start.gg error');
}
if (!payload.data) {
@@ -117,286 +172,75 @@ const requestStartGG = async <T>(query: string, variables: Record<string, unknow
return payload.data;
};
// ─── Resolución de países ──────────────────────────────────────────────────────
const countries = getData();
const countryByCode = new Set(countries.map((country: CountryRecord) => country.code.toUpperCase()));
const countryByName = new Map(countries.map((country: CountryRecord) => [country.name.toLowerCase(), country.code.toUpperCase()]));
const countryByCode = new Set(countries.map((c: CountryRecord) => c.code.toUpperCase()));
const countryByName = new Map(
countries.map((c: CountryRecord) => [c.name.toLowerCase(), c.code.toUpperCase()]),
);
const resolveCountryCodeFromStartGG = (country: string | null | undefined): string => {
const raw = (country || '').trim();
if (!raw) {
return '';
}
const raw = (country ?? '').trim();
if (!raw) return '';
const upper = raw.toUpperCase();
if (countryByCode.has(upper)) {
return upper;
}
if (countryByCode.has(upper)) return upper;
return countryByName.get(raw.toLowerCase()) ?? '';
};
// ─── Utilidades ────────────────────────────────────────────────────────────────
const getStringProp = (payload: unknown, key: string): string => {
if (typeof payload !== 'object' || payload === null || !(key in payload)) return '';
const value = (payload as Record<string, unknown>)[key];
return typeof value === 'string' ? value.trim() : String(value ?? '').trim();
};
const sendAck = (ack: unknown, error: string | null, response?: unknown) => {
if (typeof ack !== 'function') {
return;
}
ack(error, response);
if (typeof ack === 'function') 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, sessionId) => {
if (session.expiresAt <= now && session.status === 'pending') {
updateOAuthSession(sessionId, { status: 'expired' });
}
});
};
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));
};
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 parseOAuthTokenPayload = async (response: Response): Promise<OAuthTokenResponse> => {
const rawBody = await response.text();
try {
return JSON.parse(rawBody) as OAuthTokenResponse;
} catch {
return { message: rawBody };
}
};
const exchangeOAuthCodeForToken = async (
code: string,
redirectUri: string,
oauthConfig: OAuthConfig,
): Promise<string> => {
const params = new URLSearchParams({
grant_type: 'authorization_code',
code,
client_id: oauthConfig.clientId,
client_secret: oauthConfig.clientSecret,
redirect_uri: redirectUri,
});
let lastError = 'Unknown OAuth token exchange error';
for (const tokenEndpoint of STARTGG_OAUTH_TOKEN_ENDPOINTS) {
const response = await fetch(tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params.toString(),
});
const payload = await parseOAuthTokenPayload(response);
if (response.ok) {
const token = String(payload.access_token || '').trim();
if (token) {
return token;
}
lastError = payload.error_description || payload.error || payload.message || 'OAuth token response did not include an access token';
continue;
}
lastError = payload.error_description || payload.error || payload.message || `OAuth token request failed (${response.status})`;
if (response.status !== 404) {
break;
}
}
throw new Error(lastError);
};
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) {
respondWithCallbackHtml(res, 400, 'Invalid OAuth', 'No active session was found for this authorization.');
return;
}
if (session.expiresAt <= Date.now()) {
updateOAuthSession(session.sessionId, { status: 'expired' });
respondWithCallbackHtml(res, 400, 'Session expired', 'The OAuth session expired. Start the process again from Scoreko.');
return;
}
if (error) {
updateOAuthSession(session.sessionId, { status: 'error', error });
respondWithCallbackHtml(res, 400, 'OAuth canceled', `start.gg returned this error: ${error}`);
return;
}
if (!code) {
updateOAuthSession(session.sessionId, {
status: 'error',
error: 'Missing authorization code',
});
respondWithCallbackHtml(res, 400, 'Incomplete OAuth', 'No authorization code was received.');
return;
}
void exchangeOAuthCodeForToken(code, callbackUrl, oauthConfig)
.then((token) => {
updateOAuthSession(session.sessionId, { status: 'completed', token, error: undefined });
})
.catch((exchangeError) => {
const message = exchangeError instanceof Error ? exchangeError.message : 'Failed to exchange authorization code';
updateOAuthSession(session.sessionId, { status: 'error', error: message });
});
respondWithCallbackHtml(res, 200, 'Authorization received', 'Your authorization was received. Finishing sign-in in the background...');
});
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;
};
// ─── Listeners de NodeCG ───────────────────────────────────────────────────────
nodecg.listenFor('startgg:createOAuthSession', async (_payload: unknown, ack) => {
const oauthConfig = getOAuthConfig();
if (!oauthConfig) {
sendAck(ack, 'OAuth is not configured in this installation (missing startggClientId/startggClientSecret). Use the Client ID and Client Secret from a start.gg OAuth app.');
const config = getOAuthConfig();
if (!config) {
sendAck(
ack,
'OAuth is not configured in this installation (missing startggClientId/startggClientSecret). Use the Client ID and Client Secret from a start.gg OAuth app.',
);
return;
}
try {
await ensureOAuthCallbackServer(oauthConfig);
} catch (serverError) {
const message = serverError instanceof Error ? serverError.message : 'Could not start the local OAuth callback';
sendAck(ack, message);
await oauthServer.ensureServer(config);
} catch (err) {
sendAck(ack, err instanceof Error ? err.message : 'Could not start the local OAuth callback');
return;
}
cleanupExpiredOAuthSessions();
const sessionId = randomUUID();
const state = randomUUID();
const session: OAuthSession = {
sessionId,
state,
expiresAt: Date.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()}`,
});
const session = oauthServer.createSession(config);
sendAck(ack, null, session);
});
nodecg.listenFor('startgg:getOAuthSessionStatus', (payload: unknown, ack) => {
cleanupExpiredOAuthSessions();
const sessionId = getStringProp(payload, 'sessionId');
if (!sessionId) {
sendAck(ack, 'Missing OAuth session id');
return;
}
const session = oauthSessions.get(sessionId);
if (!session) {
const status = oauthServer.getSessionStatus(sessionId);
if (!status) {
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,
});
sendAck(ack, null, status);
});
nodecg.listenFor('startgg:fetchRecentTournaments', async (payload: unknown, ack) => {
const token = getStringProp(payload, 'token');
if (!token) {
sendAck(ack, 'Missing start.gg API token');
return;
@@ -423,21 +267,15 @@ nodecg.listenFor('startgg:fetchRecentTournaments', async (payload: unknown, ack)
currentUser: { tournaments: { nodes: RecentTournament[] } } | null;
}>(query, { perPage: RECENT_TOURNAMENTS_LIMIT }, token);
const tournaments = data.currentUser?.tournaments.nodes
.filter((item) => item.slug)
.sort((a, b) => (b.startAt ?? 0) - (a.startAt ?? 0))
.map((item) => ({
id: item.id,
name: item.name,
slug: item.slug,
startAt: item.startAt,
endAt: item.endAt,
})) ?? [];
const tournaments =
data.currentUser?.tournaments.nodes
.filter((item) => item.slug)
.sort((a, b) => (b.startAt ?? 0) - (a.startAt ?? 0))
.map(({ id, name, slug, startAt, endAt }) => ({ id, name, slug, startAt, endAt })) ?? [];
sendAck(ack, null, tournaments);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error while loading tournaments';
sendAck(ack, message);
sendAck(ack, error instanceof Error ? error.message : 'Unknown error while loading tournaments');
}
});
@@ -445,15 +283,8 @@ nodecg.listenFor('startgg:fetchTournamentPlayers', async (payload: unknown, ack)
const token = getStringProp(payload, 'token');
const slug = getStringProp(payload, 'slug');
if (!token) {
sendAck(ack, 'Missing start.gg API token');
return;
}
if (!slug) {
sendAck(ack, 'Missing tournament slug');
return;
}
if (!token) { sendAck(ack, 'Missing start.gg API token'); return; }
if (!slug) { sendAck(ack, 'Missing tournament slug'); return; }
const query = `
query TournamentParticipants($slug: String!, $page: Int!, $perPage: Int!) {
@@ -491,50 +322,37 @@ nodecg.listenFor('startgg:fetchTournamentPlayers', async (payload: unknown, ack)
id: number;
gamerTag: string | null;
prefix: string | null;
user: {
location: {
country: string | null;
} | null;
} | null;
user: { location: { country: string | null } | null } | null;
}>;
};
} | null;
}>(query, {
slug,
page: currentPage,
perPage: PARTICIPANTS_PAGE_SIZE,
}, token);
}>(query, { slug, page: currentPage, perPage: PARTICIPANTS_PAGE_SIZE }, token);
if (!data.tournament) {
throw new Error('Tournament not found');
}
if (!data.tournament) throw new Error('Tournament not found');
const apiTotalPages = Number(data.tournament.participants.pageInfo.totalPages);
totalPages = Number.isFinite(apiTotalPages) ? Math.max(apiTotalPages, 1) : 1;
data.tournament.participants.nodes.forEach((participant) => {
for (const participant of data.tournament.participants.nodes) {
const playerId = String(participant.id);
const gamertag = (participant.gamerTag || '').trim();
if (!gamertag) {
return;
}
const country = resolveCountryCodeFromStartGG(participant.user?.location?.country);
const gamertag = (participant.gamerTag ?? '').trim();
if (!gamertag) continue;
playersMap.set(playerId, {
id: playerId,
gamertag,
name: gamertag,
team: (participant.prefix || '').trim(),
country,
team: (participant.prefix ?? '').trim(),
country: resolveCountryCodeFromStartGG(participant.user?.location?.country),
twitter: '',
});
});
}
currentPage += 1;
}
sendAck(ack, null, Array.from(playersMap.values()));
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error while importing players';
sendAck(ack, message);
sendAck(ack, error instanceof Error ? error.message : 'Unknown error while importing players');
}
});