Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,9 @@
</div>
</div>

<!-- App-level overlays (shown on the menu and in-game) -->
<session-expired-modal></session-expired-modal>

<!-- Game modals and overlays -->
<emoji-table></emoji-table>
<build-menu></build-menu>
Expand Down
7 changes: 7 additions & 0 deletions resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/client/AccountModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
73 changes: 63 additions & 10 deletions src/client/Api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@ import {
UserMeResponseSchema,
} from "../core/ApiSchemas";
import { AnalyticsRecord, AnalyticsRecordSchema } from "../core/Schemas";
import { getAuthHeader, logOut, userAuth } from "./Auth";
import {
getAuthHeader,
LINKED_ACCOUNT_KEY,
markAuthOutcome,
userAuth,
} from "./Auth";

export async function fetchPlayerById(
playerId: string,
Expand Down Expand Up @@ -53,14 +58,25 @@ export async function fetchPlayerById(
}
}

// Whether the signed-in user had a linked account, used to decide whether a
// session-expiry should prompt re-login (linked users) or stay silent (guests).
// Backed by localStorage (LINKED_ACCOUNT_KEY) so it survives a reload and a
// transient /users/@me outage — it records what the user *was*, which is exactly
// what we need when the session later expires.
export function wasLinkedAccount(): boolean {
return localStorage.getItem(LINKED_ACCOUNT_KEY) === "true";
}

let __userMe: Promise<UserMeResponse | false> | null = null;
export async function getUserMe(): Promise<UserMeResponse | false> {
if (__userMe !== null) {
return __userMe;
}
__userMe = (async () => {
const pending = (async () => {
try {
const userAuthResult = await userAuth();
// No usable token: refreshJwt() already recorded why (transient/expired),
// so don't overwrite that outcome here.
if (!userAuthResult) return false;
const { jwt } = userAuthResult;

Expand All @@ -70,24 +86,49 @@ export async function getUserMe(): Promise<UserMeResponse | false> {
authorization: `Bearer ${jwt}`,
},
});
if (response.status === 401) {
await logOut();
// A non-200 here (including a 401) is treated as transient/ambiguous, not
// an authoritative logout: the JWT was just validated by userAuth(), so we
// neither revoke the session nor wipe identity. We record the outcome so
// callers can offer "try again" instead of bouncing a valid session.
if (response.status !== 200) {
markAuthOutcome("transient");
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);
markAuthOutcome("transient");
return false;
}
markAuthOutcome("ok");
localStorage.setItem(
LINKED_ACCOUNT_KEY,
hasLinkedAccount(result.data) ? "true" : "false",
);
return result.data;
} catch (e) {
// Network error reaching /users/@me — transient, not a logout.
markAuthOutcome("transient");
return false;
}
})();
return __userMe;
__userMe = pending;
// Only memoize a successful result; a `false` (transient/ambiguous failure)
// must not stick for the whole page session, or recovery would need a full
// reload. Clear the cache on failure so the next call retries — but only while
// we still own the slot, so a stale in-flight request that settles after an
// invalidate + newer request can't clobber the newer one.
void pending.then(
(result) => {
if (result === false && __userMe === pending) __userMe = null;
},
() => {
if (__userMe === pending) __userMe = null;
},
);
return pending;
}

