feat: account system, OIDC, meta-progression research tree, and automated tests

Account & Persistence Layer (server/db.mjs)
- SQLite via node:sqlite DatabaseSync with WAL mode, foreign keys, and
  synchronous=NORMAL for microsecond response times
- Users table: UUID primary key, unique lowercase username, scrypt-hashed
  password, optional OIDC sub/issuer, crystal balance, timestamps
- Sessions table: 64-byte random hex token, FK to users, configurable TTL
  with automatic expiry cleanup every 10 minutes
- User upgrades table: composite PK (user_id, upgrade_id), level tracking,
  ON CONFLICT DO UPDATE for idempotent merges
- User stats table: games played/won, total kills/score, highest wave
- Password hashing: crypto.scrypt with 16-byte salt and 64-byte derived
  key, constant-time comparison via crypto.timingSafeEqual
- Guest merge: caps crystals at 100,000, caps levels to defined maxLevels,
  uses MAX(level, new) to preserve higher account levels, ignores unknown
  upgrade IDs, transactional with BEGIN IMMEDIATE/COMMIT/ROLLBACK
- Atomic crystal purchasing: subtracts cost only if balance sufficient,
  upgrades level within transaction, returns full user object on success

Meta-Progression Definitions (shared/meta-upgrades.mjs)
- Single source of truth shared between server (authoritative validation)
  and client (talent tree UI)
- 3 branches: Economy (start_gold, wave_bonus, obstacle_discount),
  Defense (bonus_lives, shockwave, fortress_shield),
  Towers (tower_range, tower_speed, dot_potency)
- 9 upgrades with 1–5 levels each, progressive cost curves
- calcCrystalsEarned(wave, score, win): base 2.5 per wave, +40 for victory,
  +floor(score/250), minimum 1 crystal per game

REST API (server/server.mjs)
- /api/auth/register: username 3–16 chars a-z0-9_-; password min 8 chars;
  case-insensitive uniqueness; auto-creates user_stats row; returns
  session cookie (HttpOnly, SameSite=Lax, Secure when HTTPS)
- /api/auth/login: constant-time username lookup via scrypt verify;
  rate-limited 20 auth attempts/IP/minute
- /api/auth/logout: deletes session server-side, clears cookie
- /api/auth/me: returns publicUser (id, username, crystals, upgrades,
  stats, oidc flag) or null
- /api/auth/oidc/login: PKCE Authorization Code flow with SHA-256 S256
  challenge/verifier; discovers .well-known/openid-configuration;
  verifies RS256 id_token signature via JWKS public key; validates
  issuer, audience, and expiry; finds or creates user by OIDC sub/issuer
- /api/auth/oidc/callback: exchanges code for tokens, verifies id_token,
  issues session cookie, redirects to /?auth=ok or /?auth=error
- /api/upgrades/buy: validates upgrade ID against META_UPGRADES,
  checks current level < maxLevel, deducts cost from crystals
- /api/game/finish: server-authoritative crystal calculation;
  bounds-checks inputs (wave ≤ 9999, score ≤ 10M, kills ≤ 1M);
  updates user_stats (games_played, games_won, total_kills, total_score,
  highest_wave via MAX)
- /api/auth/merge-guest: one-time guest-to-account crystal and upgrade
  migration with level caps
- /api/config: public endpoint exposing OIDC enabled state and button label
- Security: CSP header on all responses, X-Content-Type-Options: nosniff,
  X-Frame-Options: DENY, Referrer-Policy: no-referrer, cache-control
  no-store on API responses, path-traversal protection on static serving

Multiplayer Fairness
- Meta-upgrades (start_gold, bonus_lives, tower_range, tower_speed,
  dot_potency, wave_bonus, obstacle_discount, shockwave, fortress_shield)
  applied only in solo campaign mode
- Co-op and Duel multiplayer sessions reset all meta buffs to zero,
  preserving lockstep determinism and competitive balance
