remove from jwt

This commit is contained in:
Ryan Barlow
2026-05-25 19:04:27 +01:00
parent 4cf234fb7a
commit edf1d03275
3 changed files with 73 additions and 229 deletions
+73 -1
View File
@@ -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<boolean | null> {
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...`);
-87
View File
@@ -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<boolean | null> {
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;
}
}
-141
View File
@@ -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<string, string>).Accept).toBe(
"application/json",
);
});
});