From 700c1cbbbeaecf435b91c24f8c6ebf627f3f965c Mon Sep 17 00:00:00 2001 From: FragginWagon Date: Tue, 3 Feb 2026 12:50:11 -0500 Subject: [PATCH] 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. --- .github/copilot-instructions.md | 4 + code/websites/pokedex.online/nginx.conf | 37 -- .../websites/pokedex.online/package-lock.json | 128 ++++- .../server/data/oauth-tokens.json | 7 + .../pokedex.online/server/middleware/csrf.js | 68 +++ .../pokedex.online/server/middleware/sid.js | 96 ++++ .../pokedex.online/server/oauth-proxy.js | 291 ++--------- .../pokedex.online/server/package.json | 6 +- .../pokedex.online/server/routes/challonge.js | 253 +++++++++ .../pokedex.online/server/routes/discord.js | 41 ++ .../pokedex.online/server/routes/oauth.js | 480 ++++++++++++++++++ .../pokedex.online/server/routes/session.js | 152 ++++++ .../server/services/oauth-token-store.js | 224 ++++++++ .../server/utils/cookie-options.js | 77 +++ .../server/utils/env-validator.js | 34 ++ .../src/components/DeveloperTools.vue | 6 +- .../src/composables/useChallongeApiKey.js | 107 ++-- .../src/composables/useChallongeClient.js | 55 +- .../useChallongeClientCredentials.js | 318 ++++-------- .../src/composables/useChallongeOAuth.js | 188 +++---- .../src/composables/useChallongeTests.js | 10 +- .../src/composables/useDiscordOAuth.js | 25 +- .../src/composables/useOAuth.js | 212 +++----- .../pokedex.online/src/config/platforms.js | 10 +- code/websites/pokedex.online/src/main.js | 12 +- .../src/services/challonge-v1.service.js | 3 +- .../src/services/challonge-v2.1.service.js | 60 ++- .../src/utilities/api-client.js | 39 +- .../src/utilities/tournament-query.js | 30 +- .../src/views/ApiKeyManager.vue | 15 +- .../src/views/AuthenticationHub.vue | 34 +- .../src/views/ChallongeTest.vue | 94 ++-- .../src/views/ClientCredentialsManager.vue | 31 +- .../composables/useChallongeClient.test.js | 8 +- .../tests/unit/utilities/api-client.test.js | 4 +- code/websites/pokedex.online/vite.config.js | 46 +- .../.obsidian/workspace.json | 8 +- .../.sync/Archive/.obsidian/workspace.81.json | 220 ++++++++ .../Professor University/Untitled.md | 0 39 files changed, 2434 insertions(+), 999 deletions(-) create mode 100644 code/websites/pokedex.online/server/data/oauth-tokens.json create mode 100644 code/websites/pokedex.online/server/middleware/csrf.js create mode 100644 code/websites/pokedex.online/server/middleware/sid.js create mode 100644 code/websites/pokedex.online/server/routes/challonge.js create mode 100644 code/websites/pokedex.online/server/routes/discord.js create mode 100644 code/websites/pokedex.online/server/routes/oauth.js create mode 100644 code/websites/pokedex.online/server/routes/session.js create mode 100644 code/websites/pokedex.online/server/services/oauth-token-store.js create mode 100644 code/websites/pokedex.online/server/utils/cookie-options.js create mode 100644 docs/projects/pokemon-professor/.sync/Archive/.obsidian/workspace.81.json create mode 100644 docs/projects/pokemon-professor/Professor University/Untitled.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index cfeec97..d6157a1 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,5 +1,9 @@ # GitHub Copilot Instructions for Memory Palace +## MUST FOLLOW +Do not run commands in the same terminal where the server was started. +Always open a new terminal for running new commands or tests if the active terminal has a long-running process. + ## Project Overview Hybrid workspace combining Obsidian-style knowledge management with code development. Uses Obsidian MD for VSCode extension (wiki-links, backlinks, graph view) alongside JavaScript/Python development tools. diff --git a/code/websites/pokedex.online/nginx.conf b/code/websites/pokedex.online/nginx.conf index 79c815f..c96a505 100644 --- a/code/websites/pokedex.online/nginx.conf +++ b/code/websites/pokedex.online/nginx.conf @@ -65,43 +65,6 @@ server { proxy_buffers 8 4k; } - # Proxy Challonge API requests to avoid CORS - location /api/challonge/ { - # Remove /api/challonge prefix and forward to Challonge API - rewrite ^/api/challonge/(.*) /$1 break; - - proxy_pass https://api.challonge.com; - proxy_ssl_server_name on; - proxy_ssl_protocols TLSv1.2 TLSv1.3; - - # Proxy headers - proxy_set_header Host api.challonge.com; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - # CORS headers for browser requests - add_header Access-Control-Allow-Origin $http_origin always; - add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always; - add_header Access-Control-Allow-Headers "Authorization, Content-Type, Accept" always; - add_header Access-Control-Allow-Credentials "true" always; - - # Handle preflight OPTIONS requests - if ($request_method = OPTIONS) { - add_header Access-Control-Allow-Origin $http_origin always; - add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always; - add_header Access-Control-Allow-Headers "Authorization, Content-Type, Accept" always; - add_header Access-Control-Max-Age 86400; - add_header Content-Length 0; - return 204; - } - - # Timeout settings - proxy_connect_timeout 10s; - proxy_send_timeout 30s; - proxy_read_timeout 30s; - } - # Health check endpoint location /health { access_log off; diff --git a/code/websites/pokedex.online/package-lock.json b/code/websites/pokedex.online/package-lock.json index 7722f3b..9ebebb1 100644 --- a/code/websites/pokedex.online/package-lock.json +++ b/code/websites/pokedex.online/package-lock.json @@ -1774,6 +1774,12 @@ "node": ">=8" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -2097,6 +2103,25 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, "node_modules/cookie-signature": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", @@ -2352,6 +2377,15 @@ "dev": true, "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/editorconfig": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz", @@ -3565,6 +3599,55 @@ "node": ">=18" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/kuler": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", @@ -3595,6 +3678,48 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/logform": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", @@ -4561,7 +4686,6 @@ "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -5892,10 +6016,12 @@ "name": "pokedex-online-server", "version": "1.0.0", "dependencies": { + "cookie-parser": "^1.4.6", "cors": "^2.8.5", "dotenv": "^16.6.1", "express": "^4.18.2", "express-rate-limit": "^7.1.5", + "jsonwebtoken": "^9.0.2", "node-fetch": "^3.3.2", "winston": "^3.11.0" }, diff --git a/code/websites/pokedex.online/server/data/oauth-tokens.json b/code/websites/pokedex.online/server/data/oauth-tokens.json new file mode 100644 index 0000000..e2e199b --- /dev/null +++ b/code/websites/pokedex.online/server/data/oauth-tokens.json @@ -0,0 +1,7 @@ +{ + "version": 1, + "alg": "aes-256-gcm", + "iv": "kpWH+zywSfvDciE2", + "tag": "iKy403D2R0VWgFlepKYwiA==", + "ciphertext": "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" +} \ No newline at end of file diff --git a/code/websites/pokedex.online/server/middleware/csrf.js b/code/websites/pokedex.online/server/middleware/csrf.js new file mode 100644 index 0000000..2fd07d4 --- /dev/null +++ b/code/websites/pokedex.online/server/middleware/csrf.js @@ -0,0 +1,68 @@ +import { COOKIE_NAMES } from '../utils/cookie-options.js'; + +const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); + +function getCookieValuesFromHeader(cookieHeader, name) { + if (!cookieHeader || typeof cookieHeader !== 'string') return []; + const values = []; + const pattern = new RegExp(`(?:^|;\\s*)${name}=([^;]*)`, 'g'); + let match; + while ((match = pattern.exec(cookieHeader)) !== null) { + values.push(match[1]); + } + return values; +} + +export function csrfMiddleware(options = {}) { + const { + cookieName = COOKIE_NAMES.csrf, + headerName = 'x-csrf-token', + requireOriginCheck = false, + allowedOrigin = null + } = options; + + return function csrf(req, res, next) { + if (SAFE_METHODS.has(req.method)) return next(); + + // Optional origin check hardening (recommended in production) + if (requireOriginCheck && allowedOrigin) { + const origin = req.headers.origin; + const referer = req.headers.referer; + const ok = + (origin && origin === allowedOrigin) || + (!origin && referer && referer.startsWith(allowedOrigin)); + + if (!ok) { + return res.status(403).json({ + error: 'CSRF origin check failed', + code: 'CSRF_ORIGIN_FAILED' + }); + } + } + + const csrfCookie = req.cookies?.[cookieName]; + const csrfHeader = req.headers[headerName]; + + // Handle duplicate cookies with the same name (e.g. legacy '/api' path plus + // current '/' path). cookie-parser will pick one value, but the browser may + // send both. Accept if the header matches ANY provided cookie value. + const rawHeader = req.headers?.cookie || ''; + const rawValues = getCookieValuesFromHeader(rawHeader, cookieName).map(v => { + try { + return decodeURIComponent(v); + } catch { + return v; + } + }); + const anyMatch = csrfHeader && rawValues.includes(csrfHeader); + + if (!csrfHeader || (!csrfCookie && !anyMatch) || (csrfCookie !== csrfHeader && !anyMatch)) { + return res.status(403).json({ + error: 'CSRF validation failed', + code: 'CSRF_FAILED' + }); + } + + return next(); + }; +} diff --git a/code/websites/pokedex.online/server/middleware/sid.js b/code/websites/pokedex.online/server/middleware/sid.js new file mode 100644 index 0000000..1e09de3 --- /dev/null +++ b/code/websites/pokedex.online/server/middleware/sid.js @@ -0,0 +1,96 @@ +import crypto from 'node:crypto'; +import { + COOKIE_NAMES, + generateToken, + getLegacyCsrfCookieOptions, + getLegacySidCookieOptions, + getSidCookieOptions +} from '../utils/cookie-options.js'; + +function signSid(sessionSecret, sid) { + return crypto + .createHmac('sha256', sessionSecret) + .update(sid) + .digest('base64url'); +} + +function parseAndVerifySignedSid(sessionSecret, signedValue) { + if (!signedValue || typeof signedValue !== 'string') return null; + const idx = signedValue.lastIndexOf('.'); + if (idx <= 0) return null; + + const sid = signedValue.slice(0, idx); + const sig = signedValue.slice(idx + 1); + if (!sid || !sig) return null; + + const expected = signSid(sessionSecret, sid); + try { + if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) { + return null; + } + } catch { + return null; + } + + return sid; +} + +function getCookieValuesFromHeader(cookieHeader, name) { + if (!cookieHeader || typeof cookieHeader !== 'string') return []; + + // Multiple cookies with the same name can exist if older cookies were scoped + // to a different path (e.g. '/api') than newer ones ('/'). + const values = []; + const pattern = new RegExp(`(?:^|;\\s*)${name}=([^;]*)`, 'g'); + let match; + while ((match = pattern.exec(cookieHeader)) !== null) { + values.push(match[1]); + } + return values; +} + +export function sidMiddleware({ sessionSecret, config }) { + if (!sessionSecret) { + throw new Error('sidMiddleware requires sessionSecret'); + } + + return function sid(req, res, next) { + // If older cookies (scoped to '/api') exist alongside newer cookies + // (scoped to '/'), browsers may send both. Some parsers will then pick the + // "wrong" one depending on header order, causing auth to appear connected + // in one request and missing in another. + const rawCookieHeader = req.headers?.cookie || ''; + if (rawCookieHeader.includes(`${COOKIE_NAMES.sid}=`)) { + res.clearCookie(COOKIE_NAMES.sid, getLegacySidCookieOptions(config)); + } + if (rawCookieHeader.includes(`${COOKIE_NAMES.csrf}=`)) { + res.clearCookie(COOKIE_NAMES.csrf, getLegacyCsrfCookieOptions(config)); + } + + const signedCandidates = getCookieValuesFromHeader( + rawCookieHeader, + COOKIE_NAMES.sid + ); + const signedFromParser = req.cookies?.[COOKIE_NAMES.sid]; + if (signedFromParser) signedCandidates.push(signedFromParser); + + // If multiple signed SIDs are present (legacy '/api' cookie + current '/'), + // browsers tend to send the more-specific path cookie first. + // Prefer the last valid SID to bias towards the newer '/' cookie. + let sid = null; + for (let i = signedCandidates.length - 1; i >= 0; i -= 1) { + const signed = signedCandidates[i]; + sid = parseAndVerifySignedSid(sessionSecret, signed); + if (sid) break; + } + + if (!sid) { + sid = generateToken(24); + const signedSid = `${sid}.${signSid(sessionSecret, sid)}`; + res.cookie(COOKIE_NAMES.sid, signedSid, getSidCookieOptions(config)); + } + + req.sid = sid; + next(); + }; +} diff --git a/code/websites/pokedex.online/server/oauth-proxy.js b/code/websites/pokedex.online/server/oauth-proxy.js index f3ee7d8..7f7e645 100644 --- a/code/websites/pokedex.online/server/oauth-proxy.js +++ b/code/websites/pokedex.online/server/oauth-proxy.js @@ -12,34 +12,22 @@ import 'dotenv/config'; import express from 'express'; import cors from 'cors'; -import fetch from 'node-fetch'; +import cookieParser from 'cookie-parser'; import gamemasterRouter from './gamemaster-api.js'; import { createAuthRouter } from './routes/auth.js'; +import { createOAuthRouter } from './routes/oauth.js'; +import { createSessionRouter } from './routes/session.js'; +import { createChallongeProxyRouter } from './routes/challonge.js'; +import { createDiscordRouter } from './routes/discord.js'; import { validateOrExit, getConfig } from './utils/env-validator.js'; import logger, { requestLogger, errorLogger } from './utils/logger.js'; import { setupGracefulShutdown, createHealthCheckMiddleware } from './utils/graceful-shutdown.js'; - -async function safeParseJsonResponse(response) { - const rawText = await response.text(); - if (!rawText) { - return { data: {}, rawText: '' }; - } - - try { - return { data: JSON.parse(rawText), rawText }; - } catch (error) { - return { - data: { - error: 'Invalid JSON response from upstream', - raw: rawText.slice(0, 1000) - }, - rawText - }; - } -} +import { sidMiddleware } from './middleware/sid.js'; +import { csrfMiddleware } from './middleware/csrf.js'; +import { createOAuthTokenStore } from './services/oauth-token-store.js'; // Validate environment variables validateOrExit(); @@ -49,11 +37,31 @@ const config = getConfig(); const app = express(); +// Behind nginx reverse proxy in production +app.set('trust proxy', 1); + // Middleware -app.use(cors({ origin: config.cors.origin })); +app.use( + cors({ + origin: config.cors.origin, + credentials: true + }) +); +app.use(cookieParser()); app.use(express.json()); app.use(requestLogger); +// Per-session identity (httpOnly signed SID cookie) +app.use( + sidMiddleware({ + sessionSecret: config.session.secret, + config + }) +); + +// Encrypted per-session provider token store +const tokenStore = createOAuthTokenStore({ sessionSecret: config.session.secret }); + // Mount API routes (nginx strips /api/ prefix before forwarding) app.use('/gamemaster', gamemasterRouter); app.use( @@ -64,235 +72,22 @@ app.use( }) ); -/** - * Exchange authorization code for access token - * POST /oauth/token - * Supports multiple providers: Challonge, Discord - */ -app.post('/oauth/token', async (req, res) => { - const { code, provider = 'challonge' } = req.body; +// Session + CSRF helpers +app.use('/session', createSessionRouter({ config, tokenStore })); - if (!code) { - logger.warn('OAuth token request missing authorization code'); - return res.status(400).json({ error: 'Missing authorization code' }); - } +// Provider OAuth (server-owned tokens; browser never receives access/refresh tokens) +app.use( + '/oauth', + csrfMiddleware({ + requireOriginCheck: config.isProduction, + allowedOrigin: config.cors.origin + }) +); +app.use('/oauth', createOAuthRouter({ config, tokenStore })); - try { - // Handle Discord OAuth - if (provider === 'discord') { - const clientId = process.env.VITE_DISCORD_CLIENT_ID; - const clientSecret = process.env.DISCORD_CLIENT_SECRET; - const redirectUri = - process.env.DISCORD_REDIRECT_URI || - process.env.VITE_DISCORD_REDIRECT_URI; - - if (!clientId || !clientSecret || !redirectUri) { - logger.warn('Discord OAuth not configured', { - hasClientId: !!clientId, - hasClientSecret: !!clientSecret, - hasRedirectUri: !!redirectUri - }); - return res.status(503).json({ - error: 'Discord OAuth not configured', - message: - 'Set VITE_DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET, and DISCORD_REDIRECT_URI environment variables' - }); - } - - logger.debug('Exchanging Discord authorization code for access token'); - const response = await fetch('https://discord.com/api/oauth2/token', { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: new URLSearchParams({ - client_id: clientId, - client_secret: clientSecret, - grant_type: 'authorization_code', - code: code, - redirect_uri: redirectUri - }) - }); - - const { data, rawText } = await safeParseJsonResponse(response); - - if (!response.ok) { - logger.error('Discord token exchange failed', { - status: response.status, - data - }); - return res.status(response.status).json(data); - } - - if (!data?.access_token) { - logger.error('Discord token exchange returned invalid payload', { - status: response.status, - raw: rawText.slice(0, 1000) - }); - return res.status(502).json({ - error: 'Invalid response from Discord', - raw: rawText.slice(0, 1000) - }); - } - - // Fetch Discord user info to check permissions - try { - const userResponse = await fetch('https://discord.com/api/users/@me', { - headers: { - Authorization: `Bearer ${data.access_token}` - } - }); - - if (userResponse.ok) { - const userData = await userResponse.json(); - const username = userData.username?.toLowerCase(); - const globalName = userData.global_name?.toLowerCase(); - const discordId = userData.id; - - logger.info('Discord user authenticated', { - username: userData.username, - id: discordId - }); - - // Check if user is in admin list - const isAdmin = config.discord.adminUsers.some( - adminUser => - adminUser === username || - adminUser === globalName || - adminUser === discordId - ); - - // Add user info and permissions to response - data.discord_user = { - id: discordId, - username: userData.username, - global_name: userData.global_name, - discriminator: userData.discriminator, - avatar: userData.avatar - }; - - data.permissions = isAdmin ? ['developer_tools.view'] : []; - - if (isAdmin) { - logger.info('Discord user granted developer access', { - username: userData.username - }); - } - } else { - logger.warn('Failed to fetch Discord user info', { - status: userResponse.status - }); - } - } catch (userError) { - logger.warn('Error fetching Discord user info', { - error: userError.message - }); - // Continue without user info - token is still valid - } - - logger.info('Discord token exchange successful'); - return res.json(data); - } - - // Handle Challonge OAuth (default) - if (!config.challonge.configured) { - logger.warn('OAuth token request received but Challonge not configured'); - return res.status(503).json({ - error: 'Challonge OAuth not configured', - message: - 'Set CHALLONGE_CLIENT_ID and CHALLONGE_CLIENT_SECRET environment variables' - }); - } - - logger.debug('Exchanging Challonge authorization code for access token'); - 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: 'authorization_code', - client_id: config.challonge.clientId, - client_secret: config.challonge.clientSecret, - code: code, - redirect_uri: config.challonge.redirectUri - }) - }); - - const data = await response.json(); - - if (!response.ok) { - logger.error('Challonge token exchange failed', { - status: response.status, - data - }); - return res.status(response.status).json(data); - } - - logger.info('Challonge token exchange successful'); - res.json(data); - } catch (error) { - logger.error('Token exchange error', { provider, error: error.message }); - res.status(500).json({ - error: 'Token exchange failed', - message: error.message - }); - } -}); - -/** - * Refresh access token - * POST /oauth/refresh - */ -app.post('/oauth/refresh', async (req, res) => { - if (!config.challonge.configured) { - logger.warn('OAuth refresh request received but Challonge not configured'); - return res.status(503).json({ - error: 'Challonge OAuth not configured', - message: - 'Set CHALLONGE_CLIENT_ID and CHALLONGE_CLIENT_SECRET environment variables' - }); - } - - const { refresh_token } = req.body; - - if (!refresh_token) { - logger.warn('OAuth refresh request missing refresh token'); - return res.status(400).json({ error: 'Missing refresh token' }); - } - - try { - logger.debug('Refreshing access token'); - 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: refresh_token - }) - }); - - const data = await response.json(); - - if (!response.ok) { - logger.error('Token refresh failed', { status: response.status, data }); - return res.status(response.status).json(data); - } - - logger.info('Token refresh successful'); - res.json(data); - } catch (error) { - logger.error('Token refresh error', { error: error.message }); - res.status(500).json({ - error: 'Token refresh failed', - message: error.message - }); - } -}); +// Provider API proxies (no split brain) +app.use('/challonge', createChallongeProxyRouter({ config, tokenStore })); +app.use('/discord', createDiscordRouter({ tokenStore })); /** * Health check endpoint (with graceful shutdown support) diff --git a/code/websites/pokedex.online/server/package.json b/code/websites/pokedex.online/server/package.json index 65ea807..c48db17 100644 --- a/code/websites/pokedex.online/server/package.json +++ b/code/websites/pokedex.online/server/package.json @@ -6,9 +6,9 @@ "main": "oauth-proxy.js", "scripts": { "start": "node oauth-proxy.js", - "dev": "node oauth-proxy.js", + "dev": "DOTENV_CONFIG_PATH=.env.development node oauth-proxy.js", "build": "echo 'Backend is Node.js - no build step required'", - "gamemaster": "node gamemaster-api.js", + "gamemaster": "DOTENV_CONFIG_PATH=.env.development node gamemaster-api.js", "test": "vitest", "test:run": "vitest run", "lint": "echo 'Add ESLint when ready'", @@ -16,8 +16,10 @@ }, "dependencies": { "cors": "^2.8.5", + "cookie-parser": "^1.4.6", "dotenv": "^16.6.1", "express": "^4.18.2", + "jsonwebtoken": "^9.0.2", "node-fetch": "^3.3.2", "express-rate-limit": "^7.1.5", "winston": "^3.11.0" diff --git a/code/websites/pokedex.online/server/routes/challonge.js b/code/websites/pokedex.online/server/routes/challonge.js new file mode 100644 index 0000000..ca92f60 --- /dev/null +++ b/code/websites/pokedex.online/server/routes/challonge.js @@ -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; +} diff --git a/code/websites/pokedex.online/server/routes/discord.js b/code/websites/pokedex.online/server/routes/discord.js new file mode 100644 index 0000000..626d64a --- /dev/null +++ b/code/websites/pokedex.online/server/routes/discord.js @@ -0,0 +1,41 @@ +import express from 'express'; +import fetch from 'node-fetch'; + +export function createDiscordRouter({ tokenStore }) { + const router = express.Router(); + + router.get('/profile', async (req, res) => { + if (!req.sid) { + return res.status(500).json({ error: 'SID middleware not configured' }); + } + + const record = await tokenStore.getProviderRecord(req.sid, 'discord'); + const accessToken = record?.access_token; + if (!accessToken) { + return res.status(401).json({ + error: 'Not connected to Discord', + code: 'DISCORD_NOT_CONNECTED' + }); + } + + const response = await fetch('https://discord.com/api/users/@me', { + headers: { + Authorization: `Bearer ${accessToken}` + } + }); + + if (!response.ok) { + const details = await response.text(); + return res.status(response.status).json({ + error: 'Failed to fetch Discord profile', + code: 'DISCORD_PROFILE_FAILED', + details: details.slice(0, 1000) + }); + } + + const user = await response.json(); + return res.json({ user }); + }); + + return router; +} diff --git a/code/websites/pokedex.online/server/routes/oauth.js b/code/websites/pokedex.online/server/routes/oauth.js new file mode 100644 index 0000000..28461ec --- /dev/null +++ b/code/websites/pokedex.online/server/routes/oauth.js @@ -0,0 +1,480 @@ +import express from 'express'; +import fetch from 'node-fetch'; +import logger from '../utils/logger.js'; + +function computeExpiresAt(expiresInSeconds) { + const ttl = Number(expiresInSeconds || 0); + if (!ttl || Number.isNaN(ttl)) return null; + return Date.now() + ttl * 1000; +} + +function redactProviderRecord(provider, record) { + if (!record) { + if (provider === 'challonge') { + return { + provider, + connected: false, + methods: { + user_oauth: { + connected: false, + expires_at: null, + scope: null + }, + client_credentials: { + stored: false, + connected: false, + expires_at: null, + scope: null + }, + api_key: { + stored: false, + connected: false + } + } + }; + } + + if (provider === 'discord') { + return { + provider, + connected: false, + expires_at: null, + scope: null + }; + } + + return { provider, connected: false }; + } + + if (provider === 'discord') { + return { + provider, + connected: !!record.access_token, + expires_at: record.expires_at || null, + scope: record.scope || null + }; + } + + if (provider === 'challonge') { + const user = record.user_oauth; + const app = record.client_credentials; + const apiKey = record.api_key; + + return { + provider, + connected: !!(user?.access_token || app?.access_token || apiKey?.token), + methods: { + user_oauth: { + connected: !!user?.access_token, + expires_at: user?.expires_at || null, + scope: user?.scope || null + }, + client_credentials: { + stored: !!(app?.client_id && app?.client_secret), + connected: !!app?.access_token, + expires_at: app?.expires_at || null, + scope: app?.scope || null + }, + api_key: { + stored: !!apiKey?.token, + connected: !!apiKey?.token + } + } + }; + } + + return { provider, connected: true }; +} + +export function createOAuthRouter({ config, tokenStore }) { + const router = express.Router(); + + router.get('/:provider/status', async (req, res) => { + const { provider } = req.params; + if (!req.sid) { + return res.status(500).json({ error: 'SID middleware not configured' }); + } + + const record = await tokenStore.getProviderRecord(req.sid, provider); + return res.json(redactProviderRecord(provider, record)); + }); + + router.post('/:provider/disconnect', async (req, res) => { + const { provider } = req.params; + if (!req.sid) { + return res.status(500).json({ error: 'SID middleware not configured' }); + } + + await tokenStore.deleteProviderRecord(req.sid, provider); + return res.json({ ok: true }); + }); + + // Exchange authorization code (server stores tokens; frontend never receives them) + router.post('/:provider/exchange', async (req, res) => { + const { provider } = req.params; + const { code } = req.body || {}; + + if (!req.sid) { + return res.status(500).json({ error: 'SID middleware not configured' }); + } + + if (!code) { + return res.status(400).json({ error: 'Authorization code is required', code: 'MISSING_CODE' }); + } + + if (provider === 'discord') { + const clientId = process.env.VITE_DISCORD_CLIENT_ID; + const clientSecret = process.env.DISCORD_CLIENT_SECRET; + const redirectUri = process.env.DISCORD_REDIRECT_URI || process.env.VITE_DISCORD_REDIRECT_URI; + + if (!clientId || !clientSecret || !redirectUri) { + return res.status(503).json({ + error: 'Discord OAuth not configured', + code: 'DISCORD_NOT_CONFIGURED' + }); + } + + const response = await fetch('https://discord.com/api/oauth2/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri + }) + }); + + const text = await response.text(); + let payload; + try { + payload = text ? JSON.parse(text) : {}; + } catch { + payload = { raw: text.slice(0, 1000) }; + } + + if (!response.ok) { + logger.warn('Discord token exchange failed', { status: response.status, payload }); + return res.status(response.status).json({ + error: 'Discord token exchange failed', + code: 'DISCORD_TOKEN_EXCHANGE_FAILED', + details: payload + }); + } + + const record = { + access_token: payload.access_token, + refresh_token: payload.refresh_token, + token_type: payload.token_type, + scope: payload.scope, + expires_at: computeExpiresAt(payload.expires_in) + }; + + await tokenStore.setProviderRecord(req.sid, 'discord', record); + return res.json(redactProviderRecord('discord', record)); + } + + if (provider === 'challonge') { + if (!config.challonge.configured || !config.challonge.redirectUri) { + return res.status(503).json({ + error: 'Challonge OAuth not configured', + code: 'CHALLONGE_NOT_CONFIGURED' + }); + } + + 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: 'authorization_code', + client_id: config.challonge.clientId, + client_secret: config.challonge.clientSecret, + code, + redirect_uri: config.challonge.redirectUri + }) + }); + + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + logger.warn('Challonge token exchange failed', { status: response.status, payload }); + return res.status(response.status).json({ + error: 'Challonge token exchange failed', + code: 'CHALLONGE_TOKEN_EXCHANGE_FAILED', + details: payload + }); + } + + const existing = (await tokenStore.getProviderRecord(req.sid, 'challonge')) || {}; + const user_oauth = { + access_token: payload.access_token, + refresh_token: payload.refresh_token, + token_type: payload.token_type, + scope: payload.scope, + expires_at: computeExpiresAt(payload.expires_in) + }; + + const record = { + ...existing, + user_oauth + }; + + await tokenStore.setProviderRecord(req.sid, 'challonge', record); + return res.json(redactProviderRecord('challonge', record)); + } + + return res.status(400).json({ error: `Unknown provider: ${provider}`, code: 'UNKNOWN_PROVIDER' }); + }); + + // Store Challonge API key (v1 compatibility) per session + router.post('/challonge/api-key', async (req, res) => { + let { apiKey } = req.body || {}; + if (!req.sid) { + return res.status(500).json({ error: 'SID middleware not configured' }); + } + if (!apiKey) { + return res.status(400).json({ error: 'apiKey is required', code: 'MISSING_API_KEY' }); + } + + apiKey = String(apiKey).trim(); + if (apiKey.toLowerCase().startsWith('bearer ')) { + apiKey = apiKey.slice('bearer '.length).trim(); + } + if (!apiKey) { + return res.status(400).json({ error: 'apiKey is required', code: 'MISSING_API_KEY' }); + } + + const existing = (await tokenStore.getProviderRecord(req.sid, 'challonge')) || {}; + const record = { + ...existing, + api_key: { + token: apiKey + } + }; + await tokenStore.setProviderRecord(req.sid, 'challonge', record); + return res.json(redactProviderRecord('challonge', record)); + }); + + router.post('/challonge/api-key/clear', async (req, res) => { + if (!req.sid) { + return res.status(500).json({ error: 'SID middleware not configured' }); + } + + const existing = (await tokenStore.getProviderRecord(req.sid, 'challonge')) || {}; + const record = { ...existing }; + if (record.api_key) delete record.api_key; + await tokenStore.setProviderRecord(req.sid, 'challonge', record); + return res.json(redactProviderRecord('challonge', record)); + }); + + // Store Challonge client credentials and exchange token per session + router.post('/challonge/client-credentials', async (req, res) => { + let { clientId, clientSecret, scope } = req.body || {}; + if (!req.sid) { + return res.status(500).json({ error: 'SID middleware not configured' }); + } + + if (typeof clientId === 'string') clientId = clientId.trim(); + if (typeof clientSecret === 'string') clientSecret = clientSecret.trim(); + if (typeof scope === 'string') scope = scope.trim(); + + const existing = (await tokenStore.getProviderRecord(req.sid, 'challonge')) || {}; + const prev = existing.client_credentials || {}; + const effectiveClientId = clientId || prev.client_id; + const effectiveClientSecret = clientSecret || prev.client_secret; + const effectiveScope = scope || prev.scope; + + if (!effectiveClientId || !effectiveClientSecret) { + return res.status(400).json({ + error: 'clientId and clientSecret are required (or must already be stored for this session)', + code: 'MISSING_CLIENT_CREDENTIALS' + }); + } + + 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: effectiveClientId, + client_secret: effectiveClientSecret, + ...(effectiveScope ? { scope: effectiveScope } : {}) + }) + }); + + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + logger.warn('Challonge client_credentials token exchange failed', { status: response.status, payload }); + return res.status(response.status).json({ + error: 'Challonge client credentials exchange failed', + code: 'CHALLONGE_CLIENT_CREDENTIALS_FAILED', + details: payload + }); + } + + const record = { + ...existing, + client_credentials: { + client_id: effectiveClientId, + client_secret: effectiveClientSecret, + access_token: payload.access_token, + token_type: payload.token_type, + scope: payload.scope, + expires_at: computeExpiresAt(payload.expires_in) + } + }; + + await tokenStore.setProviderRecord(req.sid, 'challonge', record); + return res.json(redactProviderRecord('challonge', record)); + }); + + router.post('/challonge/client-credentials/clear', async (req, res) => { + if (!req.sid) { + return res.status(500).json({ error: 'SID middleware not configured' }); + } + + const existing = (await tokenStore.getProviderRecord(req.sid, 'challonge')) || {}; + const record = { ...existing }; + if (record.client_credentials) delete record.client_credentials; + await tokenStore.setProviderRecord(req.sid, 'challonge', record); + return res.json(redactProviderRecord('challonge', record)); + }); + + // Logout client credentials token but retain stored client_id/client_secret + router.post('/challonge/client-credentials/logout', async (req, res) => { + if (!req.sid) { + return res.status(500).json({ error: 'SID middleware not configured' }); + } + + const existing = (await tokenStore.getProviderRecord(req.sid, 'challonge')) || {}; + const creds = existing.client_credentials; + if (!creds) { + return res.json(redactProviderRecord('challonge', existing)); + } + + const record = { + ...existing, + client_credentials: { + client_id: creds.client_id, + client_secret: creds.client_secret + } + }; + + await tokenStore.setProviderRecord(req.sid, 'challonge', record); + return res.json(redactProviderRecord('challonge', record)); + }); + + // Refresh stored OAuth tokens (no tokens returned to browser) + router.post('/:provider/refresh', async (req, res) => { + const { provider } = req.params; + if (!req.sid) { + return res.status(500).json({ error: 'SID middleware not configured' }); + } + + const record = await tokenStore.getProviderRecord(req.sid, provider); + if (!record) { + return res.status(400).json({ error: 'No stored tokens', code: 'NO_TOKENS' }); + } + + if (provider === 'discord') { + const refreshToken = record.refresh_token; + if (!refreshToken) { + return res.status(400).json({ error: 'No refresh token available', code: 'NO_REFRESH_TOKEN' }); + } + + const clientId = process.env.VITE_DISCORD_CLIENT_ID; + const clientSecret = process.env.DISCORD_CLIENT_SECRET; + if (!clientId || !clientSecret) { + return res.status(503).json({ error: 'Discord OAuth not configured', code: 'DISCORD_NOT_CONFIGURED' }); + } + + const response = await fetch('https://discord.com/api/oauth2/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + grant_type: 'refresh_token', + refresh_token: refreshToken + }) + }); + + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + return res.status(response.status).json({ + error: 'Discord refresh failed', + code: 'DISCORD_REFRESH_FAILED', + details: payload + }); + } + + const updated = { + ...record, + access_token: payload.access_token, + refresh_token: payload.refresh_token || record.refresh_token, + token_type: payload.token_type, + scope: payload.scope, + expires_at: computeExpiresAt(payload.expires_in) + }; + + await tokenStore.setProviderRecord(req.sid, 'discord', updated); + return res.json(redactProviderRecord('discord', updated)); + } + + if (provider === 'challonge') { + const user = record.user_oauth; + if (!user?.refresh_token) { + return res.status(400).json({ + error: 'No refresh token available', + code: 'NO_REFRESH_TOKEN' + }); + } + + if (!config.challonge.configured) { + return res.status(503).json({ + error: 'Challonge OAuth not configured', + code: 'CHALLONGE_NOT_CONFIGURED' + }); + } + + 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: user.refresh_token + }) + }); + + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + return res.status(response.status).json({ + error: 'Challonge refresh failed', + code: 'CHALLONGE_REFRESH_FAILED', + details: payload + }); + } + + const updatedRecord = { + ...record, + user_oauth: { + ...user, + access_token: payload.access_token, + refresh_token: payload.refresh_token || user.refresh_token, + token_type: payload.token_type, + scope: payload.scope, + expires_at: computeExpiresAt(payload.expires_in) + } + }; + + await tokenStore.setProviderRecord(req.sid, 'challonge', updatedRecord); + return res.json(redactProviderRecord('challonge', updatedRecord)); + } + + return res.status(400).json({ error: `Unknown provider: ${provider}`, code: 'UNKNOWN_PROVIDER' }); + }); + + return router; +} diff --git a/code/websites/pokedex.online/server/routes/session.js b/code/websites/pokedex.online/server/routes/session.js new file mode 100644 index 0000000..e02244b --- /dev/null +++ b/code/websites/pokedex.online/server/routes/session.js @@ -0,0 +1,152 @@ +import express from 'express'; +import fetch from 'node-fetch'; +import { COOKIE_NAMES, getCsrfCookieOptions, generateToken } from '../utils/cookie-options.js'; + +export function createSessionRouter({ config, tokenStore }) { + const router = express.Router(); + + async function probeChallonge(url, headers) { + try { + const resp = await fetch(url, { method: 'GET', headers }); + const contentType = resp.headers.get('content-type') || ''; + let bodyText = ''; + try { + bodyText = await resp.text(); + } catch { + bodyText = ''; + } + + // Keep response small & safe + const snippet = (bodyText || '').slice(0, 500); + let json = null; + if (contentType.includes('application/json')) { + try { + json = bodyText ? JSON.parse(bodyText) : null; + } catch { + json = null; + } + } + + return { + ok: resp.ok, + status: resp.status, + contentType, + snippet, + json + }; + } catch (err) { + return { + ok: false, + status: null, + contentType: null, + snippet: err?.message || 'probe failed', + json: null + }; + } + } + + // Ensure SID exists (sid middleware should run before this) + router.get('/init', async (req, res) => { + try { + if (!req.sid) { + return res.status(500).json({ error: 'SID middleware not configured' }); + } + await tokenStore.touchSession(req.sid); + return res.json({ ok: true }); + } catch (err) { + return res.status(500).json({ + error: err.message || 'Failed to init session', + code: 'SESSION_INIT_FAILED' + }); + } + }); + + // Issue/refresh CSRF token cookie + router.get('/csrf', (req, res) => { + const token = generateToken(24); + res.cookie(COOKIE_NAMES.csrf, token, getCsrfCookieOptions(config)); + res.json({ csrfToken: token }); + }); + + // Dev helper: confirm which SID the browser is using and whether provider + // credentials are present for that SID. Does not return secrets. + router.get('/whoami', async (req, res) => { + if (!req.sid) { + return res.status(500).json({ error: 'SID middleware not configured' }); + } + + const challonge = (await tokenStore.getProviderRecord(req.sid, 'challonge')) || {}; + + return res.json({ + sid: req.sid, + challonge: { + hasApiKey: !!challonge.api_key?.token, + hasUserOAuth: !!challonge.user_oauth?.access_token, + userOAuthExpiresAt: challonge.user_oauth?.expires_at || null, + hasClientCredentials: !!(challonge.client_credentials?.client_id && challonge.client_credentials?.client_secret), + hasClientCredentialsToken: !!challonge.client_credentials?.access_token, + clientCredentialsExpiresAt: challonge.client_credentials?.expires_at || null + } + }); + }); + + // Dev-only: verify challonge upstream auth for this SID (no secrets returned) + router.get('/challonge/verify', async (req, res) => { + if (!req.sid) { + return res.status(500).json({ error: 'SID middleware not configured' }); + } + + const challonge = (await tokenStore.getProviderRecord(req.sid, 'challonge')) || {}; + + const base = 'https://api.challonge.com/v2.1/tournaments.json?page=1&per_page=1&state=pending'; + const results = { + sid: req.sid, + endpoints: { + userTournamentsSample: base, + appTournamentsSample: 'https://api.challonge.com/v2.1/application/tournaments.json?page=1&per_page=1&state=pending' + }, + methods: { + user_oauth: { present: !!challonge.user_oauth?.access_token, probe: null }, + api_key: { present: !!challonge.api_key?.token, probe: null }, + client_credentials: { + present: !!challonge.client_credentials?.access_token, + probe: null + } + } + }; + + if (challonge.user_oauth?.access_token) { + results.methods.user_oauth.probe = await probeChallonge(base, { + Accept: 'application/json', + 'Content-Type': 'application/vnd.api+json', + authorization: `Bearer ${challonge.user_oauth.access_token}`, + 'authorization-type': 'v2' + }); + } + + if (challonge.api_key?.token) { + results.methods.api_key.probe = await probeChallonge(base, { + Accept: 'application/json', + 'Content-Type': 'application/vnd.api+json', + authorization: challonge.api_key.token, + 'authorization-type': 'v1' + }); + } + + if (challonge.client_credentials?.access_token) { + results.methods.client_credentials.probe = await probeChallonge( + results.endpoints.appTournamentsSample, + { + Accept: 'application/json', + 'Content-Type': 'application/vnd.api+json', + authorization: `Bearer ${challonge.client_credentials.access_token}`, + 'authorization-type': 'v2' + } + ); + } + + return res.json(results); + }); + + return router; +} diff --git a/code/websites/pokedex.online/server/services/oauth-token-store.js b/code/websites/pokedex.online/server/services/oauth-token-store.js new file mode 100644 index 0000000..af63a16 --- /dev/null +++ b/code/websites/pokedex.online/server/services/oauth-token-store.js @@ -0,0 +1,224 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import logger from '../utils/logger.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const STORE_PATH = path.join(__dirname, '..', 'data', 'oauth-tokens.json'); + +const STORE_VERSION = 1; +const ONE_DAY_MS = 24 * 60 * 60 * 1000; +const SEVEN_DAYS_MS = 7 * ONE_DAY_MS; + +function now() { + return Date.now(); +} + +function getEncryptionKey(sessionSecret) { + const raw = process.env.OAUTH_TOKEN_ENC_KEY; + if (raw) { + // Expect base64 32 bytes. If it's longer, hash it down. + const buf = Buffer.from(raw, 'base64'); + if (buf.length === 32) return buf; + return crypto.createHash('sha256').update(raw).digest(); + } + + // Dev fallback: derive from session secret (still better than plaintext) + logger.warn('OAUTH_TOKEN_ENC_KEY not set; deriving key from SESSION_SECRET (dev only).'); + return crypto.createHash('sha256').update(sessionSecret).digest(); +} + +function encryptJson(key, plaintextObj) { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); + const plaintext = Buffer.from(JSON.stringify(plaintextObj), 'utf8'); + const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]); + const tag = cipher.getAuthTag(); + + return { + version: STORE_VERSION, + alg: 'aes-256-gcm', + iv: iv.toString('base64'), + tag: tag.toString('base64'), + ciphertext: ciphertext.toString('base64') + }; +} + +function decryptJson(key, envelope) { + if (!envelope || envelope.version !== STORE_VERSION) { + return { sessions: {}, version: STORE_VERSION }; + } + if (envelope.alg !== 'aes-256-gcm') { + throw new Error(`Unsupported store encryption alg: ${envelope.alg}`); + } + + const iv = Buffer.from(envelope.iv, 'base64'); + const tag = Buffer.from(envelope.tag, 'base64'); + const ciphertext = Buffer.from(envelope.ciphertext, 'base64'); + + const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); + decipher.setAuthTag(tag); + const plaintext = Buffer.concat([ + decipher.update(ciphertext), + decipher.final() + ]); + return JSON.parse(plaintext.toString('utf8')); +} + +async function readStoreFile() { + try { + const raw = await fs.readFile(STORE_PATH, 'utf8'); + return JSON.parse(raw); + } catch (err) { + if (err.code === 'ENOENT') return null; + throw err; + } +} + +async function writeStoreFile(envelope) { + await fs.mkdir(path.dirname(STORE_PATH), { recursive: true }); + const tmp = `${STORE_PATH}.${crypto.randomUUID()}.tmp`; + try { + await fs.writeFile(tmp, JSON.stringify(envelope, null, 2), 'utf8'); + await fs.rename(tmp, STORE_PATH); + } finally { + // Best-effort cleanup if something failed before rename. + try { + await fs.unlink(tmp); + } catch { + // ignore + } + } +} + +export function createOAuthTokenStore({ sessionSecret }) { + if (!sessionSecret) { + throw new Error('createOAuthTokenStore requires sessionSecret'); + } + + const key = getEncryptionKey(sessionSecret); + + let cache = null; + let cacheLoadedAt = 0; + + // Serialize writes to avoid races under concurrent requests. + let writeChain = Promise.resolve(); + + async function load() { + if (cache) return cache; + + const envelope = await readStoreFile(); + if (!envelope) { + cache = { version: STORE_VERSION, sessions: {} }; + cacheLoadedAt = now(); + return cache; + } + + try { + cache = decryptJson(key, envelope); + if (!cache.sessions) cache.sessions = {}; + cache.version = STORE_VERSION; + cacheLoadedAt = now(); + return cache; + } catch (err) { + logger.error('Failed to decrypt oauth token store; starting fresh', { + error: err.message + }); + cache = { version: STORE_VERSION, sessions: {} }; + cacheLoadedAt = now(); + return cache; + } + } + + async function persist(state) { + const envelope = encryptJson(key, state); + const run = async () => { + await writeStoreFile(envelope); + }; + + // Keep the chain alive even if a prior write failed. + writeChain = writeChain.then(run, run); + await writeChain; + } + + function ensureSession(state, sid) { + const existing = state.sessions[sid]; + const ts = now(); + if (existing) { + existing.lastSeenAt = ts; + existing.expiresAt = Math.min(existing.createdAt + SEVEN_DAYS_MS, ts + ONE_DAY_MS); + return existing; + } + + const createdAt = ts; + const session = { + createdAt, + lastSeenAt: ts, + expiresAt: Math.min(createdAt + SEVEN_DAYS_MS, ts + ONE_DAY_MS), + providers: {} + }; + + state.sessions[sid] = session; + return session; + } + + function sweep(state) { + const ts = now(); + let removed = 0; + for (const [sid, session] of Object.entries(state.sessions)) { + if (!session?.expiresAt || session.expiresAt <= ts) { + delete state.sessions[sid]; + removed++; + } + } + if (removed > 0) { + logger.info('Swept expired OAuth sessions', { removed }); + } + } + + async function touchSession(sid) { + const state = await load(); + sweep(state); + ensureSession(state, sid); + await persist(state); + } + + async function getProviderRecord(sid, provider) { + const state = await load(); + sweep(state); + const session = ensureSession(state, sid); + await persist(state); + return session.providers?.[provider] || null; + } + + async function setProviderRecord(sid, provider, record) { + const state = await load(); + sweep(state); + const session = ensureSession(state, sid); + session.providers[provider] = { + ...record, + updatedAt: now() + }; + await persist(state); + } + + async function deleteProviderRecord(sid, provider) { + const state = await load(); + sweep(state); + const session = ensureSession(state, sid); + if (session.providers?.[provider]) { + delete session.providers[provider]; + await persist(state); + } + } + + return { + touchSession, + getProviderRecord, + setProviderRecord, + deleteProviderRecord + }; +} diff --git a/code/websites/pokedex.online/server/utils/cookie-options.js b/code/websites/pokedex.online/server/utils/cookie-options.js new file mode 100644 index 0000000..bddf54e --- /dev/null +++ b/code/websites/pokedex.online/server/utils/cookie-options.js @@ -0,0 +1,77 @@ +import crypto from 'node:crypto'; + +const ONE_DAY_SECONDS = 60 * 60 * 24; +const SEVEN_DAYS_SECONDS = ONE_DAY_SECONDS * 7; + +export const COOKIE_NAMES = { + sid: 'pdx_sid', + csrf: 'pdx_csrf' +}; + +export function getCookieSecurityConfig(config) { + const deploymentTarget = config?.deploymentTarget || process.env.DEPLOYMENT_TARGET; + const nodeEnv = config?.nodeEnv || process.env.NODE_ENV; + + const isProdTarget = deploymentTarget === 'production' || nodeEnv === 'production'; + + return { + secure: isProdTarget, + sameSite: 'lax' + }; +} + +export function getSidCookieOptions(config) { + const { secure, sameSite } = getCookieSecurityConfig(config); + + return { + httpOnly: true, + secure, + sameSite, + path: '/', + maxAge: SEVEN_DAYS_SECONDS * 1000 + }; +} + +// Legacy cookie options used before widening cookie scope to '/'. +// Clearing these prevents browsers from sending multiple cookies with the same +// name but different paths (e.g. '/api' and '/'), which can cause session +// split-brain. +export function getLegacySidCookieOptions(config) { + const { secure, sameSite } = getCookieSecurityConfig(config); + + return { + httpOnly: true, + secure, + sameSite, + path: '/api', + maxAge: SEVEN_DAYS_SECONDS * 1000 + }; +} + +export function getCsrfCookieOptions(config) { + const { secure, sameSite } = getCookieSecurityConfig(config); + + return { + httpOnly: false, + secure, + sameSite, + path: '/', + maxAge: ONE_DAY_SECONDS * 1000 + }; +} + +export function getLegacyCsrfCookieOptions(config) { + const { secure, sameSite } = getCookieSecurityConfig(config); + + return { + httpOnly: false, + secure, + sameSite, + path: '/api', + maxAge: ONE_DAY_SECONDS * 1000 + }; +} + +export function generateToken(bytes = 24) { + return crypto.randomBytes(bytes).toString('base64url'); +} diff --git a/code/websites/pokedex.online/server/utils/env-validator.js b/code/websites/pokedex.online/server/utils/env-validator.js index 677fdc3..484a1d3 100644 --- a/code/websites/pokedex.online/server/utils/env-validator.js +++ b/code/websites/pokedex.online/server/utils/env-validator.js @@ -73,6 +73,36 @@ const REQUIRED_ENV_VARS = { : null }, + // Token encryption key (required for server-side OAuth token storage in production) + OAUTH_TOKEN_ENC_KEY: { + required: false, + description: + 'Base64-encoded 32-byte key for encrypting OAuth tokens at rest (AES-256-GCM)', + validate: (val, env) => { + const target = env?.DEPLOYMENT_TARGET; + if (target !== 'production') return true; + if (!val) { + console.error( + '❌ OAUTH_TOKEN_ENC_KEY is required in production to encrypt OAuth tokens' + ); + return false; + } + // Best-effort validation: base64 decode should yield 32 bytes + try { + const buf = Buffer.from(val, 'base64'); + return buf.length === 32; + } catch { + return false; + } + } + }, + + // Admin auth + ADMIN_PASSWORD: { + required: false, + description: 'Admin password for /auth/login (recommended for production)' + }, + // Challonge OAuth (optional) CHALLONGE_CLIENT_ID: { required: false, @@ -217,6 +247,10 @@ export function getConfig() { secret: process.env.SESSION_SECRET || 'dev-secret-change-in-production' }, + // Admin auth (JWT secret uses session secret for now) + secret: process.env.SESSION_SECRET || 'dev-secret-change-in-production', + adminPassword: process.env.ADMIN_PASSWORD, + // Discord User Permissions discord: { adminUsers: process.env.DISCORD_ADMIN_USERS diff --git a/code/websites/pokedex.online/src/components/DeveloperTools.vue b/code/websites/pokedex.online/src/components/DeveloperTools.vue index f4956b3..ef261bf 100644 --- a/code/websites/pokedex.online/src/components/DeveloperTools.vue +++ b/code/websites/pokedex.online/src/components/DeveloperTools.vue @@ -139,7 +139,9 @@ const isOpen = ref(false); // 2. JWT authenticated users with developer_tools.view permission // 3. Discord authenticated users with developer_tools.view permission const isAvailable = computed(() => { - const isDev = process.env.NODE_ENV === 'development'; + // Vite-native dev detection (reliable in the browser). + // In production builds, this is always false. + const isDev = import.meta.env.DEV === true; // Check JWT auth permissions const hasJwtPermission = user.value?.permissions?.includes( @@ -154,7 +156,7 @@ const isAvailable = computed(() => { return isDev || hasPermission; }); -const nodeEnv = computed(() => process.env.NODE_ENV || 'unknown'); +const nodeEnv = computed(() => import.meta.env.MODE || 'unknown'); const appVersion = computed( () => import.meta.env.VITE_APP_VERSION || '1.0.0-dev' ); diff --git a/code/websites/pokedex.online/src/composables/useChallongeApiKey.js b/code/websites/pokedex.online/src/composables/useChallongeApiKey.js index 09dd1f7..dd9419b 100644 --- a/code/websites/pokedex.online/src/composables/useChallongeApiKey.js +++ b/code/websites/pokedex.online/src/composables/useChallongeApiKey.js @@ -1,58 +1,57 @@ /** * useChallongeApiKey Composable - * Manages Challonge API key storage in browser localStorage - * Works on mobile, desktop, and tablets + * Manages Challonge API key storage on the backend per-session (SID cookie) + * No-split-brain: API key never lives in browser storage. */ import { ref, computed } from 'vue'; +import { apiClient } from '../utilities/api-client.js'; -const STORAGE_KEY = 'challonge_api_key'; -const storedKey = ref(getStoredKey()); +const status = ref(null); +const loading = ref(false); +const error = ref(null); /** - * Get API key from localStorage - * @returns {string|null} Stored API key or null - */ -function getStoredKey() { - try { - return localStorage.getItem(STORAGE_KEY) || null; - } catch (error) { - console.warn('localStorage not available:', error); - return null; - } -} - -/** - * Save API key to localStorage + * Save API key server-side (per-session) * @param {string} apiKey - The API key to store - * @returns {boolean} Success status + * @returns {Promise} Success status */ -function saveApiKey(apiKey) { - try { - if (!apiKey || typeof apiKey !== 'string') { - throw new Error('Invalid API key format'); - } - localStorage.setItem(STORAGE_KEY, apiKey); - storedKey.value = apiKey; - return true; - } catch (error) { - console.error('Failed to save API key:', error); +async function saveApiKey(apiKey) { + if (!apiKey || typeof apiKey !== 'string') { + error.value = 'Invalid API key format'; return false; } + + loading.value = true; + error.value = null; + try { + const data = await apiClient.post('/oauth/challonge/api-key', { apiKey }); + status.value = data; + return true; + } catch (err) { + error.value = err.message; + return false; + } finally { + loading.value = false; + } } /** - * Clear API key from localStorage - * @returns {boolean} Success status + * Clear API key server-side (per-session) + * @returns {Promise} Success status */ -function clearApiKey() { +async function clearApiKey() { + loading.value = true; + error.value = null; try { - localStorage.removeItem(STORAGE_KEY); - storedKey.value = null; + const data = await apiClient.post('/oauth/challonge/api-key/clear', {}); + status.value = data; return true; - } catch (error) { - console.error('Failed to clear API key:', error); + } catch (err) { + error.value = err.message; return false; + } finally { + loading.value = false; } } @@ -62,34 +61,54 @@ function clearApiKey() { * @returns {string|null} Masked key or null */ const maskedKey = computed(() => { - if (!storedKey.value) return null; - const key = storedKey.value; - if (key.length < 8) return 'β€’β€’β€’β€’β€’β€’β€’β€’'; - return `${key.slice(0, 4)}β€’β€’β€’β€’β€’β€’β€’${key.slice(-4)}`; + if (!isKeyStored.value) return null; + return 'stored on server'; }); +// Backwards-compat for older views: truthy string when stored +const storedKey = computed(() => maskedKey.value); + /** * Check if API key is stored * @returns {boolean} True if key exists */ -const isKeyStored = computed(() => !!storedKey.value); +const isKeyStored = computed(() => { + return !!status.value?.methods?.api_key?.connected; +}); /** * Get the full API key (use with caution) * @returns {string|null} Full API key or null */ function getApiKey() { - return storedKey.value; + // No-split-brain: never expose raw key to browser + return null; +} + +async function refreshStatus() { + try { + status.value = await apiClient.get('/oauth/challonge/status'); + } catch { + // ignore + } } export function useChallongeApiKey() { + // Fire-and-forget initial status load + if (!status.value && !loading.value) { + refreshStatus(); + } + return { saveApiKey, clearApiKey, getApiKey, - getStoredKey, - storedKey: computed(() => storedKey.value), + refreshStatus, + status: computed(() => status.value), + loading: computed(() => loading.value), + error: computed(() => error.value), maskedKey, + storedKey, isKeyStored }; } diff --git a/code/websites/pokedex.online/src/composables/useChallongeClient.js b/code/websites/pokedex.online/src/composables/useChallongeClient.js index 936cb2f..bbf84c5 100644 --- a/code/websites/pokedex.online/src/composables/useChallongeClient.js +++ b/code/websites/pokedex.online/src/composables/useChallongeClient.js @@ -37,9 +37,8 @@ export function useChallongeClient(options = {}) { const { debug = false } = options; // Get authentication sources - const { getApiKey } = useChallongeApiKey(); - const { isAuthenticated: isOAuthAuthenticated, accessToken: oauthToken } = - useChallongeOAuth(); + const { isKeyStored } = useChallongeApiKey(); + const { isAuthenticated: isOAuthAuthenticated } = useChallongeOAuth(); const { isAuthenticated: isClientCredsAuthenticated, accessToken: clientCredsToken @@ -50,13 +49,13 @@ export function useChallongeClient(options = {}) { const tournamentScope = ref(ScopeType.USER); const debugMode = ref(debug); - // Reactive API key - const apiKey = computed(() => getApiKey()); + // No-split-brain: raw keys/tokens are never available in the browser + const apiKey = computed(() => null); // Masked API key for display const maskedApiKey = computed(() => { - if (!apiKey.value) return ''; - return apiKey.value.slice(0, 4) + 'β€’β€’β€’β€’β€’β€’β€’' + apiKey.value.slice(-4); + if (!isKeyStored.value) return ''; + return 'stored on server'; }); /** @@ -65,8 +64,8 @@ export function useChallongeClient(options = {}) { const client = computed(() => { if (apiVersion.value === 'v1') { // v1 only supports API key - if (!apiKey.value) return null; - return createChallongeV1Client(apiKey.value); + if (!isKeyStored.value) return null; + return createChallongeV1Client(null); } else { // v2.1 supports OAuth, client credentials, and API key // Smart priority based on scope selection: @@ -75,7 +74,7 @@ export function useChallongeClient(options = {}) { if (tournamentScope.value === ScopeType.APPLICATION) { // APPLICATION scope - prefer client credentials - if (isClientCredsAuthenticated.value && clientCredsToken.value) { + if (isClientCredsAuthenticated.value) { if (debugMode.value) { console.log( 'πŸ” Using Client Credentials token for APPLICATION scope' @@ -85,43 +84,38 @@ export function useChallongeClient(options = {}) { { token: clientCredsToken.value, type: AuthType.OAUTH }, { debug: debugMode.value } ); - } else if (isOAuthAuthenticated.value && oauthToken.value) { - if (debugMode.value) { - console.log('πŸ” Using OAuth user token for APPLICATION scope'); - } - return createChallongeV2Client( - { token: oauthToken.value, type: AuthType.OAUTH }, - { debug: debugMode.value } - ); } + + // Backend requires client_credentials for /v2.1/application/* + return null; } else { // USER scope - prefer OAuth user tokens or API key - if (isOAuthAuthenticated.value && oauthToken.value) { + if (isOAuthAuthenticated.value) { if (debugMode.value) { console.log('πŸ” Using OAuth user token for USER scope'); } return createChallongeV2Client( - { token: oauthToken.value, type: AuthType.OAUTH }, + { token: null, type: AuthType.OAUTH }, { debug: debugMode.value } ); - } else if (apiKey.value) { + } else if (isKeyStored.value) { if (debugMode.value) { console.log('πŸ”‘ Using API Key for USER scope'); } return createChallongeV2Client( - { token: apiKey.value, type: AuthType.API_KEY }, + { token: null, type: AuthType.API_KEY }, { debug: debugMode.value } ); } } // Fallback: try API key - if (apiKey.value) { + if (isKeyStored.value) { if (debugMode.value) { console.log('πŸ”‘ Using API Key (fallback)'); } return createChallongeV2Client( - { token: apiKey.value, type: AuthType.API_KEY }, + { token: null, type: AuthType.API_KEY }, { debug: debugMode.value } ); } @@ -137,13 +131,14 @@ export function useChallongeClient(options = {}) { if (apiVersion.value === 'v1') { return 'API Key'; } - if (isClientCredsAuthenticated.value) { - return 'Client Credentials'; + + if (tournamentScope.value === ScopeType.APPLICATION) { + return isClientCredsAuthenticated.value ? 'Client Credentials' : 'None'; } - if (isOAuthAuthenticated.value) { - return 'OAuth'; - } - return 'API Key'; + + if (isOAuthAuthenticated.value) return 'OAuth'; + if (isKeyStored.value) return 'API Key'; + return 'None'; }); /** diff --git a/code/websites/pokedex.online/src/composables/useChallongeClientCredentials.js b/code/websites/pokedex.online/src/composables/useChallongeClientCredentials.js index 89f4582..b9086ea 100644 --- a/code/websites/pokedex.online/src/composables/useChallongeClientCredentials.js +++ b/code/websites/pokedex.online/src/composables/useChallongeClientCredentials.js @@ -1,278 +1,136 @@ /** - * Challonge Client Credentials Flow Composable + * Challonge Client Credentials Composable (SERVER-SIDE) * - * Manages client credentials OAuth flow for server-to-server authentication - * Used for APPLICATION scope access (application:manage) - * - * Features: - * - Client credentials token exchange - * - Automatic token refresh - * - Secure credential storage - * - Token expiration handling - * - * Usage: - * ```javascript - * import { useChallongeClientCredentials } from '@/composables/useChallongeClientCredentials' - * - * const { - * isAuthenticated, - * accessToken, - * authenticate, - * logout, - * saveCredentials - * } = useChallongeClientCredentials() - * - * // Save client credentials (one time) - * saveCredentials('your_client_id', 'your_client_secret') - * - * // Get access token (will auto-refresh if expired) - * await authenticate('application:manage tournaments:read tournaments:write') - * const token = accessToken.value - * ``` + * No-split-brain: client credentials and tokens are stored on the backend + * per-session (SID cookie) and never returned to the browser. */ import { ref, computed } from 'vue'; +import { apiClient } from '../utilities/api-client.js'; -const CREDENTIALS_KEY = 'challonge_client_credentials'; -const TOKEN_KEY = 'challonge_client_token'; - -// Shared state across all instances -const credentials = ref(null); -const tokenData = ref(null); +const status = ref(null); const loading = ref(false); -const error = ref(null); +const error = ref(''); -// Load credentials and token from localStorage on module initialization -try { - const storedCreds = localStorage.getItem(CREDENTIALS_KEY); - if (storedCreds) { - credentials.value = JSON.parse(storedCreds); - } - - const storedToken = localStorage.getItem(TOKEN_KEY); - if (storedToken) { - tokenData.value = JSON.parse(storedToken); - - // Check if token is expired - if ( - tokenData.value.expires_at && - Date.now() >= tokenData.value.expires_at - ) { - console.log('πŸ”„ Client credentials token expired, will need to refresh'); - } - } -} catch (err) { - console.error('Failed to load client credentials:', err); +function secondsUntil(expiresAt) { + if (!expiresAt) return null; + const diff = expiresAt - Date.now(); + return diff > 0 ? Math.floor(diff / 1000) : 0; } export function useChallongeClientCredentials() { - const isAuthenticated = computed(() => { - return !!tokenData.value?.access_token && !isExpired.value; - }); - - const isExpired = computed(() => { - if (!tokenData.value?.expires_at) return true; - return Date.now() >= tokenData.value.expires_at; - }); - - const accessToken = computed(() => { - if (isExpired.value) return null; - return tokenData.value?.access_token || null; - }); + const method = computed(() => status.value?.methods?.client_credentials); const hasCredentials = computed(() => { - return !!(credentials.value?.client_id && credentials.value?.client_secret); + return !!method.value?.stored; + }); + + const isAuthenticated = computed(() => { + return !!method.value?.connected; }); const maskedClientId = computed(() => { - if (!credentials.value?.client_id) return null; - const id = credentials.value.client_id; - if (id.length < 12) return id.slice(0, 4) + 'β€’β€’β€’β€’'; - return id.slice(0, 6) + 'β€’β€’β€’β€’β€’β€’β€’' + id.slice(-4); + if (!hasCredentials.value) return ''; + return 'stored on server'; }); - /** - * Save client credentials to localStorage - * @param {string} clientId - OAuth client ID - * @param {string} clientSecret - OAuth client secret - * @returns {boolean} Success status - */ - function saveCredentials(clientId, clientSecret) { - try { - if (!clientId || !clientSecret) { - throw new Error('Client ID and secret are required'); - } + const tokenInfo = computed(() => { + const expiresAt = method.value?.expires_at; + return { + expiresAt: expiresAt || null, + expiresIn: secondsUntil(expiresAt) + }; + }); - credentials.value = { - client_id: clientId, - client_secret: clientSecret, - saved_at: new Date().toISOString() - }; - - localStorage.setItem(CREDENTIALS_KEY, JSON.stringify(credentials.value)); - console.log('βœ… Client credentials saved'); - return true; - } catch (err) { - error.value = err.message; - console.error('Failed to save credentials:', err); - return false; - } + async function refreshStatus() { + status.value = await apiClient.get('/oauth/challonge/status'); + return status.value; } - /** - * Clear stored credentials and token - * @returns {boolean} Success status - */ - function clearCredentials() { - try { - credentials.value = null; - tokenData.value = null; - localStorage.removeItem(CREDENTIALS_KEY); - localStorage.removeItem(TOKEN_KEY); - console.log('βœ… Client credentials cleared'); - return true; - } catch (err) { - error.value = err.message; - console.error('Failed to clear credentials:', err); - return false; - } - } - - /** - * Authenticate using client credentials flow - * @param {string} scope - Requested scope (e.g., 'application:manage') - * @returns {Promise} Access token - */ - async function authenticate(scope = 'application:manage') { - if (!hasCredentials.value) { - throw new Error( - 'Client credentials not configured. Use saveCredentials() first.' - ); - } - - // Return existing token if still valid - if (isAuthenticated.value && !isExpired.value) { - console.log('βœ… Using existing valid token'); - return accessToken.value; - } - + async function saveCredentials(clientId, clientSecret, scope) { loading.value = true; - error.value = null; - + error.value = ''; try { - console.log('πŸ” Requesting client credentials token...'); - console.log(' Client ID:', maskedClientId.value); - console.log(' Scope:', 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: credentials.value.client_id, - client_secret: credentials.value.client_secret, - scope: scope - }) + status.value = await apiClient.post('/oauth/challonge/client-credentials', { + clientId, + clientSecret, + scope }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error( - errorData.error_description || - errorData.error || - `Token request failed: ${response.status}` - ); - } - - const data = await response.json(); - - // Store token with expiration - tokenData.value = { - access_token: data.access_token, - token_type: data.token_type, - scope: data.scope, - created_at: Date.now(), - expires_in: data.expires_in, - expires_at: Date.now() + data.expires_in * 1000 - }; - - // Save to localStorage - localStorage.setItem(TOKEN_KEY, JSON.stringify(tokenData.value)); - - console.log('βœ… Client credentials token obtained'); - console.log(' Expires in:', data.expires_in, 'seconds'); - console.log(' Scope:', data.scope); - - return tokenData.value.access_token; + return true; } catch (err) { - error.value = err.message; - console.error('❌ Client credentials authentication failed:', err); - throw err; + error.value = err.message || 'Failed to save credentials'; + return false; } finally { loading.value = false; } } - /** - * Force token refresh - * @param {string} scope - Requested scope - * @returns {Promise} New access token - */ - async function refresh(scope = 'application:manage') { - // Clear existing token - tokenData.value = null; - localStorage.removeItem(TOKEN_KEY); + async function authenticate(scope = 'application:manage') { + loading.value = true; + error.value = ''; + try { + status.value = await apiClient.post('/oauth/challonge/client-credentials', { scope }); + return true; + } finally { + loading.value = false; + } + } - // Get new token + async function refresh(scope = 'application:manage') { return authenticate(scope); } - /** - * Logout and clear token (keeps credentials) - */ - function logout() { - tokenData.value = null; - localStorage.removeItem(TOKEN_KEY); - console.log('βœ… Logged out (credentials retained)'); + async function logout() { + loading.value = true; + error.value = ''; + try { + status.value = await apiClient.post('/oauth/challonge/client-credentials/logout', {}); + return true; + } catch (err) { + error.value = err.message || 'Logout failed'; + return false; + } finally { + loading.value = false; + } } - /** - * Get token info for debugging - */ - const tokenInfo = computed(() => { - if (!tokenData.value) return null; + async function clearCredentials() { + loading.value = true; + error.value = ''; + try { + status.value = await apiClient.post('/oauth/challonge/client-credentials/clear', {}); + return true; + } catch (err) { + error.value = err.message || 'Failed to clear credentials'; + return false; + } finally { + loading.value = false; + } + } - const now = Date.now(); - const expiresAt = tokenData.value.expires_at; - const timeUntilExpiry = expiresAt ? expiresAt - now : 0; - - return { - hasToken: !!tokenData.value.access_token, - isExpired: isExpired.value, - scope: tokenData.value.scope, - expiresIn: Math.floor(timeUntilExpiry / 1000), - expiresAt: expiresAt ? new Date(expiresAt).toLocaleString() : null - }; - }); + // Best-effort initial status load + if (!status.value && !loading.value) { + refreshStatus().catch(() => {}); + } return { - // State - isAuthenticated, - isExpired, - accessToken, hasCredentials, maskedClientId, - loading, - error, + isAuthenticated, + loading: computed(() => loading.value), + error: computed({ + get: () => error.value, + set: v => { + error.value = v || ''; + } + }), tokenInfo, - - // Actions saveCredentials, clearCredentials, authenticate, refresh, - logout + logout, + refreshStatus, + status: computed(() => status.value) }; } diff --git a/code/websites/pokedex.online/src/composables/useChallongeOAuth.js b/code/websites/pokedex.online/src/composables/useChallongeOAuth.js index 494ef2a..5847fda 100644 --- a/code/websites/pokedex.online/src/composables/useChallongeOAuth.js +++ b/code/websites/pokedex.online/src/composables/useChallongeOAuth.js @@ -11,45 +11,71 @@ */ import { ref, computed } from 'vue'; +import { apiClient } from '../utilities/api-client.js'; + +function getCookie(name) { + if (typeof document === 'undefined') return null; + const parts = document.cookie.split(';').map(p => p.trim()); + for (const part of parts) { + if (part.startsWith(`${name}=`)) { + return decodeURIComponent(part.slice(name.length + 1)); + } + } + return null; +} + +async function ensureCsrfCookie() { + const csrf = getCookie('pdx_csrf'); + if (csrf) return; + try { + await apiClient.get('/session/csrf', { deduplicate: false }); + } catch { + // Let the POST surface the failure. + } +} -const STORAGE_KEY = 'challonge_oauth_tokens'; const CLIENT_ID = import.meta.env.VITE_CHALLONGE_CLIENT_ID; const REDIRECT_URI = import.meta.env.VITE_CHALLONGE_REDIRECT_URI || `${window.location.origin}/oauth/callback`; // Shared state across all instances -const tokens = ref(null); +const status = ref(null); const loading = ref(false); const error = ref(null); -// Load tokens from localStorage on module initialization -try { - const stored = localStorage.getItem(STORAGE_KEY); - if (stored) { - tokens.value = JSON.parse(stored); - - // Check if token is expired - if (tokens.value.expires_at && Date.now() >= tokens.value.expires_at) { - console.log('πŸ”„ Token expired, will need to refresh'); - } +async function refreshStatus() { + try { + const data = await apiClient.get('/oauth/challonge/status', { + deduplicate: false + }); + if (data) status.value = data; + } catch { + // best-effort } -} catch (err) { - console.error('Failed to load OAuth tokens:', err); } export function useChallongeOAuth() { const isAuthenticated = computed(() => { - return !!tokens.value?.access_token; + return !!status.value?.methods?.user_oauth?.connected; }); const isExpired = computed(() => { - if (!tokens.value?.expires_at) return false; - return Date.now() >= tokens.value.expires_at; + const expiresAt = status.value?.methods?.user_oauth?.expires_at; + if (!expiresAt) return false; + return Date.now() >= expiresAt; + }); + + const expiresIn = computed(() => { + const expiresAt = status.value?.methods?.user_oauth?.expires_at; + if (!expiresAt) return null; + const diff = expiresAt - Date.now(); + return diff > 0 ? Math.floor(diff / 1000) : 0; }); const accessToken = computed(() => { - return tokens.value?.access_token || null; + // No-split-brain: token never available in browser + return null; }); /** @@ -59,13 +85,18 @@ export function useChallongeOAuth() { * @returns {Object} Object with authUrl and state */ function getAuthorizationUrl( - scope = 'tournaments:read tournaments:write', + scopeOrOptions = 'tournaments:read tournaments:write', state = null ) { if (!CLIENT_ID) { throw new Error('VITE_CHALLONGE_CLIENT_ID not configured'); } + const scope = + typeof scopeOrOptions === 'string' + ? scopeOrOptions + : scopeOrOptions?.scope || 'tournaments:read tournaments:write'; + // Generate state if not provided const oauthState = state || generateState(); @@ -94,6 +125,10 @@ export function useChallongeOAuth() { // Store state for CSRF protection sessionStorage.setItem('oauth_state', state); + sessionStorage.setItem('oauth_provider', 'challonge'); + if (typeof scope === 'object' && scope?.return_to) { + sessionStorage.setItem('oauth_return_to', scope.return_to); + } console.log('πŸ” Starting OAuth flow with state:', state); @@ -130,44 +165,14 @@ export function useChallongeOAuth() { error.value = null; try { - const response = await fetch('/api/oauth/token', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ code }) - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error( - errorData.error_description || - errorData.error || - 'Token exchange failed' - ); - } - - const data = await response.json(); - - // Calculate expiration time - const expiresAt = Date.now() + data.expires_in * 1000; - - tokens.value = { - access_token: data.access_token, - refresh_token: data.refresh_token, - token_type: data.token_type, - expires_in: data.expires_in, - expires_at: expiresAt, - scope: data.scope, - created_at: Date.now() - }; - - // Store tokens - localStorage.setItem(STORAGE_KEY, JSON.stringify(tokens.value)); + await ensureCsrfCookie(); + const data = await apiClient.post('/oauth/challonge/exchange', { code }); + status.value = data; sessionStorage.removeItem('oauth_state'); + sessionStorage.removeItem('oauth_provider'); console.log('βœ… OAuth authentication successful'); - return tokens.value; + return status.value; } catch (err) { error.value = err.message; console.error('Token exchange error:', err); @@ -181,53 +186,14 @@ export function useChallongeOAuth() { * Refresh access token using refresh token */ async function refreshToken() { - if (!tokens.value?.refresh_token) { - throw new Error('No refresh token available'); - } - loading.value = true; error.value = null; try { - const response = await fetch('/api/oauth/refresh', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - refresh_token: tokens.value.refresh_token - }) - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error( - errorData.error_description || - errorData.error || - 'Token refresh failed' - ); - } - - const data = await response.json(); - - // Calculate expiration time - const expiresAt = Date.now() + data.expires_in * 1000; - - tokens.value = { - access_token: data.access_token, - refresh_token: data.refresh_token || tokens.value.refresh_token, // Keep old if not provided - token_type: data.token_type, - expires_in: data.expires_in, - expires_at: expiresAt, - scope: data.scope, - refreshed_at: Date.now() - }; - - // Store updated tokens - localStorage.setItem(STORAGE_KEY, JSON.stringify(tokens.value)); + status.value = await apiClient.post('/oauth/challonge/refresh', {}); console.log('βœ… Token refreshed successfully'); - return tokens.value; + return status.value; } catch (err) { error.value = err.message; console.error('Token refresh error:', err); @@ -244,30 +210,20 @@ export function useChallongeOAuth() { * Get valid access token (refreshes if expired) */ async function getValidToken() { - if (!tokens.value) { - throw new Error('Not authenticated'); - } - - // If token is expired or about to expire (within 5 minutes), refresh it - const expiresIn = tokens.value.expires_at - Date.now(); - const fiveMinutes = 5 * 60 * 1000; - - if (expiresIn < fiveMinutes) { - console.log('πŸ”„ Token expired or expiring soon, refreshing...'); - await refreshToken(); - } - - return tokens.value.access_token; + throw new Error( + 'No-split-brain: Challonge OAuth token is not accessible in the browser' + ); } /** * Logout and clear tokens */ function logout() { - tokens.value = null; - localStorage.removeItem(STORAGE_KEY); + status.value = null; sessionStorage.removeItem('oauth_state'); - console.log('πŸ‘‹ Logged out'); + sessionStorage.removeItem('oauth_provider'); + apiClient.post('/oauth/challonge/disconnect', {}).catch(() => {}); + console.log('πŸ‘‹ Disconnected'); } /** @@ -283,9 +239,10 @@ export function useChallongeOAuth() { return { // State - tokens: computed(() => tokens.value), + tokens: computed(() => status.value), isAuthenticated, isExpired, + expiresIn, accessToken, loading: computed(() => loading.value), error: computed(() => error.value), @@ -299,3 +256,8 @@ export function useChallongeOAuth() { getAuthorizationUrl }; } + +// Fire-and-forget initial status load +if (!status.value && !loading.value) { + refreshStatus(); +} diff --git a/code/websites/pokedex.online/src/composables/useChallongeTests.js b/code/websites/pokedex.online/src/composables/useChallongeTests.js index ec5946a..862784a 100644 --- a/code/websites/pokedex.online/src/composables/useChallongeTests.js +++ b/code/websites/pokedex.online/src/composables/useChallongeTests.js @@ -105,7 +105,8 @@ export function useChallongeTests(client, apiVersion, tournamentScope) { */ async function testListTournaments(resetPagination = true) { if (!client.value) { - console.error('No API client available'); + tournamentListState.error.value = + 'No Challonge client available. Configure API key/OAuth/client credentials, and ensure the selected API version + scope is supported.'; return; } @@ -131,13 +132,6 @@ export function useChallongeTests(client, apiVersion, tournamentScope) { scopeType: tournamentScope.value }); - console.log('πŸ“Š Tournament API Response:', { - page: currentPage.value, - perPage: perPage.value, - scope: tournamentScope.value, - resultsCount: result.length - }); - totalTournaments.value = result.length; hasNextPage.value = result.length >= perPage.value; return result; diff --git a/code/websites/pokedex.online/src/composables/useDiscordOAuth.js b/code/websites/pokedex.online/src/composables/useDiscordOAuth.js index dba26bf..8c2147e 100644 --- a/code/websites/pokedex.online/src/composables/useDiscordOAuth.js +++ b/code/websites/pokedex.online/src/composables/useDiscordOAuth.js @@ -13,6 +13,7 @@ import { ref, computed } from 'vue'; import { useOAuth } from './useOAuth.js'; +import { apiClient } from '../utilities/api-client.js'; // Shared Discord user profile data const discordUser = ref(null); @@ -47,24 +48,7 @@ export function useDiscordOAuth() { */ async function fetchUserProfile() { try { - const token = oauth.accessToken.value; - if (!token) { - throw new Error('Not authenticated with Discord'); - } - - // Fetch from backend which has the Discord token - const response = await fetch('/api/auth/discord/profile', { - headers: { - Authorization: `Bearer ${token}` - } - }); - - if (!response.ok) { - const error = await response.json().catch(() => ({})); - throw new Error(error.error || 'Failed to fetch Discord profile'); - } - - const data = await response.json(); + const data = await apiClient.get('/discord/profile'); discordUser.value = data.user; console.log(`βœ… Loaded Discord profile: ${data.user.username}`); @@ -103,9 +87,8 @@ export function useDiscordOAuth() { * @returns {boolean} True if user has developer access */ function hasDevAccess() { - // Check if tokens include permissions - const permissions = oauth.tokens.value?.permissions || []; - return permissions.includes('developer_tools.view'); + // No-split-brain: permissions are not surfaced via OAuth token exchange anymore. + return false; } return { diff --git a/code/websites/pokedex.online/src/composables/useOAuth.js b/code/websites/pokedex.online/src/composables/useOAuth.js index 7a5affa..40894c6 100644 --- a/code/websites/pokedex.online/src/composables/useOAuth.js +++ b/code/websites/pokedex.online/src/composables/useOAuth.js @@ -21,9 +21,35 @@ import { ref, computed } from 'vue'; import { PLATFORMS } from '../config/platforms.js'; +import { apiClient } from '../utilities/api-client.js'; -// Multi-provider token storage (shared across all instances) -const tokenStores = new Map(); +function getCookie(name) { + if (typeof document === 'undefined') return null; + const parts = document.cookie.split(';').map(p => p.trim()); + for (const part of parts) { + if (part.startsWith(`${name}=`)) { + return decodeURIComponent(part.slice(name.length + 1)); + } + } + return null; +} + +async function ensureCsrfCookie() { + // The backend requires double-submit CSRF for all unsafe methods. + // The OAuth callback page can be loaded directly after provider redirect, + // before the app's normal startup has fetched /session/csrf. + const csrf = getCookie('pdx_csrf'); + if (csrf) return; + + try { + await apiClient.get('/session/csrf', { deduplicate: false }); + } catch { + // If this fails, the subsequent POST will surface the real error. + } +} + +// Multi-provider status storage (shared across all instances) +const statusStores = new Map(); /** * Initialize OAuth state for a provider @@ -33,8 +59,8 @@ const tokenStores = new Map(); */ function initializeProvider(provider) { // Return existing state if already initialized - if (tokenStores.has(provider)) { - return tokenStores.get(provider); + if (statusStores.has(provider)) { + return statusStores.get(provider); } // Validate platform exists @@ -43,35 +69,28 @@ function initializeProvider(provider) { throw new Error(`Platform not found: ${provider}`); } - // Get storage key from OAuth config const oauthConfig = platformConfig.auth.oauth; if (!oauthConfig?.enabled) { throw new Error(`OAuth not enabled for ${provider}`); } - const storageKey = oauthConfig.storageKey; - // Create provider-specific state const state = { tokens: ref(null), loading: ref(false), error: ref(null), - provider, - storageKey + provider }; - // Load existing tokens from localStorage on initialization - try { - const stored = localStorage.getItem(storageKey); - if (stored) { - state.tokens.value = JSON.parse(stored); - console.log(`βœ… Loaded ${provider} OAuth tokens from storage`); - } - } catch (err) { - console.error(`Failed to load ${provider} OAuth tokens:`, err); - } + // Best-effort initial status fetch + apiClient + .get(`/oauth/${provider}/status`, { deduplicate: false }) + .then(data => { + if (data) state.tokens.value = data; + }) + .catch(() => {}); - tokenStores.set(provider, state); + statusStores.set(provider, state); return state; } @@ -87,26 +106,29 @@ export function useOAuth(provider = 'challonge') { // Computed properties for token state const isAuthenticated = computed(() => { - return !!state.tokens.value?.access_token; + return !!state.tokens.value?.connected; }); const isExpired = computed(() => { - if (!state.tokens.value?.expires_at) return false; - return Date.now() >= state.tokens.value.expires_at; + const expiresAt = state.tokens.value?.expires_at; + if (!expiresAt) return false; + return Date.now() >= expiresAt; }); const expiresIn = computed(() => { - if (!state.tokens.value?.expires_at) return null; - const diff = state.tokens.value.expires_at - Date.now(); + const expiresAt = state.tokens.value?.expires_at; + if (!expiresAt) return null; + const diff = expiresAt - Date.now(); return diff > 0 ? Math.floor(diff / 1000) : 0; }); const accessToken = computed(() => { - return state.tokens.value?.access_token || null; + // No-split-brain: tokens are never available in the browser + return null; }); const refreshToken = computed(() => { - return state.tokens.value?.refresh_token || null; + return null; }); /** @@ -232,46 +254,9 @@ export function useOAuth(provider = 'challonge') { state.error.value = null; try { - // Exchange code for tokens via backend endpoint - const response = await fetch(oauthConfig.tokenEndpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - code, - provider - }) - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error( - errorData.error_description || - errorData.error || - `Token exchange failed with status ${response.status}` - ); - } - - const data = await response.json(); - - // Calculate token expiration time (expires_in is in seconds) - const expiresAt = Date.now() + (data.expires_in || 3600) * 1000; - - // Store tokens (including permissions if provided) - const tokens = { - access_token: data.access_token, - refresh_token: data.refresh_token || null, - token_type: data.token_type || 'Bearer', - expires_in: data.expires_in || 3600, - expires_at: expiresAt, - scope: data.scope, - permissions: data.permissions || [], // Store permissions from backend - created_at: Date.now() - }; - - state.tokens.value = tokens; - localStorage.setItem(state.storageKey, JSON.stringify(tokens)); + await ensureCsrfCookie(); + const data = await apiClient.post(oauthConfig.tokenEndpoint, { code }); + state.tokens.value = data; // Clean up session storage sessionStorage.removeItem('oauth_state'); @@ -279,13 +264,27 @@ export function useOAuth(provider = 'challonge') { sessionStorage.removeItem('oauth_return_to'); console.log( - `βœ… ${provider} OAuth authentication successful, expires in ${data.expires_in}s` + `βœ… ${provider} OAuth authentication successful` ); - return tokens; + return data; } catch (err) { - state.error.value = err.message; + const backendCode = err?.data?.code; + const backendError = err?.data?.error; + const details = err?.data?.details; + + // Prefer a helpful, user-visible message over a generic HTTP status. + let message = err?.message || 'Token exchange failed'; + if (backendError) message = backendError; + if (backendCode) message = `${message} (${backendCode})`; + + // If Challonge returns structured OAuth error info, surface it. + const detailText = + details?.error_description || details?.error || details?.message; + if (detailText) message = `${message}: ${detailText}`; + + state.error.value = message; console.error(`${provider} token exchange error:`, err); - throw err; + throw new Error(message); } finally { state.loading.value = false; } @@ -299,54 +298,15 @@ export function useOAuth(provider = 'challonge') { * @throws {Error} If no refresh token available or refresh fails */ async function refreshTokenFn() { - if (!state.tokens.value?.refresh_token) { - throw new Error(`No refresh token available for ${provider}`); - } - state.loading.value = true; state.error.value = null; try { - const response = await fetch(oauthConfig.refreshEndpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - refresh_token: state.tokens.value.refresh_token, - provider - }) - }); + const data = await apiClient.post(oauthConfig.refreshEndpoint, {}); + state.tokens.value = data; - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error( - errorData.error_description || - errorData.error || - `Token refresh failed with status ${response.status}` - ); - } - - const data = await response.json(); - const expiresAt = Date.now() + (data.expires_in || 3600) * 1000; - - // Update tokens (keep old refresh token if new one not provided) - const tokens = { - ...state.tokens.value, - access_token: data.access_token, - refresh_token: data.refresh_token || state.tokens.value.refresh_token, - expires_in: data.expires_in || 3600, - expires_at: expiresAt, - refreshed_at: Date.now() - }; - - state.tokens.value = tokens; - localStorage.setItem(state.storageKey, JSON.stringify(tokens)); - - console.log( - `βœ… ${provider} token refreshed, new expiry in ${data.expires_in}s` - ); - return tokens; + console.log(`βœ… ${provider} token refreshed`); + return data; } catch (err) { state.error.value = err.message; console.error(`${provider} token refresh error:`, err); @@ -367,23 +327,9 @@ export function useOAuth(provider = 'challonge') { * @throws {Error} If not authenticated */ async function getValidToken() { - if (!state.tokens.value) { - throw new Error(`Not authenticated with ${provider}`); - } - - // Calculate time until expiry - const expiresIn = state.tokens.value.expires_at - Date.now(); - const fiveMinutes = 5 * 60 * 1000; - - // Refresh if expired or expiring within 5 minutes - if (expiresIn < fiveMinutes) { - console.log( - `πŸ”„ ${provider} token expiring in ${Math.floor(expiresIn / 1000)}s, refreshing...` - ); - await refreshTokenFn(); - } - - return state.tokens.value.access_token; + throw new Error( + `No-split-brain: ${provider} OAuth token is not accessible in the browser` + ); } /** @@ -392,10 +338,12 @@ export function useOAuth(provider = 'challonge') { */ function logout() { state.tokens.value = null; - localStorage.removeItem(state.storageKey); sessionStorage.removeItem('oauth_state'); sessionStorage.removeItem('oauth_provider'); sessionStorage.removeItem('oauth_return_to'); + if (oauthConfig.disconnectEndpoint) { + apiClient.post(oauthConfig.disconnectEndpoint, {}).catch(() => {}); + } console.log(`πŸ‘‹ ${provider} logged out`); } diff --git a/code/websites/pokedex.online/src/config/platforms.js b/code/websites/pokedex.online/src/config/platforms.js index 110eb35..c552e5b 100644 --- a/code/websites/pokedex.online/src/config/platforms.js +++ b/code/websites/pokedex.online/src/config/platforms.js @@ -26,8 +26,9 @@ export const PLATFORMS = { label: 'OAuth 2.0', description: 'User token authentication for v2.1 API', endpoint: 'https://api.challonge.com/oauth/authorize', - tokenEndpoint: '/api/oauth/token', - refreshEndpoint: '/api/oauth/refresh', + tokenEndpoint: '/oauth/challonge/exchange', + refreshEndpoint: '/oauth/challonge/refresh', + disconnectEndpoint: '/oauth/challonge/disconnect', scopes: ['tournaments:read', 'tournaments:write'], storageKey: 'challonge_oauth_tokens' }, @@ -52,8 +53,9 @@ export const PLATFORMS = { label: 'OAuth 2.0', description: 'Verify your Discord identity', endpoint: 'https://discord.com/api/oauth2/authorize', - tokenEndpoint: '/api/oauth/token', - refreshEndpoint: '/api/oauth/refresh', + tokenEndpoint: '/oauth/discord/exchange', + refreshEndpoint: '/oauth/discord/refresh', + disconnectEndpoint: '/oauth/discord/disconnect', scopes: ['identify'], storageKey: 'discord_oauth_tokens', userEndpoint: 'https://discord.com/api/users/@me' diff --git a/code/websites/pokedex.online/src/main.js b/code/websites/pokedex.online/src/main.js index 617f22c..2aa86a5 100644 --- a/code/websites/pokedex.online/src/main.js +++ b/code/websites/pokedex.online/src/main.js @@ -20,4 +20,14 @@ app.use(router); app.use(VueVirtualScroller); app.directive('highlight', vHighlight); -app.mount('#app'); +// Prime session + CSRF cookies (server uses SID cookies and double-submit CSRF) +(async () => { + try { + await fetch('/api/session/init', { credentials: 'include' }); + await fetch('/api/session/csrf', { credentials: 'include' }); + } catch (err) { + console.warn('Failed to initialize session/CSRF cookies:', err); + } finally { + app.mount('#app'); + } +})(); diff --git a/code/websites/pokedex.online/src/services/challonge-v1.service.js b/code/websites/pokedex.online/src/services/challonge-v1.service.js index f2bebaf..90409f9 100644 --- a/code/websites/pokedex.online/src/services/challonge-v1.service.js +++ b/code/websites/pokedex.online/src/services/challonge-v1.service.js @@ -37,7 +37,8 @@ export function createChallongeV1Client(apiKey) { ? endpoint.slice(1) : endpoint; const url = new URL(`${baseURL}${cleanEndpoint}`, window.location.origin); - url.searchParams.append('api_key', apiKey); + // No-split-brain: do not send api_key from the browser. + // Backend proxy injects the per-session stored API key. if (options.params) { Object.entries(options.params).forEach(([key, value]) => { diff --git a/code/websites/pokedex.online/src/services/challonge-v2.1.service.js b/code/websites/pokedex.online/src/services/challonge-v2.1.service.js index e91fe43..6e09460 100644 --- a/code/websites/pokedex.online/src/services/challonge-v2.1.service.js +++ b/code/websites/pokedex.online/src/services/challonge-v2.1.service.js @@ -50,14 +50,10 @@ export const ScopeType = { * @returns {Object} API client with methods */ export function createChallongeV2Client(auth, options = {}) { - const { token, type = AuthType.API_KEY } = auth; + const { token, type = AuthType.API_KEY } = auth || {}; const { communityId: defaultCommunityId, debug = false } = options; const baseURL = getBaseURL(); - if (!token) { - throw new Error('Authentication token is required'); - } - // Request tracking for debug mode let requestCount = 0; @@ -109,16 +105,15 @@ export function createChallongeV2Client(auth, options = {}) { ...headers }; - // Add authorization header - if (type === AuthType.OAUTH) { - requestHeaders['Authorization'] = `Bearer ${token}`; - } else { - requestHeaders['Authorization'] = token; - } + // No-split-brain: never send Challonge tokens from the browser. + // Backend proxy derives auth from the per-session SID cookie and the Authorization-Type hint. + // (Token is intentionally ignored here.) const fetchOptions = { method, - headers: requestHeaders + headers: requestHeaders, + credentials: 'include', + cache: 'no-store' }; if (body && method !== 'GET') { @@ -149,16 +144,29 @@ export function createChallongeV2Client(auth, options = {}) { return null; } + // Parse response body (prefer JSON when declared) + const contentType = response.headers.get('content-type') || ''; let data; try { - data = await response.json(); + if (contentType.includes('application/json')) { + data = await response.json(); + } else { + const text = await response.text(); + // Best-effort: if it's actually JSON but wrong content-type, parse it. + data = text; + if (text && (text.startsWith('{') || text.startsWith('['))) { + try { + data = JSON.parse(text); + } catch { + // keep as text + } + } + } } catch (parseError) { - // If JSON parsing fails, create an error with the status - if (debug) + if (debug) { console.error('[Challonge v2.1 JSON Parse Error]', parseError); - const error = new Error( - `HTTP ${response.status}: Failed to parse response` - ); + } + const error = new Error(`HTTP ${response.status}: Failed to parse response`); error.status = response.status; throw error; } @@ -186,14 +194,24 @@ export function createChallongeV2Client(auth, options = {}) { .join('\n'); const error = new Error(errorMessage); + error.status = response.status; error.errors = errorDetails; error.response = data; throw error; } // Handle non-JSON:API error format - const error = new Error( - `HTTP ${response.status}: ${data.message || response.statusText}` - ); + const messageFromBody = + typeof data === 'string' + ? data + : data?.error || data?.message || response.statusText; + + const fallbackMessage = response.statusText || 'Request failed'; + const finalMessage = + typeof messageFromBody === 'string' && messageFromBody.trim().length === 0 + ? fallbackMessage + : messageFromBody || fallbackMessage; + + const error = new Error(`HTTP ${response.status}: ${finalMessage}`); error.status = response.status; error.response = data; throw error; diff --git a/code/websites/pokedex.online/src/utilities/api-client.js b/code/websites/pokedex.online/src/utilities/api-client.js index 19148aa..d7cb462 100644 --- a/code/websites/pokedex.online/src/utilities/api-client.js +++ b/code/websites/pokedex.online/src/utilities/api-client.js @@ -15,6 +15,19 @@ const activeRequests = new Map(); +function getCookie(name) { + if (typeof document === 'undefined') return null; + const parts = document.cookie.split(';').map(p => p.trim()); + for (const part of parts) { + if (part.startsWith(`${name}=`)) { + return decodeURIComponent(part.slice(name.length + 1)); + } + } + return null; +} + +const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); + /** * Create an API client with configuration * @param {Object} config - Client configuration @@ -68,13 +81,30 @@ export function createApiClient(config = {}) { async function makeRequest(url, options) { const { retries = maxRetries, ...fetchOptions } = options; + const method = (fetchOptions.method || 'GET').toUpperCase(); + // Merge headers const headers = { - 'Content-Type': 'application/json', ...defaultHeaders, ...fetchOptions.headers }; + // Default JSON content type unless caller overrides / uses FormData + const hasBody = fetchOptions.body !== undefined && fetchOptions.body !== null; + const isFormData = + typeof FormData !== 'undefined' && fetchOptions.body instanceof FormData; + if (hasBody && !isFormData && !headers['Content-Type']) { + headers['Content-Type'] = 'application/json'; + } + + // Double-submit CSRF: mirror cookie into header for state-changing requests + if (!SAFE_METHODS.has(method)) { + const csrf = getCookie('pdx_csrf'); + if (csrf && !headers['X-CSRF-Token']) { + headers['X-CSRF-Token'] = csrf; + } + } + // Create abort controller for timeout const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); @@ -88,6 +118,8 @@ export function createApiClient(config = {}) { let requestOptions = { ...fetchOptions, headers, + credentials: fetchOptions.credentials || 'include', + cache: fetchOptions.cache || 'no-store', signal: controller.signal }; if (onRequest) { @@ -127,6 +159,11 @@ export function createApiClient(config = {}) { throw error; } + // Some endpoints may return 204/304 (no body). Avoid JSON parse errors. + if (processedResponse.status === 204 || processedResponse.status === 304) { + return null; + } + // Parse response const contentType = processedResponse.headers.get('content-type'); if (contentType?.includes('application/json')) { diff --git a/code/websites/pokedex.online/src/utilities/tournament-query.js b/code/websites/pokedex.online/src/utilities/tournament-query.js index 19097f4..67ffd15 100644 --- a/code/websites/pokedex.online/src/utilities/tournament-query.js +++ b/code/websites/pokedex.online/src/utilities/tournament-query.js @@ -39,17 +39,9 @@ export async function queryAllTournaments(client, options = {}) { communityId, page = 1, per_page = 25, - states = [ - 'pending', - 'checking_in', - 'checked_in', - 'accepting_predictions', - 'group_stages_underway', - 'group_stages_finalized', - 'underway', - 'awaiting_review', - 'complete' - ], + // Challonge v2.1 tournament list supports these canonical states. + // (Older v1-style states like "checking_in" are not accepted.) + states = ['pending', 'in_progress', 'ended'], includeCommunities = false } = options; @@ -61,6 +53,8 @@ export async function queryAllTournaments(client, options = {}) { per_page }; + let firstAuthError = null; + // Query all states in parallel const promises = states.map(state => client.tournaments @@ -69,6 +63,10 @@ export async function queryAllTournaments(client, options = {}) { state }) .catch(err => { + const status = err?.status || err?.errors?.[0]?.status; + if ((status === 401 || status === 403) && !firstAuthError) { + firstAuthError = err; + } console.error(`Error querying ${state} tournaments:`, err); return []; }) @@ -77,6 +75,16 @@ export async function queryAllTournaments(client, options = {}) { // Wait for all requests const results = await Promise.all(promises); + // If we hit an auth error and fetched nothing at all, surface the auth error + // so the UI can prompt to connect/reconnect Challonge. + const totalCount = results.reduce( + (sum, arr) => sum + (Array.isArray(arr) ? arr.length : 0), + 0 + ); + if (firstAuthError && totalCount === 0) { + throw firstAuthError; + } + // Flatten and deduplicate by tournament ID const tournamentMap = new Map(); results.forEach(tournamentArray => { diff --git a/code/websites/pokedex.online/src/views/ApiKeyManager.vue b/code/websites/pokedex.online/src/views/ApiKeyManager.vue index 13176d9..6743751 100644 --- a/code/websites/pokedex.online/src/views/ApiKeyManager.vue +++ b/code/websites/pokedex.online/src/views/ApiKeyManager.vue @@ -94,15 +94,15 @@