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