import { base64 as b64, http } from "./deps.ts";import { decodeJwt, encodeJwt } from "./jwt.ts";
const COOKIE_JWT_HEADER = b64.encode(JSON.stringify({ alg: "HS256" }));
function matchesDomainPath(req: Request, domain?: string, path?: string) { if (path || domain) { const p = new URLPattern({ hostname: domain ? `{*.}?${domain}` : "*", pathname: path ? `${path}/*?` : "*", }); if (!p.exec(req.url)) { return false; } } return true;}
export async function encodeCookie(opt: { name: string; value: string; exp?: number; keys?: string | string[];}) { if (opt.exp) { return (await encodeJwt([opt.name, opt.value, opt.exp], opt.keys)) .split(".").slice(1).join("."); } return (await encodeJwt([opt.name, opt.value], opt.keys)) .split(".").slice(1).join(".");}
export async function decodeCookie(cookie: string, keys?: string | string[]) { const jwt = await decodeJwt(COOKIE_JWT_HEADER + "." + cookie, keys); const [name, value, exp] = jwt as [string, string, number | undefined]; return { name, value, exp };}
export interface CookieJar { get: (name: string, opt?: { signed?: boolean }) => string | undefined; set: (name: string, value: string, opt?: CookieSetOptions) => void; delete: (name: string, opt?: CookieDeleteOptions) => void; entries: () => [string, string][]; has: (name: string, opt?: { signed?: boolean }) => boolean; setCookies: (headers: Headers) => Promise<void>;}
export interface CookieSetOptions extends Omit<http.Cookie, "name" | "value"> { signed?: boolean;}
export interface CookieDeleteOptions { path?: string; domain?: string;}
export async function cookieJar( req: Request, keys?: string | string[],): Promise<CookieJar> { const unsigned = new Map(Object.entries(http.getCookies(req.headers))); const signed = new Map<string, string>();
const updates: ( | { op: "set"; name: string; value: string; opt?: CookieSetOptions } | { op: "delete"; name: string; opt?: CookieDeleteOptions } )[] = [];
for (const [k, v] of unsigned.entries()) { try { const { name, value, exp } = await decodeCookie(v, keys); if ( name !== k || typeof value !== "string" || (typeof exp !== "number" && typeof exp !== "undefined") ) { continue; }
if (typeof exp === "number" && Date.now() > exp) { updates.push({ op: "delete", name }); unsigned.delete(k); continue; }
signed.set(k, value); unsigned.delete(k); } catch { } }
return { get: (name, opt) => { if (opt?.signed) { return signed.get(name); } return unsigned.get(name); }, set: (name, value, opt) => { updates.push({ op: "set", name, value, opt });
if (!matchesDomainPath(req, opt?.domain, opt?.path)) { return; }
if (opt?.expires && opt.expires.getTime() < Date.now()) { signed.delete(name); unsigned.delete(name); return; }
if (opt?.signed) { signed.set(name, value); unsigned.delete(name); } else { unsigned.set(name, value); signed.delete(name); } }, delete: (name, opt) => { updates.push({ op: "delete", name, opt });
if (!matchesDomainPath(req, opt?.domain, opt?.path)) { return; }
signed.delete(name); unsigned.delete(name); }, entries: () => { return [ ...signed.entries(), ...unsigned.entries(), ]; }, has: (name, opt) => { if (opt?.signed) { return signed.has(name); } return unsigned.has(name); }, setCookies: async (headers) => { for (const u of updates) { if (u.op === "delete") { http.deleteCookie(headers, u.name, u.opt); continue; }
if (u.opt?.maxAge) { u.opt.expires = new Date(Date.now() + 1000 * u.opt.maxAge); }
if (u.opt?.signed) { const jwt = await encodeCookie({ name: u.name, value: u.value, exp: u.opt?.expires?.getTime(), keys, });
http.setCookie(headers, { ...u.opt, name: u.name, value: jwt }); continue; }
http.setCookie(headers, { ...u.opt, name: u.name, value: u.value }); } }, };}