- Updated CSRF middleware to enhance cookie value decoding. - Reformatted OAuth proxy token store initialization for better clarity. - Adjusted Challonge proxy router for consistent line breaks and readability. - Enhanced OAuth router error handling and response formatting. - Improved session router for better readability and consistency in fetching provider records. - Refactored OAuth token store to improve key derivation logging. - Cleaned up cookie options utility for better readability. - Enhanced Challonge client credentials composable for consistent API calls. - Streamlined OAuth composable for improved logging. - Refactored main.js for better readability in session initialization. - Improved Challonge v2.1 service error handling for better clarity. - Cleaned up API client utility for improved readability. - Enhanced ApiKeyManager.vue for better text formatting. - Refactored ChallongeTest.vue for improved readability in composable usage.
271 lines
9.2 KiB
JavaScript
271 lines
9.2 KiB
JavaScript
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;
|
|
}
|