threesome fixed

This commit is contained in:
API runner
2026-08-14 20:55:11 +00:00
parent b6ecf6ad7c
commit b933f09187
260 changed files with 17747 additions and 270 deletions
+152 -76
View File
@@ -7,6 +7,8 @@ import { logDebug, logInfo } from './server_log';
const BCRYPT_ROUNDS = 10;
const TOKEN_TTL_SEC = 60 * 60 * 24 * 7; // 7 days
/** Unity should POST /auth/keepalive every 10s. Session stays active for 3 missed pings. */
const SESSION_ACTIVE_MS = 30_000;
const USERNAME_MIN = 2;
const PASSWORD_MIN = 6;
const EMAIL_MAX = 254;
@@ -55,15 +57,15 @@ export function createSupabase(settings: Settings): SupabaseClient {
});
}
function signToken(userId: string, secret: string): string {
function signToken(userId: string, sessionId: string, secret: string): string {
const exp = Math.floor(Date.now() / 1000) + TOKEN_TTL_SEC;
const payload = JSON.stringify({ sub: userId, exp });
const payload = JSON.stringify({ sub: userId, exp, jti: sessionId });
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 {
export function verifyAuthToken(token: string, secret: string): { userId: string; sessionId: string } | null {
const parts = token.split('.');
if (parts.length !== 2) return null;
const [bodyB64, sig] = parts;
@@ -75,15 +77,74 @@ export function verifyAuthToken(token: string, secret: string): { userId: string
const payload = JSON.parse(Buffer.from(bodyB64, 'base64url').toString('utf8')) as {
sub?: string;
exp?: number;
jti?: string;
};
if (!payload.sub || typeof payload.exp !== 'number') return null;
if (typeof payload.jti !== 'string' || payload.jti.length < 1) return null;
if (payload.exp < Math.floor(Date.now() / 1000)) return null;
return { userId: payload.sub };
return { userId: payload.sub, sessionId: payload.jti };
} catch {
return null;
}
}
function isSessionActive(lastKeepaliveAt: unknown): boolean {
if (lastKeepaliveAt == null || lastKeepaliveAt === '') return false;
const ms = Date.parse(String(lastKeepaliveAt));
if (!Number.isFinite(ms)) return false;
return Date.now() - ms < SESSION_ACTIVE_MS;
}
type AuthSessionOk = {
ok: true;
supabase: SupabaseClient;
userId: string;
sessionId: string;
row: Record<string, unknown>;
};
type AuthSessionFail = { ok: false; status: number; error: string };
/**
* Verifies Bearer token, loads the user, and requires users.session_id === token jti.
*/
async function authenticateSession(
req: Request,
settings: Settings
): Promise<AuthSessionOk | AuthSessionFail> {
if (!authConfigured(settings)) {
return { ok: false, status: 503, error: 'Auth is not configured on the server' };
}
const token = readBearer(req);
if (!token) {
return { ok: false, status: 401, error: 'Missing Authorization: Bearer <token>' };
}
const v = verifyAuthToken(token, settings.auth_jwt_secret!.trim());
if (!v) {
return { ok: false, status: 401, error: 'Invalid or expired token' };
}
const supabase = createSupabase(settings);
const { data, error } = await supabase
.from('users')
.select('id, session_id, username, email, cc, rc, created_at, last_logged_at')
.eq('id', v.userId)
.maybeSingle();
if (error || !data) {
return { ok: false, status: 404, error: 'User not found' };
}
const stored = typeof data.session_id === 'string' ? data.session_id : '';
if (!stored || stored !== v.sessionId) {
return { ok: false, status: 401, error: 'Invalid or expired token' };
}
return {
ok: true,
supabase,
userId: v.userId,
sessionId: v.sessionId,
row: data as Record<string, unknown>,
};
}
function readBearer(req: Request): string | null {
const h = req.headers.authorization;
if (!h || typeof h !== 'string') return null;
@@ -176,31 +237,19 @@ 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 auth = await authenticateSession(req, settings);
if (!auth.ok) {
const message = auth.status === 503 ? 'Auth not configured' : auth.error;
return { ok: false, status: auth.status, message };
}
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) {
if (typeof auth.row.username !== 'string' || auth.row.username.length < 2) {
return { ok: false, status: 404, message: 'User not found' };
}
const userId = coerceDbUserId(data.id);
const userId = coerceDbUserId(auth.row.id);
if (userId == null) {
return { ok: false, status: 500, message: 'Invalid user id' };
}
return { ok: true, user: { userId, username: data.username } };
return { ok: true, user: { userId, username: auth.row.username } };
}
export function registerAuthRoutes(app: Express, settings: Settings): void {
@@ -242,6 +291,8 @@ export function registerAuthRoutes(app: Express, settings: Settings): void {
const hash = await bcrypt.hash(password, BCRYPT_ROUNDS);
const clientIp = readClientIp(req);
const sessionId = crypto.randomUUID();
const nowIso = new Date().toISOString();
const { data: row, error } = await supabase
.from('users')
.insert({
@@ -251,6 +302,9 @@ export function registerAuthRoutes(app: Express, settings: Settings): void {
cc: 0,
rc: 0,
ip_address: clientIp,
session_id: sessionId,
last_keepalive_at: nowIso,
last_logged_at: nowIso,
})
.select('id, username, email, cc, rc, created_at, last_logged_at')
.single();
@@ -272,7 +326,7 @@ export function registerAuthRoutes(app: Express, settings: Settings): void {
}
const user = rowToAuthUserPublic(row as Record<string, unknown>);
const token = signToken(user.id, settings.auth_jwt_secret!.trim());
const token = signToken(user.id, sessionId, settings.auth_jwt_secret!.trim());
res.status(201).json({ ok: true, token, user });
});
@@ -291,7 +345,7 @@ export function registerAuthRoutes(app: Express, settings: Settings): void {
const supabase = createSupabase(settings);
const { data: row, error } = await supabase
.from('users')
.select('id, username, email, password, cc, rc, created_at')
.select('id, username, email, password, cc, rc, created_at, session_id, last_keepalive_at')
.eq('username', username)
.maybeSingle();
@@ -306,45 +360,82 @@ export function registerAuthRoutes(app: Express, settings: Settings): void {
return;
}
const existingSession = typeof row.session_id === 'string' ? row.session_id : '';
if (existingSession && isSessionActive(row.last_keepalive_at)) {
res.status(409).json({
ok: false,
error: 'Already logged in on another device',
code: 'already_logged_in',
});
return;
}
const sessionId = crypto.randomUUID();
const nowIso = new Date().toISOString();
const clientIp = readClientIp(req);
await supabase.from('users').update({ last_logged_at: nowIso, ip_address: clientIp }).eq('id', row.id);
await supabase
.from('users')
.update({
last_logged_at: nowIso,
ip_address: clientIp,
session_id: sessionId,
last_keepalive_at: nowIso,
})
.eq('id', row.id);
const user: AuthUserPublic = {
...rowToAuthUserPublic(row as Record<string, unknown>),
last_logged_at: nowIso,
};
const token = signToken(user.id, settings.auth_jwt_secret!.trim());
const token = signToken(user.id, sessionId, settings.auth_jwt_secret!.trim());
res.json({ ok: true, token, user });
});
app.post('/auth/keepalive', async (req: Request, res: Response) => {
const auth = await authenticateSession(req, settings);
if (!auth.ok) {
res.status(auth.status).json({ ok: false, error: auth.error });
return;
}
const nowIso = new Date().toISOString();
const { error } = await auth.supabase
.from('users')
.update({ last_keepalive_at: nowIso })
.eq('id', auth.userId)
.eq('session_id', auth.sessionId);
if (error) {
res.status(500).json({ ok: false, error: error.message ?? 'Keepalive failed' });
return;
}
res.json({ ok: true });
});
app.post('/auth/logout', async (req: Request, res: Response) => {
const auth = await authenticateSession(req, settings);
if (!auth.ok) {
res.status(auth.status).json({ ok: false, error: auth.error });
return;
}
const { error } = await auth.supabase
.from('users')
.update({ session_id: null, last_keepalive_at: null })
.eq('id', auth.userId)
.eq('session_id', auth.sessionId);
if (error) {
res.status(500).json({ ok: false, error: error.message ?? 'Logout failed' });
return;
}
res.json({ ok: true });
});
/** 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' });
const auth = await authenticateSession(req, settings);
if (!auth.ok) {
res.status(auth.status).json({ ok: false, error: auth.error });
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, email, 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;
}
res.json({ ok: true, user: rowToAuthUserPublic(data as Record<string, unknown>) });
res.json({ ok: true, user: rowToAuthUserPublic(auth.row) });
};
app.get('/auth/me', handleGetCurrentUser);
@@ -362,18 +453,9 @@ export function registerAuthRoutes(app: Express, settings: Settings): void {
/** Client reports observed ping for a match. User id comes from login token. */
app.post('/auth/ping-report', 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' });
const auth = await authenticateSession(req, settings);
if (!auth.ok) {
res.status(auth.status).json({ ok: false, error: auth.error });
return;
}
@@ -394,15 +476,14 @@ export function registerAuthRoutes(app: Express, settings: Settings): void {
return;
}
const userId = coerceDbUserId(v.userId);
const userId = coerceDbUserId(auth.userId);
if (userId == null || userId < 1) {
res.status(400).json({ ok: false, error: 'Invalid token user id' });
return;
}
const supabase = createSupabase(settings);
const ipAddress = readClientIp(req);
const { data: inserted, error } = await supabase
const { data: inserted, error } = await auth.supabase
.from('ping_reports')
.insert({
user_id: userId,
@@ -440,16 +521,12 @@ export function registerAuthRoutes(app: Express, settings: Settings): void {
});
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' });
const auth = await authenticateSession(req, settings);
if (!auth.ok) {
res.status(auth.status).json({ ok: false, error: auth.error });
return;
}
const supabase = auth.supabase;
const packId = typeof req.body?.pack === 'string' ? req.body.pack.trim() : '';
const pack = RC_PACKS[packId];
if (!pack) {
@@ -461,8 +538,7 @@ export function registerAuthRoutes(app: Express, settings: Settings): void {
return;
}
const supabase = createSupabase(settings);
const buyerId = coerceDbUserId(v.userId);
const buyerId = coerceDbUserId(auth.userId);
if (buyerId == null || buyerId < 1) {
res.status(400).json({ ok: false, error: 'Invalid token user id' });
return;
@@ -479,7 +555,7 @@ export function registerAuthRoutes(app: Express, settings: Settings): void {
self_purchase: selfPurchase,
});
const { data: rpcData, error: rpcErr } = await supabase.rpc('purchase_rc', {
const { data: rpcData, error: rpcErr } = await auth.supabase.rpc('purchase_rc', {
p_buyer_id: buyerId,
p_amount: amount,
});