mirror of
https://github.com/Pandipipas/scoreko-dev.git
synced 2026-06-06 03:32:06 +00:00
Add start.gg OAuth login flow for local users
This commit is contained in:
@@ -4,7 +4,7 @@ import { useHead } from '@unhead/vue';
|
||||
defineOptions({ name: 'PlayersView' });
|
||||
|
||||
import type { QTableColumn } from 'quasar';
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { countryOptions, getCountryLabel } from '../../../shared/countries';
|
||||
import type { Schemas } from '../../../types';
|
||||
import { usePlayersStore } from '../stores/players';
|
||||
@@ -96,6 +96,21 @@ const selectedTournament = ref<StartGGTournament | null>(null);
|
||||
const startGGPlayers = ref<StartGGImportedPlayer[]>([]);
|
||||
const selectedStartGGPlayerIds = ref<string[]>([]);
|
||||
|
||||
const oauthLoading = ref(false);
|
||||
const oauthSessionId = ref('');
|
||||
let oauthPollingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
interface OAuthSessionResponse {
|
||||
sessionId: string;
|
||||
authUrl: string;
|
||||
}
|
||||
|
||||
interface OAuthStatusResponse {
|
||||
status: 'pending' | 'completed' | 'error' | 'expired';
|
||||
token?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
watch(startGGToken, (value) => {
|
||||
localStorage.setItem(STARTGG_TOKEN_STORAGE_KEY, value);
|
||||
});
|
||||
@@ -111,6 +126,66 @@ const sendNodeCGMessage = <T>(messageName: string, payload: unknown): Promise<T>
|
||||
});
|
||||
});
|
||||
|
||||
const clearOAuthPolling = () => {
|
||||
if (oauthPollingTimer) {
|
||||
clearInterval(oauthPollingTimer);
|
||||
oauthPollingTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const checkOAuthStatus = async () => {
|
||||
if (!oauthSessionId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await sendNodeCGMessage<OAuthStatusResponse>('startgg:getOAuthSessionStatus', {
|
||||
sessionId: oauthSessionId.value,
|
||||
});
|
||||
|
||||
if (status.status === 'completed' && status.token) {
|
||||
startGGToken.value = status.token;
|
||||
oauthLoading.value = false;
|
||||
clearOAuthPolling();
|
||||
oauthSessionId.value = '';
|
||||
tournamentsError.value = '';
|
||||
await loadRecentTournaments();
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.status === 'error' || status.status === 'expired') {
|
||||
oauthLoading.value = false;
|
||||
clearOAuthPolling();
|
||||
oauthSessionId.value = '';
|
||||
tournamentsError.value = status.error || 'No se pudo completar el login OAuth con start.gg.';
|
||||
}
|
||||
} catch (error) {
|
||||
oauthLoading.value = false;
|
||||
clearOAuthPolling();
|
||||
oauthSessionId.value = '';
|
||||
tournamentsError.value = error instanceof Error ? error.message : 'No se pudo verificar el estado OAuth.';
|
||||
}
|
||||
};
|
||||
|
||||
const connectWithStartGGOAuth = async () => {
|
||||
oauthLoading.value = true;
|
||||
tournamentsError.value = '';
|
||||
clearOAuthPolling();
|
||||
|
||||
try {
|
||||
const session = await sendNodeCGMessage<OAuthSessionResponse>('startgg:createOAuthSession', {});
|
||||
oauthSessionId.value = session.sessionId;
|
||||
window.open(session.authUrl, '_blank', 'noopener,noreferrer');
|
||||
|
||||
oauthPollingTimer = setInterval(() => {
|
||||
void checkOAuthStatus();
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
oauthLoading.value = false;
|
||||
tournamentsError.value = error instanceof Error ? error.message : 'No se pudo iniciar OAuth con start.gg.';
|
||||
}
|
||||
};
|
||||
|
||||
const loadRecentTournaments = async () => {
|
||||
const token = startGGToken.value.trim();
|
||||
if (!token) {
|
||||
@@ -252,6 +327,10 @@ onMounted(() => {
|
||||
void loadRecentTournaments();
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearOAuthPolling();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -346,7 +425,7 @@ onMounted(() => {
|
||||
Integración start.gg
|
||||
</div>
|
||||
<div class="text-caption q-mb-md">
|
||||
Pega tu token personal de start.gg para cargar automáticamente tus torneos creados o donde eres admin.
|
||||
Conecta por OAuth (recomendado) o pega tu token personal para cargar tus torneos creados o donde eres admin.
|
||||
</div>
|
||||
<div class="row q-col-gutter-sm items-center">
|
||||
<div class="col-12">
|
||||
@@ -358,6 +437,15 @@ onMounted(() => {
|
||||
type="password"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<QBtn
|
||||
color="primary"
|
||||
icon="login"
|
||||
label="Conectar con start.gg"
|
||||
:loading="oauthLoading"
|
||||
@click="connectWithStartGGOAuth"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<QBtn
|
||||
color="secondary"
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user