Files
OpenFrontIO/src/client/jwt.ts
T
Evan 824e1d61f5 update login flow (#2455)
Simplify the login flow.

## 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
2025-11-14 16:45:22 -08:00

299 lines
7.6 KiB
TypeScript

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