import { Request, Response, Express } from 'express'; import { createClient, SupabaseClient } from '@supabase/supabase-js'; import * as bcrypt from 'bcrypt'; import * as crypto from 'crypto'; import { Settings } from './types'; const BCRYPT_ROUNDS = 10; const TOKEN_TTL_SEC = 60 * 60 * 24 * 7; // 7 days const USERNAME_MIN = 2; const PASSWORD_MIN = 6; /** 1 USD = 4 RC (IAP packs) */ export const RC_PER_USD = 4; /** Fixed packs: $5, $20, $50 */ export const RC_PACKS: Record = { usd_5: { usd: 5, rc: 5 * RC_PER_USD }, usd_20: { usd: 20, rc: 20 * RC_PER_USD }, usd_50: { usd: 50, rc: 50 * RC_PER_USD }, }; function isIapDummyMode(settings: Settings): boolean { return settings.iap_dummy_mode === true; } export interface AuthUserPublic { id: string; username: string; cc: number; rc: number; created_at: string; last_logged_at: string | null; } function authConfigured(settings: Settings): boolean { return Boolean( settings.supabase_url?.trim() && settings.supabase_service_role_key?.trim() && settings.auth_jwt_secret?.trim() ); } /** Supabase URL + service role only (e.g. match history without full auth). */ export function supabaseConfigured(settings: Settings): boolean { return Boolean(settings.supabase_url?.trim() && settings.supabase_service_role_key?.trim()); } export function createSupabase(settings: Settings): SupabaseClient { return createClient(settings.supabase_url!.trim(), settings.supabase_service_role_key!.trim(), { auth: { persistSession: false, autoRefreshToken: false }, }); } function signToken(userId: string, secret: string): string { const exp = Math.floor(Date.now() / 1000) + TOKEN_TTL_SEC; const payload = JSON.stringify({ sub: userId, exp }); const bodyB64 = Buffer.from(payload, 'utf8').toString('base64url'); const sig = crypto.createHmac('sha256', secret).update(bodyB64).digest('base64url'); return `${bodyB64}.${sig}`; } export function verifyAuthToken(token: string, secret: string): { userId: string } | null { const parts = token.split('.'); if (parts.length !== 2) return null; const [bodyB64, sig] = parts; const expectedSig = crypto.createHmac('sha256', secret).update(bodyB64).digest('base64url'); const a = Buffer.from(sig, 'utf8'); const b = Buffer.from(expectedSig, 'utf8'); if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null; try { const payload = JSON.parse(Buffer.from(bodyB64, 'base64url').toString('utf8')) as { sub?: string; exp?: number; }; if (!payload.sub || typeof payload.exp !== 'number') return null; if (payload.exp < Math.floor(Date.now() / 1000)) return null; return { userId: payload.sub }; } catch { return null; } } function readBearer(req: Request): string | null { const h = req.headers.authorization; if (!h || typeof h !== 'string') return null; const m = /^Bearer\s+(.+)$/i.exec(h.trim()); return m ? m[1]!.trim() : null; } function coerceDbUserId(id: unknown): number | null { if (id == null) return null; if (typeof id === 'bigint') return Number(id); if (typeof id === 'number' && Number.isFinite(id)) return id; if (typeof id === 'string') { const n = parseInt(id, 10); return Number.isNaN(n) ? null : n; } return null; } export interface MatchmakingUser { userId: number; username: string; } /** * Validates Bearer session token and loads the user from Supabase (id + username). * Used by GET / and GET /cancel matchmaking. */ export async function authenticateMatchmakingRequest( req: Request, settings: Settings ): Promise<{ ok: true; user: MatchmakingUser } | { ok: false; status: number; message: string }> { if (!authConfigured(settings)) { return { ok: false, status: 503, message: 'Auth not configured' }; } const token = readBearer(req); if (!token) { return { ok: false, status: 401, message: 'Missing Authorization: Bearer ' }; } const v = verifyAuthToken(token, settings.auth_jwt_secret!.trim()); if (!v) { return { ok: false, status: 401, message: 'Invalid or expired token' }; } const supabase = createSupabase(settings); const { data, error } = await supabase .from('users') .select('id, username') .eq('id', v.userId) .maybeSingle(); if (error || !data || typeof data.username !== 'string' || data.username.length < 2) { return { ok: false, status: 404, message: 'User not found' }; } const userId = coerceDbUserId(data.id); if (userId == null) { return { ok: false, status: 500, message: 'Invalid user id' }; } return { ok: true, user: { userId, username: data.username } }; } export function registerAuthRoutes(app: Express, settings: Settings): void { app.post('/auth/register', async (req: Request, res: Response) => { if (!authConfigured(settings)) { res.status(503).json({ ok: false, error: 'Auth is not configured on the server' }); return; } const username = typeof req.body?.username === 'string' ? req.body.username.trim() : ''; const password = typeof req.body?.password === 'string' ? req.body.password : ''; if (username.length < USERNAME_MIN) { res.status(400).json({ ok: false, error: `Username must be at least ${USERNAME_MIN} characters` }); return; } if (password.length < PASSWORD_MIN) { res.status(400).json({ ok: false, error: `Password must be at least ${PASSWORD_MIN} characters` }); return; } const supabase = createSupabase(settings); const { data: existing } = await supabase.from('users').select('id').eq('username', username).maybeSingle(); if (existing) { res.status(409).json({ ok: false, error: 'Username already taken' }); return; } const hash = await bcrypt.hash(password, BCRYPT_ROUNDS); const { data: row, error } = await supabase .from('users') .insert({ username, password: hash, cc: 0, rc: 0, }) .select('id, username, cc, rc, created_at, last_logged_at') .single(); if (error || !row) { res.status(500).json({ ok: false, error: error?.message ?? 'Registration failed' }); return; } const user: AuthUserPublic = { id: row.id, username: row.username, cc: row.cc, rc: row.rc, created_at: row.created_at, last_logged_at: row.last_logged_at ?? null, }; const token = signToken(user.id, settings.auth_jwt_secret!.trim()); res.status(201).json({ ok: true, token, user }); }); app.post('/auth/login', async (req: Request, res: Response) => { if (!authConfigured(settings)) { res.status(503).json({ ok: false, error: 'Auth is not configured on the server' }); return; } const username = typeof req.body?.username === 'string' ? req.body.username.trim() : ''; const password = typeof req.body?.password === 'string' ? req.body.password : ''; if (username.length < USERNAME_MIN || !password) { res.status(400).json({ ok: false, error: 'Invalid username or password' }); return; } const supabase = createSupabase(settings); const { data: row, error } = await supabase .from('users') .select('id, username, password, cc, rc, created_at') .eq('username', username) .maybeSingle(); if (error || !row) { res.status(401).json({ ok: false, error: 'Invalid username or password' }); return; } const match = await bcrypt.compare(password, row.password); if (!match) { res.status(401).json({ ok: false, error: 'Invalid username or password' }); return; } const nowIso = new Date().toISOString(); await supabase.from('users').update({ last_logged_at: nowIso }).eq('id', row.id); const user: AuthUserPublic = { id: row.id, username: row.username, cc: row.cc, rc: row.rc, created_at: row.created_at, last_logged_at: nowIso, }; const token = signToken(user.id, settings.auth_jwt_secret!.trim()); res.json({ ok: true, token, user }); }); /** Current user from DB (requires login token). Same behavior as GET /auth/user. */ const handleGetCurrentUser = async (req: Request, res: Response) => { if (!authConfigured(settings)) { res.status(503).json({ ok: false, error: 'Auth is not configured on the server' }); return; } const token = readBearer(req); if (!token) { res.status(401).json({ ok: false, error: 'Missing Authorization: Bearer ' }); return; } const v = verifyAuthToken(token, settings.auth_jwt_secret!.trim()); if (!v) { res.status(401).json({ ok: false, error: 'Invalid or expired token' }); return; } const supabase = createSupabase(settings); const { data, error } = await supabase .from('users') .select('id, username, cc, rc, created_at, last_logged_at') .eq('id', v.userId) .maybeSingle(); if (error || !data) { res.status(404).json({ ok: false, error: 'User not found' }); return; } const user: AuthUserPublic = { id: data.id, username: data.username, cc: data.cc, rc: data.rc, created_at: data.created_at, last_logged_at: data.last_logged_at ?? null, }; res.json({ ok: true, user }); }; app.get('/auth/me', handleGetCurrentUser); app.get('/auth/user', handleGetCurrentUser); /** Public catalog for Unity UI (no auth). */ app.get('/auth/rc-packs', (_req: Request, res: Response) => { const packs = Object.entries(RC_PACKS).map(([id, p]) => ({ id, usd: p.usd, rc: p.rc, })); res.json({ ok: true, rc_per_usd: RC_PER_USD, packs }); }); /** * Dummy IAP: adds RC for a named pack when `iap_dummy_mode` is true in settings. * Later: same route can require a verified store receipt when dummy mode is off. */ app.post('/auth/purchase-rc', async (req: Request, res: Response) => { if (!authConfigured(settings)) { res.status(503).json({ ok: false, error: 'Auth is not configured on the server' }); return; } if (!isIapDummyMode(settings)) { res.status(403).json({ ok: false, error: 'Dummy purchases are disabled. Configure server-side purchase verification.', }); return; } const token = readBearer(req); if (!token) { res.status(401).json({ ok: false, error: 'Missing Authorization: Bearer ' }); return; } const v = verifyAuthToken(token, settings.auth_jwt_secret!.trim()); if (!v) { res.status(401).json({ ok: false, error: 'Invalid or expired token' }); return; } const packId = typeof req.body?.pack === 'string' ? req.body.pack.trim() : ''; const pack = RC_PACKS[packId]; if (!pack) { const valid = Object.keys(RC_PACKS).join(', '); res.status(400).json({ ok: false, error: `Unknown pack "${packId}". Valid: ${valid}`, }); return; } const supabase = createSupabase(settings); const { data: row, error: selErr } = await supabase .from('users') .select('rc') .eq('id', v.userId) .maybeSingle(); if (selErr || row === null) { res.status(404).json({ ok: false, error: 'User not found' }); return; } const newRc = (row.rc ?? 0) + pack.rc; const { data: updated, error: upErr } = await supabase .from('users') .update({ rc: newRc }) .eq('id', v.userId) .select('id, username, cc, rc, created_at, last_logged_at') .single(); if (upErr || !updated) { res.status(500).json({ ok: false, error: upErr?.message ?? 'Failed to update balance' }); return; } const user: AuthUserPublic = { id: updated.id, username: updated.username, cc: updated.cc, rc: updated.rc, created_at: updated.created_at, last_logged_at: updated.last_logged_at ?? null, }; res.json({ ok: true, pack: { id: packId, usd: pack.usd, rc_added: pack.rc }, user, }); }); }