mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:20:47 +00:00
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:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -5,7 +5,7 @@ import {
|
||||
TokenPayloadSchema,
|
||||
UserMeResponse,
|
||||
UserMeResponseSchema,
|
||||
} from "./ApiSchemas";
|
||||
} from "../core/ApiSchemas";
|
||||
|
||||
function getAudience() {
|
||||
const { hostname } = new URL(window.location.href);
|
||||
|
||||
+27
-1
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user