99 lines
3.5 KiB
TypeScript
99 lines
3.5 KiB
TypeScript
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
|
|
/**
|
|
* Signed-cookie session for the manager app — adapted from (not imported
|
|
* from) the main NextChair app's src/lib/booking-draft/sign.ts and
|
|
* src/lib/security/secrets-match.ts. Deliberately copied rather than
|
|
* shared: this app must never import anything from the main app's src/ —
|
|
* a previous attempt at this exact tool was built as routes inside the
|
|
* main app and repeatedly broke from transitively importing Prisma/
|
|
* Auth.js/business data into a mode that has no business database at
|
|
* all — see ../../README.md's "Why it's a separate app" for the full
|
|
* history. Keeping this app's module graph fully separate is what makes
|
|
* that failure structurally impossible to repeat here.
|
|
*
|
|
* HMAC-signed (tamper-evident), not encrypted — the payload is just an
|
|
* email and an expiry, nothing sensitive to disclose to the browser
|
|
* holding its own cookie. Session length: 7 days.
|
|
*/
|
|
|
|
const CONTEXT = "nextchair-manager-session-v1";
|
|
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
|
|
export interface ManagerSession {
|
|
email: string;
|
|
exp: number;
|
|
}
|
|
|
|
function getSigningKey(): Buffer {
|
|
const secret = process.env.MANAGER_SESSION_SECRET;
|
|
if (!secret) {
|
|
throw new Error("MANAGER_SESSION_SECRET must be set.");
|
|
}
|
|
return createHmac("sha256", secret).update(CONTEXT).digest();
|
|
}
|
|
|
|
function sign(payload: string): string {
|
|
const mac = createHmac("sha256", getSigningKey()).update(payload).digest("base64url");
|
|
const encodedPayload = Buffer.from(payload, "utf8").toString("base64url");
|
|
return `${encodedPayload}.${mac}`;
|
|
}
|
|
|
|
function verifyToken(token: string): string | null {
|
|
const separatorIndex = token.indexOf(".");
|
|
if (separatorIndex === -1) return null;
|
|
|
|
const encodedPayload = token.slice(0, separatorIndex);
|
|
const mac = token.slice(separatorIndex + 1);
|
|
if (!encodedPayload || !mac) return null;
|
|
|
|
let payload: string;
|
|
try {
|
|
payload = Buffer.from(encodedPayload, "base64url").toString("utf8");
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
const expectedMac = createHmac("sha256", getSigningKey()).update(payload).digest("base64url");
|
|
const macBuffer = Buffer.from(mac);
|
|
const expectedBuffer = Buffer.from(expectedMac);
|
|
if (macBuffer.length !== expectedBuffer.length) return null;
|
|
if (!timingSafeEqual(macBuffer, expectedBuffer)) return null;
|
|
|
|
return payload;
|
|
}
|
|
|
|
export function createSessionToken(email: string): string {
|
|
const payload: ManagerSession = { email, exp: Date.now() + SESSION_TTL_MS };
|
|
return sign(JSON.stringify(payload));
|
|
}
|
|
|
|
/** Returns the session if the token is validly signed and not expired, otherwise null. Never throws on malformed input. */
|
|
export function verifySessionToken(token: string | undefined): ManagerSession | null {
|
|
if (!token) return null;
|
|
const payload = verifyToken(token);
|
|
if (!payload) return null;
|
|
|
|
let session: ManagerSession;
|
|
try {
|
|
session = JSON.parse(payload);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (typeof session.email !== "string" || typeof session.exp !== "number") return null;
|
|
if (Date.now() > session.exp) return null;
|
|
return session;
|
|
}
|
|
|
|
/**
|
|
* Same signal/reasoning as the main app's src/lib/security/cookie-
|
|
* security.ts's isSecureDeployment() — NODE_ENV=production doesn't mean
|
|
* TLS is actually in front of this app, so the cookie's Secure attribute
|
|
* is derived from the deployer-declared public URL instead.
|
|
*/
|
|
export function isSecureDeployment(): boolean {
|
|
return (process.env.MANAGER_PUBLIC_URL ?? "").startsWith("https://");
|
|
}
|
|
|
|
export const SESSION_COOKIE_NAME = "manager_session";
|