capsule AI-native Unix-like composition layer

src/_lib/auth.js

3,780 bytes · 122 lines · capsule://quake0day/[email protected] raw on github

// Shared auth helpers for Cloudflare Pages Functions

const COOKIE = "yl_admin";
const TTL_SECONDS = 60 * 60 * 24 * 7; // 7 days

const enc = new TextEncoder();
const dec = new TextDecoder();

function b64urlEncode(buf) {
  const bytes = new Uint8Array(buf);
  let str = "";
  for (let i = 0; i < bytes.length; i++) str += String.fromCharCode(bytes[i]);
  return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function b64urlDecode(s) {
  s = s.replace(/-/g, "+").replace(/_/g, "/");
  while (s.length % 4) s += "=";
  const bin = atob(s);
  const bytes = new Uint8Array(bin.length);
  for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
  return bytes;
}

async function hmacKey(secret) {
  return await crypto.subtle.importKey(
    "raw", enc.encode(secret),
    { name: "HMAC", hash: "SHA-256" },
    false, ["sign", "verify"]
  );
}

async function constantTimeEqual(a, b) {
  if (a.length !== b.length) return false;
  let mismatch = 0;
  for (let i = 0; i < a.length; i++) mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
  return mismatch === 0;
}

export async function createSession(env) {
  const secret = env.SESSION_SECRET || env.ADMIN_PASSWORD || "fallback";
  const key = await hmacKey(secret);
  const exp = Math.floor(Date.now() / 1000) + TTL_SECONDS;
  const payload = `admin.${exp}`;
  const sig = await crypto.subtle.sign("HMAC", key, enc.encode(payload));
  return `${payload}.${b64urlEncode(sig)}`;
}

export async function verifySession(token, env) {
  if (!token) return false;
  const parts = token.split(".");
  if (parts.length !== 3) return false;
  const [scope, expStr, sigB64] = parts;
  if (scope !== "admin") return false;
  const exp = parseInt(expStr, 10);
  if (!exp || exp < Math.floor(Date.now() / 1000)) return false;
  const secret = env.SESSION_SECRET || env.ADMIN_PASSWORD || "fallback";
  const key = await hmacKey(secret);
  try {
    const sig = b64urlDecode(sigB64);
    const ok = await crypto.subtle.verify("HMAC", key, sig, enc.encode(`${scope}.${expStr}`));
    return ok;
  } catch {
    return false;
  }
}

export function readCookie(request, name = COOKIE) {
  const header = request.headers.get("Cookie") || "";
  const match = header.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`));
  return match ? decodeURIComponent(match[1]) : null;
}

export function setSessionCookie(token) {
  const parts = [
    `${COOKIE}=${encodeURIComponent(token)}`,
    `Path=/`,
    `HttpOnly`,
    `Secure`,
    `SameSite=Strict`,
    `Max-Age=${TTL_SECONDS}`
  ];
  return parts.join("; ");
}

export function clearSessionCookie() {
  return `${COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=0`;
}

export async function isAuthed(request, env) {
  const token = readCookie(request);
  return await verifySession(token, env);
}

export function json(data, init = {}) {
  return new Response(JSON.stringify(data), {
    ...init,
    headers: {
      "Content-Type": "application/json; charset=utf-8",
      "Cache-Control": "no-store",
      ...(init.headers || {})
    }
  });
}

export function unauthorized() {
  return json({ error: "Unauthorized" }, { status: 401 });
}

export async function checkPasswordRateLimit(env, ip) {
  // Simple per-IP rate limit using KV (5 attempts per 5 min)
  if (!env.YL_DATA) return { ok: true };
  const key = `rl:auth:${ip}`;
  const raw = await env.YL_DATA.get(key);
  const now = Date.now();
  let entry = raw ? JSON.parse(raw) : { count: 0, until: now + 300000 };
  if (entry.until < now) entry = { count: 0, until: now + 300000 };
  if (entry.count >= 5) return { ok: false, retryAfter: Math.ceil((entry.until - now) / 1000) };
  entry.count++;
  await env.YL_DATA.put(key, JSON.stringify(entry), { expirationTtl: 600 });
  return { ok: true };
}