60 lines
2.1 KiB
TypeScript
60 lines
2.1 KiB
TypeScript
import { createHmac, randomBytes, timingSafeEqual } from "crypto";
|
|
|
|
const EMAIL_HMAC_KEY = process.env.EMAIL_HMAC_KEY || "pingql-default-hmac-key";
|
|
|
|
export function generateKey(): string {
|
|
return randomBytes(32).toString("base64url");
|
|
}
|
|
|
|
export function hashEmail(email: string): string {
|
|
return createHmac("sha256", EMAIL_HMAC_KEY).update(email.toLowerCase().trim()).digest("hex");
|
|
}
|
|
|
|
export function extractAuthKey(headers: Record<string, string | undefined>, cookie: any): string | null {
|
|
const authHeader = headers["authorization"] ?? "";
|
|
const bearer = authHeader.match(/^bearer\s+(.+)$/i)?.[1]?.trim();
|
|
return bearer ?? cookie?.pingql_key?.value ?? null;
|
|
}
|
|
|
|
export async function resolveKey(
|
|
sql: any, key: string, opts?: { trackUsage?: boolean }
|
|
): Promise<{ accountId: string; keyId: string | null; plan: string } | null> {
|
|
const [account] = await sql`SELECT id, plan FROM accounts WHERE key = ${key}`;
|
|
if (account) return { accountId: account.id, keyId: null, plan: account.plan };
|
|
|
|
const [apiKey] = await sql`SELECT k.id, k.account_id, a.plan FROM api_keys k JOIN accounts a ON a.id = k.account_id WHERE k.key = ${key}`;
|
|
if (apiKey) {
|
|
if (opts?.trackUsage !== false) {
|
|
sql`UPDATE api_keys SET last_used_at = now() WHERE id = ${apiKey.id}`.catch(() => {});
|
|
}
|
|
return { accountId: apiKey.account_id, keyId: apiKey.id, plan: apiKey.plan };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export const COOKIE_OPTS = {
|
|
httpOnly: true,
|
|
secure: process.env.COOKIE_SECURE !== "false",
|
|
sameSite: "none" as const,
|
|
path: "/",
|
|
domain: process.env.COOKIE_DOMAIN ?? ".pingql.com",
|
|
maxAge: 60 * 60 * 24 * 30,
|
|
};
|
|
|
|
export function safeTokenCompare(a: string | undefined, b: string | undefined): boolean {
|
|
if (!a || !b) return false;
|
|
const bufA = Buffer.from(a);
|
|
const bufB = Buffer.from(b);
|
|
if (bufA.length !== bufB.length) return false;
|
|
return timingSafeEqual(bufA, bufB);
|
|
}
|
|
|
|
export const SECURITY_HEADERS = {
|
|
"X-Content-Type-Options": "nosniff",
|
|
"X-Frame-Options": "DENY",
|
|
"Strict-Transport-Security": "max-age=63072000; includeSubDomains",
|
|
"X-XSS-Protection": "0",
|
|
"Referrer-Policy": "strict-origin-when-cross-origin",
|
|
};
|