- Multiplayer results set crystalsEarned: 0 to prevent duplicate rewards

Frontend (Vue 3 + TypeScript)
- AuthModal.vue: username/password login and registration form with
  validation, OIDC single sign-on button (shown when configured),
  guest-to-account upgrade on first login
- UserProfileBar.vue: top-bar indicator showing crystal count (💎),
  user display name, research and login/logout buttons, reactive
  auth state via auth controller
- ResearchTree.vue: interactive talent tree modal with 3 branches,
  per-upgrade cost/level display, purchase confirmation, disabled
  state for unaffordable/maxed upgrades, branch icons and descriptions
- auth.ts: reactive controller managing login, registration, OIDC
  redirect detection (?auth=ok/?auth=error), guest profile migration
  on first login, upgrade purchasing, and game result reporting
- meta.ts: frontend helpers for branch definitions, upgrade costs,
  and guest profile persistence in localStorage
- engine.ts: solo meta bonus application (start_gold, bonus_lives,
  tower_range, tower_speed, dot_potency, wave_bonus, obstacle_discount,
  shockwave, fortress_shield); crystal rewards in finish() path
- mpgame.ts: meta buff reset in multiplayer sessions; crystalsEarned: 0
- sound.ts: shield sound synthesis for fortress_shield absorption
- store.ts: auth state, research tree toggle, upgrade snapshot
- types.ts: SfxName extended with "shield" sound

Docker Configuration
- Multi-stage build: node:22-alpine build → node:22-alpine runtime
  (production deps only: ws)
- VOLUME /app/data for persistent SQLite database
- HEALTHCHECK on /health endpoint
- docker-compose.yml: port 3001, persistent ./data volume,
  commented OIDC environment variables (OIDC_ENABLED, OIDC_ISSUER,
  OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_REDIRECT_URI, OIDC_BUTTON_LABEL)
- .gitignore: data/, *.db, *.db-journal, *.db-wal, *.db-shm

Automated Tests (npm test)
- scripts/test-db.mjs (17 unit tests): isolated SQLite persistence –
  scrypt hash/verify roundtrip, timing-safe constant-time comparison,
  user creation with lowercase enforcement, UNIQUE constraint, session
  create/get/delete lifecycle, expired session invalidation, crystal
  addition, 5-level upgrade cost progression with max-level guard,
  guest merge (crystal cap 100k, level cap, MAX() semantics, unknown
  upgrade rejection), game result stats accumulation, calcCrystalsEarned
  formula verification
- scripts/test-auth.mjs (31 integration tests): spawns real server with
  isolated DATA_DIR, exercises full REST flow – register validation
  (username too short, password too short, duplicate, case-insensitive),
  login (wrong password, correct), session cookie attributes (HttpOnly,
  SameSite=Lax, Path=/), /me endpoint, upgrade purchase (insufficient
  crystals, unknown ID, successful purchase), game finish rewards
  (victory, defeat, negative values clamped to 1 crystal minimum),
  guest merge (crystals, level caps, unknown upgrades), logout,
  OIDC-disabled endpoints (400), unauthenticated guards (401), 404
  routing, CSP header on static files, rate limiting (429 after 20+
  auth attempts per minute), and persistence across server restart
  (kill + respawn with same DATA_DIR preserves all state)
This commit is contained in:
Tronax 2026-08-16 16:22:38 +02:00
parent ce2484bb8d
commit 1a9ac5bf45
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
24 changed files with 2216 additions and 12 deletions

294
server/db.mjs Normal file
View file

