From 67d9d20b560437ae0e10f22c84e4d1e9196056b6 Mon Sep 17 00:00:00 2001 From: Pandipipas Date: Mon, 18 May 2026 21:47:06 +0200 Subject: [PATCH] feat: enhance OAuth configuration to support proxy mode and update related logic --- configschema.json | 26 ++--- src/extension/challonge.ts | 155 +++++++++++++++++++++-------- src/extension/startgg.ts | 143 +++++++++++++++++++++----- src/extension/util/oauth-server.ts | 4 +- 4 files changed, 242 insertions(+), 86 deletions(-) diff --git a/configschema.json b/configschema.json index d0a093b..0f490ee 100644 --- a/configschema.json +++ b/configschema.json @@ -3,45 +3,39 @@ "type": "object", "additionalProperties": false, "properties": { - "exampleProperty": { - "type": "string" + "oauthProxyUrl": { + "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": { "type": "string", - "default": "", - "description": "Client ID de tu OAuth app de start.gg" + "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)." }, "startggClientSecret": { "type": "string", - "default": "", - "description": "Client Secret de tu OAuth app de start.gg" + "description": "DEV ONLY: Client Secret de tu propia OAuth app de start.gg. NUNCA subas este valor a git." }, "startggOAuthPort": { "type": "integer", "default": 34920, "minimum": 1, "maximum": 65535, - "description": "Puerto local para callback OAuth" + "description": "Puerto local para el servidor de callback OAuth de start.gg." }, "challongeClientId": { "type": "string", - "default": "", - "description": "Client ID de tu OAuth app de Challonge" + "description": "DEV ONLY: Client ID de tu propia OAuth app de Challonge. Si está presente junto a challongeClientSecret, activa el modo dev." }, "challongeClientSecret": { "type": "string", - "default": "", - "description": "Client Secret de tu OAuth app de Challonge" + "description": "DEV ONLY: Client Secret de tu propia OAuth app de Challonge. NUNCA subas este valor a git." }, "challongeOAuthPort": { "type": "integer", "default": 34921, "minimum": 1, "maximum": 65535, - "description": "Puerto local para callback OAuth de Challonge" + "description": "Puerto local para el servidor de callback OAuth de Challonge." } - }, - "required": [ - "exampleProperty" - ] + } } diff --git a/src/extension/challonge.ts b/src/extension/challonge.ts index a999abe..1bcde62 100644 --- a/src/extension/challonge.ts +++ b/src/extension/challonge.ts @@ -20,6 +20,14 @@ const CHALLONGE_OAUTH_DEFAULT_PORT = 34921; const CHALLONGE_OAUTH_SESSION_TTL_MS = 10 * 60 * 1000; 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 ───────────────────────────────────────────────────────────────────── interface OAuthTokenResponse { @@ -46,34 +54,54 @@ interface ImportedPlayer { 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 => { - const bundleConfig = nodecg.bundleConfig as unknown as Record; - const clientId = String(bundleConfig.challongeClientId ?? '').trim(); +type OAuthMode = + | { type: 'dev'; clientId: string; clientSecret: string; callbackPort: number } + | { type: 'proxy'; proxyBaseUrl: string; callbackPort: number }; + +const getOAuthMode = (): OAuthMode => { + const bundleConfig = nodecg.bundleConfig as Record; + const clientId = String(bundleConfig.challongeClientId ?? '').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 = 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, redirectUri: string, - config: OAuthConfig, + clientId: string, + clientSecret: string, ): Promise => { const params = new URLSearchParams({ - grant_type: 'authorization_code', + grant_type: 'authorization_code', code, - client_id: config.clientId, - client_secret: config.clientSecret, - redirect_uri: redirectUri, + client_id: clientId, + client_secret: clientSecret, + redirect_uri: redirectUri, }); const response = await fetch(CHALLONGE_OAUTH_TOKEN_ENDPOINT, { @@ -112,6 +140,50 @@ const exchangeOAuthCodeForToken = async ( 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 => { + 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 => { + const mode = getOAuthMode(); + if (mode.type === 'dev') { + return exchangeCodeDirectly(code, redirectUri, mode.clientId, mode.clientSecret); + } + return exchangeCodeViaProxy(code, redirectUri, mode.proxyBaseUrl); +}; + // ─── Servidor OAuth ──────────────────────────────────────────────────────────── const oauthServer = createOAuthServer({ @@ -137,17 +209,6 @@ const parseJsonResponse = async (response: Response): Promise => { } }; -/** - * 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 => { const requestUrl = `${CHALLONGE_API_BASE}${path}`; @@ -297,19 +358,13 @@ const parseImportedPlayers = (payload: unknown): ImportedPlayer[] => { 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 pipeMatch = PIPE_PATTERN.exec(rawDisplayName); const teamFromName = pipeMatch ? pipeMatch[1].trim() : ''; 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; - // 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, { id, gamertag, @@ -361,24 +416,40 @@ const sendAck = (ack: unknown, error: string | null, response?: unknown) => { // ─── Listeners de NodeCG ─────────────────────────────────────────────────────── nodecg.listenFor('challonge:createOAuthSession', async (_payload: unknown, ack) => { - const config = getOAuthConfig(); - if (!config) { - sendAck( - ack, - 'OAuth is not configured in this installation (missing challongeClientId/challongeClientSecret). Use the Client ID and Client Secret from a Challonge OAuth app.', - ); - return; + const mode = getOAuthMode(); + let serverConfig: OAuthConfig; + + if (mode.type === 'dev') { + serverConfig = { + clientId: mode.clientId, + 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 { - await oauthServer.ensureServer(config); + await oauthServer.ensureServer(serverConfig); } 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; } - const session = oauthServer.createSession(config); - sendAck(ack, null, session); + sendAck(ack, null, oauthServer.createSession(serverConfig)); }); nodecg.listenFor('challonge:getOAuthSessionStatus', (payload: unknown, ack) => { diff --git a/src/extension/startgg.ts b/src/extension/startgg.ts index 9c6de0d..1aec737 100644 --- a/src/extension/startgg.ts +++ b/src/extension/startgg.ts @@ -1,6 +1,6 @@ import { getData, type CountryRecord } from 'country-list'; -import { createOAuthServer, type OAuthConfig } from './util/oauth-server.js'; import { nodecg } from './util/nodecg.js'; +import { createOAuthServer, type OAuthConfig } from './util/oauth-server.js'; // ─── Constantes ──────────────────────────────────────────────────────────────── @@ -18,6 +18,14 @@ const STARTGG_OAUTH_SESSION_TTL_MS = 10 * 60 * 1000; const RECENT_TOURNAMENTS_LIMIT = 12; 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 ───────────────────────────────────────────────────────────────────── interface StartGGGraphQLResponse { @@ -49,22 +57,41 @@ interface OAuthTokenResponse { 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 => { - const bundleConfig = nodecg.bundleConfig as unknown as Record; - const clientId = String(bundleConfig.startggClientId ?? '').trim(); +type OAuthMode = + | { type: 'dev'; clientId: string; clientSecret: string; callbackPort: number } + | { type: 'proxy'; proxyBaseUrl: string; callbackPort: number }; + +const getOAuthMode = (): OAuthMode => { + const bundleConfig = nodecg.bundleConfig as Record; + const clientId = String(bundleConfig.startggClientId ?? '').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 = 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 => { const rawBody = await response.text(); @@ -75,17 +102,19 @@ const parseOAuthTokenPayload = async (response: Response): Promise => { const params = new URLSearchParams({ - grant_type: 'authorization_code', + grant_type: 'authorization_code', code, - client_id: config.clientId, - client_secret: config.clientSecret, - redirect_uri: redirectUri, + client_id: clientId, + client_secret: clientSecret, + redirect_uri: redirectUri, }); let lastError = 'Unknown OAuth token exchange error'; @@ -116,13 +145,56 @@ const exchangeOAuthCodeForToken = async ( payload.message ?? `OAuth token request failed (${response.status})`; - // Solo 404 justifica probar el siguiente endpoint if (response.status !== 404) break; } 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 => { + 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 => { + const mode = getOAuthMode(); + if (mode.type === 'dev') { + return exchangeCodeDirectly(code, redirectUri, mode.clientId, mode.clientSecret); + } + return exchangeCodeViaProxy(code, redirectUri, mode.proxyBaseUrl); +}; + // ─── Servidor OAuth ──────────────────────────────────────────────────────────── const oauthServer = createOAuthServer({ @@ -203,24 +275,41 @@ const sendAck = (ack: unknown, error: string | null, response?: unknown) => { // ─── Listeners de NodeCG ─────────────────────────────────────────────────────── nodecg.listenFor('startgg:createOAuthSession', async (_payload: unknown, ack) => { - 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; + const mode = getOAuthMode(); + let serverConfig: OAuthConfig; + + if (mode.type === 'dev') { + serverConfig = { + clientId: mode.clientId, + 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 { - await oauthServer.ensureServer(config); + await oauthServer.ensureServer(serverConfig); } 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; } - const session = oauthServer.createSession(config); - sendAck(ack, null, session); + sendAck(ack, null, oauthServer.createSession(serverConfig)); }); nodecg.listenFor('startgg:getOAuthSessionStatus', (payload: unknown, ack) => { diff --git a/src/extension/util/oauth-server.ts b/src/extension/util/oauth-server.ts index 6bc95a8..89ec710 100644 --- a/src/extension/util/oauth-server.ts +++ b/src/extension/util/oauth-server.ts @@ -5,7 +5,9 @@ import { randomUUID } from 'node:crypto'; export interface OAuthConfig { 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; }