mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-02 19:38:07 +00:00
remove from jwt
This commit is contained in:
+73
-1
@@ -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...`);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user