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