Files
vosonandCursor f5b7011c45 Ship Steam login, D1 player sync, and cached「我」dashboard.
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>
2026-08-01 01:24:30 +08:00

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