Players get a fast TTL-backed homepage (local profile / Cloudflare D1) with dense UI polish; login unlocks /home without blocking on every OpenDota refresh. Co-authored-by: Cursor <cursoragent@cursor.com>
210 lines
6.7 KiB
JavaScript
210 lines
6.7 KiB
JavaScript
/**
|
|
* Shared Steam OpenID + signed-session helpers for Pages Functions.
|
|
*
|
|
* Env: STEAM_API_KEY, SESSION_SECRET (required for login/me).
|
|
* Cookie: climperor_steam (HttpOnly, signed payload).
|
|
*/
|
|
|
|
const COOKIE_NAME = "climperor_steam";
|
|
const SESSION_DAYS = 30;
|
|
const STEAM_OPENID = "https://steamcommunity.com/openid/login";
|
|
const STEAM_ID_PREFIX = "https://steamcommunity.com/openid/id/";
|
|
|
|
export function jsonResponse(body, status = 200, extraHeaders = {}) {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: {
|
|
"Content-Type": "application/json; charset=utf-8",
|
|
"Cache-Control": "no-store",
|
|
...extraHeaders,
|
|
},
|
|
});
|
|
}
|
|
|
|
export function envOf(context) {
|
|
return (context && context.env) || {};
|
|
}
|
|
|
|
function b64urlEncode(bytes) {
|
|
let bin = "";
|
|
const arr = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
for (let i = 0; i < arr.length; i++) bin += String.fromCharCode(arr[i]);
|
|
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
}
|
|
|
|
function b64urlDecode(str) {
|
|
const pad = "=".repeat((4 - (str.length % 4)) % 4);
|
|
const b64 = (str + pad).replace(/-/g, "+").replace(/_/g, "/");
|
|
const bin = atob(b64);
|
|
const out = new Uint8Array(bin.length);
|
|
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
return out;
|
|
}
|
|
|
|
async function hmacSign(secret, message) {
|
|
const enc = new TextEncoder();
|
|
const key = await crypto.subtle.importKey(
|
|
"raw",
|
|
enc.encode(secret),
|
|
{ name: "HMAC", hash: "SHA-256" },
|
|
false,
|
|
["sign"]
|
|
);
|
|
const sig = await crypto.subtle.sign("HMAC", key, enc.encode(message));
|
|
return b64urlEncode(sig);
|
|
}
|
|
|
|
export function steamId64ToAccountId(steamId64) {
|
|
try {
|
|
const n = BigInt(String(steamId64));
|
|
const account = n - 76561197960265728n;
|
|
if (account <= 0n) return null;
|
|
return Number(account);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function parseSteamIdFromClaimedId(claimedId) {
|
|
if (!claimedId || typeof claimedId !== "string") return null;
|
|
if (!claimedId.startsWith(STEAM_ID_PREFIX)) return null;
|
|
const id = claimedId.slice(STEAM_ID_PREFIX.length).replace(/\/$/, "");
|
|
if (!/^\d{17}$/.test(id)) return null;
|
|
return id;
|
|
}
|
|
|
|
export async function createSessionToken(secret, payload) {
|
|
const body = {
|
|
...payload,
|
|
exp: Math.floor(Date.now() / 1000) + SESSION_DAYS * 86400,
|
|
};
|
|
const raw = b64urlEncode(new TextEncoder().encode(JSON.stringify(body)));
|
|
const sig = await hmacSign(secret, raw);
|
|
return `${raw}.${sig}`;
|
|
}
|
|
|
|
export async function verifySessionToken(secret, token) {
|
|
if (!secret || !token || typeof token !== "string") return null;
|
|
const parts = token.split(".");
|
|
if (parts.length !== 2) return null;
|
|
const [raw, sig] = parts;
|
|
const expect = await hmacSign(secret, raw);
|
|
if (sig.length !== expect.length) return null;
|
|
let ok = 0;
|
|
for (let i = 0; i < sig.length; i++) ok |= sig.charCodeAt(i) ^ expect.charCodeAt(i);
|
|
if (ok !== 0) return null;
|
|
try {
|
|
const json = new TextDecoder().decode(b64urlDecode(raw));
|
|
const data = JSON.parse(json);
|
|
if (!data || !data.exp || data.exp < Math.floor(Date.now() / 1000)) return null;
|
|
if (!data.steamid || !data.account_id) return null;
|
|
return data;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function readCookie(request, name = COOKIE_NAME) {
|
|
const header = request.headers.get("Cookie") || "";
|
|
const parts = header.split(";").map((s) => s.trim());
|
|
for (const p of parts) {
|
|
if (p.startsWith(name + "=")) {
|
|
return decodeURIComponent(p.slice(name.length + 1));
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function sessionCookieHeader(token, { clear = false } = {}) {
|
|
if (clear) {
|
|
return `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
|
|
}
|
|
const maxAge = SESSION_DAYS * 86400;
|
|
return `${COOKIE_NAME}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`;
|
|
}
|
|
|
|
/** Add Secure on non-localhost. */
|
|
export function sessionCookieHeaderForRequest(request, token, { clear = false } = {}) {
|
|
let base = sessionCookieHeader(token, { clear });
|
|
const host = new URL(request.url).hostname;
|
|
if (host !== "127.0.0.1" && host !== "localhost") {
|
|
base = base.replace("SameSite=Lax", "Secure; SameSite=Lax");
|
|
}
|
|
return base;
|
|
}
|
|
|
|
export function steamLoginRedirectUrl(origin) {
|
|
const returnTo = `${origin}/api/auth/steam/callback`;
|
|
const params = new URLSearchParams({
|
|
"openid.ns": "http://specs.openid.net/auth/2.0",
|
|
"openid.mode": "checkid_setup",
|
|
"openid.return_to": returnTo,
|
|
"openid.realm": origin,
|
|
"openid.identity": "http://specs.openid.net/auth/2.0/identifier_select",
|
|
"openid.claimed_id": "http://specs.openid.net/auth/2.0/identifier_select",
|
|
});
|
|
return `${STEAM_OPENID}?${params.toString()}`;
|
|
}
|
|
|
|
export async function verifySteamOpenId(query) {
|
|
const mode = query.get("openid.mode");
|
|
if (mode !== "id_res") return { ok: false, error: "bad mode" };
|
|
const claimed = query.get("openid.claimed_id");
|
|
const steamid = parseSteamIdFromClaimedId(claimed);
|
|
if (!steamid) return { ok: false, error: "bad claimed_id" };
|
|
|
|
const body = new URLSearchParams();
|
|
for (const [k, v] of query.entries()) {
|
|
if (k.startsWith("openid.")) body.set(k, v);
|
|
}
|
|
body.set("openid.mode", "check_authentication");
|
|
|
|
const res = await fetch(STEAM_OPENID, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
"User-Agent": "climperor-steam-auth",
|
|
},
|
|
body,
|
|
});
|
|
const text = await res.text();
|
|
if (!/is_valid\s*:\s*true/i.test(text)) {
|
|
return { ok: false, error: "openid invalid" };
|
|
}
|
|
return { ok: true, steamid };
|
|
}
|
|
|
|
export async function fetchSteamPersona(apiKey, steamid) {
|
|
if (!apiKey) return { personaname: null, avatar: null };
|
|
const url = new URL(
|
|
"https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/"
|
|
);
|
|
url.searchParams.set("key", apiKey);
|
|
url.searchParams.set("steamids", steamid);
|
|
try {
|
|
const res = await fetch(url.toString(), {
|
|
headers: { "User-Agent": "climperor-steam-auth" },
|
|
});
|
|
if (!res.ok) return { personaname: null, avatar: null };
|
|
const data = await res.json();
|
|
const players = data && data.response && data.response.players;
|
|
const p = Array.isArray(players) && players[0] ? players[0] : null;
|
|
if (!p) return { personaname: null, avatar: null };
|
|
return {
|
|
personaname: p.personaname || null,
|
|
avatar: p.avatarfull || p.avatarmedium || p.avatar || null,
|
|
};
|
|
} catch {
|
|
return { personaname: null, avatar: null };
|
|
}
|
|
}
|
|
|
|
export async function sessionFromRequest(context) {
|
|
const env = envOf(context);
|
|
const secret = (env.SESSION_SECRET || "").trim();
|
|
if (!secret) return null;
|
|
const token = readCookie(context.request);
|
|
if (!token) return null;
|
|
return verifySessionToken(secret, token);
|
|
}
|