Files
OpenFrontIO/tests/server/TurnstileReadmit.test.ts
T
Evan 2436eebaa7 fix: don't re-challenge Turnstile on lobby reconnect (#4420)
## Problem

A player who joins a **private** lobby and waits for the start timer can
get an alert — `connection refused: Unauthorized: Turnstile token
rejected` — the moment the game starts. Turnstile is only supposed to
gate the *first* join, so this looks wrong.

## Root cause

A websocket **reconnect during the lobby phase** re-sends the original
Turnstile token via `joinGame()` (`ClientGameRunner.ts` lobby
`onconnect` → `Transport.joinGame()`, line 417). Cloudflare Turnstile
tokens are **single-use** and `lobbyConfig.turnstileToken` is never
refreshed, so re-verifying the already-redeemed token returns `rejected`
→ `ws.close(1002, ...)` (`Worker.ts`).

Normally the server skips Turnstile for reconnects: a `join` first tries
`rejoinClient` and returns early if the player is a known member
(`Worker.ts:359-366`). But on a **lobby-phase disconnect**, the close
handler **deletes** the `persistentId → clientId` mapping to free the
slot (`GameServer.ts`, `if (!this._hasStarted) {
persistentIdToClientId.delete(...) }`). With the mapping gone,
`rejoinClient` fails and the reconnect falls through to a full join + a
doomed Turnstile re-check.

**Why at game start:** `GameManager.tick()` calls `prestart()`
immediately but schedules `start()` 2s later, so `_hasStarted` is still
`false` for ~2s — exactly while the client runs its heavy terrain-decode
+ WebGL init, which stalls the ping loop and makes a socket drop (`1006`
→ `reconnect()`) likely. A reconnect in that window re-sends the spent
token and gets rejected.

## Fix

Decouple **"was admitted"** from the slot-mapping:

- `GameServer` tracks `admittedPersistentIds` (populated on a successful
`joinClient`) that **survives** lobby-phase disconnects, plus a
`wasAdmitted()` accessor.
- `GameManager.wasAdmitted(gameID, persistentID)` exposes it.
- `Worker` skips the Turnstile check for an already-admitted player: `if
(env !== Dev && !gm.wasAdmitted(gameID, persistentId))`.

A reconnecting admitted player now proceeds through `joinClient`
normally instead of failing on the spent token.

### Safety
Only the Turnstile check is skipped. Every other gate still runs on
every join: token-signature, ban, flares, clan tag, cosmetics,
allowlist, maxPlayers, and **kick**. Genuine first joins are still
challenged (no admission record yet). The set is per-game and excludes
kicked players, and `persistentId` comes from the verified token so it
can't be spoofed.

## Testing
- New `tests/server/TurnstileReadmit.test.ts` (4 tests), incl. a
regression that fires the real `ws.on("close")` handler and asserts
`getClientIdForPersistentId` goes null **but `wasAdmitted` stays true**.
- Full server suite: 126/126 pass · `tsc --noEmit` clean · eslint clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 14:54:57 -07:00

131 lines
3.6 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../src/core/Schemas", async () => {
const actual = (await vi.importActual("../../src/core/Schemas")) as any;
return {
...actual,
GameStartInfoSchema: {
safeParse: (data: any) => ({ success: true, data }),
},
ServerPrestartMessageSchema: {
safeParse: (data: any) => ({ success: true, data }),
},
ClientMessageSchema: {
safeParse: (data: any) => ({ success: true, data }),
},
};
});
import { GameType } from "../../src/core/game/Game";
import { Client } from "../../src/server/Client";
import { GameServer } from "../../src/server/GameServer";
// Stateful mock that records listeners so a test can fire the "close" event,
// exercising GameServer's real ws.on("close") handler.
function makeMockWs() {
const listeners: Record<string, ((...args: any[]) => void)[]> = {};
return {
on(event: string, cb: (...args: any[]) => void) {
(listeners[event] ??= []).push(cb);
},
removeAllListeners() {
for (const k of Object.keys(listeners)) delete listeners[k];
},
emit(event: string, ...args: any[]) {
(listeners[event] ?? []).forEach((cb) => cb(...args));
},
send: vi.fn(),
close: vi.fn(),
readyState: 1,
};
}
function makeClient(
clientID: string,
persistentID: string,
ws: ReturnType<typeof makeMockWs>,
): Client {
return new Client(
clientID,
persistentID,
null,
null,
undefined,
"127.0.0.1",
"TestUser",
null,
ws as any,
undefined,
undefined,
[],
);
}
describe("GameServer - wasAdmitted (Turnstile re-admission)", () => {
let mockLogger: any;
beforeEach(() => {
vi.useFakeTimers();
mockLogger = {
child: vi.fn().mockReturnThis(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
});
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllTimers();
vi.useRealTimers();
});
function makeGame() {
return new GameServer("test-game", mockLogger, Date.now(), {
gameType: GameType.Private,
} as any);
}
it("reports unknown players as not admitted", () => {
const game = makeGame();
expect(game.wasAdmitted("nobody")).toBe(false);
});
it("marks a player admitted after a successful join", () => {
const game = makeGame();
expect(game.joinClient(makeClient("c1", "p1", makeMockWs()))).toBe(
"joined",
);
expect(game.wasAdmitted("p1")).toBe(true);
});
// Core regression: a lobby-phase disconnect clears the reconnect mapping (to
// free the slot), but admission must survive so the reconnect skips the
// single-use Turnstile re-check instead of failing on the spent token.
it("keeps a player admitted after a lobby-phase disconnect clears their reconnect mapping", () => {
const game = makeGame();
const ws = makeMockWs();
expect(game.joinClient(makeClient("c1", "p1", ws))).toBe("joined");
expect(game.getClientIdForPersistentId("p1")).toBe("c1");
expect(game.wasAdmitted("p1")).toBe(true);
// Socket drops before the game starts -> the close handler clears the
// persistentID->clientID mapping.
ws.emit("close");
expect(game.getClientIdForPersistentId("p1")).toBeNull();
expect(game.wasAdmitted("p1")).toBe(true);
});
it("does not treat a kicked player as admitted (kick still forces the gate)", () => {
const game = makeGame();
expect(game.joinClient(makeClient("c1", "p1", makeMockWs()))).toBe(
"joined",
);
expect(game.wasAdmitted("p1")).toBe(true);
game.kickClient("c1");
expect(game.wasAdmitted("p1")).toBe(false);
});
});