@stacksjs/router
Version:
The Stacks framework router.
77 lines (76 loc) • 3.27 kB
JavaScript
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 });
}