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
+48
View File
@@ -0,0 +1,48 @@
import { z } from "zod";
import { base64urlToUuid } from "./Base64";
export const RefreshResponseSchema = z.object({
token: z.string(),
});
export type RefreshResponse = z.infer<typeof RefreshResponseSchema>;
export const TokenPayloadSchema = z.object({
jti: z.string(),
sub: z
.string()
.refine(
(val) => {
const uuid = base64urlToUuid(val);
return uuid != null;
},
{
message: "Invalid base64-encoded UUID",
},
)
.transform((val) => {
const uuid = base64urlToUuid(val);
if (!uuid) throw new Error("Invalid base64 UUID");
return uuid;
}),
iat: z.number(),
iss: z.string(),
aud: z.string(),
exp: z.number(),
rol: z
.string()
.optional()
.transform((val) => val.split(",")),
});
export type TokenPayload = z.infer<typeof TokenPayloadSchema>;
export const UserMeResponseSchema = z.object({
user: z.object({
id: z.string(),
avatar: z.string(),
username: z.string(),
global_name: z.string(),
discriminator: z.string(),
locale: z.string(),
}),
});
export type UserMeResponse = z.infer<typeof UserMeResponseSchema>;
+37
View File
@@ -0,0 +1,37 @@
import { base64url } from "jose";
/**
* Converts a UUID string to a base64url-encoded binary representation.
* @param uuid - The UUID string (e.g., '123e4567-e89b-12d3-a456-426614174000')
* @returns base64url string (e.g., 'Ej5FZ+i7EtOkVkJmFBdAAA')
*/
export function uuidToBase64url(uuid: string): string {
const hex = uuid.replace(/-/g, "");
const bytes = new Uint8Array(16);
for (let i = 0; i < 16; i++) {
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
return base64url.encode(bytes);
}
/**
* Converts a base64url-encoded binary UUID back to its canonical UUID string.
* @param encoded - base64url string (e.g., 'Ej5FZ+i7EtOkVkJmFBdAAA')
* @returns UUID string (e.g., '123e4567-e89b-12d3-a456-426614174000')
*/
export function base64urlToUuid(encoded: string): string {
const bytes = base64url.decode(encoded);
const hex = Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return [
hex.slice(0, 8),
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20),
].join("-");
}
+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";
}
})();