- 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.
225 lines
6.0 KiB
JavaScript
225 lines
6.0 KiB
JavaScript
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
|
|
};
|
|
}
|