@ -0,0 +1,294 @@
import { DatabaseSync } from 'node:sqlite'
import fs from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'data')
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true })
}
const DB_PATH = path.join(DATA_DIR, 'trxtd.db')
const db = new DatabaseSync(DB_PATH)
// Performance optimizations (WAL mode, normal synchronous)
db.exec(`
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
display_name TEXT NOT NULL,
password_hash TEXT,
oidc_sub TEXT UNIQUE,
oidc_issuer TEXT,
crystals INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
last_login INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS user_upgrades (
user_id TEXT NOT NULL,
upgrade_id TEXT NOT NULL,
level INTEGER NOT NULL DEFAULT 1,
unlocked_at INTEGER NOT NULL,
PRIMARY KEY(user_id, upgrade_id),
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS user_stats (
user_id TEXT PRIMARY KEY,
games_played INTEGER NOT NULL DEFAULT 0,
games_won INTEGER NOT NULL DEFAULT 0,
total_kills INTEGER NOT NULL DEFAULT 0,
total_score INTEGER NOT NULL DEFAULT 0,
highest_wave INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_users_oidc ON users(oidc_sub, oidc_issuer);
`)
export function closeDb() {
db.close()
}
// Helper: Password hashing using scrypt
export async function hashPassword(password) {
const salt = crypto.randomBytes(16).toString('hex')
return new Promise((resolve, reject) => {
crypto.scrypt(password, salt, 64, (err, derivedKey) => {
if (err) return reject(err)
resolve(`${salt}:${derivedKey.toString('hex')}`)
})
})
}
export async function verifyPassword(password, storedHash) {
if (!storedHash || !storedHash.includes(':')) return false
const [salt, key] = storedHash.split(':')
return new Promise((resolve, reject) => {
crypto.scrypt(password, salt, 64, (err, derivedKey) => {
if (err) return reject(err)
const keyBuffer = Buffer.from(key, 'hex')
resolve(crypto.timingSafeEqual(derivedKey, keyBuffer))
})
})
}
// User operations
export function createUserLocal(username, displayName, passwordHash) {
const id = crypto.randomUUID()
const now = Date.now()
const insertUser = db.prepare(`
INSERT INTO users (id, username, display_name, password_hash, crystals, created_at, last_login)
VALUES (?, ?, ?, ?, 0, ?, ?)
`)
insertUser.run(id, username.toLowerCase(), displayName, passwordHash, now, now)
const insertStats = db.prepare(`
INSERT INTO user_stats (user_id, games_played, games_won, total_kills, total_score, highest_wave)
VALUES (?, 0, 0, 0, 0, 0)
`)
insertStats.run(id)
return getUserById(id)
}
export function findOrCreateUserOidc(oidcSub, oidcIssuer, preferredUsername, displayName) {
const findStmt = db.prepare(`SELECT * FROM users WHERE oidc_sub = ? AND oidc_issuer = ?`)
let user = findStmt.get(oidcSub, oidcIssuer)
const now = Date.now()
if (user) {
db.prepare(`UPDATE users SET last_login = ?, display_name = ? WHERE id = ?`).run(now, displayName || user.display_name, user.id)
return getUserById(user.id)
}
// Create unique username if taken
let cleanUsername = (preferredUsername || 'user').toLowerCase().replace(/[^a-z0-9_-]/g, '').slice(0, 16) || 'player'
let finalUsername = cleanUsername
let counter = 1
while (getUserByUsername(finalUsername)) {
finalUsername = `${cleanUsername}${counter++}`
}
const id = crypto.randomUUID()
const insertUser = db.prepare(`
INSERT INTO users (id, username, display_name, oidc_sub, oidc_issuer, crystals, created_at, last_login)
VALUES (?, ?, ?, ?, ?, 0, ?, ?)
`)
insertUser.run(id, finalUsername, displayName || finalUsername, oidcSub, oidcIssuer, now, now)
const insertStats = db.prepare(`
INSERT INTO user_stats (user_id, games_played, games_won, total_kills, total_score, highest_wave)
VALUES (?, 0, 0, 0, 0, 0)
`)
insertStats.run(id)
return getUserById(id)
}
export function getUserById(id) {
const user = db.prepare(`SELECT id, username, display_name, oidc_sub, crystals, created_at, last_login FROM users WHERE id = ?`).get(id)
if (!user) return null
const upgrades = db.prepare(`SELECT upgrade_id, level FROM user_upgrades WHERE user_id = ?`).all(id)
const upgradeMap = {}
for (const u of upgrades) {
upgradeMap[u.upgrade_id] = u.level
}
const stats = db.prepare(`SELECT * FROM user_stats WHERE user_id = ?`).get(id) || {
games_played: 0,
games_won: 0,
total_kills: 0,
total_score: 0,
highest_wave: 0,
}
return {
...user,
upgrades: upgradeMap,
stats: {
gamesPlayed: stats.games_played,
gamesWon: stats.games_won,
totalKills: stats.total_kills,
totalScore: stats.total_score,
highestWave: stats.highest_wave,
},
}
}
export function getUserByUsername(username) {
return db.prepare(`SELECT * FROM users WHERE username = ?`).get(username.toLowerCase())
}
// Session operations
export function createSession(userId, ttlDays = 30) {
const token = crypto.randomBytes(32).toString('hex')
const now = Date.now()
const expiresAt = now + ttlDays * 24 * 60 * 60 * 1000
db.prepare(`INSERT INTO sessions (token, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)`).run(token, userId, expiresAt, now)
return { token, expiresAt }
}
export function getSession(token) {
if (!token) return null
const now = Date.now()
const row = db.prepare(`
SELECT s.token, s.user_id, s.expires_at, u.username, u.display_name
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token = ? AND s.expires_at > ?
`).get(token, now)
return row || null
}
export function deleteSession(token) {
if (token) {
db.prepare(`DELETE FROM sessions WHERE token = ?`).run(token)
}
}
export function cleanExpiredSessions() {
const now = Date.now()
db.prepare(`DELETE FROM sessions WHERE expires_at <= ?`).run(now)
}
// Meta-Progression operations
export function addCrystals(userId, amount) {
db.prepare(`UPDATE users SET crystals = crystals + ? WHERE id = ?`).run(amount, userId)
return getUserById(userId)
}
export function buyUpgrade(userId, upgradeId, cost, maxLevel = 5) {
const user = getUserById(userId)
if (!user || user.crystals < cost) return { ok: false, error: 'Nicht genügend Kristalle.' }
const currentLevel = user.upgrades[upgradeId] || 0
if (currentLevel >= maxLevel) return { ok: false, error: 'Upgrade bereits auf Maximalstufe.' }
const nextLevel = currentLevel + 1
const now = Date.now()
db.exec('BEGIN IMMEDIATE')
try {
db.prepare(`UPDATE users SET crystals = crystals - ? WHERE id = ? AND crystals >= ?`).run(cost, userId, cost)
db.prepare(`
INSERT INTO user_upgrades (user_id, upgrade_id, level, unlocked_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(user_id, upgrade_id) DO UPDATE SET level = ?
`).run(userId, upgradeId, nextLevel, now, nextLevel)
db.exec('COMMIT')
return { ok: true, user: getUserById(userId) }
} catch (e) {
db.exec('ROLLBACK')
return { ok: false, error: e.message }
}
}
export function mergeGuest(userId, crystals, upgrades, maxLevels) {
const cappedCrystals = Math.max(0, Math.min(100000, Math.floor(Number(crystals) || 0)))
db.exec('BEGIN IMMEDIATE')
try {
if (cappedCrystals > 0) {
db.prepare(`UPDATE users SET crystals = crystals + ? WHERE id = ?`).run(cappedCrystals, userId)
}
if (upgrades && typeof upgrades === 'object') {
for (const [upgradeId, rawLevel] of Object.entries(upgrades)) {
const max = maxLevels[upgradeId]
if (!max) continue
const level = Math.max(0, Math.min(max, Math.floor(Number(rawLevel) || 0)))
if (level <= 0) continue
const now = Date.now()
db.prepare(`
INSERT INTO user_upgrades (user_id, upgrade_id, level, unlocked_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(user_id, upgrade_id) DO UPDATE SET level = MAX(level, ?)
`).run(userId, upgradeId, level, now, level)
}
}
db.exec('COMMIT')
return getUserById(userId)
} catch (e) {
db.exec('ROLLBACK')
throw e
}
}
export function recordGameResult(userId, { win, score, wave, kills, crystalsEarned }) {
const now = Date.now()
db.exec('BEGIN IMMEDIATE')
try {
if (crystalsEarned > 0) {
db.prepare(`UPDATE users SET crystals = crystals + ? WHERE id = ?`).run(crystalsEarned, userId)
}
db.prepare(`
UPDATE user_stats SET
games_played = games_played + 1,
games_won = games_won + ?,
total_kills = total_kills + ?,
total_score = total_score + ?,
highest_wave = MAX(highest_wave, ?)
WHERE user_id = ?
`).run(win ? 1 : 0, kills, score, wave, userId)
db.exec('COMMIT')
return getUserById(userId)
} catch (e) {
db.exec('ROLLBACK')
throw e
}
}

