Files
memory-infrastructure-palace/code/websites/pokedex.online/server/services/oauth-token-store.js
FragginWagon 700c1cbbbe 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.
2026-02-03 12:50:11 -05:00

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
};
}