UNPKG

@stacksjs/router

Version:
77 lines (76 loc) 3.27 kB
import { timingSafeEqual } from "node:crypto"; import { Buffer } from "node:buffer"; import process from "node:process"; import { url as buildUrl } from "./stacks-router"; const SIGNATURE_PARAM = "signature", EXPIRES_PARAM = "expires"; function getSigningSecret() { const secret = process.env.APP_KEY || process.env.STACKS_SIGNED_URL_SECRET; if (!secret || secret.length < 16) throw Error("[router] signed URLs require APP_KEY (\u2265 16 chars) or STACKS_SIGNED_URL_SECRET. " + "Run `./buddy key:generate` or set the env var."); return secret; } function hmacHex(payload, secret) { return new Bun.CryptoHasher("sha256", secret).update(payload).digest("hex"); } function safeEqualHex(a, b) { const ba = Buffer.from(a.toLowerCase(), "hex"), bb = Buffer.from(b.toLowerCase(), "hex"); if (ba.length !== bb.length) return !1; return timingSafeEqual(ba, bb); } function stripSignatureParam(input) { const u = new URL(input.toString()); u.searchParams.delete(SIGNATURE_PARAM); return u; } function buildSignaturePayload(input) { const stripped = stripSignatureParam(input); stripped.searchParams.sort(); return stripped.toString(); } export function signUrl(input, options = {}) { const secret = getSigningSecret(), url = input.startsWith("http") ? new URL(input) : new URL(input.startsWith("/") ? input : `/${input}`, process.env.APP_URL || "https://localhost"); if (options.ttl !== void 0 && options.expiresAt !== void 0) warnOnce("dual-expiry", "[router] signUrl: both `ttl` and `expiresAt` provided \u2014 using `ttl`."); if (options.ttl !== void 0) url.searchParams.set(EXPIRES_PARAM, String(Math.floor(Date.now() / 1000) + options.ttl)); else if (options.expiresAt !== void 0) url.searchParams.set(EXPIRES_PARAM, String(Math.floor(options.expiresAt))); const payload = buildSignaturePayload(url); url.searchParams.set(SIGNATURE_PARAM, hmacHex(payload, secret)); return url.toString(); } const _warnedKeys = new Set; function warnOnce(key, message) { if (_warnedKeys.has(key)) return; _warnedKeys.add(key); console.warn(message); } export function signedUrl(routeName, params = {}, options = {}) { return signUrl(buildUrl(routeName, params), options); } export function verifySignedUrl(input) { const url = typeof input === "string" ? new URL(input, process.env.APP_URL || "https://localhost") : input, presented = url.searchParams.get(SIGNATURE_PARAM); if (!presented) return { valid: !1, reason: "missing-signature" }; const expiresRaw = url.searchParams.get(EXPIRES_PARAM); if (expiresRaw !== null) { const expires = Number.parseInt(expiresRaw, 10); if (!Number.isFinite(expires) || Date.now() / 1000 > expires) return { valid: !1, reason: "expired" }; } try { const expected = hmacHex(buildSignaturePayload(url), getSigningSecret()); return safeEqualHex(presented, expected) ? { valid: !0 } : { valid: !1, reason: "invalid-signature" }; } catch { return { valid: !1, reason: "invalid-signature" }; } } export async function verifySignedUrlMiddleware(req) { const result = verifySignedUrl(req.url); if (result.valid) return; const status = result.reason === "expired" ? 410 : 401; throw Response.json({ error: result.reason }, { status }); }