Update auth & login to follow best practices (#2559)

## Description:

The previous login system used long lived jwts which could be stolen by
XSS. The current system uses long lived refresh tokens that are stored
as http-only cookies. Then the client calls /refresh to get a short
lived jwt using the refresh token. The jwt is stored in memory only so
it's discarded on page close. This way a XSS can only steal the
short-lived jwt.

It also updates how accounts work: players get an account automatically
when they join the webpage. They can see their stats even if not logged
in. If a player wants to keep their account, they can tie it to their
Discord or email, allowing them to log in if cookies are lost.

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

evan
This commit is contained in:
Evan
2025-12-11 11:25:27 -08:00
committed by GitHub
parent 9f44d990af
commit a09f0c67f1
15 changed files with 519 additions and 511 deletions
+8 -2
View File
@@ -157,10 +157,15 @@
},
"account_modal": {
"title": "Account",
"logged_in_as": "Logged in as {email}",
"logged_in_as": "Logged in as {account_name}",
"fetching_account": "Fetching account information...",
"logged_in_with_discord": "Logged in with Discord",
"recovery_email_sent": "Recovery email sent to {email}"
"recovery_email_sent": "Recovery email sent to {email}",
"player_id": "Player ID: {id}",
"not_found": "Not Found",
"clear_session": "Clear Session",
"failed_to_send_recovery_email": "Failed to send recovery email",
"enter_email_address": "Please enter an email address"
},
"stats_modal": {
"title": "Stats",
@@ -704,6 +709,7 @@
"colors": "Colors",
"purchase": "Purchase",
"show_only_owned": "My Skins",
"not_logged_in": "Not logged in",
"blocked": {
"login": "You must be logged in to access this skin.",
"purchase": "Purchase this skin to unlock it."
+97 -111
View File
@@ -5,19 +5,14 @@ import {
PlayerStatsTree,
UserMeResponse,
} from "../core/ApiSchemas";
import { fetchPlayerById, getUserMe } from "./Api";
import { discordLogin, logOut, sendMagicLink } from "./Auth";
import "./components/baseComponents/stats/DiscordUserHeader";
import "./components/baseComponents/stats/GameList";
import "./components/baseComponents/stats/PlayerStatsTable";
import "./components/baseComponents/stats/PlayerStatsTree";
import "./components/Difficulties";
import "./components/PatternButton";
import {
discordLogin,
fetchPlayerById,
getApiBase,
getUserMe,
logOut,
} from "./jwt";
import { isInIframe, translateText } from "./Utils";
@customElement("account-modal")
@@ -30,10 +25,7 @@ export class AccountModal extends LitElement {
@state() private email: string = "";
@state() private isLoadingUser: boolean = false;
private loggedInEmail: string | null = null;
private loggedInDiscord: string | null = null;
private userMeResponse: UserMeResponse | null = null;
private playerId: string | null = null;
private statsTree: PlayerStatsTree | null = null;
private recentGames: PlayerGame[] = [];
@@ -44,8 +36,7 @@ export class AccountModal extends LitElement {
const customEvent = event as CustomEvent;
if (customEvent.detail) {
this.userMeResponse = customEvent.detail as UserMeResponse;
this.playerId = this.userMeResponse?.player?.publicId;
if (this.playerId === undefined) {
if (this.userMeResponse?.player?.publicId === undefined) {
this.statsTree = null;
this.recentGames = [];
}
@@ -67,31 +58,90 @@ export class AccountModal extends LitElement {
id="account-modal"
title="${translateText("account_modal.title") || "Account"}"
>
${this.renderInner()}
${this.isLoadingUser
? html`
<div
class="flex flex-col items-center justify-center p-6 text-white"
>
<p class="mb-2">
${translateText("account_modal.fetching_account")}
</p>
<div
class="w-6 h-6 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"
></div>
</div>
`
: this.renderInner()}
</o-modal>
`;
}
private renderInner() {
if (this.isLoadingUser) {
return html`
<div class="flex flex-col items-center justify-center p-6 text-white">
<p class="mb-2">${translateText("account_modal.fetching_account")}</p>
<div
class="w-6 h-6 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"
></div>
</div>
`;
}
if (this.loggedInDiscord) {
return this.renderLoggedInDiscord();
} else if (this.loggedInEmail) {
return this.renderLoggedInEmail();
if (this.userMeResponse?.user) {
return this.renderAccountInfo();
} else {
return this.renderLoginOptions();
}
}
private renderAccountInfo() {
return html`
<div class="p-6">
<div class="mb-4">
<p class="text-white mb-4 text-center">
${translateText("account_modal.player_id", {
id:
this.userMeResponse?.player?.publicId ??
translateText("account_modal.not_found"),
})}
</p>
</div>
<div class="mb-4 text-center">
<p class="text-white mb-4">${this.renderLoggedInAs()}</p>
</div>
<div class="flex flex-col items-center mt-2 mb-4">
<discord-user-header
.data=${this.userMeResponse?.user?.discord ?? null}
></discord-user-header>
</div>
${this.renderPlayerStats()}
</div>
`;
}
private renderLoggedInAs(): TemplateResult {
const me = this.userMeResponse?.user;
if (me?.discord) {
return html`<p>
${translateText("account_modal.logged_in_as", {
account_name: me.discord.global_name ?? "",
})}
</p>
${this.renderLogoutButton()}`;
} else if (me?.email) {
return html`<p>
${translateText("account_modal.logged_in_as", {
account_name: me.email,
})}
</p>
${this.renderLogoutButton()}`;
}
return this.renderLoginOptions();
}
private renderPlayerStats(): TemplateResult {
return html`
<player-stats-tree-view
.statsTree=${this.statsTree}
></player-stats-tree-view>
<hr class="w-2/3 border-gray-600 my-2" />
<game-list
.games=${this.recentGames}
.onViewGame=${(id: string) => this.viewGame(id)}
></game-list>
`;
}
private viewGame(gameId: string): void {
this.close();
const path = location.pathname;
@@ -103,46 +153,7 @@ export class AccountModal extends LitElement {
window.dispatchEvent(new HashChangeEvent("hashchange"));
}
private renderLoggedInDiscord() {
return html`
<div class="p-6">
<div class="mb-4 text-center">
<p class="text-white mb-4">
Logged in with Discord as ${this.loggedInDiscord}
</p>
${this.logoutButton()}
</div>
<div class="flex flex-col items-center mt-2 mb-4">
<discord-user-header
.data=${this.userMeResponse?.user?.discord ?? null}
></discord-user-header>
<player-stats-tree-view
.statsTree=${this.statsTree}
></player-stats-tree-view>
<hr class="w-2/3 border-gray-600 my-2" />
<game-list
.games=${this.recentGames}
.onViewGame=${(id: string) => this.viewGame(id)}
></game-list>
</div>
</div>
`;
}
private renderLoggedInEmail(): TemplateResult {
return html`
<div class="p-6">
<div class="mb-4">
<p class="text-white text-center mb-4">
Logged in as ${this.loggedInEmail}
</p>
</div>
${this.logoutButton()}
</div>
`;
}
private logoutButton(): TemplateResult {
private renderLogoutButton(): TemplateResult {
return html`
<button
@click="${this.handleLogout}"
@@ -157,10 +168,6 @@ export class AccountModal extends LitElement {
return html`
<div class="p-6">
<div class="mb-6">
<h3 class="text-lg font-medium text-white mb-4 text-center">
Choose your login method
</h3>
<!-- Discord Login Button -->
<div class="mb-6">
<button
@@ -195,7 +202,6 @@ export class AccountModal extends LitElement {
for="email"
class="block text-sm font-medium text-white mb-2"
>
Recover account by email
</label>
<input
type="email"
@@ -225,6 +231,12 @@ export class AccountModal extends LitElement {
</button>
</div>
</div>
<button
@click="${this.handleLogout}"
class="px-3 py-1 text-xs font-medium text-white bg-red-600 border border-transparent rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-colors duration-200"
>
${translateText("account_modal.clear_session")}
</button>
`;
}
@@ -235,41 +247,19 @@ export class AccountModal extends LitElement {
private async handleSubmit() {
if (!this.email) {
alert("Please enter an email address");
alert(translateText("account_modal.enter_email_address"));
return;
}
try {
const apiBase = getApiBase();
const response = await fetch(`${apiBase}/magic-link`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
redirectDomain: window.location.origin,
const success = await sendMagicLink(this.email);
if (success) {
alert(
translateText("account_modal.recovery_email_sent", {
email: this.email,
}),
});
if (response.ok) {
alert(
translateText("account_modal.recovery_email_sent", {
email: this.email,
}),
);
this.close();
} else {
console.error(
"Failed to send recovery email:",
response.status,
response.statusText,
);
alert("Failed to send recovery email. Please try again.");
}
} catch (error) {
console.error("Error sending recovery email:", error);
alert("Error sending recovery email. Please try again.");
);
} else {
alert(translateText("account_modal.failed_to_send_recovery_email"));
}
}
@@ -284,14 +274,10 @@ export class AccountModal extends LitElement {
void getUserMe()
.then((userMe) => {
if (userMe) {
this.loggedInEmail = userMe.user.email ?? null;
this.loggedInDiscord = userMe.user.discord?.global_name ?? null;
if (this.playerId) {
this.loadFromApi(this.playerId);
this.userMeResponse = userMe;
if (this.userMeResponse?.player?.publicId) {
this.loadPlayerProfile(this.userMeResponse.player.publicId);
}
} else {
this.loggedInEmail = null;
this.loggedInDiscord = null;
}
this.isLoadingUser = false;
this.requestUpdate();
@@ -315,9 +301,9 @@ export class AccountModal extends LitElement {
window.location.reload();
}
private async loadFromApi(playerId: string): Promise<void> {
private async loadPlayerProfile(publicId: string): Promise<void> {
try {
const data = await fetchPlayerById(playerId);
const data = await fetchPlayerById(publicId);
if (!data) {
this.requestUpdate();
return;
+133
View File
@@ -0,0 +1,133 @@
import { z } from "zod";
import {
PlayerProfile,
PlayerProfileSchema,
UserMeResponse,
UserMeResponseSchema,
} from "../core/ApiSchemas";
import { getAuthHeader, logOut, userAuth } from "./Auth";
export async function fetchPlayerById(
playerId: string,
): Promise<PlayerProfile | false> {
try {
const userAuthResult = await userAuth();
if (!userAuthResult) return false;
const { jwt } = userAuthResult;
const url = `${getApiBase()}/player/${encodeURIComponent(playerId)}`;
const res = await fetch(url, {
headers: {
Accept: "application/json",
Authorization: `Bearer ${jwt}`,
},
});
if (res.status !== 200) {
console.warn(
"fetchPlayerById: unexpected status",
res.status,
res.statusText,
);
return false;
}
const json = await res.json();
const parsed = PlayerProfileSchema.safeParse(json);
if (!parsed.success) {
console.warn("fetchPlayerById: Zod validation failed", parsed.error);
return false;
}
return parsed.data;
} catch (err) {
console.warn("fetchPlayerById: request failed", err);
return false;
}
}
export async function getUserMe(): Promise<UserMeResponse | false> {
try {
const userAuthResult = await userAuth();
if (!userAuthResult) return false;
const { jwt } = userAuthResult;
// Get the user object
const response = await fetch(getApiBase() + "/users/@me", {
headers: {
authorization: `Bearer ${jwt}`,
},
});
if (response.status === 401) {
await logOut();
return false;
}
if (response.status !== 200) return false;
const body = await response.json();
const result = UserMeResponseSchema.safeParse(body);
if (!result.success) {
const error = z.prettifyError(result.error);
console.error("Invalid response", error);
return false;
}
return result.data;
} catch (e) {
return false;
}
}
export async function createCheckoutSession(
priceId: string,
colorPaletteName: string | null,
): Promise<string | false> {
try {
const response = await fetch(
`${getApiBase()}/stripe/create-checkout-session`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: await getAuthHeader(),
},
body: JSON.stringify({
priceId: priceId,
hostname: window.location.origin,
colorPaletteName: colorPaletteName,
}),
},
);
if (!response.ok) {
console.error(
"createCheckoutSession: request failed",
response.status,
response.statusText,
);
return false;
}
const json = await response.json();
return json.url;
} catch (e) {
console.error("createCheckoutSession: request failed", e);
return false;
}
}
export function getApiBase() {
const domainname = getAudience();
if (domainname === "localhost") {
const apiDomain = process?.env?.API_DOMAIN;
if (apiDomain) {
return `https://${apiDomain}`;
}
return localStorage.getItem("apiHost") ?? "http://localhost:8787";
}
return `https://api.${domainname}`;
}
export function getAudience() {
const { hostname } = new URL(window.location.href);
const domainname = hostname.split(".").slice(-2).join(".");
return domainname;
}
+222
View File
@@ -0,0 +1,222 @@
import { decodeJwt } from "jose";
import { z } from "zod";
import { TokenPayload, TokenPayloadSchema } from "../core/ApiSchemas";
import { base64urlToUuid } from "../core/Base64";
import { getApiBase, getAudience } from "./Api";
import { generateCryptoRandomUUID } from "./Utils";
export type UserAuth = { jwt: string; claims: TokenPayload } | false;
const PERSISTENT_ID_KEY = "player_persistent_id";
let __jwt: string | null = null;
export function discordLogin() {
const redirectUri = encodeURIComponent(window.location.href);
window.location.href = `${getApiBase()}/auth/login/discord?redirect_uri=${redirectUri}`;
}
export async function tempTokenLogin(token: string): Promise<string | null> {
const response = await fetch(
`${getApiBase()}/auth/login/token?login-token=${token}`,
{
credentials: "include",
},
);
if (response.status !== 200) {
console.error("Token login failed", response);
return null;
}
const json = await response.json();
const { email } = json;
return email;
}
export async function getAuthHeader(): Promise<string> {
const userAuthResult = await userAuth();
if (!userAuthResult) return "";
const { jwt } = userAuthResult;
return `Bearer ${jwt}`;
}
export async function logOut(allSessions: boolean = false): Promise<boolean> {
try {
const response = await fetch(
getApiBase() + (allSessions ? "/auth/revoke" : "/auth/logout"),
{
method: "POST",
credentials: "include",
},
);
if (response.ok === false) {
console.error("Logout failed", response);
return false;
}
return true;
} catch (e) {
console.error("Logout failed", e);
return false;
} finally {
__jwt = null;
localStorage.removeItem(PERSISTENT_ID_KEY);
}
}
export async function isLoggedIn(): Promise<boolean> {
const userAuthResult = await userAuth();
return userAuthResult !== false;
}
export async function userAuth(
shouldRefresh: boolean = true,
): Promise<UserAuth> {
try {
const jwt = __jwt;
if (!jwt) {
if (!shouldRefresh) {
console.warn("No JWT found and shouldRefresh is false");
return false;
}
console.log("No JWT found");
await refreshJwt();
return userAuth(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(),
// });
const payload = decodeJwt(jwt);
const { iss, aud, exp } = payload;
if (iss !== getApiBase()) {
// JWT was not issued by the correct server
console.error('unexpected "iss" claim value');
logOut();
return false;
}
const myAud = getAudience();
if (myAud !== "localhost" && aud !== myAud) {
// JWT was not issued for this website
console.error('unexpected "aud" claim value');
logOut();
return false;
}
const now = Math.floor(Date.now() / 1000);
if (exp !== undefined && now >= exp - 3 * 60) {
console.log("jwt expired or about to expire");
if (!shouldRefresh) {
console.error("jwt expired and shouldRefresh is false");
return false;
}
await refreshJwt();
// Try to get login info again after refreshing
return userAuth(false);
}
const result = TokenPayloadSchema.safeParse(payload);
if (!result.success) {
const error = z.prettifyError(result.error);
console.error("Invalid payload", error);
return false;
}
const claims = result.data;
return { jwt, claims };
} catch (e) {
console.error("isLoggedIn failed", e);
return false;
}
}
async function refreshJwt(): Promise<void> {
try {
console.log("Refreshing jwt");
const response = await fetch(getApiBase() + "/auth/refresh", {
method: "POST",
credentials: "include",
});
if (response.status !== 200) {
console.error("Refresh failed", response);
logOut();
return;
}
const json = await response.json();
const { jwt } = json;
console.log("Refresh succeeded");
__jwt = jwt;
} catch (e) {
console.error("Refresh failed", e);
logOut();
return;
}
}
export async function sendMagicLink(email: string): Promise<boolean> {
try {
const apiBase = getApiBase();
const response = await fetch(`${apiBase}/auth/magic-link`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify({
redirectDomain: window.location.origin,
email: email,
}),
});
if (response.ok) {
return true;
} else {
console.error(
"Failed to send recovery email:",
response.status,
response.statusText,
);
return false;
}
} catch (error) {
console.error("Error sending recovery email:", error);
return false;
}
}
// WARNING: DO NOT EXPOSE THIS ID
export async function getPlayToken(): Promise<string> {
const result = await userAuth();
if (result !== false) return result.jwt;
return getPersistentIDFromLocalStorage();
}
// WARNING: DO NOT EXPOSE THIS ID
export function getPersistentID(): string {
const jwt = __jwt;
if (!jwt) return getPersistentIDFromLocalStorage();
const payload = decodeJwt(jwt);
const sub = payload.sub;
if (!sub) return getPersistentIDFromLocalStorage();
return base64urlToUuid(sub);
}
// WARNING: DO NOT EXPOSE THIS ID
function getPersistentIDFromLocalStorage(): string {
// Try to get existing localStorage
const value = localStorage.getItem(PERSISTENT_ID_KEY);
if (value) return value;
// If no localStorage exists, create new ID and set localStorage
const newID = generateCryptoRandomUUID();
localStorage.setItem(PERSISTENT_ID_KEY, newID);
return newID;
}
+2 -2
View File
@@ -26,6 +26,7 @@ import { GameView, PlayerView } from "../core/game/GameView";
import { loadTerrainMap, TerrainMapData } from "../core/game/TerrainMapLoader";
import { UserSettings } from "../core/game/UserSettings";
import { WorkerClient } from "../core/worker/WorkerClient";
import { getPersistentID } from "./Auth";
import {
AutoUpgradeEvent,
DoBoatAttackEvent,
@@ -36,7 +37,6 @@ import {
TickMetricsEvent,
} from "./InputHandler";
import { endGame, startGame, startTime } from "./LocalPersistantStats";
import { getPersistentID } from "./Main";
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
import {
SendAttackIntentEvent,
@@ -228,7 +228,7 @@ export class ClientGameRunner {
this.lastMessageTime = Date.now();
}
private saveGame(update: WinUpdate) {
private async saveGame(update: WinUpdate) {
if (this.myPlayer === null) {
return;
}
+6 -29
View File
@@ -5,8 +5,7 @@ import {
CosmeticsSchema,
Pattern,
} from "../core/CosmeticSchemas";
import { getApiBase, getAuthHeader } from "./jwt";
import { getPersistentID } from "./Main";
import { createCheckoutSession, getApiBase } from "./Api";
export async function handlePurchase(
pattern: Pattern,
@@ -17,37 +16,15 @@ export async function handlePurchase(
return;
}
const response = await fetch(
`${getApiBase()}/stripe/create-checkout-session`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
authorization: getAuthHeader(),
"X-Persistent-Id": getPersistentID(),
},
body: JSON.stringify({
priceId: pattern.product.priceId,
hostname: window.location.origin,
colorPaletteName: colorPalette?.name,
}),
},
const url = await createCheckoutSession(
pattern.product.priceId,
colorPalette?.name ?? null,
);
if (!response.ok) {
console.error(
`Error purchasing pattern:${response.status} ${response.statusText}`,
);
if (response.status === 401) {
alert("You are not logged in. Please log in to purchase a pattern.");
} else {
alert("Something went wrong. Please try again later.");
}
if (url === false) {
alert("Failed to create checkout session.");
return;
}
const { url } = await response.json();
// Redirect to Stripe checkout
window.location.href = url;
}
+1 -1
View File
@@ -4,10 +4,10 @@ import { translateText } from "../client/Utils";
import { GameInfo, GameRecordSchema } from "../core/Schemas";
import { generateID } from "../core/Util";
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
import { getApiBase } from "./Api";
import { JoinLobbyEvent } from "./Main";
import "./components/baseComponents/Button";
import "./components/baseComponents/Modal";
import { getApiBase } from "./jwt";
@customElement("join-private-lobby-modal")
export class JoinPrivateLobbyModal extends LitElement {
@query("o-modal") private modalEl!: HTMLElement & {
+1 -1
View File
@@ -17,9 +17,9 @@ import {
getClanTag,
replacer,
} from "../core/Util";
import { getPersistentID } from "./Auth";
import { LobbyConfig } from "./ClientGameRunner";
import { ReplaySpeedChangeEvent } from "./InputHandler";
import { getPersistentID } from "./Main";
import { defaultReplaySpeedMultiplier } from "./utilities/ReplaySpeedMultiplier";
export class LocalServer {
+6 -49
View File
@@ -7,6 +7,8 @@ import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
import { GameType } from "../core/game/Game";
import { UserSettings } from "../core/game/UserSettings";
import "./AccountModal";
import { getUserMe } from "./Api";
import { getPlayToken, userAuth } from "./Auth";
import { joinLobby } from "./ClientGameRunner";
import { fetchCosmetics } from "./Cosmetics";
import "./DarkModeButton";
@@ -36,14 +38,9 @@ import { SendKickPlayerIntentEvent } from "./Transport";
import { UserSettingModal } from "./UserSettingModal";
import "./UsernameInput";
import { UsernameInput } from "./UsernameInput";
import {
generateCryptoRandomUUID,
incrementGamesPlayed,
isInIframe,
} from "./Utils";
import { incrementGamesPlayed, isInIframe } from "./Utils";
import "./components/baseComponents/Button";
import "./components/baseComponents/Modal";
import { getUserMe, isLoggedIn } from "./jwt";
import "./styles.css";
declare global {
@@ -115,7 +112,7 @@ class Client {
constructor() {}
initialize(): void {
async initialize(): Promise<void> {
// Prefetch turnstile token so it is available when
// the user joins a lobby.
this.turnstileTokenPromise = getTurnstileToken();
@@ -284,7 +281,7 @@ class Client {
}
};
if (isLoggedIn() === false) {
if ((await userAuth()) === false) {
// Not logged in
onUserMe(false);
} else {
@@ -498,7 +495,7 @@ class Client {
},
turnstileToken: await this.getTurnstileToken(lobby),
playerName: this.usernameInput?.getCurrentUsername() ?? "",
token: getPlayToken(),
token: await getPlayToken(),
clientID: lobby.clientID,
gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.info,
gameRecord: lobby.gameRecord,
@@ -650,46 +647,6 @@ document.addEventListener("DOMContentLoaded", () => {
new Client().initialize();
});
// WARNING: DO NOT EXPOSE THIS ID
export function getPlayToken(): string {
const result = isLoggedIn();
if (result !== false) return result.token;
return getPersistentIDFromCookie();
}
// WARNING: DO NOT EXPOSE THIS ID
export function getPersistentID(): string {
const result = isLoggedIn();
if (result !== false) return result.claims.sub;
return getPersistentIDFromCookie();
}
// WARNING: DO NOT EXPOSE THIS ID
function getPersistentIDFromCookie(): string {
const COOKIE_NAME = "player_persistent_id";
// Try to get existing cookie
const cookies = document.cookie.split(";");
for (const cookie of cookies) {
const [cookieName, cookieValue] = cookie.split("=").map((c) => c.trim());
if (cookieName === COOKIE_NAME) {
return cookieValue;
}
}
// If no cookie exists, create new ID and set cookie
const newID = generateCryptoRandomUUID();
document.cookie = [
`${COOKIE_NAME}=${newID}`,
`max-age=${5 * 365 * 24 * 60 * 60}`, // 5 years
"path=/",
"SameSite=Strict",
"Secure",
].join(";");
return newID;
}
async function getTurnstileToken(): Promise<{
token: string;
createdAt: number;
+4 -3
View File
@@ -2,9 +2,10 @@ import { html, LitElement } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
import { generateID } from "../core/Util";
import { getPlayToken } from "./Auth";
import "./components/Difficulties";
import "./components/PatternButton";
import { getPlayToken, JoinLobbyEvent } from "./Main";
import { JoinLobbyEvent } from "./Main";
import { translateText } from "./Utils";
@customElement("matchmaking-modal")
@@ -53,7 +54,7 @@ export class MatchmakingModal extends LitElement {
const config = await getServerConfigFromClient();
this.socket = new WebSocket(`${config.jwtIssuer()}/matchmaking/join`);
this.socket.onopen = () => {
this.socket.onopen = async () => {
console.log("Connected to matchmaking server");
setTimeout(() => {
// Set a delay so the user can see the "connecting" message,
@@ -64,7 +65,7 @@ export class MatchmakingModal extends LitElement {
this.socket?.send(
JSON.stringify({
type: "auth",
playToken: getPlayToken(),
playToken: await getPlayToken(),
}),
);
};
+1 -1
View File
@@ -4,7 +4,7 @@ import {
ClanLeaderboardResponse,
ClanLeaderboardResponseSchema,
} from "../core/ApiSchemas";
import { getApiBase } from "./jwt";
import { getApiBase } from "./Api";
import { translateText } from "./Utils";
@customElement("stats-modal")
+35 -11
View File
@@ -134,17 +134,9 @@ export class TerritoryPatternsModal extends LitElement {
return html`
<div class="flex flex-col gap-2">
<div class="flex justify-center">
<button
class="px-4 py-2 text-sm font-medium transition-colors duration-200 rounded-lg ${this
.showOnlyOwned
? "bg-blue-500 text-white hover:bg-blue-600"
: "bg-gray-700 text-gray-300 hover:bg-gray-600"}"
@click=${() => {
this.showOnlyOwned = !this.showOnlyOwned;
}}
>
${translateText("territory_patterns.show_only_owned")}
</button>
${this.isLoggedIn()
? this.renderMySkinsButton()
: this.renderNotLoggedInWarning()}
</div>
<div
class="flex flex-wrap gap-4 p-2"
@@ -164,6 +156,28 @@ export class TerritoryPatternsModal extends LitElement {
`;
}
private renderMySkinsButton(): TemplateResult {
return html`<button
class="px-4 py-2 text-sm font-medium transition-colors duration-200 rounded-lg ${this
.showOnlyOwned
? "bg-blue-500 text-white hover:bg-blue-600"
: "bg-gray-700 text-gray-300 hover:bg-gray-600"}"
@click=${() => {
this.showOnlyOwned = !this.showOnlyOwned;
}}
>
${translateText("territory_patterns.show_only_owned")}
</button>`;
}
private renderNotLoggedInWarning(): TemplateResult {
return html`<label
class="px-4 py-2 text-sm font-medium transition-colors duration-200 rounded-lg bg-red-500 text-white"
>
${translateText("territory_patterns.not_logged_in")}
</label>`;
}
private renderColorSwatchGrid(): TemplateResult {
const hexCodes = (
this.userMeResponse === false
@@ -276,4 +290,14 @@ export class TerritoryPatternsModal extends LitElement {
render(preview, this.previewButton);
this.requestUpdate();
}
private isLoggedIn(): boolean {
if (this.userMeResponse === false) {
return false;
}
return (
this.userMeResponse.user.discord !== undefined ||
this.userMeResponse.user.email !== undefined
);
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators.js";
import { tempTokenLogin } from "./Auth";
import "./components/Difficulties";
import "./components/PatternButton";
import { tokenLogin } from "./jwt";
import { translateText } from "./Utils";
@customElement("token-login")
@@ -79,7 +79,7 @@ export class TokenLoginModal extends LitElement {
return;
}
try {
this.email = await tokenLogin(this.token);
this.email = await tempTokenLogin(this.token);
if (!this.email) {
return;
}
+1 -1
View File
@@ -10,13 +10,13 @@ import { ColorPalette, Pattern } from "../../../core/CosmeticSchemas";
import { EventBus } from "../../../core/EventBus";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView } from "../../../core/game/GameView";
import { getUserMe } from "../../Api";
import "../../components/PatternButton";
import {
fetchCosmetics,
handlePurchase,
patternRelationship,
} from "../../Cosmetics";
import { getUserMe } from "../../jwt";
import { SendWinnerEvent } from "../../Transport";
import { Layer } from "./Layer";
-298
View File
@@ -1,298 +0,0 @@
import { decodeJwt } from "jose";
import { z } from "zod";
import {
PlayerProfile,
PlayerProfileSchema,
RefreshResponseSchema,
TokenPayload,
TokenPayloadSchema,
UserMeResponse,
UserMeResponseSchema,
} from "../core/ApiSchemas";
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
function getAudience() {
const { hostname } = new URL(window.location.href);
const domainname = hostname.split(".").slice(-2).join(".");
return domainname;
}
export function getApiBase() {
const domainname = getAudience();
if (domainname === "localhost") {
const apiDomain = process?.env?.API_DOMAIN;
if (apiDomain) {
return `https://${apiDomain}`;
}
return localStorage.getItem("apiHost") ?? "http://localhost:8787";
}
return `https://api.${domainname}`;
}
function getToken(): string | null {
// 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 tokenLogin(token: string): Promise<string | null> {
const response = await fetch(
`${getApiBase()}/login/token?login-token=${token}`,
{
credentials: "include",
},
);
if (response.status !== 200) {
console.error("Token login failed", response);
return null;
}
const json = await response.json();
const { email } = json;
return email;
}
export function getAuthHeader(): string {
const token = getToken();
if (!token) return "";
return `Bearer ${token}`;
}
export async function logOut(allSessions: boolean = false) {
const token = getToken();
if (token === null) return;
clearToken();
const response = await fetch(
getApiBase() + (allSessions ? "/revoke" : "/logout"),
{
method: "POST",
headers: {
authorization: `Bearer ${token}`,
},
},
);
if (response.ok === false) {
console.error("Logout failed", response);
return false;
}
return true;
}
export type IsLoggedInResponse =
| { token: string; claims: TokenPayload }
| false;
let __isLoggedIn: IsLoggedInResponse | undefined = undefined;
export function isLoggedIn(): IsLoggedInResponse {
__isLoggedIn ??= _isLoggedIn();
return __isLoggedIn;
}
function _isLoggedIn(): IsLoggedInResponse {
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),
);
logOut();
return false;
}
const myAud = getAudience();
if (myAud !== "localhost" && aud !== myAud) {
// JWT was not issued for this website
console.error(
'unexpected "aud" claim value',
// JSON.stringify(payload, null, 2),
);
logOut();
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),
);
logOut();
return false;
}
const refreshAge: number = 3 * 24 * 3600; // 3 days
if (iat !== undefined && now >= iat + refreshAge) {
console.log("Refreshing access token...");
postRefresh().then((success) => {
if (success) {
console.log("Refreshed access token successfully.");
} else {
console.error("Failed to refresh access token.");
// TODO: Update the UI to show logged out state
}
});
}
const result = TokenPayloadSchema.safeParse(payload);
if (!result.success) {
const error = z.prettifyError(result.error);
// Invalid response
console.error("Invalid payload", error);
return false;
}
const claims = result.data;
return { token, claims };
} catch (e) {
console.log(e);
return false;
}
}
export async function postRefresh(): Promise<boolean> {
try {
const token = getToken();
if (!token) return false;
// Refresh the JWT
const response = await fetch(getApiBase() + "/refresh", {
method: "POST",
credentials: "include",
headers: {
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);
if (!result.success) {
const error = z.prettifyError(result.error);
console.error("Invalid response", error);
return false;
}
localStorage.setItem("token", result.data.token);
// Clear the cached logged in state
// so that the next call to isLoggedIn() will refresh the token
__isLoggedIn = undefined;
return true;
} catch (e) {
__isLoggedIn = false;
return false;
}
}
export async function getUserMe(): Promise<UserMeResponse | false> {
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 === 401) {
clearToken();
return false;
}
if (response.status !== 200) return false;
const body = await response.json();
const result = UserMeResponseSchema.safeParse(body);
if (!result.success) {
const error = z.prettifyError(result.error);
console.error("Invalid response", error);
return false;
}
return result.data;
} catch (e) {
__isLoggedIn = false;
return false;
}
}
export async function fetchPlayerById(
playerId: string,
): Promise<PlayerProfile | false> {
try {
const base = getApiBase();
const token = getToken();
if (!token) return false;
const url = `${base}/player/${encodeURIComponent(playerId)}`;
const res = await fetch(url, {
headers: {
Accept: "application/json",
Authorization: `Bearer ${token}`,
},
});
if (res.status !== 200) {
console.warn(
"fetchPlayerById: unexpected status",
res.status,
res.statusText,
);
return false;
}
const json = await res.json();
const parsed = PlayerProfileSchema.safeParse(json);
if (!parsed.success) {
console.warn("fetchPlayerById: Zod validation failed", parsed.error);
return false;
}
return parsed.data;
} catch (err) {
console.warn("fetchPlayerById: request failed", err);
return false;
}
}