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>
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
Reference in New Issue
Block a user