CI / test (ubuntu-latest, node 22) (push) Failing after 33m10s
CI / test (ubuntu-latest, node 24) (push) Failing after 34m41s
CI / tooling (lint, imports, typecheck) (push) Failing after 36m11s
CI / test (windows-latest, node 22) (push) Canceled after 0s
CI / test (windows-latest, node 24) (push) Canceled after 0s
188 lines
11 KiB
JavaScript
188 lines
11 KiB
JavaScript
// 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:<playerId>` 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(); },
|
|
};
|
|
}
|