diff --git a/src/client/Main.ts b/src/client/Main.ts
index 684881255..467ca08f8 100644
--- a/src/client/Main.ts
+++ b/src/client/Main.ts
@@ -1,7 +1,9 @@
import page from "page";
import favicon from "../../resources/images/Favicon.svg";
+import { UserMeResponse } from "../core/ApiSchemas";
import { consolex } from "../core/Consolex";
import { GameRecord, GameStartInfo } from "../core/Schemas";
+import { ServerConfig } from "../core/configuration/Config";
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
import { GameType } from "../core/game/Game";
import { UserSettings } from "../core/game/UserSettings";
@@ -173,12 +175,103 @@ class Client {
hlpModal.open();
});
+ loginDiscordButton.addEventListener("click", discordLogin);
+ const onUserMe = async (userMeResponse: UserMeResponse | false) => {
+ const config = await getServerConfigFromClient();
+ if (!hasAllowedFlare(userMeResponse, config)) {
+ if (userMeResponse === false) {
+ // Login is required
+ document.body.innerHTML = `
+
+
+
Login is required to access this website.
+
You are being redirected...
+
+
+
+
+
+ `;
+ setTimeout(discordLogin, 5000);
+ } else {
+ // Unauthorized
+ document.body.innerHTML = `
+
+
+
You are not authorized to access this website.
+
If you believe you are seeing this message in error, please contact the website administrator.
+
+
+
+ `;
+ }
+ return;
+ } else if (userMeResponse === false) {
+ // Not logged in
+ loginDiscordButton.disable = false;
+ loginDiscordButton.hidden = false;
+ loginDiscordButton.translationKey = "main.login_discord";
+ logoutDiscordButton.hidden = true;
+ } else {
+ // Authorized
+ console.log(
+ `Your player ID is ${userMeResponse.player.publicId}\n` +
+ "Sharing this ID will allow others to view your game history and stats.",
+ );
+ loginDiscordButton.translationKey = "main.logged_in";
+ loginDiscordButton.hidden = true;
+ }
+ };
+
if (isLoggedIn() === false) {
// Not logged in
- loginDiscordButton.disable = false;
- loginDiscordButton.translationKey = "main.login_discord";
- loginDiscordButton.addEventListener("click", discordLogin);
- logoutDiscordButton.hidden = true;
+ onUserMe(false);
} else {
// JWT appears to be valid
loginDiscordButton.disable = true;
@@ -187,30 +280,11 @@ class Client {
logoutDiscordButton.addEventListener("click", () => {
// Log out
logOut();
- loginDiscordButton.disable = false;
- loginDiscordButton.translationKey = "main.login_discord";
- loginDiscordButton.addEventListener("click", discordLogin);
- logoutDiscordButton.hidden = true;
+ onUserMe(false);
});
// Look up the discord user object.
// TODO: Add caching
- getUserMe().then((userMeResponse) => {
- if (userMeResponse === false) {
- // Not logged in
- loginDiscordButton.disable = false;
- loginDiscordButton.translationKey = "main.login_discord";
- loginDiscordButton.addEventListener("click", discordLogin);
- logoutDiscordButton.hidden = true;
- return;
- }
- console.log(
- `Your player ID is ${userMeResponse.player.publicId}\n` +
- "Sharing this ID will allow others to view your game history and stats.",
- );
- loginDiscordButton.translationKey = "main.logged_in";
- loginDiscordButton.hidden = true;
- const { user, player } = userMeResponse;
- });
+ getUserMe().then(onUserMe);
}
const settingsModal = document.querySelector(
@@ -427,3 +501,15 @@ function getPersistentIDFromCookie(): string {
return newID;
}
+
+function hasAllowedFlare(
+ userMeResponse: UserMeResponse | false,
+ config: ServerConfig,
+) {
+ const allowed = config.allowedFlares();
+ if (allowed === undefined) return true;
+ if (userMeResponse === false) return false;
+ const flares = userMeResponse.player.flares;
+ if (flares === undefined) return false;
+ return allowed.length === 0 || allowed.some((f) => flares.includes(f));
+}
diff --git a/src/client/jwt.ts b/src/client/jwt.ts
index 4fdf8ae15..7a8d94330 100644
--- a/src/client/jwt.ts
+++ b/src/client/jwt.ts
@@ -7,6 +7,7 @@ import {
UserMeResponse,
UserMeResponseSchema,
} from "../core/ApiSchemas";
+import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
function getAudience() {
const { hostname } = new URL(window.location.href);
@@ -22,6 +23,7 @@ function getApiBase() {
}
function getToken(): string | null {
+ // Check window hash
const { hash } = window.location;
if (hash.startsWith("#")) {
const params = new URLSearchParams(hash.slice(1));
@@ -33,21 +35,44 @@ function getToken(): string | null {
history.replaceState(
null,
"",
- window.location.pathname + window.location.search,
+ window.location.pathname +
+ window.location.search +
+ (params.size > 0 ? "#" + params.toString() : ""),
);
}
+
+ // Check cookie
+ const cookie = document.cookie
+ .split(";")
+ .find((c) => c.trim().startsWith("token="))
+ ?.trim()
+ .substring(6);
+ if (cookie !== undefined) {
+ return cookie;
+ }
+
+ // Check local storage
return localStorage.getItem("token");
}
+async function clearToken() {
+ localStorage.removeItem("token");
+ __isLoggedIn = false;
+ const config = await getServerConfigFromClient();
+ const audience = config.jwtAudience();
+ const isSecure = window.location.protocol === "https:";
+ const secure = isSecure ? "; Secure" : "";
+ document.cookie = `token=logged_out; Path=/; Max-Age=0; Domain=${audience}${secure}`;
+}
+
export function discordLogin() {
window.location.href = `${getApiBase()}/login/discord?redirect_uri=${window.location.href}`;
}
export async function logOut(allSessions: boolean = false) {
- const token = localStorage.getItem("token");
+ const token = getToken();
if (token === null) return;
- localStorage.removeItem("token");
- __isLoggedIn = false;
+ clearToken();
const response = await fetch(
getApiBase() + (allSessions ? "/revoke" : "/logout"),
@@ -165,6 +190,10 @@ export async function postRefresh(): Promise {
authorization: `Bearer ${token}`,
},
});
+ if (response.status === 401) {
+ clearToken();
+ return false;
+ }
if (response.status !== 200) return false;
const body = await response.json();
const result = RefreshResponseSchema.safeParse(body);
@@ -192,6 +221,10 @@ export async function getUserMe(): Promise {
authorization: `Bearer ${token}`,
},
});
+ if (response.status === 401) {
+ clearToken();
+ return false;
+ }
if (response.status !== 200) return false;
const body = await response.json();
const result = UserMeResponseSchema.safeParse(body);
diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts
index aec1ae506..1f5bcd2bd 100644
--- a/src/core/ApiSchemas.ts
+++ b/src/core/ApiSchemas.ts
@@ -1,4 +1,4 @@
-import { z } from "zod";
+import { z } from "zod/v4";
import { base64urlToUuid } from "./Base64";
export const RefreshResponseSchema = z.object({
@@ -43,6 +43,7 @@ export const UserMeResponseSchema = z.object({
player: z.object({
publicId: z.string(),
roles: z.string().array().optional(),
+ flares: z.string().array().optional(),
}),
});
export type UserMeResponse = z.infer;
diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts
index e6f4af76f..27f5e5e6b 100644
--- a/src/core/configuration/Config.ts
+++ b/src/core/configuration/Config.ts
@@ -56,6 +56,14 @@ export interface ServerConfig {
jwtAudience(): string;
jwtIssuer(): string;
jwkPublicKey(): Promise;
+ domain(): string;
+ subdomain(): string;
+ cloudflareAccountId(): string;
+ cloudflareApiToken(): string;
+ cloudflareConfigPath(): string;
+ cloudflareCredsPath(): string;
+ stripePublishableKey(): string;
+ allowedFlares(): string[] | undefined;
}
export interface NukeMagnitude {
diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts
index 894c1d3c3..5410b95bf 100644
--- a/src/core/configuration/DefaultConfig.ts
+++ b/src/core/configuration/DefaultConfig.ts
@@ -68,6 +68,31 @@ const numPlayersConfig = {
} as const satisfies Record;
export abstract class DefaultServerConfig implements ServerConfig {
+ allowedFlares(): string[] | undefined {
+ return;
+ }
+ stripePublishableKey(): string {
+ return process.env.STRIPE_PUBLISHABLE_KEY ?? "";
+ }
+ domain(): string {
+ return process.env.DOMAIN ?? "";
+ }
+ subdomain(): string {
+ return process.env.SUBDOMAIN ?? "";
+ }
+ cloudflareAccountId(): string {
+ return process.env.CF_ACCOUNT_ID ?? "";
+ }
+ cloudflareApiToken(): string {
+ return process.env.CF_API_TOKEN ?? "";
+ }
+ cloudflareConfigPath(): string {
+ return process.env.CF_CONFIG_PATH ?? "";
+ }
+ cloudflareCredsPath(): string {
+ return process.env.CF_CREDS_PATH ?? "";
+ }
+
private publicKey: JWK;
abstract jwtAudience(): string;
jwtIssuer(): string {
diff --git a/src/core/configuration/DevConfig.ts b/src/core/configuration/DevConfig.ts
index 9da20511a..95b3c2412 100644
--- a/src/core/configuration/DevConfig.ts
+++ b/src/core/configuration/DevConfig.ts
@@ -38,6 +38,19 @@ export class DevServerConfig extends DefaultServerConfig {
gitCommit(): string {
return "DEV";
}
+
+ domain(): string {
+ return "localhost";
+ }
+
+ subdomain(): string {
+ return "";
+ }
+ allowedFlares(): string[] | undefined {
+ return [
+ // Require login but do not rqeuire any flares
+ ];
+ }
}
export class DevConfig extends DefaultConfig {
diff --git a/src/core/configuration/PreprodConfig.ts b/src/core/configuration/PreprodConfig.ts
index 78662578c..a0567aaa9 100644
--- a/src/core/configuration/PreprodConfig.ts
+++ b/src/core/configuration/PreprodConfig.ts
@@ -11,4 +11,9 @@ export const preprodConfig = new (class extends DefaultServerConfig {
jwtAudience(): string {
return "openfront.dev";
}
+ allowedFlares(): string[] | undefined {
+ return [
+ // "access:openfront.dev"
+ ];
+ }
})();
diff --git a/src/server/Worker.ts b/src/server/Worker.ts
index 068799fed..69471d44c 100644
--- a/src/server/Worker.ts
+++ b/src/server/Worker.ts
@@ -309,51 +309,80 @@ export function startWorker() {
return;
}
- const { persistentId, claims } = await verifyClientToken(
- clientMsg.token,
- config,
- );
+ // Verify token signature
+ const result = await verifyClientToken(clientMsg.token, config);
+ if (result.claims === null) {
+ log.warn("Unauthorized: Invalid token");
+ ws.close(1002, "Unauthorized");
+ return;
+ }
+ const { persistentId, claims } = result;
let roles: string[] | undefined;
+ let flares: string[] | undefined;
- // Check user roles
- if (claims !== null) {
+ const allowedFlares = config.allowedFlares();
+ if (claims === null) {
+ if (allowedFlares !== undefined) {
+ log.warn("Unauthorized: Anonymous user attempted to join game");
+ ws.close(1002, "Unauthorized");
+ return;
+ }
+ } else {
+ // Verify token and get player permissions
const result = await getUserMe(clientMsg.token, config);
if (result === false) {
- log.warn("Token is not valid", claims);
+ log.warn("Unauthorized: Invalid session");
+ ws.close(1002, "Unauthorized");
return;
}
roles = result.player.roles;
+ flares = result.player.flares;
+
+ if (allowedFlares !== undefined) {
+ const allowed =
+ allowedFlares.length === 0 ||
+ allowedFlares.some((f) => flares?.includes(f));
+ if (!allowed) {
+ log.warn(
+ "Forbidden: player without an allowed flare attempted to join game",
+ );
+ ws.close(1002, "Forbidden");
+ return;
+ }
+ }
}
- // TODO: Validate client settings based on roles
+ // Check if the flag is allowed
+ if (clientMsg.flag !== undefined) {
+ // TODO: Validate client settings based on roles
- // Create client and add to game
- const client = new Client(
- clientMsg.clientID,
- persistentId,
- claims,
- roles,
- ip,
- clientMsg.username,
- ws,
- clientMsg.flag,
- );
-
- const wasFound = gm.addClient(
- client,
- clientMsg.gameID,
- clientMsg.lastTurn,
- );
-
- if (!wasFound) {
- log.info(
- `game ${clientMsg.gameID} not found on worker ${workerId}`,
+ // Create client and add to game
+ const client = new Client(
+ clientMsg.clientID,
+ persistentId,
+ claims,
+ roles,
+ ip,
+ clientMsg.username,
+ ws,
+ clientMsg.flag,
);
- // Handle game not found case
+
+ const wasFound = gm.addClient(
+ client,
+ clientMsg.gameID,
+ clientMsg.lastTurn,
+ );
+
+ if (!wasFound) {
+ log.info(
+ `game ${clientMsg.gameID} not found on worker ${workerId}`,
+ );
+ // Handle game not found case
+ }
}
}
-
// Handle other message types
} catch (error) {
log.warn(
diff --git a/tests/util/TestServerConfig.ts b/tests/util/TestServerConfig.ts
index 4c1f76eb4..c5e34ab30 100644
--- a/tests/util/TestServerConfig.ts
+++ b/tests/util/TestServerConfig.ts
@@ -4,6 +4,30 @@ import { GameMapType } from "../../src/core/game/Game";
import { GameID } from "../../src/core/Schemas";
export class TestServerConfig implements ServerConfig {
+ allowedFlares(): string[] | undefined {
+ throw new Error("Method not implemented.");
+ }
+ stripePublishableKey(): string {
+ throw new Error("Method not implemented.");
+ }
+ cloudflareConfigPath(): string {
+ throw new Error("Method not implemented.");
+ }
+ cloudflareCredsPath(): string {
+ throw new Error("Method not implemented.");
+ }
+ domain(): string {
+ throw new Error("Method not implemented.");
+ }
+ subdomain(): string {
+ throw new Error("Method not implemented.");
+ }
+ cloudflareAccountId(): string {
+ throw new Error("Method not implemented.");
+ }
+ cloudflareApiToken(): string {
+ throw new Error("Method not implemented.");
+ }
jwtAudience(): string {
throw new Error("Method not implemented.");
}