Refactor authentication handling and improve API client security
- Updated OAuth endpoints for Challonge and Discord in platforms configuration. - Implemented session and CSRF cookie initialization in main application entry. - Enhanced Challonge API client to avoid sending sensitive API keys from the browser. - Modified tournament querying to handle new state definitions and improved error handling. - Updated UI components to reflect server-side storage of authentication tokens. - Improved user experience in API Key Manager and Authentication Hub with clearer messaging. - Refactored client credentials management to support asynchronous operations. - Adjusted API client tests to validate new request configurations. - Updated Vite configuration to support session and CSRF handling through proxies.
This commit is contained in:
253
code/websites/pokedex.online/server/routes/challonge.js
Normal file
253
code/websites/pokedex.online/server/routes/challonge.js
Normal file
@@ -0,0 +1,253 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user