export function invalidateUserMe() {
Expand Down Expand Up @@ -115,7 +156,10 @@ export async function purchaseWithCurrency(
}),
});
if (response.status === 401) {
await logOut();
// A 401 here is ambiguous (a transient/edge rejection is possible), so we
// return false WITHOUT logging out — a spurious 401 must not revoke the
// session. A genuinely expired token is handled by the central refresh
// path on the next authenticated call.
return false;
}
if (!response.ok) {
Expand Down Expand Up @@ -178,7 +222,10 @@ export async function cancelSubscription(): Promise<boolean> {
},
});
if (response.status === 401) {
await logOut();
// A 401 here is ambiguous (a transient/edge rejection is possible), so we
// return false WITHOUT logging out — a spurious 401 must not revoke the
// session. A genuinely expired token is handled by the central refresh
// path on the next authenticated call.
return false;
}
if (!response.ok) {
Expand Down Expand Up @@ -212,7 +259,10 @@ export async function changeSubscriptionTier(
},
);
if (response.status === 401) {
await logOut();
// A 401 here is ambiguous (a transient/edge rejection is possible), so we
// return false WITHOUT logging out — a spurious 401 must not revoke the
// session. A genuinely expired token is handled by the central refresh
// path on the next authenticated call.
return false;
}
if (!response.ok) {
Expand Down Expand Up @@ -243,7 +293,10 @@ export async function openSubscriptionPortal(): Promise<string | false> {
}),
});
if (response.status === 401) {
await logOut();
// A 401 here is ambiguous (a transient/edge rejection is possible), so we
// return false WITHOUT logging out — a spurious 401 must not revoke the
// session. A genuinely expired token is handled by the central refresh
// path on the next authenticated call.
return false;
}
if (!response.ok) {
Expand Down
162 changes: 138 additions & 24 deletions src/client/Auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ import { generateCryptoRandomUUID } from "./Utils";
export type UserAuth = { jwt: string; claims: TokenPayload } | false;

const PERSISTENT_ID_KEY = "player_persistent_id";
// Remembers whether the signed-in user had a linked account, so a later
// session-expiry can prompt re-login (linked users) vs. stay silent (guests)
// even when /users/@me can't be reached at that moment. Persisted (not just
// in-memory) so it survives a reload and an @me outage; cleared on logout.
export const LINKED_ACCOUNT_KEY = "was_linked_account";

let __jwt: string | null = null;
let __refreshPromise: Promise<void> | null = null;
Expand Down Expand Up @@ -77,7 +82,22 @@ export async function getAuthHeader(): Promise<string> {
return `Bearer ${jwt}`;
}

export async function logOut(allSessions: boolean = false): Promise<boolean> {
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<boolean> {
try {
const response = await fetch(
getApiBase() + (allSessions ? "/auth/revoke" : "/auth/logout"),
Expand All @@ -98,9 +118,15 @@ export async function logOut(allSessions: boolean = false): Promise<boolean> {
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);
localStorage.removeItem(LINKED_ACCOUNT_KEY);
new UserSettings().clearFlag();
new UserSettings().setSelectedPatternName(undefined);
}
}
}

Expand Down Expand Up @@ -188,29 +214,117 @@ async function refreshJwt(): Promise<void> {
}
}

async function doRefreshJwt(): Promise<void> {
// Outcome of the most recent authentication check — a /auth/refresh attempt or
// a /users/@me call — so callers (e.g. ranked matchmaking) can tell "your
// session expired" apart from "we couldn't reach the auth server right now". It
// spans both layers because either can be the call that just failed.
export type AuthOutcome = "ok" | "expired" | "transient";
let __lastAuthOutcome: AuthOutcome = "ok";

export function getLastAuthOutcome(): AuthOutcome {
return __lastAuthOutcome;
}

// Lets the /users/@me path (Api.ts) record its own outcome into the same signal,
// so the transient-vs-expired distinction reflects whichever call last ran.
export function markAuthOutcome(outcome: AuthOutcome): void {
__lastAuthOutcome = outcome;
}

const REFRESH_MAX_ATTEMPTS = 3;
// Backoff between attempts, indexed by (attempt - 1): 500ms, then 1000ms.
const REFRESH_BACKOFF_MS = [500, 1000];
const REFRESH_TIMEOUT_MS = 10_000;

function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

// fetch() with a per-attempt timeout, so a stalled connection can't hang the
// shared refresh promise (and every caller awaiting it) indefinitely.
async function fetchWithTimeout(
url: string,
options: RequestInit,
timeoutMs: number,
): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
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;
return await fetch(url, { ...options, signal: controller.signal });
} finally {
clearTimeout(timer);
}
}

function notifySessionExpired(): void {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent("auth-session-expired"));
}

async function doRefreshJwt(): Promise<void> {
// 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 fetchWithTimeout(
getApiBase() + "/auth/refresh",
{ method: "POST", credentials: "include" },
REFRESH_TIMEOUT_MS,
);

if (response.status === 200) {
const json = await response.json();
const { jwt, expiresIn } = json;
// A 200 with an unusable body would otherwise corrupt our state
// (undefined jwt makes hadSession lie; NaN expiry disables future
// refresh). Treat it as a transient failure and retry instead.
if (typeof jwt === "string" && typeof expiresIn === "number") {
__expiresAt = Date.now() + expiresIn * 1000;
__jwt = jwt;
__lastAuthOutcome = "ok";
console.log("Refresh succeeded");
return;
}
console.error("Refresh returned 200 with an invalid body");
} else if (response.status === 401 || response.status === 403) {
// 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.
console.error("Refresh rejected — session expired", response.status);
__jwt = null;
__lastAuthOutcome = "expired";
if (hadSession) notifySessionExpired();
return;
} else {
// 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 / timeout / server unreachable — transient, retry.
console.error(
`Refresh failed (network), attempt ${attempt}/${REFRESH_MAX_ATTEMPTS}`,
e,
);
}

if (attempt < REFRESH_MAX_ATTEMPTS) {
await delay(REFRESH_BACKOFF_MS[attempt - 1] ?? 1000);
}
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;
__lastAuthOutcome = "transient";
}

export async function sendMagicLink(email: string): Promise<boolean> {
Expand Down
Loading
Loading