Files
kickkingsapi/auth_bridge.ts
T
2026-04-04 18:43:36 +00:00

365 lines
13 KiB
TypeScript

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<string, { usd: number; rc: number }> = {
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 <token>' };
}
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 <token>' });
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 <token>' });
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,
});
});
}