105 lines
3.3 KiB
TypeScript
105 lines
3.3 KiB
TypeScript
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
import type { H3Event } from "h3";
|
|
import {
|
|
createError,
|
|
deleteCookie,
|
|
getCookie,
|
|
getRequestHeader,
|
|
setCookie,
|
|
} from "h3";
|
|
const COOKIE = "quickdrop_auth";
|
|
const DAY = 86400000;
|
|
function config() {
|
|
const runtime = useRuntimeConfig();
|
|
const configuredTtl = Number(
|
|
process.env.AUTH_TOKEN_TTL_DAYS || runtime.authTokenTtlDays || 7,
|
|
);
|
|
const ttlDays =
|
|
Number.isFinite(configuredTtl) && configuredTtl > 0 ? configuredTtl : 7;
|
|
return {
|
|
passcode: String(process.env.AUTH_PASSCODE || runtime.authPasscode || ""),
|
|
secret: String(process.env.AUTH_SECRET || runtime.authSecret || ""),
|
|
ttl: ttlDays * DAY,
|
|
};
|
|
}
|
|
function mac(value: string, secret: string) {
|
|
return createHmac("sha256", secret).update(value).digest("base64url");
|
|
}
|
|
export function authConfigured() {
|
|
const c = config();
|
|
return Boolean(c.passcode && c.secret);
|
|
}
|
|
export function issueSession(event: H3Event) {
|
|
const c = config();
|
|
if (!c.secret) throw new Error("Authentication is not configured.");
|
|
const payload = `${Date.now() + c.ttl}.${randomBytes(24).toString("base64url")}`;
|
|
setCookie(event, COOKIE, `${payload}.${mac(payload, c.secret)}`, {
|
|
httpOnly: true,
|
|
sameSite: "lax",
|
|
secure: process.env.NODE_ENV === "production",
|
|
path: "/",
|
|
maxAge: Math.floor(c.ttl / 1000),
|
|
});
|
|
}
|
|
export function clearAuthCookie(event: H3Event) {
|
|
deleteCookie(event, COOKIE, { path: "/" });
|
|
}
|
|
export function isAuthenticated(event: H3Event): boolean {
|
|
const c = config();
|
|
const token = getCookie(event, COOKIE);
|
|
if (!token || !c.secret) return false;
|
|
const split = token.lastIndexOf(".");
|
|
if (split < 1) return false;
|
|
const payload = token.slice(0, split);
|
|
const supplied = Buffer.from(token.slice(split + 1));
|
|
const expected = Buffer.from(mac(payload, c.secret));
|
|
if (
|
|
supplied.length !== expected.length ||
|
|
!timingSafeEqual(supplied, expected)
|
|
)
|
|
return false;
|
|
const expiry = Number(payload.slice(0, payload.indexOf(".")));
|
|
return Number.isSafeInteger(expiry) && expiry > Date.now();
|
|
}
|
|
export function requireAuth(event: H3Event) {
|
|
if (!isAuthenticated(event))
|
|
throw createError({
|
|
statusCode: 401,
|
|
statusMessage: "Authentication required.",
|
|
});
|
|
requireSameOrigin(event);
|
|
}
|
|
export function verifyPasscode(value: unknown): boolean {
|
|
const c = config();
|
|
if (!c.passcode || typeof value !== "string") return false;
|
|
const supplied = Buffer.from(value);
|
|
const expected = Buffer.from(c.passcode);
|
|
return (
|
|
supplied.length === expected.length && timingSafeEqual(supplied, expected)
|
|
);
|
|
}
|
|
export function requireSameOrigin(event: H3Event) {
|
|
const origin = getRequestHeader(event, "origin");
|
|
const site = getRequestHeader(event, "sec-fetch-site");
|
|
if (
|
|
site &&
|
|
["cross-site", "same-site"].includes(site) &&
|
|
site === "cross-site"
|
|
)
|
|
throw createError({
|
|
statusCode: 403,
|
|
statusMessage: "Cross-site state change rejected.",
|
|
});
|
|
if (origin) {
|
|
const host = getRequestHeader(event, "host");
|
|
const proto = getRequestHeader(event, "x-forwarded-proto") || "http";
|
|
try {
|
|
if (new URL(origin).host !== host || !["http", "https"].includes(proto))
|
|
throw new Error();
|
|
} catch {
|
|
throw createError({ statusCode: 403, statusMessage: "Origin rejected." });
|
|
}
|
|
}
|
|
}
|
|
export { COOKIE };
|