mirror of
https://github.com/Pandipipas/scoreko-dev.git
synced 2026-06-06 03:32:06 +00:00
feat: enhance OAuth configuration to support proxy mode and update related logic
This commit is contained in:
+10
-16
@@ -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
@@ -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
@@ -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) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user