diff --git a/index.html b/index.html index 4af07ad2d6..f824addc9e 100644 --- a/index.html +++ b/index.html @@ -329,6 +329,9 @@ + + + diff --git a/resources/lang/en.json b/resources/lang/en.json index 187677aa90..e88171ba4a 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -994,6 +994,7 @@ "unfavorite": "Remove from favourites" }, "matchmaking_button": { + "connection_issue": "Couldn't verify your login. Please try again.", "description": "(ALPHA)", "login_required": "Login to play ranked!", "must_login": "You must be logged in to play ranked matchmaking.", @@ -1221,6 +1222,12 @@ "slider_tooltip": "{percent}% • {amount}", "title_with_name": "Send Troops to {name}" }, + "session_expired": { + "body": "Your session has expired. Please log in again to continue.", + "dismiss": "Dismiss", + "log_in": "Log in", + "title": "Signed out" + }, "single_modal": { "bots": "Tribes: ", "bots_disabled": "Disabled", diff --git a/src/client/AccountModal.ts b/src/client/AccountModal.ts index 6fa65a669a..567c78bc89 100644 --- a/src/client/AccountModal.ts +++ b/src/client/AccountModal.ts @@ -585,7 +585,7 @@ export class AccountModal extends BaseModal { } private async handleLogout() { - await logOut(); + await logOut({ userInitiated: true }); this.close(); // Refresh the page after logout to update the UI state window.location.reload(); diff --git a/src/client/Api.ts b/src/client/Api.ts index 9659ed9a1a..53ce832166 100644 --- a/src/client/Api.ts +++ b/src/client/Api.ts @@ -53,6 +53,15 @@ export async function fetchPlayerById( } } +// Remembers whether the last successful /users/@me showed a linked account. +// Used to decide whether a session-expiry should prompt re-login (linked users) +// or be handled silently (guests). Survives a later auth failure on purpose — +// it records what the user *was*, which is exactly what we need at that point. +let __lastKnownLinked = false; +export function wasLinkedAccount(): boolean { + return __lastKnownLinked; +} + let __userMe: Promise | null = null; export async function getUserMe(): Promise { if (__userMe !== null) { @@ -70,10 +79,11 @@ export async function getUserMe(): Promise { authorization: `Bearer ${jwt}`, }, }); - if (response.status === 401) { - await logOut(); - return false; - } + // A 401 here is treated like any other non-200 (return false). We + // deliberately do NOT logOut(): the JWT was just refreshed by userAuth(), + // so a 401 from /users/@me is transient/ambiguous and must not revoke the + // session or wipe the persistent identity. The backend already made + // /users/@me 401 non-authoritative. if (response.status !== 200) return false; const body = await response.json(); const result = UserMeResponseSchema.safeParse(body); @@ -82,6 +92,7 @@ export async function getUserMe(): Promise { console.error("Invalid response", error); return false; } + __lastKnownLinked = hasLinkedAccount(result.data); return result.data; } catch (e) { return false; diff --git a/src/client/Auth.ts b/src/client/Auth.ts index e2b51e3829..25d25b8bc9 100644 --- a/src/client/Auth.ts +++ b/src/client/Auth.ts @@ -77,7 +77,22 @@ export async function getAuthHeader(): Promise { return `Bearer ${jwt}`; } -export async function logOut(allSessions: boolean = false): Promise { +export interface LogOutOptions { + /** Revoke every session (/auth/revoke) instead of just the current one. */ + allSessions?: boolean; + /** + * Set only for an explicit, user-initiated logout — the one case where we + * also wipe the local persistent identity + cosmetics. Error-path callers + * must leave this false, or a transient failure becomes a permanent new + * guest account. + */ + userInitiated?: boolean; +} + +export async function logOut({ + allSessions = false, + userInitiated = false, +}: LogOutOptions = {}): Promise { try { const response = await fetch( getApiBase() + (allSessions ? "/auth/revoke" : "/auth/logout"), @@ -98,9 +113,14 @@ export async function logOut(allSessions: boolean = false): Promise { return false; } finally { __jwt = null; - localStorage.removeItem(PERSISTENT_ID_KEY); - new UserSettings().clearFlag(); - new UserSettings().setSelectedPatternName(undefined); + // Only destroy the local persistent identity / cosmetics on an explicit + // user logout. Error-path callers must NOT wipe identity, or a transient + // failure turns into a permanent brand-new guest account. + if (userInitiated) { + localStorage.removeItem(PERSISTENT_ID_KEY); + new UserSettings().clearFlag(); + new UserSettings().setSelectedPatternName(undefined); + } } } @@ -188,29 +208,91 @@ async function refreshJwt(): Promise { } } +// Outcome of the most recent refresh attempt, so callers (e.g. ranked +// matchmaking) can tell "your session expired" apart from "we couldn't reach +// the auth server right now". +export type RefreshOutcome = "ok" | "expired" | "transient"; +let __lastRefreshOutcome: RefreshOutcome = "ok"; + +export function getLastRefreshOutcome(): RefreshOutcome { + return __lastRefreshOutcome; +} + +const REFRESH_MAX_ATTEMPTS = 3; + +function refreshBackoffMs(attempt: number): number { + // Backoff between attempts: 500ms, then 1000ms. + return 500 * 2 ** (attempt - 1); +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function notifySessionExpired(): void { + if (typeof window === "undefined") return; + window.dispatchEvent(new CustomEvent("auth-session-expired")); +} + async function doRefreshJwt(): Promise { - 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; + // Whether an authenticated session was active before this attempt. Only a + // session that dies mid-use should surface the "signed out" UI. + const hadSession = __jwt !== null; + + for (let attempt = 1; attempt <= REFRESH_MAX_ATTEMPTS; attempt++) { + try { + console.log( + `Refreshing jwt (attempt ${attempt}/${REFRESH_MAX_ATTEMPTS})`, + ); + const response = await fetch(getApiBase() + "/auth/refresh", { + method: "POST", + credentials: "include", + }); + + if (response.status === 200) { + const json = await response.json(); + const { jwt, expiresIn } = json; + __expiresAt = Date.now() + expiresIn * 1000; + __jwt = jwt; + __lastRefreshOutcome = "ok"; + console.log("Refresh succeeded"); + return; + } + + // 401/403 are definitive: the refresh token is genuinely invalid or + // expired, so retrying can't help. Do a "soft" logout — clear the + // in-memory JWT but preserve the session cookie + persistent identity — + // and let a previously-signed-in user be prompted to log in again. + if (response.status === 401 || response.status === 403) { + console.error("Refresh rejected — session expired", response.status); + __jwt = null; + __lastRefreshOutcome = "expired"; + if (hadSession) notifySessionExpired(); + return; + } + + // Everything else (5xx, 429, ...) is transient — fall through to retry. + console.error( + `Refresh failed (status ${response.status}), attempt ${attempt}/${REFRESH_MAX_ATTEMPTS}`, + ); + } catch (e) { + // Network error / server unreachable — transient, fall through to retry. + console.error( + `Refresh failed (network), attempt ${attempt}/${REFRESH_MAX_ATTEMPTS}`, + e, + ); + } + + if (attempt < REFRESH_MAX_ATTEMPTS) { + await delay(refreshBackoffMs(attempt)); } - const json = await response.json(); - const { jwt, expiresIn } = json; - __expiresAt = Date.now() + expiresIn * 1000; - console.log("Refresh succeeded"); - __jwt = jwt; - } catch (e) { - console.error("Refresh failed", e); - // if server unreachable, just clear jwt - __jwt = null; - return; } + + // Transient failures exhausted. Clear the in-memory JWT only — keep the + // session cookie and persistent identity so the next refresh can recover. + console.error("Refresh failed after retries; staying recoverable"); + __jwt = null; + __lastRefreshOutcome = "transient"; } export async function sendMagicLink(email: string): Promise { diff --git a/src/client/Main.ts b/src/client/Main.ts index b3dabb9ada..4975389c87 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -43,6 +43,7 @@ import { modalRouter } from "./ModalRouter"; import { initNavigation } from "./Navigation"; import "./NewsModal"; import "./PatternInput"; +import "./SessionExpiredModal"; import "./SinglePlayerModal"; import { StoreModal } from "./Store"; import "./TerritoryPatternsModal"; diff --git a/src/client/Matchmaking.ts b/src/client/Matchmaking.ts index ed848430e1..a6c2250b76 100644 --- a/src/client/Matchmaking.ts +++ b/src/client/Matchmaking.ts @@ -3,7 +3,7 @@ import { customElement, state } from "lit/decorators.js"; import { ClientEnv } from "src/client/ClientEnv"; import { UserMeResponse } from "../core/ApiSchemas"; import { getUserMe, hasLinkedAccount } from "./Api"; -import { getPlayToken } from "./Auth"; +import { getLastRefreshOutcome, getPlayToken } from "./Auth"; import { BaseModal } from "./components/BaseModal"; import "./components/Difficulties"; import { modalHeader } from "./components/ui/ModalHeader"; @@ -126,6 +126,21 @@ export class MatchmakingModal extends BaseModal { userMe.user.google !== undefined || userMe.user.email !== undefined); if (!isLoggedIn) { + // A transient auth-server hiccup (rather than a genuine "not logged in") + // shouldn't bounce the player to the login page — ask them to retry. + if (getLastRefreshOutcome() === "transient") { + window.dispatchEvent( + new CustomEvent("show-message", { + detail: { + message: translateText("matchmaking_button.connection_issue"), + color: "red", + duration: 3000, + }, + }), + ); + this.close(); + return; + } window.dispatchEvent( new CustomEvent("show-message", { detail: { diff --git a/src/client/SessionExpiredModal.ts b/src/client/SessionExpiredModal.ts new file mode 100644 index 0000000000..4f53b7bdc6 --- /dev/null +++ b/src/client/SessionExpiredModal.ts @@ -0,0 +1,66 @@ +import { html } from "lit"; +import { customElement } from "lit/decorators.js"; +import { wasLinkedAccount } from "./Api"; +import { BaseModal, ModalConfig } from "./components/BaseModal"; +import { translateText } from "./Utils"; + +/** + * App-level warning shown when a previously-signed-in user's auth session + * expires (a definitive 401/403 on /auth/refresh). Auth.ts dispatches the + * "auth-session-expired" window event; we only surface it for users who were + * actually logged in to an account — guests get a fresh session silently. + */ +@customElement("session-expired-modal") +export class SessionExpiredModal extends BaseModal { + connectedCallback(): void { + super.connectedCallback(); + window.addEventListener("auth-session-expired", this.onSessionExpired); + } + + disconnectedCallback(): void { + window.removeEventListener("auth-session-expired", this.onSessionExpired); + super.disconnectedCallback(); + } + + private onSessionExpired = (): void => { + if (!wasLinkedAccount()) return; + if (this.isOpen()) return; + this.open(); + }; + + protected modalConfig(): ModalConfig { + return { + title: translateText("session_expired.title"), + hideHeader: false, + hideCloseButton: false, + maxWidth: "420px", + }; + } + + private logIn(): void { + this.close(); + window.showPage?.("page-account"); + } + + protected renderBody() { + return html` +
+

${translateText("session_expired.body")}

+
+ + +
+
+ `; + } +} diff --git a/tests/client/Auth.test.ts b/tests/client/Auth.test.ts new file mode 100644 index 0000000000..74fca278b3 --- /dev/null +++ b/tests/client/Auth.test.ts @@ -0,0 +1,136 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Auth.ts derives the API origin from window.location via ./Api. Pin it so the +// fetch URLs are deterministic in the jsdom environment. +vi.mock("../../src/client/Api", () => ({ + getApiBase: () => "http://localhost:8787", + getAudience: () => "localhost", +})); + +import { getLastRefreshOutcome, logOut, userAuth } from "../../src/client/Auth"; + +const PERSISTENT_ID_KEY = "player_persistent_id"; + +// Build a decodeable JWT whose `iss` matches getApiBase() so userAuth()'s +// claim checks pass. Only the payload matters to decodeJwt. +function fakeJwt(payload: Record): string { + const b64 = (o: unknown) => + Buffer.from(JSON.stringify(o)).toString("base64url"); + return `${b64({ alg: "none" })}.${b64(payload)}.sig`; +} + +const okJson = (data: unknown) => ({ + status: 200, + ok: true, + json: async () => data, +}); + +describe("Auth: refresh resilience and session-expiry handling", () => { + beforeEach(() => { + localStorage.clear(); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("retries transient (5xx) refresh failures, then preserves identity without logging out", async () => { + localStorage.setItem(PERSISTENT_ID_KEY, "keep-me-123"); + const fetchMock = vi.fn(async (url: unknown) => { + if (String(url).includes("/auth/refresh")) { + return { status: 503, ok: false, json: async () => ({}) }; + } + return okJson({}); + }); + vi.stubGlobal("fetch", fetchMock); + + const result = await userAuth(); + + expect(result).toBe(false); + // Transient failures are retried (3 attempts) before giving up. + const refreshCalls = fetchMock.mock.calls.filter(([u]) => + String(u).includes("/auth/refresh"), + ).length; + expect(refreshCalls).toBe(3); + expect(getLastRefreshOutcome()).toBe("transient"); + // Identity preserved and the session is never revoked on a transient blip. + expect(localStorage.getItem(PERSISTENT_ID_KEY)).toBe("keep-me-123"); + expect( + fetchMock.mock.calls.some(([u]) => String(u).includes("/auth/logout")), + ).toBe(false); + }); + + it("does NOT retry a definitive 401, and (no prior session) does not raise session-expired", async () => { + localStorage.setItem(PERSISTENT_ID_KEY, "keep-me-401"); + const onExpired = vi.fn(); + window.addEventListener("auth-session-expired", onExpired); + const fetchMock = vi.fn(async (url: unknown) => { + if (String(url).includes("/auth/refresh")) { + return { status: 401, ok: false, json: async () => ({}) }; + } + return okJson({}); + }); + vi.stubGlobal("fetch", fetchMock); + + const result = await userAuth(); + + expect(result).toBe(false); + // 401 is definitive — exactly one attempt, no retry. + const refreshCalls = fetchMock.mock.calls.filter(([u]) => + String(u).includes("/auth/refresh"), + ).length; + expect(refreshCalls).toBe(1); + expect(getLastRefreshOutcome()).toBe("expired"); + expect(localStorage.getItem(PERSISTENT_ID_KEY)).toBe("keep-me-401"); + // No active session existed, so we must not nag with the modal. + expect(onExpired).not.toHaveBeenCalled(); + window.removeEventListener("auth-session-expired", onExpired); + }); + + it("raises auth-session-expired when an ACTIVE session is rejected with a 401", async () => { + const onExpired = vi.fn(); + window.addEventListener("auth-session-expired", onExpired); + // First refresh mints a session (already-expired so the next userAuth + // re-refreshes); the second refresh is rejected. + let call = 0; + const fetchMock = vi.fn(async (url: unknown) => { + if (String(url).includes("/auth/refresh")) { + call++; + if (call === 1) { + return okJson({ + jwt: fakeJwt({ iss: "http://localhost:8787" }), + expiresIn: 0, + }); + } + return { status: 401, ok: false, json: async () => ({}) }; + } + return okJson({}); + }); + vi.stubGlobal("fetch", fetchMock); + + await userAuth(); // establishes __jwt via the first (200) refresh + await userAuth(); // session now active -> second refresh 401s + + expect(getLastRefreshOutcome()).toBe("expired"); + expect(onExpired).toHaveBeenCalledTimes(1); + window.removeEventListener("auth-session-expired", onExpired); + }); + + it("wipes local identity only on an explicit user-initiated logOut", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => okJson({})), + ); + + localStorage.setItem(PERSISTENT_ID_KEY, "keep-me-456"); + await logOut(); // error-path / programmatic logout + expect(localStorage.getItem(PERSISTENT_ID_KEY)).toBe("keep-me-456"); + + await logOut({ userInitiated: true }); // the real "Log out" button + expect(localStorage.getItem(PERSISTENT_ID_KEY)).toBeNull(); + }); +});