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