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