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 { 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 { 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 { 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 { 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; } }