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>
This commit is contained in:
Scott Anderson
2025-05-12 14:51:40 -04:00
committed by GitHub
parent b402a3549e
commit f8a052a6ce
16 changed files with 123 additions and 20 deletions
+2 -2
View File
@@ -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();
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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),
+1 -1
View File
@@ -5,7 +5,7 @@ import {
TokenPayloadSchema,
UserMeResponse,
UserMeResponseSchema,
} from "./ApiSchemas";
} from "../core/ApiSchemas";
function getAudience() {
const { hostname } = new URL(window.location.href);
+27 -1
View File
@@ -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,
+4 -1
View File
@@ -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<JWK>;
}
export interface NukeMagnitude {
+30 -1
View File
@@ -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<JWK> {
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;
}
+3 -3
View File
@@ -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";
}
+3 -3
View File
@@ -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";
}
})();
+2 -2
View File
@@ -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";
}
})();
+2
View File
@@ -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,
+8 -1
View File
@@ -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,
+29
View File
@@ -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<TokenVerificationResult> {
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 };
}
+10 -3
View File
@@ -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<JWK> {
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.");
}