feat: enhance OAuth configuration to support proxy mode and update related logic

This commit is contained in:
2026-05-18 21:47:06 +02:00
parent 79f6653d94
commit 67d9d20b56
4 changed files with 242 additions and 86 deletions
+10 -16
View File
@@ -3,45 +3,39 @@
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"exampleProperty": { "oauthProxyUrl": {
"type": "string" "type": "string",
"description": "Sobreescribe la URL base del proxy OAuth (por defecto usa la constante del código). Útil para staging o desarrollo del proxy."
}, },
"startggClientId": { "startggClientId": {
"type": "string", "type": "string",
"default": "", "description": "DEV ONLY: Client ID de tu propia OAuth app de start.gg. Si está presente junto a startggClientSecret, activa el modo dev (exchange directo, sin proxy)."
"description": "Client ID de tu OAuth app de start.gg"
}, },
"startggClientSecret": { "startggClientSecret": {
"type": "string", "type": "string",
"default": "", "description": "DEV ONLY: Client Secret de tu propia OAuth app de start.gg. NUNCA subas este valor a git."
"description": "Client Secret de tu OAuth app de start.gg"
}, },
"startggOAuthPort": { "startggOAuthPort": {
"type": "integer", "type": "integer",
"default": 34920, "default": 34920,
"minimum": 1, "minimum": 1,
"maximum": 65535, "maximum": 65535,
"description": "Puerto local para callback OAuth" "description": "Puerto local para el servidor de callback OAuth de start.gg."
}, },
"challongeClientId": { "challongeClientId": {
"type": "string", "type": "string",
"default": "", "description": "DEV ONLY: Client ID de tu propia OAuth app de Challonge. Si está presente junto a challongeClientSecret, activa el modo dev."
"description": "Client ID de tu OAuth app de Challonge"
}, },
"challongeClientSecret": { "challongeClientSecret": {
"type": "string", "type": "string",
"default": "", "description": "DEV ONLY: Client Secret de tu propia OAuth app de Challonge. NUNCA subas este valor a git."
"description": "Client Secret de tu OAuth app de Challonge"
}, },
"challongeOAuthPort": { "challongeOAuthPort": {
"type": "integer", "type": "integer",
"default": 34921, "default": 34921,
"minimum": 1, "minimum": 1,
"maximum": 65535, "maximum": 65535,
"description": "Puerto local para callback OAuth de Challonge" "description": "Puerto local para el servidor de callback OAuth de Challonge."
} }
}, }
"required": [
"exampleProperty"
]
} }
+113 -42
View File
@@ -20,6 +20,14 @@ const CHALLONGE_OAUTH_DEFAULT_PORT = 34921;
const CHALLONGE_OAUTH_SESSION_TTL_MS = 10 * 60 * 1000; const CHALLONGE_OAUTH_SESSION_TTL_MS = 10 * 60 * 1000;
const RECENT_TOURNAMENTS_LIMIT = 20; const RECENT_TOURNAMENTS_LIMIT = 20;
// ─── URL del proxy OAuth ───────────────────────────────────────────────────────
// Rellena esta constante con la URL de tu Cloudflare Worker tras el deploy.
// Formato: 'https://scoreko-oauth-proxy.TU-SUBDOMINIO.workers.dev'
//
// También puedes sobreescribirla en cfg/scoreko.json con "oauthProxyUrl"
// (útil para apuntar a un entorno de staging sin recompilar).
const OAUTH_PROXY_BASE_URL = 'https://scoreko-oauth-proxy.panver.workers.dev';
// ─── Tipos ───────────────────────────────────────────────────────────────────── // ─── Tipos ─────────────────────────────────────────────────────────────────────
interface OAuthTokenResponse { interface OAuthTokenResponse {
@@ -46,34 +54,54 @@ interface ImportedPlayer {
twitter: string; twitter: string;
} }
// ─── Config OAuth ────────────────────────────────────────────────────────────── // ─── Modo OAuth ────────────────────────────────────────────────────────────────
//
// DEV: cfg/scoreko.json tiene challongeClientId + challongeClientSecret.
// El exchange se hace directamente contra Challonge.
//
// PROXY: No hay credenciales en la config local.
// El clientId se obtiene del Worker (es público, no secreto).
// El exchange lo hace el Worker, que guarda el clientSecret en sus env vars.
const getOAuthConfig = (): OAuthConfig | null => { type OAuthMode =
const bundleConfig = nodecg.bundleConfig as unknown as Record<string, unknown>; | { type: 'dev'; clientId: string; clientSecret: string; callbackPort: number }
const clientId = String(bundleConfig.challongeClientId ?? '').trim(); | { type: 'proxy'; proxyBaseUrl: string; callbackPort: number };
const getOAuthMode = (): OAuthMode => {
const bundleConfig = nodecg.bundleConfig as Record<string, unknown>;
const clientId = String(bundleConfig.challongeClientId ?? '').trim();
const clientSecret = String(bundleConfig.challongeClientSecret ?? '').trim(); const clientSecret = String(bundleConfig.challongeClientSecret ?? '').trim();
const rawPort = Number(bundleConfig.challongeOAuthPort ?? CHALLONGE_OAUTH_DEFAULT_PORT); const rawPort = Number(bundleConfig.challongeOAuthPort ?? CHALLONGE_OAUTH_DEFAULT_PORT);
const callbackPort = const callbackPort =
Number.isFinite(rawPort) && rawPort > 0 ? rawPort : CHALLONGE_OAUTH_DEFAULT_PORT; Number.isFinite(rawPort) && rawPort > 0 ? rawPort : CHALLONGE_OAUTH_DEFAULT_PORT;
if (!clientId || !clientSecret) return null; const proxyBaseUrl =
String(bundleConfig.oauthProxyUrl ?? '').trim() || OAUTH_PROXY_BASE_URL;
return { clientId, clientSecret, callbackPort }; if (clientId && clientSecret) {
nodecg.log.info('[Challonge] OAuth: modo dev (credenciales locales)');
return { type: 'dev', clientId, clientSecret, callbackPort };
}
nodecg.log.info(`[Challonge] OAuth: modo proxy → ${proxyBaseUrl}`);
return { type: 'proxy', proxyBaseUrl, callbackPort };
}; };
// ─── Intercambio de token ────────────────────────────────────────────────────── // ─── Exchange de token ─────────────────────────────────────────────────────────
const exchangeOAuthCodeForToken = async ( /** Modo dev: exchange directo con Challonge usando credenciales locales */
const exchangeCodeDirectly = async (
code: string, code: string,
redirectUri: string, redirectUri: string,
config: OAuthConfig, clientId: string,
clientSecret: string,
): Promise<string> => { ): Promise<string> => {
const params = new URLSearchParams({ const params = new URLSearchParams({
grant_type: 'authorization_code', grant_type: 'authorization_code',
code, code,
client_id: config.clientId, client_id: clientId,
client_secret: config.clientSecret, client_secret: clientSecret,
redirect_uri: redirectUri, redirect_uri: redirectUri,
}); });
const response = await fetch(CHALLONGE_OAUTH_TOKEN_ENDPOINT, { const response = await fetch(CHALLONGE_OAUTH_TOKEN_ENDPOINT, {
@@ -112,6 +140,50 @@ const exchangeOAuthCodeForToken = async (
return token; return token;
}; };
/** Modo proxy: el Worker hace el exchange; el clientSecret nunca sale del Worker */
const exchangeCodeViaProxy = async (
code: string,
redirectUri: string,
proxyBaseUrl: string,
): Promise<string> => {
const response = await fetch(`${proxyBaseUrl}/oauth/challonge/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, redirectUri }),
});
const rawBody = await response.text();
let payload: { access_token?: string; error?: string };
try {
payload = JSON.parse(rawBody) as typeof payload;
} catch {
payload = { error: rawBody };
}
if (!response.ok) {
throw new Error(payload.error ?? `Proxy responded with ${response.status}`);
}
const token = String(payload.access_token ?? '').trim();
if (!token) throw new Error(payload.error ?? 'Proxy did not return a token');
return token;
};
/**
* Callback que recibe oauth-server.ts cuando llega el código de autorización.
* Delega al modo correcto; _config no se usa porque el modo ya está determinado.
*/
const exchangeOAuthCodeForToken = async (
code: string,
redirectUri: string,
_config: OAuthConfig,
): Promise<string> => {
const mode = getOAuthMode();
if (mode.type === 'dev') {
return exchangeCodeDirectly(code, redirectUri, mode.clientId, mode.clientSecret);
}
return exchangeCodeViaProxy(code, redirectUri, mode.proxyBaseUrl);
};
// ─── Servidor OAuth ──────────────────────────────────────────────────────────── // ─── Servidor OAuth ────────────────────────────────────────────────────────────
const oauthServer = createOAuthServer({ const oauthServer = createOAuthServer({
@@ -137,17 +209,6 @@ const parseJsonResponse = async (response: Response): Promise<unknown> => {
} }
}; };
/**
* Realiza una petición autenticada a la API de Challonge.
*
* Intenta primero con OAuth v2 (Bearer token).
* Si recibe 401, reintenta con autenticación v1 (API key personal pegada manualmente).
* En cualquier otro error no-2xx, lanza inmediatamente.
*
* CORRECCIÓN: en la versión anterior, el bloque de error final era dead code
* porque el body de v2 ya había sido consumido y la condición `!v2Response.ok`
* nunca se alcanzaba tras el fallback v1.
*/
const requestChallonge = async (path: string, token: string): Promise<unknown> => { const requestChallonge = async (path: string, token: string): Promise<unknown> => {
const requestUrl = `${CHALLONGE_API_BASE}${path}`; const requestUrl = `${CHALLONGE_API_BASE}${path}`;
@@ -297,19 +358,13 @@ const parseImportedPlayers = (payload: unknown): ImportedPlayer[] => {
if (!id || !rawDisplayName) return; if (!id || !rawDisplayName) return;
// Detectar patrón "TEAM | Gamertag" o "TEAM |Gamertag" (muy común en fighting games).
// Si se detecta, extraer el equipo del propio nombre y limpiar el gamertag.
const PIPE_PATTERN = /^(.+?)\s*\|\s*(.+)$/; const PIPE_PATTERN = /^(.+?)\s*\|\s*(.+)$/;
const pipeMatch = PIPE_PATTERN.exec(rawDisplayName); const pipeMatch = PIPE_PATTERN.exec(rawDisplayName);
const teamFromName = pipeMatch ? pipeMatch[1].trim() : ''; const teamFromName = pipeMatch ? pipeMatch[1].trim() : '';
const gamertag = pipeMatch ? pipeMatch[2].trim() : rawDisplayName; const gamertag = pipeMatch ? pipeMatch[2].trim() : rawDisplayName;
// team_name de la API tiene prioridad; si no existe, usar el extraído del nombre.
const team = String(attributes.team_name ?? '').trim() || teamFromName; const team = String(attributes.team_name ?? '').trim() || teamFromName;
// Challonge no expone un campo de nombre real separado del username/display_name.
// Se deja vacío para no duplicar el gamertag en el campo name.
map.set(id, { map.set(id, {
id, id,
gamertag, gamertag,
@@ -361,24 +416,40 @@ const sendAck = (ack: unknown, error: string | null, response?: unknown) => {
// ─── Listeners de NodeCG ─────────────────────────────────────────────────────── // ─── Listeners de NodeCG ───────────────────────────────────────────────────────
nodecg.listenFor('challonge:createOAuthSession', async (_payload: unknown, ack) => { nodecg.listenFor('challonge:createOAuthSession', async (_payload: unknown, ack) => {
const config = getOAuthConfig(); const mode = getOAuthMode();
if (!config) { let serverConfig: OAuthConfig;
sendAck(
ack, if (mode.type === 'dev') {
'OAuth is not configured in this installation (missing challongeClientId/challongeClientSecret). Use the Client ID and Client Secret from a Challonge OAuth app.', serverConfig = {
); clientId: mode.clientId,
return; callbackPort: mode.callbackPort,
};
} else {
// Modo proxy: el clientId viene del Worker (es público, no secreto)
try {
const res = await fetch(`${mode.proxyBaseUrl}/oauth/challonge/client-id`);
if (!res.ok) throw new Error(`Proxy responded with ${res.status}`);
const data = await res.json() as { clientId?: string };
const clientId = String(data.clientId ?? '').trim();
if (!clientId) throw new Error('Proxy did not return a clientId');
serverConfig = { clientId, callbackPort: mode.callbackPort };
} catch (err) {
sendAck(
ack,
err instanceof Error ? err.message : 'Could not fetch OAuth config from proxy',
);
return;
}
} }
try { try {
await oauthServer.ensureServer(config); await oauthServer.ensureServer(serverConfig);
} catch (err) { } catch (err) {
sendAck(ack, err instanceof Error ? err.message : 'Could not start the local OAuth callback'); sendAck(ack, err instanceof Error ? err.message : 'Could not start the OAuth callback server');
return; return;
} }
const session = oauthServer.createSession(config); sendAck(ack, null, oauthServer.createSession(serverConfig));
sendAck(ack, null, session);
}); });
nodecg.listenFor('challonge:getOAuthSessionStatus', (payload: unknown, ack) => { nodecg.listenFor('challonge:getOAuthSessionStatus', (payload: unknown, ack) => {
+116 -27
View File
@@ -1,6 +1,6 @@
import { getData, type CountryRecord } from 'country-list'; import { getData, type CountryRecord } from 'country-list';
import { createOAuthServer, type OAuthConfig } from './util/oauth-server.js';
import { nodecg } from './util/nodecg.js'; import { nodecg } from './util/nodecg.js';
import { createOAuthServer, type OAuthConfig } from './util/oauth-server.js';
// ─── Constantes ──────────────────────────────────────────────────────────────── // ─── Constantes ────────────────────────────────────────────────────────────────
@@ -18,6 +18,14 @@ const STARTGG_OAUTH_SESSION_TTL_MS = 10 * 60 * 1000;
const RECENT_TOURNAMENTS_LIMIT = 12; const RECENT_TOURNAMENTS_LIMIT = 12;
const PARTICIPANTS_PAGE_SIZE = 120; const PARTICIPANTS_PAGE_SIZE = 120;
// ─── URL del proxy OAuth ───────────────────────────────────────────────────────
// Rellena esta constante con la URL de tu Cloudflare Worker tras el deploy.
// Formato: 'https://scoreko-oauth-proxy.TU-SUBDOMINIO.workers.dev'
//
// También puedes sobreescribirla en cfg/scoreko.json con "oauthProxyUrl"
// (útil para apuntar a un entorno de staging sin recompilar).
const OAUTH_PROXY_BASE_URL = 'https://scoreko-oauth-proxy.panver.workers.dev';
// ─── Tipos ───────────────────────────────────────────────────────────────────── // ─── Tipos ─────────────────────────────────────────────────────────────────────
interface StartGGGraphQLResponse<T> { interface StartGGGraphQLResponse<T> {
@@ -49,22 +57,41 @@ interface OAuthTokenResponse {
message?: string; message?: string;
} }
// ─── Config OAuth ────────────────────────────────────────────────────────────── // ─── Modo OAuth ────────────────────────────────────────────────────────────────
//
// DEV: cfg/scoreko.json tiene startggClientId + startggClientSecret.
// El exchange se hace directamente contra start.gg.
//
// PROXY: No hay credenciales en la config local.
// El clientId se obtiene del Worker (es público, no secreto).
// El exchange lo hace el Worker, que guarda el clientSecret en sus env vars.
const getOAuthConfig = (): OAuthConfig | null => { type OAuthMode =
const bundleConfig = nodecg.bundleConfig as unknown as Record<string, unknown>; | { type: 'dev'; clientId: string; clientSecret: string; callbackPort: number }
const clientId = String(bundleConfig.startggClientId ?? '').trim(); | { type: 'proxy'; proxyBaseUrl: string; callbackPort: number };
const getOAuthMode = (): OAuthMode => {
const bundleConfig = nodecg.bundleConfig as Record<string, unknown>;
const clientId = String(bundleConfig.startggClientId ?? '').trim();
const clientSecret = String(bundleConfig.startggClientSecret ?? '').trim(); const clientSecret = String(bundleConfig.startggClientSecret ?? '').trim();
const rawPort = Number(bundleConfig.startggOAuthPort ?? STARTGG_OAUTH_DEFAULT_PORT); const rawPort = Number(bundleConfig.startggOAuthPort ?? STARTGG_OAUTH_DEFAULT_PORT);
const callbackPort = const callbackPort =
Number.isFinite(rawPort) && rawPort > 0 ? rawPort : STARTGG_OAUTH_DEFAULT_PORT; Number.isFinite(rawPort) && rawPort > 0 ? rawPort : STARTGG_OAUTH_DEFAULT_PORT;
if (!clientId || !clientSecret) return null; // oauthProxyUrl en config permite apuntar a un proxy distinto sin recompilar
const proxyBaseUrl =
String(bundleConfig.oauthProxyUrl ?? '').trim() || OAUTH_PROXY_BASE_URL;
return { clientId, clientSecret, callbackPort }; if (clientId && clientSecret) {
nodecg.log.info('[start.gg] OAuth: modo dev (credenciales locales)');
return { type: 'dev', clientId, clientSecret, callbackPort };
}
nodecg.log.info(`[start.gg] OAuth: modo proxy → ${proxyBaseUrl}`);
return { type: 'proxy', proxyBaseUrl, callbackPort };
}; };
// ─── Intercambio de token (multi-endpoint) ───────────────────────────────────── // ─── Exchange de token ─────────────────────────────────────────────────────────
const parseOAuthTokenPayload = async (response: Response): Promise<OAuthTokenResponse> => { const parseOAuthTokenPayload = async (response: Response): Promise<OAuthTokenResponse> => {
const rawBody = await response.text(); const rawBody = await response.text();
@@ -75,17 +102,19 @@ const parseOAuthTokenPayload = async (response: Response): Promise<OAuthTokenRes
} }
}; };
const exchangeOAuthCodeForToken = async ( /** Modo dev: exchange directo con start.gg usando credenciales locales */
const exchangeCodeDirectly = async (
code: string, code: string,
redirectUri: string, redirectUri: string,
config: OAuthConfig, clientId: string,
clientSecret: string,
): Promise<string> => { ): Promise<string> => {
const params = new URLSearchParams({ const params = new URLSearchParams({
grant_type: 'authorization_code', grant_type: 'authorization_code',
code, code,
client_id: config.clientId, client_id: clientId,
client_secret: config.clientSecret, client_secret: clientSecret,
redirect_uri: redirectUri, redirect_uri: redirectUri,
}); });
let lastError = 'Unknown OAuth token exchange error'; let lastError = 'Unknown OAuth token exchange error';
@@ -116,13 +145,56 @@ const exchangeOAuthCodeForToken = async (
payload.message ?? payload.message ??
`OAuth token request failed (${response.status})`; `OAuth token request failed (${response.status})`;
// Solo 404 justifica probar el siguiente endpoint
if (response.status !== 404) break; if (response.status !== 404) break;
} }
throw new Error(lastError); throw new Error(lastError);
}; };
/** Modo proxy: el Worker hace el exchange; el clientSecret nunca sale del Worker */
const exchangeCodeViaProxy = async (
code: string,
redirectUri: string,
proxyBaseUrl: string,
): Promise<string> => {
const response = await fetch(`${proxyBaseUrl}/oauth/startgg/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, redirectUri }),
});
const rawBody = await response.text();
let payload: { access_token?: string; error?: string };
try {
payload = JSON.parse(rawBody) as typeof payload;
} catch {
payload = { error: rawBody };
}
if (!response.ok) {
throw new Error(payload.error ?? `Proxy responded with ${response.status}`);
}
const token = String(payload.access_token ?? '').trim();
if (!token) throw new Error(payload.error ?? 'Proxy did not return a token');
return token;
};
/**
* Callback que recibe oauth-server.ts cuando llega el código de autorización.
* Delega al modo correcto; _config no se usa porque el modo ya está determinado.
*/
const exchangeOAuthCodeForToken = async (
code: string,
redirectUri: string,
_config: OAuthConfig,
): Promise<string> => {
const mode = getOAuthMode();
if (mode.type === 'dev') {
return exchangeCodeDirectly(code, redirectUri, mode.clientId, mode.clientSecret);
}
return exchangeCodeViaProxy(code, redirectUri, mode.proxyBaseUrl);
};
// ─── Servidor OAuth ──────────────────────────────────────────────────────────── // ─── Servidor OAuth ────────────────────────────────────────────────────────────
const oauthServer = createOAuthServer({ const oauthServer = createOAuthServer({
@@ -203,24 +275,41 @@ const sendAck = (ack: unknown, error: string | null, response?: unknown) => {
// ─── Listeners de NodeCG ─────────────────────────────────────────────────────── // ─── Listeners de NodeCG ───────────────────────────────────────────────────────
nodecg.listenFor('startgg:createOAuthSession', async (_payload: unknown, ack) => { nodecg.listenFor('startgg:createOAuthSession', async (_payload: unknown, ack) => {
const config = getOAuthConfig(); const mode = getOAuthMode();
if (!config) { let serverConfig: OAuthConfig;
sendAck(
ack, if (mode.type === 'dev') {
'OAuth is not configured in this installation (missing startggClientId/startggClientSecret). Use the Client ID and Client Secret from a start.gg OAuth app.', serverConfig = {
); clientId: mode.clientId,
return; callbackPort: mode.callbackPort,
};
} else {
// Modo proxy: el clientId viene del Worker.
// Es público (va en la URL del navegador), pero no lo queremos en el repo.
try {
const res = await fetch(`${mode.proxyBaseUrl}/oauth/startgg/client-id`);
if (!res.ok) throw new Error(`Proxy responded with ${res.status}`);
const data = await res.json() as { clientId?: string };
const clientId = String(data.clientId ?? '').trim();
if (!clientId) throw new Error('Proxy did not return a clientId');
serverConfig = { clientId, callbackPort: mode.callbackPort };
} catch (err) {
sendAck(
ack,
err instanceof Error ? err.message : 'Could not fetch OAuth config from proxy',
);
return;
}
} }
try { try {
await oauthServer.ensureServer(config); await oauthServer.ensureServer(serverConfig);
} catch (err) { } catch (err) {
sendAck(ack, err instanceof Error ? err.message : 'Could not start the local OAuth callback'); sendAck(ack, err instanceof Error ? err.message : 'Could not start the OAuth callback server');
return; return;
} }
const session = oauthServer.createSession(config); sendAck(ack, null, oauthServer.createSession(serverConfig));
sendAck(ack, null, session);
}); });
nodecg.listenFor('startgg:getOAuthSessionStatus', (payload: unknown, ack) => { nodecg.listenFor('startgg:getOAuthSessionStatus', (payload: unknown, ack) => {
+3 -1
View File
@@ -5,7 +5,9 @@ import { randomUUID } from 'node:crypto';
export interface OAuthConfig { export interface OAuthConfig {
clientId: string; clientId: string;
clientSecret: string; /** Solo necesario en modo dev (exchange directo con el proveedor).
* En modo proxy el exchange lo hace el Worker y no necesita el secret. */
clientSecret?: string;
callbackPort: number; callbackPort: number;
} }