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",
"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"
]
}
+105 -34
View File
@@ -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,33 +54,53 @@ 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<string, unknown>;
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<string, unknown>;
const clientId = String(bundleConfig.challongeClientId ?? '').trim();
const clientSecret = String(bundleConfig.challongeClientSecret ?? '').trim();
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<string> => {
const params = new URLSearchParams({
grant_type: 'authorization_code',
code,
client_id: config.clientId,
client_secret: config.clientSecret,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri,
});
@@ -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<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 ────────────────────────────────────────────────────────────
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 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) {
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,
'OAuth is not configured in this installation (missing challongeClientId/challongeClientSecret). Use the Client ID and Client Secret from a Challonge OAuth app.',
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) => {
+108 -19
View File
@@ -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<T> {
@@ -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<string, unknown>;
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<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;
// 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 rawBody = await response.text();
@@ -75,16 +102,18 @@ 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,
redirectUri: string,
config: OAuthConfig,
clientId: string,
clientSecret: string,
): Promise<string> => {
const params = new URLSearchParams({
grant_type: 'authorization_code',
code,
client_id: config.clientId,
client_secret: config.clientSecret,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri,
});
@@ -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<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 ────────────────────────────────────────────────────────────
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) {
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,
'OAuth is not configured in this installation (missing startggClientId/startggClientSecret). Use the Client ID and Client Secret from a start.gg OAuth app.',
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) => {
+3 -1
View File
@@ -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;
}