/** * Signed OAuth state (userId) to prevent tampering when OAUTH_STATE_SECRET is set. */ import crypto from 'crypto' import { config } from '../config/index.mjs' export function buildOAuthState(userId) { const secret = config.oauthStateSecret if (!secret) { return JSON.stringify({ userId }) } const body = JSON.stringify({ userId, exp: Date.now() + 15 * 60 * 1000 }) const sig = crypto.createHmac('sha256', secret).update(body).digest('hex') return Buffer.from(JSON.stringify({ b: body, s: sig })).toString('base64url') } export function parseOAuthState(state) { if (!state || typeof state !== 'string') { throw new Error('invalid_state') } const trimmed = state.trim() const secret = config.oauthStateSecret if (trimmed.startsWith('{')) { const legacy = JSON.parse(trimmed) if (!legacy.userId) throw new Error('invalid_state') if (secret) { throw new Error('unsigned_state_rejected') } return { userId: legacy.userId } } const raw = Buffer.from(trimmed, 'base64url').toString('utf8') const outer = JSON.parse(raw) if (!outer.b || !outer.s) throw new Error('invalid_state') if (!secret) throw new Error('signed_state_requires_secret') const expected = crypto.createHmac('sha256', secret).update(outer.b).digest('hex') if (outer.s !== expected) throw new Error('invalid_state_signature') const payload = JSON.parse(outer.b) if (payload.exp != null && payload.exp < Date.now()) throw new Error('state_expired') if (!payload.userId) throw new Error('invalid_state') return { userId: payload.userId } }