import express from 'express'; import fetch from 'node-fetch'; import logger from '../utils/logger.js'; function isExpired(expiresAt) { if (!expiresAt) return false; return Date.now() >= expiresAt - 30_000; // 30s buffer } async function refreshUserOAuth({ config, refreshToken }) { const response = await fetch('https://api.challonge.com/oauth/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'refresh_token', client_id: config.challonge.clientId, client_secret: config.challonge.clientSecret, refresh_token: refreshToken }) }); const payload = await response.json().catch(() => ({})); if (!response.ok) { const err = new Error('Challonge refresh failed'); err.status = response.status; err.payload = payload; throw err; } return payload; } async function exchangeClientCredentials({ clientId, clientSecret, scope }) { const response = await fetch('https://api.challonge.com/oauth/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', client_id: clientId, client_secret: clientSecret, ...(scope ? { scope } : {}) }) }); const payload = await response.json().catch(() => ({})); if (!response.ok) { const err = new Error('Challonge client_credentials exchange failed'); err.status = response.status; err.payload = payload; throw err; } return payload; } function computeExpiresAt(expiresInSeconds) { const ttl = Number(expiresInSeconds || 0); if (!ttl || Number.isNaN(ttl)) return null; return Date.now() + ttl * 1000; } export function createChallongeProxyRouter({ config, tokenStore }) { const router = express.Router(); // Proxy all Challonge requests through backend; auth is derived from SID-stored credentials. router.all('/*', async (req, res) => { try { if (!req.sid) { return res.status(500).json({ error: 'SID middleware not configured' }); } const challongeRecord = (await tokenStore.getProviderRecord(req.sid, 'challonge')) || {}; // Determine upstream path relative to this router mount // This router is mounted at /challonge, so req.url starts with /v1/... or /v2.1/... const upstreamPath = req.url.replace(/^\/+/, ''); const upstreamUrl = new URL(`https://api.challonge.com/${upstreamPath}`); const authTypeRaw = req.header('Authorization-Type'); const authType = authTypeRaw?.toLowerCase(); const wantsApplication = upstreamPath.startsWith('v2.1/application/'); const isSafeMethod = req.method === 'GET' || req.method === 'HEAD'; // Build headers const headers = { ...req.headers }; delete headers.host; delete headers.connection; delete headers['content-length']; // Normalize sensitive/auth headers (avoid duplicate casing like // 'authorization-type' + 'Authorization-Type' which can confuse upstream) delete headers.authorization; delete headers.Authorization; delete headers['authorization-type']; delete headers['Authorization-Type']; // Apply auth based on request + stored credentials if (upstreamPath.startsWith('v1/')) { const apiKey = challongeRecord.api_key?.token; if (!apiKey) { return res.status(401).json({ error: 'Challonge API key not configured for this session', code: 'CHALLONGE_API_KEY_REQUIRED' }); } upstreamUrl.searchParams.set('api_key', apiKey); } else if (upstreamPath.startsWith('v2.1/')) { if (wantsApplication) { const app = challongeRecord.client_credentials; if (!app?.client_id || !app?.client_secret) { return res.status(401).json({ error: 'Challonge client credentials not configured for this session', code: 'CHALLONGE_CLIENT_CREDENTIALS_REQUIRED' }); } let accessToken = app.access_token; if (!accessToken || isExpired(app.expires_at)) { const exchanged = await exchangeClientCredentials({ clientId: app.client_id, clientSecret: app.client_secret, scope: app.scope }); challongeRecord.client_credentials = { ...app, access_token: exchanged.access_token, token_type: exchanged.token_type, scope: exchanged.scope, expires_at: computeExpiresAt(exchanged.expires_in) }; await tokenStore.setProviderRecord( req.sid, 'challonge', challongeRecord ); accessToken = challongeRecord.client_credentials.access_token; } headers['authorization'] = `Bearer ${accessToken}`; headers['authorization-type'] = 'v2'; } else if (authType === 'v1') { // v2.1 supports legacy API key via Authorization header + Authorization-Type: v1 const apiKey = challongeRecord.api_key?.token; if (!apiKey) { return res.status(401).json({ error: 'Challonge API key not configured for this session', code: 'CHALLONGE_API_KEY_REQUIRED' }); } headers['authorization'] = apiKey; headers['authorization-type'] = 'v1'; } else { // default to user OAuth (Bearer) const user = challongeRecord.user_oauth; if (!user?.access_token) { return res.status(401).json({ error: 'Challonge OAuth not connected for this session', code: 'CHALLONGE_OAUTH_REQUIRED' }); } let accessToken = user.access_token; if ( isExpired(user.expires_at) && user.refresh_token && config.challonge.configured ) { try { const refreshed = await refreshUserOAuth({ config, refreshToken: user.refresh_token }); challongeRecord.user_oauth = { ...user, access_token: refreshed.access_token, refresh_token: refreshed.refresh_token || user.refresh_token, token_type: refreshed.token_type, scope: refreshed.scope, expires_at: computeExpiresAt(refreshed.expires_in) }; await tokenStore.setProviderRecord( req.sid, 'challonge', challongeRecord ); accessToken = challongeRecord.user_oauth.access_token; } catch (err) { logger.warn('Failed to refresh Challonge user OAuth token', { status: err.status, payload: err.payload }); } } headers['authorization'] = `Bearer ${accessToken}`; headers['authorization-type'] = 'v2'; } } const fetchOptions = { method: req.method, headers }; if (req.method !== 'GET' && req.method !== 'HEAD') { if (req.body !== undefined && req.body !== null) { // express.json parsed it already fetchOptions.body = JSON.stringify(req.body); } } let upstreamResponse = await fetch(upstreamUrl.toString(), fetchOptions); // If user OAuth is present but invalid/revoked, the upstream may return 401/403. // For safe methods, fall back to the stored API key if available. // This helps avoid a confusing "connected" state that still can't query tournaments. if ( isSafeMethod && upstreamPath.startsWith('v2.1/') && !wantsApplication && authType !== 'v1' && (upstreamResponse.status === 401 || upstreamResponse.status === 403) ) { const apiKey = challongeRecord.api_key?.token; if (apiKey) { logger.warn( 'Challonge v2.1 user OAuth unauthorized; retrying with API key', { status: upstreamResponse.status, path: upstreamPath } ); const retryHeaders = { ...headers }; delete retryHeaders.authorization; delete retryHeaders.Authorization; delete retryHeaders['authorization-type']; delete retryHeaders['Authorization-Type']; retryHeaders['authorization'] = apiKey; retryHeaders['authorization-type'] = 'v1'; upstreamResponse = await fetch(upstreamUrl.toString(), { ...fetchOptions, headers: retryHeaders }); } } // Forward status + headers (minimal) res.status(upstreamResponse.status); const contentType = upstreamResponse.headers.get('content-type'); if (contentType) res.setHeader('content-type', contentType); const buf = await upstreamResponse.arrayBuffer(); return res.send(Buffer.from(buf)); } catch (err) { logger.error('Challonge proxy error', { error: err.message }); return res.status(502).json({ error: 'Challonge proxy failed', code: 'CHALLONGE_PROXY_FAILED' }); } }); return router; }