Player accounts (auth slice 1): register/login, JWT issuance, isolation

GUPI-issued local auth (path A): osint.users (handle + scrypt password_hash +
display_name + avatar_url; external_id reserved for a later glitch.university
key-exchange). POST /api/auth/register|login set the auth_token cookie via a
new signPlayerToken (sub=user id, role=player); /logout and /me added. Because
playthroughs already bind to resolveUserId, two players each "Begin" and stay
fully isolated. Verification stays issuer-agnostic for the external swap later.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 21:42:49 +02:00
co-authored by Claude Opus 4.8
parent 03266b5f40
commit ce0e82f7fb
4 changed files with 112 additions and 1 deletions
+12
View File
@@ -0,0 +1,12 @@
-- Locally-issued player accounts. GUPI mints the JWT for now; external_id is
-- reserved so a glitch.university key-exchange can later link/migrate an account
-- without changing how playthroughs bind (they key off the JWT sub = users.id).
CREATE TABLE osint.users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
handle TEXT NOT NULL UNIQUE CHECK (handle ~ '^[a-z0-9_.-]{3,32}$'),
password_hash TEXT NOT NULL,
display_name TEXT NOT NULL,
avatar_url TEXT,
external_id TEXT UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
+7
View File
@@ -49,6 +49,13 @@ export function requireAdmin(req: Request, res: Response, next: NextFunction) {
next() next()
} }
// Mint a player token (path A: GUPI is the issuer for now). Verification is
// issuer-agnostic — a glitch.university token with the same sub verifies identically.
export function signPlayerToken(user: { id: string; displayName: string }) {
if (!process.env.JWT_SECRET) throw new Error('JWT_SECRET is required')
return jwt.sign({ sub: user.id, name: user.displayName, role: 'player' }, process.env.JWT_SECRET, { expiresIn: '30d' })
}
export function createDevelopmentAdminToken() { export function createDevelopmentAdminToken() {
if (process.env.NODE_ENV === 'production') throw new Error('Development sessions are disabled in production') if (process.env.NODE_ENV === 'production') throw new Error('Development sessions are disabled in production')
if (!process.env.JWT_SECRET) throw new Error('JWT_SECRET is required') if (!process.env.JWT_SECRET) throw new Error('JWT_SECRET is required')
+33 -1
View File
@@ -8,7 +8,8 @@ import { fileURLToPath } from 'node:url'
import multer from 'multer' import multer from 'multer'
import pg from 'pg' import pg from 'pg'
import type { CaseState } from '../src/types.js' import type { CaseState } from '../src/types.js'
import { authenticateJwt, createDevelopmentAdminToken, hasAdminClaim, requireAdmin, resolvePlayerName, resolveUserId } from './auth.js' import { authenticateJwt, createDevelopmentAdminToken, hasAdminClaim, requireAdmin, resolvePlayerName, resolveUserId, signPlayerToken } from './auth.js'
import { createUserRepository } from './userRepository.js'
import { submitCaseReport } from './caseReports.js' import { submitCaseReport } from './caseReports.js'
import { createLevelRepository } from './levelRepository.js' import { createLevelRepository } from './levelRepository.js'
import { createEvidenceJudgeFromEnv } from './evidenceJudge.js' import { createEvidenceJudgeFromEnv } from './evidenceJudge.js'
@@ -33,6 +34,8 @@ const evidenceJudge = createEvidenceJudgeFromEnv()
const levels = createLevelRepository(pool, editingEnabled, objectStorage, evidenceJudge) const levels = createLevelRepository(pool, editingEnabled, objectStorage, evidenceJudge)
const narrative = createNarrativeRepository(pool, objectStorage) const narrative = createNarrativeRepository(pool, objectStorage)
const storyGraph = createStoryGraphRepository(pool) const storyGraph = createStoryGraphRepository(pool)
const users = createUserRepository(pool)
const AUTH_COOKIE = { httpOnly: true, sameSite: 'lax' as const, path: '/', maxAge: 30 * 24 * 60 * 60 * 1000 }
const STORY_NODE_TYPES: StoryNodeType[] = ['cutscene', 'dialogue', 'level', 'det_gate', 'llm_gate', 'merit', 'phone'] const STORY_NODE_TYPES: StoryNodeType[] = ['cutscene', 'dialogue', 'level', 'det_gate', 'llm_gate', 'merit', 'phone']
function wantsEdit(req: express.Request) { function wantsEdit(req: express.Request) {
@@ -59,6 +62,35 @@ app.get('/api/health', async (_req, res) => {
catch { res.status(503).json({ ok: false, database: 'unavailable' }) } catch { res.status(503).json({ ok: false, database: 'unavailable' }) }
}) })
app.get('/api/session', (req, res) => res.json({ authenticated: Boolean(req.authClaims), isAdmin: hasAdminClaim(req), playerName:resolvePlayerName(req) })) app.get('/api/session', (req, res) => res.json({ authenticated: Boolean(req.authClaims), isAdmin: hasAdminClaim(req), playerName:resolvePlayerName(req) }))
// Player accounts (path A: GUPI issues the token). register/login set the auth_token
// cookie; every game write then binds to this user via resolveUserId.
app.post('/api/auth/register', async (req, res, next) => {
try {
const body = req.body || {}
const result = await users.registerUser({ handle: String(body.handle || ''), password: String(body.password || ''), displayName: String(body.displayName || '') })
if (result.error || !result.user) return res.status(result.error === 'That handle is taken' ? 409 : 400).json({ error: result.error || 'Registration failed' })
res.cookie('auth_token', signPlayerToken(result.user), AUTH_COOKIE)
res.status(201).json({ user: result.user })
} catch (error) { next(error) }
})
app.post('/api/auth/login', async (req, res, next) => {
try {
const body = req.body || {}
const user = await users.authenticateUser(String(body.handle || ''), String(body.password || ''))
if (!user) return res.status(401).json({ error: 'Invalid handle or password' })
res.cookie('auth_token', signPlayerToken(user), AUTH_COOKIE)
res.json({ user })
} catch (error) { next(error) }
})
app.post('/api/auth/logout', (_req, res) => { res.clearCookie('auth_token', { path: '/' }); res.json({ ok: true }) })
app.get('/api/auth/me', async (req, res, next) => {
try {
const sub = req.authClaims?.sub
const user = typeof sub === 'string' ? await users.getUser(sub) : null
user ? res.json({ user }) : res.status(204).end()
} catch (error) { next(error) }
})
if (process.env.NODE_ENV !== 'production') app.get('/api/dev/admin-session', (req, res) => { if (process.env.NODE_ENV !== 'production') app.get('/api/dev/admin-session', (req, res) => {
const requestedReturn = String(req.query.returnTo || '/') const requestedReturn = String(req.query.returnTo || '/')
const returnTo = requestedReturn.startsWith('/') && !requestedReturn.startsWith('//') ? requestedReturn : '/' const returnTo = requestedReturn.startsWith('/') && !requestedReturn.startsWith('//') ? requestedReturn : '/'
+60
View File
@@ -0,0 +1,60 @@
import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'
import type { Pool } from 'pg'
export type UserDto = { id: string; handle: string; displayName: string; avatarUrl: string | null }
export interface UserRepository {
registerUser(input: { handle: string; password: string; displayName: string }): Promise<{ user?: UserDto; error?: string }>
authenticateUser(handle: string, password: string): Promise<UserDto | null>
getUser(id: string): Promise<UserDto | null>
}
const HANDLE = /^[a-z0-9_.-]{3,32}$/
// scrypt with a per-user random salt; stored as `salt:hash` hex. No dependency.
function hashPassword(password: string): string {
const salt = randomBytes(16)
return `${salt.toString('hex')}:${scryptSync(password, salt, 64).toString('hex')}`
}
function verifyPassword(password: string, stored: string): boolean {
const [saltHex, hashHex] = stored.split(':')
if (!saltHex || !hashHex) return false
const expected = Buffer.from(hashHex, 'hex')
const actual = scryptSync(password, Buffer.from(saltHex, 'hex'), 64)
return expected.length === actual.length && timingSafeEqual(expected, actual)
}
export function createUserRepository(pool: Pool): UserRepository {
const toDto = (row: { id: string; handle: string; display_name: string; avatar_url: string | null }): UserDto =>
({ id: row.id, handle: row.handle, displayName: row.display_name, avatarUrl: row.avatar_url })
return {
async registerUser({ handle: rawHandle, password, displayName: rawName }) {
const handle = rawHandle.trim().toLowerCase()
const displayName = rawName.trim().slice(0, 60)
if (!HANDLE.test(handle)) return { error: 'Handle must be 332 chars: az, 09, . _ -' }
if (password.length < 6) return { error: 'Password must be at least 6 characters' }
if (!displayName) return { error: 'A display name is required' }
const existing = await pool.query('SELECT 1 FROM osint.users WHERE handle=$1', [handle])
if (existing.rowCount) return { error: 'That handle is taken' }
const row = (await pool.query<{ id: string; handle: string; display_name: string; avatar_url: string | null }>(
'INSERT INTO osint.users (handle,password_hash,display_name) VALUES ($1,$2,$3) RETURNING id,handle,display_name,avatar_url',
[handle, hashPassword(password), displayName])).rows[0]
return { user: toDto(row) }
},
async authenticateUser(rawHandle, password) {
const handle = rawHandle.trim().toLowerCase()
const row = (await pool.query<{ id: string; handle: string; display_name: string; avatar_url: string | null; password_hash: string }>(
'SELECT id,handle,display_name,avatar_url,password_hash FROM osint.users WHERE handle=$1', [handle])).rows[0]
if (!row || !verifyPassword(password, row.password_hash)) return null
return toDto(row)
},
async getUser(id) {
const row = (await pool.query<{ id: string; handle: string; display_name: string; avatar_url: string | null }>(
'SELECT id,handle,display_name,avatar_url FROM osint.users WHERE id=$1', [id])).rows[0]
return row ? toDto(row) : null
},
}
}