View file

@ -16,8 +16,25 @@
import http from 'node:http'
import { promises as fs } from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'
import { fileURLToPath } from 'node:url'
import { WebSocketServer } from 'ws'
import {
buyUpgrade,
cleanExpiredSessions,
createSession,
createUserLocal,
deleteSession,
findOrCreateUserOidc,
getSession,
getUserById,
getUserByUsername,
hashPassword,
mergeGuest,
recordGameResult,
verifyPassword,
} from './db.mjs'
import { META_UPGRADES, calcCrystalsEarned } from '../shared/meta-upgrades.mjs'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const DIST_DIR = process.env.DIST_DIR || path.join(__dirname, '..', 'dist')
@ -30,6 +47,31 @@ const MAX_WS_TOTAL = 500 // total concurrent websocket connections
const MAX_WS_PER_IP = 20 // concurrent websocket connections per client IP
const MAX_ROOMS = 300 // concurrent rooms
// ------------------------------------------------------------------ accounts & OIDC config
const SESSION_COOKIE = 'trxtd_session'
const OIDC = {
enabled: String(process.env.OIDC_ENABLED || 'false').toLowerCase() === 'true',
issuer: String(process.env.OIDC_ISSUER || '').replace(/\/$/, ''),
clientId: String(process.env.OIDC_CLIENT_ID || ''),
clientSecret: String(process.env.OIDC_CLIENT_SECRET || ''),
redirectUri: String(process.env.OIDC_REDIRECT_URI || ''),
buttonLabel: String(process.env.OIDC_BUTTON_LABEL || 'Mit Single Sign-On anmelden'),
}
/** pending OIDC authorization attempts (state -> pkce verifier), short-lived */
const oidcPending = new Map()
let oidcDiscovery = null
let oidcDiscoveryAt = 0
// periodic session & oidc-state cleanup
setInterval(() => {
cleanExpiredSessions()
const now = Date.now()
for (const [state, entry] of oidcPending.entries()) {
if (now - entry.createdAt > 10 * 60 * 1000) oidcPending.delete(state)
}
}, 10 * 60 * 1000).unref()
const CSP =
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; " +
@ -88,6 +130,316 @@ function originAllowed(req) {
return false
}
// ------------------------------------------------------------------ auth helpers
function parseCookies(req) {
const out = {}
const header = req.headers.cookie
if (!header) return out
for (const part of header.split(';')) {
const idx = part.indexOf('=')
if (idx === -1) continue
out[part.slice(0, idx).trim()] = decodeURIComponent(part.slice(idx + 1).trim())
}
return out
}
function sessionTokenFromReq(req) {
return parseCookies(req)[SESSION_COOKIE] || null
}
function setSessionCookie(res, token, expiresAt, req) {
const proto = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim()
const secure = proto === 'https' || (req.socket?.encrypted ?? false)
const sameSite = 'Lax'
res.setHeader(
'Set-Cookie',
`${SESSION_COOKIE}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=${sameSite}${secure ? '; Secure' : ''}; Expires=${new Date(expiresAt).toUTCString()}`,
)
}
function clearSessionCookie(res) {
res.setHeader('Set-Cookie', `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`)
}
function json(res, status, obj) {
const body = JSON.stringify(obj)
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
})
res.end(body)
}
function readJsonBody(req, maxBytes = 8192) {
return new Promise((resolve, reject) => {
let size = 0
const chunks = []
req.on('data', (c) => {
size += c.length
if (size > maxBytes) {
reject(new Error('body too large'))
req.destroy()
return
}
chunks.push(c)
})
req.on('end', () => {
try {
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'))
} catch {
reject(new Error('invalid json'))
}
})
req.on('error', reject)
})
}
function publicUser(user) {
if (!user) return null
return {
id: user.id,
username: user.username,
displayName: user.display_name,
crystals: user.crystals,
upgrades: user.upgrades,
stats: user.stats,
oidc: Boolean(user.oidc_sub),
}
}
// simple per-IP rate limit for auth endpoints
const authBuckets = new Map()
function authRateLimited(ip) {
const now = Date.now()
const b = authBuckets.get(ip)
if (!b || now > b.resetAt) {
authBuckets.set(ip, { count: 1, resetAt: now + 60000 })
return false
}
b.count++
return b.count > 20 // 20 auth attempts per minute per IP
}
// ------------------------------------------------------------------ OIDC
function base64urlDecode(s) {
return Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64')
}
async function fetchJson(url) {
const res = await fetch(url, { headers: { Accept: 'application/json' } })
if (!res.ok) throw new Error(`fetch ${url} -> ${res.status}`)
return res.json()
}
async function oidcDiscover() {
const now = Date.now()
if (oidcDiscovery && now - oidcDiscoveryAt < 10 * 60 * 1000) return oidcDiscovery
const d = await fetchJson(`${OIDC.issuer}/.well-known/openid-configuration`)
if (!d.authorization_endpoint || !d.token_endpoint) throw new Error('invalid OIDC discovery')
oidcDiscovery = d
oidcDiscoveryAt = now
return d
}
async function verifyIdToken(idToken, discovery) {
const parts = idToken.split('.')
if (parts.length !== 3) throw new Error('malformed id_token')
const [h, p, sig] = parts
const header = JSON.parse(base64urlDecode(h).toString('utf8'))
const payload = JSON.parse(base64urlDecode(p).toString('utf8'))
if (header.alg !== 'RS256') throw new Error('unsupported id_token alg')
const jwks = await fetchJson(discovery.jwks_uri)
const jwk = jwks.keys.find((k) => k.kid === header.kid) || jwks.keys[0]
if (!jwk) throw new Error('no matching JWKS key')
const publicKey = crypto.createPublicKey({ key: jwk, format: 'jwk' })
const ok = crypto.verify('RSA-SHA256', Buffer.from(`${h}.${p}`), publicKey, base64urlDecode(sig))
if (!ok) throw new Error('id_token signature invalid')
if (payload.iss !== OIDC.issuer && payload.iss !== discovery.issuer) throw new Error('issuer mismatch')
const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud]
if (!aud.includes(OIDC.clientId)) throw new Error('audience mismatch')
if (payload.exp && payload.exp * 1000 < Date.now()) throw new Error('id_token expired')
return payload
}
// ------------------------------------------------------------------ REST API
async function handleApi(req, res, url) {
const ip = clientIp(req)
const p = url.pathname
// --- public config (does OIDC exist?) ---
if (p === '/api/config' && req.method === 'GET') {
return json(res, 200, {
oidcEnabled: OIDC.enabled,
oidcLabel: OIDC.buttonLabel,
})
}
// --- current session ---
if (p === '/api/auth/me' && req.method === 'GET') {
const sess = getSession(sessionTokenFromReq(req))
if (!sess) return json(res, 200, { user: null })
return json(res, 200, { user: publicUser(getUserById(sess.user_id)) })
}
// --- logout ---
if (p === '/api/auth/logout' && req.method === 'POST') {
deleteSession(sessionTokenFromReq(req))
clearSessionCookie(res)
return json(res, 200, { ok: true })
}
// --- register (local) ---
if (p === '/api/auth/register' && req.method === 'POST') {
if (authRateLimited(ip)) return json(res, 429, { error: 'Zu viele Versuche. Bitte warte kurz.' })
const body = await readJsonBody(req).catch(() => null)
if (!body) return json(res, 400, { error: 'Ungültige Eingabe.' })
const username = String(body.username || '').trim().toLowerCase()
const password = String(body.password || '')
const displayName = String(body.displayName || username).trim().slice(0, 24) || username
if (!/^[a-z0-9_-]{3,16}$/.test(username)) return json(res, 400, { error: 'Benutzername: 316 Zeichen (az, 09, _ -).' })
if (password.length < 8) return json(res, 400, { error: 'Passwort: mindestens 8 Zeichen.' })
if (getUserByUsername(username)) return json(res, 409, { error: 'Benutzername bereits vergeben.' })
const hash = await hashPassword(password)
const user = createUserLocal(username, displayName, hash)
const sess = createSession(user.id)
setSessionCookie(res, sess.token, sess.expiresAt, req)
return json(res, 200, { user: publicUser(user) })
}
// --- login (local) ---
if (p === '/api/auth/login' && req.method === 'POST') {
if (authRateLimited(ip)) return json(res, 429, { error: 'Zu viele Versuche. Bitte warte kurz.' })
const body = await readJsonBody(req).catch(() => null)
if (!body) return json(res, 400, { error: 'Ungültige Eingabe.' })
const username = String(body.username || '').trim().toLowerCase()
const password = String(body.password || '')
const row = getUserByUsername(username)
if (!row || !row.password_hash) return json(res, 401, { error: 'Benutzername oder Passwort falsch.' })
const ok = await verifyPassword(password, row.password_hash)
if (!ok) return json(res, 401, { error: 'Benutzername oder Passwort falsch.' })
const user = getUserById(row.id)
const sess = createSession(user.id)
setSessionCookie(res, sess.token, sess.expiresAt, req)
return json(res, 200, { user: publicUser(user) })
}
// --- OIDC: start login ---
if (p === '/api/auth/oidc/login' && req.method === 'GET') {
if (!OIDC.enabled) return json(res, 400, { error: 'OIDC ist nicht aktiviert.' })
try {
const d = await oidcDiscover()
const state = crypto.randomBytes(16).toString('hex')
const verifier = crypto.randomBytes(32).toString('base64url')
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url')
oidcPending.set(state, { verifier, createdAt: Date.now() })
const q = new URLSearchParams({
response_type: 'code',
client_id: OIDC.clientId,
redirect_uri: OIDC.redirectUri,
scope: 'openid profile email',
state,
code_challenge: challenge,
code_challenge_method: 'S256',
})
res.writeHead(302, { Location: `${d.authorization_endpoint}?${q.toString()}` })
return res.end()
} catch (e) {
return json(res, 502, { error: 'OIDC-Provider nicht erreichbar.' })
}
}
// --- OIDC: callback ---
if (p === '/api/auth/oidc/callback' && req.method === 'GET') {
if (!OIDC.enabled) return json(res, 400, { error: 'OIDC ist nicht aktiviert.' })
const code = url.searchParams.get('code')
const state = url.searchParams.get('state')
const pending = state ? oidcPending.get(state) : null
if (!code || !pending) {
res.writeHead(302, { Location: '/?auth=error' })
return res.end()
}
oidcPending.delete(state)
try {
const d = await oidcDiscover()
const tokenRes = await fetch(d.token_endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: OIDC.redirectUri,
client_id: OIDC.clientId,
client_secret: OIDC.clientSecret,
code_verifier: pending.verifier,
}).toString(),
})
if (!tokenRes.ok) throw new Error('token exchange failed')
const tokens = await tokenRes.json()
const payload = await verifyIdToken(tokens.id_token, d)
const user = findOrCreateUserOidc(
String(payload.sub),
OIDC.issuer,
String(payload.preferred_username || payload.email || ''),
String(payload.name || payload.preferred_username || ''),
)
const sess = createSession(user.id)
setSessionCookie(res, sess.token, sess.expiresAt, req)
res.writeHead(302, { Location: '/?auth=ok' })
return res.end()
} catch {
res.writeHead(302, { Location: '/?auth=error' })
return res.end()
}
}
// --- authenticated endpoints below ---
const sess = getSession(sessionTokenFromReq(req))
if (!sess) return json(res, 401, { error: 'Nicht angemeldet.' })
const user = getUserById(sess.user_id)
if (!user) return json(res, 401, { error: 'Nicht angemeldet.' })
// --- buy meta upgrade ---
if (p === '/api/upgrades/buy' && req.method === 'POST') {
const body = await readJsonBody(req).catch(() => null)
const upgradeId = String(body?.upgradeId || '')
const def = META_UPGRADES[upgradeId]
if (!def) return json(res, 400, { error: 'Unbekanntes Upgrade.' })
const current = user.upgrades[upgradeId] || 0
if (current >= def.maxLevel) return json(res, 400, { error: 'Bereits auf Maximalstufe.' })
const cost = def.costs[current]
const result = buyUpgrade(user.id, upgradeId, cost, def.maxLevel)
if (!result.ok) return json(res, 400, { error: result.error })
return json(res, 200, { user: publicUser(result.user) })
}
// --- merge local guest progress into the account (one-time) ---
if (p === '/api/auth/merge-guest' && req.method === 'POST') {
const body = await readJsonBody(req).catch(() => null)
const maxLevels = {}
for (const [id, def] of Object.entries(META_UPGRADES)) maxLevels[id] = def.maxLevel
const updated = mergeGuest(user.id, body?.crystals, body?.upgrades, maxLevels)
return json(res, 200, { user: publicUser(updated) })
}
// --- record finished game & grant crystals (server-authoritative) ---
if (p === '/api/game/finish' && req.method === 'POST') {
const body = await readJsonBody(req).catch(() => null)
const win = Boolean(body?.win)
const wave = Math.max(0, Math.min(9999, Number(body?.wave) || 0))
const score = Math.max(0, Math.min(10_000_000, Number(body?.score) || 0))
const kills = Math.max(0, Math.min(1_000_000, Number(body?.kills) || 0))
const crystals = calcCrystalsEarned(wave, score, win)
const updated = recordGameResult(user.id, { win, score, wave, kills, crystalsEarned: crystals })
return json(res, 200, { user: publicUser(updated), crystalsEarned: crystals })
}
return json(res, 404, { error: 'Nicht gefunden.' })
}
// ------------------------------------------------------------------ static files
const MIME = {
@ -171,6 +523,14 @@ async function serveStatic(req, res) {
}
const httpServer = http.createServer((req, res) => {
const url = new URL(req.url, 'http://localhost')
if (url.pathname.startsWith('/api/')) {
handleApi(req, res, url).catch(() => {
if (!res.headersSent) res.writeHead(500)
res.end('Internal Server Error')
})
return
}
serveStatic(req, res).catch(() => {
if (!res.headersSent) res.writeHead(500)
res.end('Internal Server Error')