// Sign-in by hand-off from InstantAdPay. There is no sign-up on PolHunter and never a mailer: // a member clicks PolHunter inside their IAP dashboard, IAP signs a short-lived token with the // shared secret, and this module turns it into a PolHunter session. // // Token: base64url(JSON payload) + '.' + base64url(HMAC-SHA256(secret, payload)). // Payload: { memberId, email, wallet, username, iat, exp, nonce }. Single use, five minutes. // Sessions: random id -> { memberId, email, wallet, username, since }, on the volume so a // restart keeps people signed in. Cookie is HttpOnly, SameSite=Lax, Secure. 'use strict'; const crypto = require('crypto'); const store = require('./store'); const SECRET = String(process.env.HUNT_SSO_SECRET || '').trim(); const SESSION_TTL = 30 * 86400000; const used = new Map(); // nonce -> exp, single-use guard (in memory is fine: tokens live five minutes) const b64u = b => Buffer.from(b).toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_'); const unb64u = s => Buffer.from(String(s).replace(/-/g, '+').replace(/_/g, '/'), 'base64'); const sign = payload => b64u(crypto.createHmac('sha256', SECRET).update(payload).digest()); function enabled() { return SECRET.length >= 32; } // what IAP does to mint one (kept here so both sides read the same definition) function mint(claims) { const payload = JSON.stringify(Object.assign({ iat: Date.now(), exp: Date.now() + 5 * 60000, nonce: crypto.randomBytes(12).toString('hex') }, claims)); return b64u(payload) + '.' + sign(payload); } function verify(token) { if (!enabled()) return { error: 'Sign-in from InstantAdPay is not configured on this server.' }; const [p, sig] = String(token || '').split('.'); if (!p || !sig) return { error: 'That sign-in link is not valid.' }; let payload; try { payload = unb64u(p).toString('utf8'); } catch (e) { return { error: 'That sign-in link is not valid.' }; } const want = sign(payload); if (want.length !== sig.length || !crypto.timingSafeEqual(Buffer.from(want), Buffer.from(sig))) return { error: 'That sign-in link is not valid.' }; let c; try { c = JSON.parse(payload); } catch (e) { return { error: 'That sign-in link is not valid.' }; } if (!c.exp || Date.now() > c.exp) return { error: 'That sign-in link has expired. Open PolHunter from your InstantAdPay dashboard again.' }; if (!c.memberId || !c.email) return { error: 'That sign-in link is missing your account.' }; for (const [n, exp] of used) if (exp < Date.now()) used.delete(n); if (used.has(c.nonce)) return { error: 'That sign-in link was already used.' }; used.set(c.nonce, c.exp); return { ok: true, claims: c }; } function startSession(claims) { const id = crypto.randomBytes(24).toString('hex'); store.update('sessions', {}, s => { const now = Date.now(); for (const k of Object.keys(s)) if ((s[k].since || 0) + SESSION_TTL < now) delete s[k]; s[id] = { memberId: Number(claims.memberId), email: String(claims.email).toLowerCase(), wallet: claims.wallet ? String(claims.wallet).toLowerCase() : null, username: claims.username || null, since: now }; return s; }); return id; } function cookie(id) { return 'ph.sid=' + id + '; Path=/; Max-Age=' + Math.floor(SESSION_TTL / 1000) + '; HttpOnly; SameSite=Lax; Secure'; } function clearCookie() { return 'ph.sid=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax; Secure'; } function fromRequest(req) { const m = /(?:^|;\s*)ph\.sid=([a-f0-9]{48})/.exec(req.headers.cookie || ''); if (!m) return null; const s = store.read('sessions', {})[m[1]]; if (!s || (s.since || 0) + SESSION_TTL < Date.now()) return null; return Object.assign({ sid: m[1] }, s); } function endSession(sid) { store.update('sessions', {}, s => { delete s[sid]; return s; }); } // a member's wallet can change on IAP; the next hand-off refreshes it here function refresh(sid, claims) { store.update('sessions', {}, s => { if (s[sid]) { s[sid].wallet = claims.wallet ? String(claims.wallet).toLowerCase() : null; s[sid].username = claims.username || s[sid].username; } return s; }); } module.exports = { enabled, mint, verify, startSession, cookie, clearCookie, fromRequest, endSession, refresh };