diff --git a/package-lock.json b/package-lock.json index 85241cb7f..0fadc421a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "html-webpack-plugin": "^5.6.3", "ip-anonymize": "^0.1.0", "jimp": "^0.22.12", + "jose": "^6.0.10", "lit": "^3.2.1", "msgpack5": "^6.0.2", "nanoid": "^3.3.6", @@ -13456,6 +13457,15 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jose": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.0.10.tgz", + "integrity": "sha512-skIAxZqcMkOrSwjJvplIPYrlXGpxTPnro2/QWTDCxAdWQrSTV5/KqspMWmi5WAx5+ULswASJiZ0a+1B/Lxt9cw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/jpeg-js": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", diff --git a/package.json b/package.json index 6ef7184a9..5966cc52e 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,7 @@ "html-webpack-plugin": "^5.6.3", "ip-anonymize": "^0.1.0", "jimp": "^0.22.12", + "jose": "^6.0.10", "lit": "^3.2.1", "msgpack5": "^6.0.2", "nanoid": "^3.3.6", diff --git a/resources/lang/en.json b/resources/lang/en.json index d7e37c046..d1cfe0f78 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -8,6 +8,8 @@ "main": { "title": "OpenFront (ALPHA)", "join_discord": "Join the Discord!", + "login_discord": "Login with Discord", + "logged_in": "Logged in!", "create_lobby": "Create Lobby", "join_lobby": "Join Lobby", "single_player": "Single Player", diff --git a/src/client/ApiSchemas.ts b/src/client/ApiSchemas.ts index 2aa164048..b3aa8e8e6 100644 --- a/src/client/ApiSchemas.ts +++ b/src/client/ApiSchemas.ts @@ -1,5 +1,15 @@ import { z } from "zod"; +export const TokenPayloadSchema = z.object({ + jti: z.string(), + sub: z.string().uuid(), + iat: z.number(), + iss: z.string(), + aud: z.string(), + exp: z.number(), +}); +export type TokenPayload = z.infer; + export const UserMeResponseSchema = z.object({ user: z.object({ id: z.string(), diff --git a/src/client/Main.ts b/src/client/Main.ts index 3769f3b3d..ef6251560 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -5,7 +5,6 @@ import { GameRecord, GameStartInfo } from "../core/Schemas"; import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; import { GameType } from "../core/game/Game"; import { UserSettings } from "../core/game/UserSettings"; -import { UserMeResponse, UserMeResponseSchema } from "./ApiSchemas"; import { joinLobby } from "./ClientGameRunner"; import "./DarkModeButton"; import { DarkModeButton } from "./DarkModeButton"; @@ -30,7 +29,9 @@ import "./UsernameInput"; import { UsernameInput } from "./UsernameInput"; import { generateCryptoRandomUUID } from "./Utils"; import "./components/baseComponents/Button"; +import { OButton } from "./components/baseComponents/Button"; import "./components/baseComponents/Modal"; +import { discordLogin, isLoggedIn } from "./jwt"; import "./styles.css"; export interface JoinLobbyEvent { @@ -59,21 +60,6 @@ class Client { constructor() {} initialize(): void { - const { hash } = window.location; - if (hash.startsWith("#")) { - const params = new URLSearchParams(hash.slice(1)); - const token = params.get("token"); - if (token) { - localStorage.setItem("token", token); - } - // Clean the URL - history.replaceState( - null, - "", - window.location.pathname + window.location.search, - ); - } - const langSelector = document.querySelector( "lang-selector", ) as LangSelector; @@ -106,21 +92,24 @@ class Client { consolex.warn("Random name button element not found"); } - const loginDiscordButton = document.getElementById("login-discord"); - isLoggedIn().then((loggedIn) => { - if (loggedIn !== false) { - console.log("Logged in", JSON.stringify(loggedIn, null, 2)); - const { user } = loggedIn; - const { id, avatar, username, global_name, discriminator } = user; - const avatarUrl = avatar - ? `https://cdn.discordapp.com/avatars/${id}/${avatar}.${avatar.startsWith("a_") ? "gif" : "png"}` - : `https://cdn.discordapp.com/embed/avatars/${Number(discriminator) % 5}.png`; - // TODO: Update the page for logged in user - } else { - localStorage.removeItem("token"); - loginDiscordButton.addEventListener("click", discordLogin); - } - }); + const loginDiscordButton = document.getElementById( + "login-discord", + ) as OButton; + const claims = isLoggedIn(); + if (claims === false) { + // Not logged in + loginDiscordButton.disable = false; + loginDiscordButton.translationKey = "main.login_discord"; + loginDiscordButton.addEventListener("click", discordLogin); + } else { + // Logged in + loginDiscordButton.disable = true; + loginDiscordButton.translationKey = "main.logged_in"; + // const avatarUrl = avatar + // ? `https://cdn.discordapp.com/avatars/${id}/${avatar}.${avatar.startsWith("a_") ? "gif" : "png"}` + // : `https://cdn.discordapp.com/embed/avatars/${Number(discriminator) % 5}.png`; + // TODO: Update the page for logged in user + } this.usernameInput = document.querySelector( "username-input", @@ -315,32 +304,6 @@ document.addEventListener("DOMContentLoaded", () => { new Client().initialize(); }); -async function isLoggedIn(): Promise { - try { - const token = localStorage.getItem("token"); - if (!token) return false; - const response = await fetch(getApiBase() + "/users/@me", { - headers: { - authorization: `Bearer ${token}`, - }, - }); - if (response.status !== 200) return false; - const body = await response.json(); - const result = UserMeResponseSchema.safeParse(body); - if (!result.success) { - console.error( - "Invalid response", - JSON.stringify(body), - JSON.stringify(result.error), - ); - return false; - } - return result.data; - } catch (e) { - return false; - } -} - function setFavicon(): void { const link = document.createElement("link"); link.type = "image/x-icon"; @@ -351,6 +314,11 @@ function setFavicon(): void { // WARNING: DO NOT EXPOSE THIS ID export function getPersistentIDFromCookie(): string { + const claims = isLoggedIn(); + if (claims !== false && claims.sub) { + return claims.sub; + } + const COOKIE_NAME = "player_persistent_id"; // Try to get existing cookie @@ -374,15 +342,3 @@ export function getPersistentIDFromCookie(): string { return newID; } - -function getApiBase() { - const { hostname } = new URL(window.location.href); - const domainname = hostname.split(".").slice(-2).join("."); - return domainname === "localhost" - ? "http://localhost:8787" - : `https://api.${domainname}`; -} - -function discordLogin() { - window.location.href = `${getApiBase()}/login/discord?redirect_uri=${window.location.href}`; -} diff --git a/src/client/index.html b/src/client/index.html index 2a91c977b..ffe9d7d6b 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -214,8 +214,8 @@
diff --git a/src/client/jwt.ts b/src/client/jwt.ts new file mode 100644 index 000000000..c7510d433 --- /dev/null +++ b/src/client/jwt.ts @@ -0,0 +1,149 @@ +import { decodeJwt } from "jose"; +import { + TokenPayload, + TokenPayloadSchema, + UserMeResponse, + UserMeResponseSchema, +} from "./ApiSchemas"; + +function getAudience() { + const { hostname } = new URL(window.location.href); + const domainname = hostname.split(".").slice(-2).join("."); + return domainname; +} + +function getApiBase() { + const domainname = getAudience(); + return domainname === "localhost" + ? "http://localhost:8787" + : `https://api.${domainname}`; +} + +function getToken(): string | null { + const { hash } = window.location; + if (hash.startsWith("#")) { + const params = new URLSearchParams(hash.slice(1)); + const token = params.get("token"); + if (token) { + localStorage.setItem("token", token); + } + // Clean the URL + history.replaceState( + null, + "", + window.location.pathname + window.location.search, + ); + } + return localStorage.getItem("token"); +} + +export function discordLogin() { + window.location.href = `${getApiBase()}/login/discord?redirect_uri=${window.location.href}`; +} + +let __isLoggedIn: TokenPayload | false | undefined = undefined; +export function isLoggedIn(): TokenPayload | false { + if (__isLoggedIn === undefined) { + __isLoggedIn = _isLoggedIn(); + } + return __isLoggedIn; +} +export function _isLoggedIn(): TokenPayload | false { + try { + const token = getToken(); + if (!token) { + // console.log("No token found"); + return false; + } + + // Verify the JWT (requires browser support) + // const jwks = createRemoteJWKSet( + // new URL(getApiBase() + "/.well-known/jwks.json"), + // ); + // const { payload, protectedHeader } = await jwtVerify(token, jwks, { + // issuer: getApiBase(), + // audience: getAudience(), + // }); + + // Decode the JWT + const payload = decodeJwt(token); + const { iss, aud, exp, iat } = payload; + + if (iss !== getApiBase()) { + // JWT was not issued by the correct server + console.error( + 'unexpected "iss" claim value', + // JSON.stringify(payload, null, 2), + ); + localStorage.removeItem("token"); + return false; + } + if (aud !== getAudience()) { + // JWT was not issued for this website + console.error( + 'unexpected "aud" claim value', + // JSON.stringify(payload, null, 2), + ); + localStorage.removeItem("token"); + return false; + } + const now = Math.floor(Date.now() / 1000); + if (exp !== undefined && now >= exp) { + // JWT expired + console.error( + 'after "exp" claim value', + // JSON.stringify(payload, null, 2), + ); + localStorage.removeItem("token"); + return false; + } + // const maxAge: number | undefined = undefined; + // if (iat !== undefined && maxAge !== undefined && now >= iat + maxAge) { + // // TODO: Refresh token... + // } + + const result = TokenPayloadSchema.safeParse(payload); + if (!result.success) { + // Invalid response + console.error( + "Invalid payload", + // JSON.stringify(payload), + JSON.stringify(result.error), + ); + return false; + } + + return result.data; + } catch (e) { + console.log(e); + return false; + } +} + +export async function getUserMe(): Promise { + try { + const token = getToken(); + if (!token) return false; + + // Get the user object + const response = await fetch(getApiBase() + "/users/@me", { + headers: { + authorization: `Bearer ${token}`, + }, + }); + if (response.status !== 200) return false; + const body = await response.json(); + const result = UserMeResponseSchema.safeParse(body); + if (!result.success) { + console.error( + "Invalid response", + JSON.stringify(body), + JSON.stringify(result.error), + ); + return false; + } + return result.data; + } catch (e) { + return false; + } +}