From f8a052a6cecbdfc84903c41ebb4dd7aeb47d54c2 Mon Sep 17 00:00:00 2001 From: Scott Anderson Date: Mon, 12 May 2025 14:51:40 -0400 Subject: [PATCH] Client JWT authentication (#723) ## Description: Send JWT to the game server for verification. ## Please complete the following: - [x] I have added screenshots for all UI updates - [ ] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors --------- Co-authored-by: Scott Anderson <662325+scottanderson@users.noreply.github.com> --- src/client/ClientGameRunner.ts | 4 ++-- src/client/Main.ts | 2 +- src/client/Transport.ts | 2 +- src/client/jwt.ts | 2 +- src/{client => core}/ApiSchemas.ts | 0 src/{client => core}/Base64.ts | 0 src/core/Schemas.ts | 28 +++++++++++++++++++++- src/core/configuration/Config.ts | 5 +++- src/core/configuration/DefaultConfig.ts | 31 ++++++++++++++++++++++++- src/core/configuration/DevConfig.ts | 6 ++--- src/core/configuration/PreprodConfig.ts | 6 ++--- src/core/configuration/ProdConfig.ts | 4 ++-- src/server/Client.ts | 2 ++ src/server/Worker.ts | 9 ++++++- src/server/jwt.ts | 29 +++++++++++++++++++++++ tests/util/TestServerConfig.ts | 13 ++++++++--- 16 files changed, 123 insertions(+), 20 deletions(-) rename src/{client => core}/ApiSchemas.ts (100%) rename src/{client => core}/Base64.ts (100%) create mode 100644 src/server/jwt.ts diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index d0c4de33c..1e89ff700 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -43,7 +43,7 @@ export interface LobbyConfig { playerName: string; clientID: ClientID; gameID: GameID; - persistentID: string; + token: string; // GameStartInfo only exists when playing a singleplayer game. gameStartInfo?: GameStartInfo; // GameRecord exists when replaying an archived game. @@ -59,7 +59,7 @@ export function joinLobby( initRemoteSender(eventBus); consolex.log( - `joinging lobby: gameID: ${lobbyConfig.gameID}, clientID: ${lobbyConfig.clientID}, persistentID: ${lobbyConfig.persistentID.slice(0, 5)}`, + `joinging lobby: gameID: ${lobbyConfig.gameID}, clientID: ${lobbyConfig.clientID}`, ); const userSettings: UserSettings = new UserSettings(); diff --git a/src/client/Main.ts b/src/client/Main.ts index 9fc9e0f2c..54e5cd2e5 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -273,7 +273,7 @@ class Client { ? "" : this.flagInput.getCurrentFlag(), playerName: this.usernameInput.getCurrentUsername(), - persistentID: getPersistentIDFromCookie(), + token: localStorage.getItem("token") ?? getPersistentIDFromCookie(), clientID: lobby.clientID, gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.gameStartInfo, gameRecord: lobby.gameRecord, diff --git a/src/client/Transport.ts b/src/client/Transport.ts index 6c52e4e17..a9659ba49 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -346,7 +346,7 @@ export class Transport { gameID: this.lobbyConfig.gameID, clientID: this.lobbyConfig.clientID, lastTurn: numTurns, - persistentID: this.lobbyConfig.persistentID, + token: this.lobbyConfig.token, username: this.lobbyConfig.playerName, flag: this.lobbyConfig.flag, } satisfies ClientJoinMessage), diff --git a/src/client/jwt.ts b/src/client/jwt.ts index 21f25f8e5..b9e194b6a 100644 --- a/src/client/jwt.ts +++ b/src/client/jwt.ts @@ -5,7 +5,7 @@ import { TokenPayloadSchema, UserMeResponse, UserMeResponseSchema, -} from "./ApiSchemas"; +} from "../core/ApiSchemas"; function getAudience() { const { hostname } = new URL(window.location.href); diff --git a/src/client/ApiSchemas.ts b/src/core/ApiSchemas.ts similarity index 100% rename from src/client/ApiSchemas.ts rename to src/core/ApiSchemas.ts diff --git a/src/client/Base64.ts b/src/core/Base64.ts similarity index 100% rename from src/client/Base64.ts rename to src/core/Base64.ts diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 1e03c623c..f0d8e8900 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -138,7 +138,33 @@ const SafeString = z ) .max(1000); +const jwtRegex = /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*$/; +// Copied from zod, modified to remove their erroneous `typ` header requirement +function isValidJWT(jwt: string, alg?: string): boolean { + if (!jwtRegex.test(jwt)) return false; + try { + const [header] = jwt.split("."); + // Convert base64url to base64 + const base64 = header + .replace(/-/g, "+") + .replace(/_/g, "/") + .padEnd(header.length + ((4 - (header.length % 4)) % 4), "="); + const decoded = JSON.parse(atob(base64)); + if (typeof decoded !== "object" || decoded === null) return false; + if (!decoded.alg) return false; + if (alg && decoded.alg !== alg) return false; + return true; + } catch { + return false; + } +} + const PersistentIdSchema = z.string().uuid(); +const TokenSchema = z + .string() + .refine((v) => PersistentIdSchema.safeParse(v).success || isValidJWT(v), { + message: "Token must be a valid UUID or JWT", + }); const EmojiSchema = z .number() @@ -405,7 +431,7 @@ export const ClientIntentMessageSchema = z.object({ export const ClientJoinMessageSchema = z.object({ type: z.literal("join"), clientID: ID, - persistentID: PersistentIdSchema, // WARNING: PII + token: TokenSchema, // WARNING: PII gameID: ID, lastTurn: z.number(), // The last turn the client saw. username: SafeString, diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 26564eaf5..d789d52b2 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -1,4 +1,5 @@ import { Colord } from "colord"; +import { JWK } from "jose"; import { GameConfig, GameID } from "../Schemas"; import { Difficulty, @@ -29,7 +30,6 @@ export interface ServerConfig { turnIntervalMs(): number; gameCreationRate(): number; lobbyMaxPlayers(map: GameMapType, mode: GameMode): number; - discordRedirectURI(): string; numWorkers(): number; workerIndex(gameID: GameID): number; workerPath(gameID: GameID): string; @@ -49,6 +49,9 @@ export interface ServerConfig { otelUsername(): string; otelPassword(): string; otelEnabled(): boolean; + jwtAudience(): string; + jwtIssuer(): string; + jwkPublicKey(): Promise; } export interface NukeMagnitude { diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 12c1b63d8..841976d0c 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -1,3 +1,5 @@ +import { JWK } from "jose"; +import { z } from "zod"; import { Difficulty, Duos, @@ -24,7 +26,35 @@ import { Config, GameEnv, NukeMagnitude, ServerConfig, Theme } from "./Config"; import { pastelTheme } from "./PastelTheme"; import { pastelThemeDark } from "./PastelThemeDark"; +const JwksSchema = z.object({ + keys: z + .object({ + alg: z.literal("EdDSA"), + crv: z.literal("Ed25519"), + kty: z.literal("OKP"), + x: z.string(), + }) + .array() + .min(1), +}); + export abstract class DefaultServerConfig implements ServerConfig { + private publicKey: JWK; + abstract jwtAudience(): string; + jwtIssuer(): string { + const audience = this.jwtAudience(); + return audience === "localhost" + ? "http://localhost:8787" + : `https://api.${audience}`; + } + async jwkPublicKey(): Promise { + if (this.publicKey) return this.publicKey; + const jwksUrl = this.jwtIssuer() + "/.well-known/jwks.json"; + const response = await fetch(jwksUrl); + const jwks = JwksSchema.parse(await response.json()); + this.publicKey = jwks.keys[0]; + return this.publicKey; + } otelEnabled(): boolean { return Boolean( this.otelEndpoint() && this.otelUsername() && this.otelPassword(), @@ -70,7 +100,6 @@ export abstract class DefaultServerConfig implements ServerConfig { } abstract numWorkers(): number; abstract env(): GameEnv; - abstract discordRedirectURI(): string; turnIntervalMs(): number { return 100; } diff --git a/src/core/configuration/DevConfig.ts b/src/core/configuration/DevConfig.ts index 0f6f0f019..4133a9620 100644 --- a/src/core/configuration/DevConfig.ts +++ b/src/core/configuration/DevConfig.ts @@ -29,12 +29,12 @@ export class DevServerConfig extends DefaultServerConfig { return 1; } - discordRedirectURI(): string { - return "http://localhost:3000/auth/callback"; - } numWorkers(): number { return 2; } + jwtAudience(): string { + return "localhost"; + } gitCommit(): string { return "DEV"; } diff --git a/src/core/configuration/PreprodConfig.ts b/src/core/configuration/PreprodConfig.ts index f1ade263f..3a842c88d 100644 --- a/src/core/configuration/PreprodConfig.ts +++ b/src/core/configuration/PreprodConfig.ts @@ -5,10 +5,10 @@ export const preprodConfig = new (class extends DefaultServerConfig { env(): GameEnv { return GameEnv.Preprod; } - discordRedirectURI(): string { - return "https://openfront.dev/auth/callback"; - } numWorkers(): number { return 3; } + jwtAudience(): string { + return "openfront.dev"; + } })(); diff --git a/src/core/configuration/ProdConfig.ts b/src/core/configuration/ProdConfig.ts index 90281e7b2..56e4a4f74 100644 --- a/src/core/configuration/ProdConfig.ts +++ b/src/core/configuration/ProdConfig.ts @@ -8,7 +8,7 @@ export const prodConfig = new (class extends DefaultServerConfig { env(): GameEnv { return GameEnv.Prod; } - discordRedirectURI(): string { - return "https://openfront.io/auth/callback"; + jwtAudience(): string { + return "openfront.io"; } })(); diff --git a/src/server/Client.ts b/src/server/Client.ts index 27bbd8d75..a3587c3cd 100644 --- a/src/server/Client.ts +++ b/src/server/Client.ts @@ -1,4 +1,5 @@ import WebSocket from "ws"; +import { TokenPayload } from "../core/ApiSchemas"; import { PlayerID, Tick } from "../core/game/Game"; import { ClientID } from "../core/Schemas"; import { generateID } from "../core/Util"; @@ -13,6 +14,7 @@ export class Client { constructor( public readonly clientID: ClientID, public readonly persistentID: string, + public readonly claims: TokenPayload | null, public readonly ip: string, public readonly username: string, public readonly ws: WebSocket, diff --git a/src/server/Worker.ts b/src/server/Worker.ts index fe6d1fb72..f711f1237 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -13,6 +13,7 @@ import { archive, readGameRecord } from "./Archive"; import { Client } from "./Client"; import { GameManager } from "./GameManager"; import { gatekeeper, LimiterType } from "./Gatekeeper"; +import { verifyClientToken } from "./jwt"; import { logger } from "./Logger"; import { initWorkerMetrics } from "./WorkerMetrics"; @@ -301,10 +302,16 @@ export function startWorker() { return; } + const { persistentId, claims } = await verifyClientToken( + clientMsg.token, + config, + ); + // Create client and add to game const client = new Client( clientMsg.clientID, - clientMsg.persistentID, + persistentId, + claims ?? null, ip, clientMsg.username, ws, diff --git a/src/server/jwt.ts b/src/server/jwt.ts new file mode 100644 index 000000000..150402a5f --- /dev/null +++ b/src/server/jwt.ts @@ -0,0 +1,29 @@ +import { jwtVerify } from "jose"; +import { TokenPayload, TokenPayloadSchema } from "../core/ApiSchemas"; +import { ServerConfig } from "../core/configuration/Config"; + +type TokenVerificationResult = { + persistentId: string; + claims: TokenPayload | null; +}; + +export async function verifyClientToken( + token: string, + config: ServerConfig, +): Promise { + if (token.length === 36) { + return { persistentId: token, claims: null }; + } + const issuer = config.jwtIssuer(); + const audience = config.jwtAudience(); + const key = await config.jwkPublicKey(); + const { payload, protectedHeader } = await jwtVerify(token, key, { + algorithms: ["EdDSA"], + issuer, + audience, + maxTokenAge: "6 days", + }); + const claims = TokenPayloadSchema.parse(payload); + const persistentId = claims.sub; + return { persistentId, claims }; +} diff --git a/tests/util/TestServerConfig.ts b/tests/util/TestServerConfig.ts index 6921c6dc6..4c1f76eb4 100644 --- a/tests/util/TestServerConfig.ts +++ b/tests/util/TestServerConfig.ts @@ -1,8 +1,18 @@ +import { JWK } from "jose"; import { GameEnv, ServerConfig } from "../../src/core/configuration/Config"; import { GameMapType } from "../../src/core/game/Game"; import { GameID } from "../../src/core/Schemas"; export class TestServerConfig implements ServerConfig { + jwtAudience(): string { + throw new Error("Method not implemented."); + } + jwtIssuer(): string { + throw new Error("Method not implemented."); + } + jwkPublicKey(): Promise { + throw new Error("Method not implemented."); + } otelEnabled(): boolean { throw new Error("Method not implemented."); } @@ -27,9 +37,6 @@ export class TestServerConfig implements ServerConfig { lobbyMaxPlayers(map: GameMapType): number { throw new Error("Method not implemented."); } - discordRedirectURI(): string { - throw new Error("Method not implemented."); - } numWorkers(): number { throw new Error("Method not implemented."); }