From edf1d032754ca164e30b21958a56be6082701297 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 25 May 2026 19:04:27 +0100 Subject: [PATCH] remove from jwt --- src/server/Worker.ts | 74 ++++++++++++++- src/server/jwt.ts | 87 ------------------ tests/server/JwtClanExists.test.ts | 141 ----------------------------- 3 files changed, 73 insertions(+), 229 deletions(-) delete mode 100644 tests/server/JwtClanExists.test.ts diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 8534e3b4c..48e61f761 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -7,6 +7,10 @@ import path from "path"; import { fileURLToPath } from "url"; import { WebSocket, WebSocketServer } from "ws"; import { z } from "zod"; +import { + ClanExistsResponseSchema, + clanExistsApiPath, +} from "../core/ApiSchemas"; import { GameEnv } from "../core/configuration/Config"; import { GameType } from "../core/game/Game"; import { @@ -21,7 +25,7 @@ import { archive, finalizeGameRecord } from "./Archive"; import { Client } from "./Client"; import { GameManager } from "./GameManager"; import { registerGamePreviewRoute } from "./GamePreviewRoute"; -import { clanExistsByTag, getUserMe, verifyClientToken } from "./jwt"; +import { getUserMe, verifyClientToken } from "./jwt"; import { logger } from "./Logger"; import { MapPlaylist } from "./MapPlaylist"; @@ -38,6 +42,74 @@ const workerId = ServerEnv.workerId() ?? 0; const log = logger.child({ comp: `w_${workerId}` }); const playlist = new MapPlaylist(); +// Clan-existence probe used by the join-time ownership check. +// Caches results briefly so a lobby surge doesn't fan out to the auth API. +// Returns null on transport errors / unexpected statuses so callers fail open. +const CLAN_EXISTS_FETCH_TIMEOUT_MS = 3000; +const CLAN_EXISTS_CACHE_TTL_MS = 60_000; +const clanExistsCache = new Map< + string, + { result: boolean; expiresAt: number } +>(); + +async function clanExistsByTag(tag: string): Promise { + const cacheKey = tag.toUpperCase(); + const entry = clanExistsCache.get(cacheKey); + if (entry !== undefined) { + if (Date.now() < entry.expiresAt) return entry.result; + clanExistsCache.delete(cacheKey); + } + + try { + const url = `${ServerEnv.jwtIssuer()}${clanExistsApiPath(tag)}`; + const response = await fetch(url, { + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(CLAN_EXISTS_FETCH_TIMEOUT_MS), + }); + if (response.status === 200) { + // Upstream currently has no body; tolerate {exists:false} for forward-compat. + try { + const text = await response.text(); + if (text.length > 0) { + const parsed = ClanExistsResponseSchema.safeParse(JSON.parse(text)); + if (parsed.success && parsed.data?.exists === false) { + clanExistsCache.set(cacheKey, { + result: false, + expiresAt: Date.now() + CLAN_EXISTS_CACHE_TTL_MS, + }); + return false; + } + } + } catch { + // Forward-compat parsing only; ignore failures. + } + clanExistsCache.set(cacheKey, { + result: true, + expiresAt: Date.now() + CLAN_EXISTS_CACHE_TTL_MS, + }); + return true; + } + if (response.status === 404) { + clanExistsCache.set(cacheKey, { + result: false, + expiresAt: Date.now() + CLAN_EXISTS_CACHE_TTL_MS, + }); + return false; + } + log.warn("clanExistsByTag: unexpected status, failing open", { + tag: cacheKey, + status: response.status, + }); + return null; + } catch (e) { + log.warn("clanExistsByTag: fetch failed, failing open", { + tag: cacheKey, + error: e instanceof Error ? e.message : String(e), + }); + return null; + } +} + // Worker setup export async function startWorker() { log.info(`Worker starting...`); diff --git a/src/server/jwt.ts b/src/server/jwt.ts index 0904796d4..293c6c265 100644 --- a/src/server/jwt.ts +++ b/src/server/jwt.ts @@ -1,8 +1,6 @@ import { jwtVerify } from "jose"; import { z } from "zod"; import { - clanExistsApiPath, - ClanExistsResponseSchema, TokenPayload, TokenPayloadSchema, UserMeResponse, @@ -10,14 +8,8 @@ import { } from "../core/ApiSchemas"; import { GameEnv } from "../core/configuration/Config"; import { PersistentIdSchema } from "../core/Schemas"; -import { logger } from "./Logger"; import { ServerEnv } from "./ServerEnv"; -const log = logger.child({ comp: "jwt" }); - -const CLAN_EXISTS_FETCH_TIMEOUT_MS = 3000; -const CLAN_EXISTS_CACHE_TTL_MS = 60_000; - type TokenVerificationResult = | { type: "success"; @@ -106,82 +98,3 @@ export async function getUserMe( }; } } - -// Module-level TTL cache. Clan existence is stable, so a short cache prevents -// repeated upstream calls during lobby-start surges. -const clanExistsCache = new Map< - string, - { result: boolean; expiresAt: number } ->(); - -function cacheGet(key: string): boolean | undefined { - const entry = clanExistsCache.get(key); - if (entry === undefined) return undefined; - if (Date.now() >= entry.expiresAt) { - clanExistsCache.delete(key); - return undefined; - } - return entry.result; -} - -function cacheSet(key: string, result: boolean) { - clanExistsCache.set(key, { - result, - expiresAt: Date.now() + CLAN_EXISTS_CACHE_TTL_MS, - }); -} - -// For tests. -export function _clearClanExistsCacheForTest() { - clanExistsCache.clear(); -} - -// Best-effort check: does a clan with this tag exist? -// Returns null on transport errors, timeouts, or unexpected statuses so callers -// can fail open — the goal is impersonation prevention, not an availability -// blocker. Logs a warn on unexpected statuses so outages are observable. -export async function clanExistsByTag(tag: string): Promise { - const cacheKey = tag.toUpperCase(); - const cached = cacheGet(cacheKey); - if (cached !== undefined) return cached; - - try { - const url = `${ServerEnv.jwtIssuer()}${clanExistsApiPath(tag)}`; - const response = await fetch(url, { - headers: { Accept: "application/json" }, - signal: AbortSignal.timeout(CLAN_EXISTS_FETCH_TIMEOUT_MS), - }); - if (response.status === 200) { - // The upstream may eventually start returning a body; tolerate either. - try { - const text = await response.text(); - if (text.length > 0) { - const parsed = ClanExistsResponseSchema.safeParse(JSON.parse(text)); - if (parsed.success && parsed.data?.exists === false) { - cacheSet(cacheKey, false); - return false; - } - } - } catch { - // Body parsing is forward-compat only; ignore failures. - } - cacheSet(cacheKey, true); - return true; - } - if (response.status === 404) { - cacheSet(cacheKey, false); - return false; - } - log.warn("clanExistsByTag: unexpected status, failing open", { - tag: cacheKey, - status: response.status, - }); - return null; - } catch (e) { - log.warn("clanExistsByTag: fetch failed, failing open", { - tag: cacheKey, - error: e instanceof Error ? e.message : String(e), - }); - return null; - } -} diff --git a/tests/server/JwtClanExists.test.ts b/tests/server/JwtClanExists.test.ts deleted file mode 100644 index b0da43fc5..000000000 --- a/tests/server/JwtClanExists.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("../../src/server/ServerEnv", () => ({ - ServerEnv: { - jwtIssuer: () => "http://auth.test", - apiKey: () => "test-key", - }, -})); - -vi.mock("../../src/server/Logger", () => ({ - logger: { - child: () => ({ - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }), - }, -})); - -import { - _clearClanExistsCacheForTest, - clanExistsByTag, -} from "../../src/server/jwt"; - -const jsonResponse = (status: number, body: unknown = "") => ({ - status, - text: async () => (typeof body === "string" ? body : JSON.stringify(body)), -}); - -beforeEach(() => { - vi.unstubAllGlobals(); - vi.clearAllMocks(); - _clearClanExistsCacheForTest(); -}); - -afterEach(() => { - vi.unstubAllGlobals(); -}); - -describe("clanExistsByTag", () => { - it("returns true on HTTP 200", async () => { - vi.stubGlobal( - "fetch", - vi.fn(() => Promise.resolve(jsonResponse(200))), - ); - await expect(clanExistsByTag("ABC")).resolves.toBe(true); - }); - - it("returns false on HTTP 404", async () => { - vi.stubGlobal( - "fetch", - vi.fn(() => Promise.resolve(jsonResponse(404))), - ); - await expect(clanExistsByTag("XYZ")).resolves.toBe(false); - }); - - it("returns null and fails open on unexpected status (5xx)", async () => { - vi.stubGlobal( - "fetch", - vi.fn(() => Promise.resolve(jsonResponse(503))), - ); - await expect(clanExistsByTag("ABC")).resolves.toBeNull(); - }); - - it("returns null and fails open on rate-limit (429)", async () => { - vi.stubGlobal( - "fetch", - vi.fn(() => Promise.resolve(jsonResponse(429))), - ); - await expect(clanExistsByTag("ABC")).resolves.toBeNull(); - }); - - it("returns null on transport error", async () => { - vi.stubGlobal( - "fetch", - vi.fn(() => Promise.reject(new Error("offline"))), - ); - await expect(clanExistsByTag("ABC")).resolves.toBeNull(); - }); - - it("caches results across calls within TTL", async () => { - const fetchSpy = vi.fn(() => Promise.resolve(jsonResponse(200))); - vi.stubGlobal("fetch", fetchSpy); - await clanExistsByTag("ABC"); - await clanExistsByTag("ABC"); - await clanExistsByTag("ABC"); - expect(fetchSpy).toHaveBeenCalledTimes(1); - }); - - it("does not cache fail-open (null) results so transient outages recover", async () => { - const fetchSpy = vi - .fn() - .mockResolvedValueOnce(jsonResponse(503)) - .mockResolvedValueOnce(jsonResponse(200)); - vi.stubGlobal("fetch", fetchSpy); - await expect(clanExistsByTag("ABC")).resolves.toBeNull(); - await expect(clanExistsByTag("ABC")).resolves.toBe(true); - expect(fetchSpy).toHaveBeenCalledTimes(2); - }); - - it("uppercases the tag in the URL", async () => { - const fetchSpy = vi.fn( - (_input: string | URL | Request, _init?: RequestInit) => - Promise.resolve(jsonResponse(200)), - ); - vi.stubGlobal("fetch", fetchSpy); - await clanExistsByTag("abc"); - const calledUrl = fetchSpy.mock.calls[0]![0] as string; - expect(calledUrl).toContain("/public/clan/ABC/exists"); - }); - - it("treats a body {exists:false} as false on 200 (forward-compat)", async () => { - vi.stubGlobal( - "fetch", - vi.fn(() => Promise.resolve(jsonResponse(200, { exists: false }))), - ); - await expect(clanExistsByTag("ABC")).resolves.toBe(false); - }); - - it("caches by uppercased tag (different cases hit the same entry)", async () => { - const fetchSpy = vi.fn(() => Promise.resolve(jsonResponse(200))); - vi.stubGlobal("fetch", fetchSpy); - await clanExistsByTag("abc"); - await clanExistsByTag("ABC"); - await clanExistsByTag("Abc"); - expect(fetchSpy).toHaveBeenCalledTimes(1); - }); - - it("sends Accept: application/json header", async () => { - const fetchSpy = vi.fn( - (_input: string | URL | Request, _init?: RequestInit) => - Promise.resolve(jsonResponse(200)), - ); - vi.stubGlobal("fetch", fetchSpy); - await clanExistsByTag("ABC"); - const init = fetchSpy.mock.calls[0]![1] as RequestInit; - expect((init.headers as Record).Accept).toBe( - "application/json", - ); - }); -});