// Persistent account authentication. Provider credentials stay in SQLite; the browser receives only a signed JWT. import { DatabaseSync } from 'node:sqlite'; import { createHmac, createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'; import { TokenBucket, isLocalIp, sanitizeName } from '../net.js'; import { roleKey, hasRoleIdentity } from '../../shared/accountIdentity.js'; export const ACCOUNT_PREFIX = '@account/'; export const ACCOUNT_COOKIE = 'sp_account'; export const LOGIN_TTL = 30 * 24 * 60 * 60; const ISSUER = 'stronghold-account', AUDIENCE = 'stronghold-game'; const json64 = (value) => Buffer.from(JSON.stringify(value)).toString('base64url'); const equal = (a, b) => a.length === b.length && timingSafeEqual(a, b); // 128 digest bits in base64url keep `n:` within g.watch's existing 32-character field limit. export const accountPlayerId = (role) => 'a_' + createHash('sha256').update(roleKey(role)).digest().subarray(0, 16).toString('base64url'); export function createAccountAuth({ file, service, registry, secret, publicOrigin, diagnosticKey, trustProxy = 'auto', now = Date.now, onExpire }) { const db = new DatabaseSync(file); db.exec(`PRAGMA journal_mode=WAL; CREATE TABLE IF NOT EXISTS account_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL); CREATE TABLE IF NOT EXISTS account_logins ( id TEXT PRIMARY KEY, subject TEXT NOT NULL, role_key TEXT, expires INTEGER NOT NULL, revoked INTEGER NOT NULL DEFAULT 0 );`); if (secret && Buffer.byteLength(secret) < 32) throw new Error('SP_AUTH_SECRET must contain at least 32 bytes'); db.prepare('INSERT OR IGNORE INTO account_meta(key,value) VALUES (?,?)').run('signing_key', randomBytes(48).toString('base64url')); const key = Buffer.from(secret || db.prepare('SELECT value FROM account_meta WHERE key=?').get('signing_key').value); const configuredOrigin = publicOrigin ? new URL(publicOrigin).origin : null; const signature = (value) => createHmac('sha256', key).update(value).digest(); const playable = (role) => hasRoleIdentity(role) && !!sanitizeName(role.nickName); const roleOf = (login) => service.status(ACCOUNT_PREFIX + login.subject).roles.find((role) => playable(role) && roleKey(role) === login.role_key) || null; const active = (login) => login && !login.revoked && login.expires > Math.floor(now() / 1000) && service.status(ACCOUNT_PREFIX + login.subject).bound; const buckets = new Map(); function limited(req) { const id = req.socket?.remoteAddress || '?'; if (!buckets.has(id)) { if (buckets.size >= 4096) buckets.delete(buckets.keys().next().value); buckets.set(id, new TokenBucket(3, 120, now())); } return !buckets.get(id).take(now()); } function originAllowed(req) { if (req.headers['sec-fetch-site'] === 'cross-site') return false; if (!req.headers.origin) return true; // Non-browser clients still need both credentials. const secure = req.socket?.encrypted || (trustProxy !== false && (trustProxy === true || isLocalIp(req.socket?.remoteAddress)) && req.headers['x-forwarded-proto'] === 'https'); return req.headers.origin === (configuredOrigin || `${secure ? 'https' : 'http'}://${req.headers.host}`); } function cookieValue(req) { const entries = (req.headers.cookie || '').split(';').map((part) => part.trim()).filter((part) => part.startsWith(ACCOUNT_COOKIE + '=')); if (entries.length > 1) throw new Error('invalid account cookie'); return entries.length ? entries[0].slice(ACCOUNT_COOKIE.length + 1) : null; } function verify(token) { try { if (typeof token !== 'string' || token.length > 4096) return null; const parts = token.split('.'); if (parts.length !== 3 || parts.some((part) => !/^[A-Za-z0-9_-]+$/.test(part))) return null; const header = JSON.parse(Buffer.from(parts[0], 'base64url').toString()); if (header.alg !== 'HS256' || header.typ !== 'JWT' || Object.keys(header).length !== 2) return null; if (!equal(signature(parts[0] + '.' + parts[1]), Buffer.from(parts[2], 'base64url'))) return null; const claims = JSON.parse(Buffer.from(parts[1], 'base64url').toString()); const at = Math.floor(now() / 1000); if (claims.iss !== ISSUER || claims.aud !== AUDIENCE || !Number.isInteger(claims.iat) || claims.iat > at || !Number.isInteger(claims.exp) || claims.exp <= at || claims.exp <= claims.iat || claims.exp - claims.iat > LOGIN_TTL || typeof claims.sub !== 'string' || typeof claims.jti !== 'string') return null; const login = db.prepare('SELECT * FROM account_logins WHERE id=?').get(claims.jti); return active(login) && login.subject === claims.sub && login.expires === claims.exp && login.role_key === claims.role ? login : null; } catch { return null; } } function authenticate(req) { const token = cookieValue(req); if (!token) return null; const login = verify(token); if (!login) throw new Error('invalid account cookie'); return login; } function setCookie(req, res, login) { const secure = req.socket?.encrypted || configuredOrigin?.startsWith('https:') || req.headers.origin?.startsWith('https:') || (trustProxy !== false && (trustProxy === true || isLocalIp(req.socket?.remoteAddress)) && req.headers['x-forwarded-proto'] === 'https'); let value = '', maxAge = 0; if (login) { const at = Math.floor(now() / 1000); const head = json64({ alg: 'HS256', typ: 'JWT' }); const body = json64({ iss: ISSUER, aud: AUDIENCE, sub: login.subject, jti: login.id, iat: at, exp: login.expires, role: login.role_key }); value = head + '.' + body + '.' + signature(head + '.' + body).toString('base64url'); maxAge = Math.max(0, login.expires - at); } res.setHeader('Set-Cookie', `${ACCOUNT_COOKIE}=${value}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}${secure ? '; Secure' : ''}`); } function publish(player) { const row = service._store.get(player); if (!row || row.status !== 'bound') return null; const roles = service.status(player).roles.filter(playable); const selected = roles.find((role) => role.isDefault) || roles[0]; const login = { id: randomUUID(), subject: randomUUID(), role_key: selected ? roleKey(selected) : null, expires: Math.floor(now() / 1000) + LOGIN_TTL, revoked: 0 }; const { player_id: _player, created_at: _created, updated_at: _updated, ...credentials } = row; service._store.update(ACCOUNT_PREFIX + login.subject, credentials); db.prepare('INSERT INTO account_logins(id,subject,role_key,expires) VALUES (?,?,?,?)') .run(login.id, login.subject, login.role_key, login.expires); service.unbind(player); // A replay of the old transport token cannot issue a fresh login after logout. return login; } function select(login, selectedKey) { const role = service.status(ACCOUNT_PREFIX + login.subject).roles.find((role) => playable(role) && roleKey(role) === selectedKey); if (!role) return null; const old = roleOf(login), session = old && registry.byId(accountPlayerId(old)); if (session?.roomCode && selectedKey !== login.role_key) return null; db.prepare('UPDATE account_logins SET role_key=? WHERE id=?').run(selectedKey, login.id); return { ...login, role_key: selectedKey }; } function ensureSelection(login) { if (roleOf(login)) return login; const roles = service.status(ACCOUNT_PREFIX + login.subject).roles.filter(playable); const role = roles.find((item) => item.isDefault) || roles[0]; const selectedKey = role ? roleKey(role) : null; if (selectedKey === login.role_key) return login; db.prepare('UPDATE account_logins SET role_key=? WHERE id=?').run(selectedKey, login.id); return { ...login, role_key: selectedKey }; } function state(login) { const status = service.status(ACCOUNT_PREFIX + login.subject), role = roleOf(login); return { ...status, loginId: login.id, selectedRoleKey: login.role_key, gamePlayerId: role ? accountPlayerId(role) : null }; } function roleSession(role) { const id = accountPlayerId(role); let session = registry.byId(id); if (session && registry.isExpired(session, registry.now())) { registry.remove(session); onExpire?.(session); // Remove the old seat before reusing its stable account playerId. session = null; } if (!session) { session = registry.create(sanitizeName(role.nickName)); if (!session) throw new Error('server full'); registry.byPlayerId.delete(session.playerId); session.playerId = id; registry.byPlayerId.set(id, session); } return session; } function revoke(login) { db.prepare('UPDATE account_logins SET revoked=1 WHERE subject=?').run(login.subject); service.unbind(ACCOUNT_PREFIX + login.subject); } const gamePolicy = { connection(req) { let login = null; try { login = authenticate(req); } catch { /* Expired cookies can still establish a login-only transport. */ } return { login, allowed: originAllowed(req) }; }, hello(conn, message) { if (!conn.account.allowed) return null; const login = conn.account.login; if (login) { const current = db.prepare('SELECT * FROM account_logins WHERE id=?').get(login.id); const role = active(current) && current.role_key === login.role_key && roleOf(current); const target = conn.session || (message.token && registry.byToken(message.token)); // Merely restoring the cookie or viewing the title must not take over another device. A current role token // resumes normally; the authenticated enter/resume POST supplies it after an explicit user action. if (role && target?.playerId === accountPlayerId(role)) return { ...message, name: sanitizeName(role.nickName), token: target.token }; } // Anonymous hellos may establish transport for login, but never resume an authenticated game identity. if (conn.session?.playerId.startsWith('a_')) return null; const target = message.token && registry.byToken(message.token); return { ...message, token: target?.playerId.startsWith('a_') ? undefined : message.token }; }, attached(conn, { repeat }) { if (!repeat && conn.session?.playerId.startsWith('a_')) registry.rotateToken(conn.session); }, authorized(conn) { const captured = conn.account?.login; if (!captured) return false; const current = db.prepare('SELECT * FROM account_logins WHERE id=?').get(captured.id); const role = active(current) && current.role_key === captured.role_key && roleOf(current); return !!role && conn.session?.playerId === accountPlayerId(role); }, }; return { authenticate, setCookie, publish, select, ensureSelection, state, roleOf, revoke, gamePolicy, originAllowed, limited, gameSession(login) { const role = roleOf(login); return role ? roleSession(role) : null; }, diagnosticAllowed(req) { const supplied = req.headers['x-sp-diagnostic-key']; return !!diagnosticKey && typeof supplied === 'string' && equal(Buffer.from(supplied), Buffer.from(diagnosticKey)); }, close() { db.close(); }, }; }