import { createHash, createHmac, randomBytes, scryptSync, timingSafeEqual } from "crypto"; export const ADMIN_SESSION_COOKIE = "pedw_admin_session"; export const ADMIN_SESSION_TTL_SECONDS = 20 * 60; export const ADMIN_PASSPHRASE_MAX_LENGTH = 512; const TOKEN_VERSION = "v1"; const AUTH_DERIVATION_SALT = "pedw-admin-auth-v1"; const MINIMUM_SECRET_LENGTH = 32; const CLOCK_SKEW_SECONDS = 60; const isValidSecret = (value) => typeof value === "string" && value.length >= MINIMUM_SECRET_LENGTH; const getAdminConfiguration = (env = process.env) => { const authSecret = env.ADMIN_AUTH_SECRET; const sessionSecret = env.ADMIN_SESSION_SECRET; if (!isValidSecret(authSecret) || !isValidSecret(sessionSecret)) { return null; } const authDigest = createHash("sha256").update(authSecret).digest(); const sessionDigest = createHash("sha256").update(sessionSecret).digest(); if (timingSafeEqual(authDigest, sessionDigest)) { return null; } return { authSecret, sessionSecret }; }; const deriveCredential = (value) => scryptSync(value, AUTH_DERIVATION_SALT, 32, { N: 16384, r: 8, p: 1, maxmem: 32 * 1024 * 1024 }); const encode = (value) => Buffer.from(value).toString("base64url"); const decode = (value) => Buffer.from(value, "base64url"); export const validateAdminPassphrase = ( passphrase, { env = process.env } = {} ) => { const configuration = getAdminConfiguration(env); if ( !configuration || typeof passphrase !== "string" || passphrase.length === 0 || passphrase.length > ADMIN_PASSPHRASE_MAX_LENGTH ) { return false; } const expected = deriveCredential(configuration.authSecret); const supplied = deriveCredential(passphrase); return timingSafeEqual(expected, supplied); }; export const issueAdminSession = ( { env = process.env, now = Date.now, nonce = () => randomBytes(16) } = {} ) => { const configuration = getAdminConfiguration(env); if (!configuration) return null; const issuedAt = Math.floor(now() / 1000); const payload = { v: 1, iat: issuedAt, exp: issuedAt + ADMIN_SESSION_TTL_SECONDS, nonce: nonce().toString("base64url") }; const encodedPayload = encode(JSON.stringify(payload)); const signature = createHmac("sha256", configuration.sessionSecret) .update(`${TOKEN_VERSION}.${encodedPayload}`) .digest("base64url"); return `${TOKEN_VERSION}.${encodedPayload}.${signature}`; }; export const validateAdminSession = ( token, { env = process.env, now = Date.now } = {} ) => { const configuration = getAdminConfiguration(env); if (!configuration || typeof token !== "string" || token.length > 2048) { return false; } const parts = token.split("."); if (parts.length !== 3 || parts[0] !== TOKEN_VERSION) return false; const [version, encodedPayload, suppliedSignature] = parts; const base64UrlPattern = /^[A-Za-z0-9_-]+$/; if ( !base64UrlPattern.test(encodedPayload) || !base64UrlPattern.test(suppliedSignature) ) { return false; } const expectedSignature = createHmac("sha256", configuration.sessionSecret) .update(`${version}.${encodedPayload}`) .digest(); let suppliedSignatureBuffer; try { suppliedSignatureBuffer = decode(suppliedSignature); } catch (_error) { return false; } if ( suppliedSignatureBuffer.length !== expectedSignature.length || !timingSafeEqual(expectedSignature, suppliedSignatureBuffer) ) { return false; } let payload; try { payload = JSON.parse(decode(encodedPayload).toString("utf8")); } catch (_error) { return false; } const currentTime = Math.floor(now() / 1000); return ( payload?.v === 1 && Number.isInteger(payload.iat) && Number.isInteger(payload.exp) && typeof payload.nonce === "string" && payload.nonce.length > 0 && payload.exp - payload.iat === ADMIN_SESSION_TTL_SECONDS && payload.iat <= currentTime + CLOCK_SKEW_SECONDS && payload.exp > currentTime ); }; const cookieSecurityAttributes = (production) => [ "Path=/", "HttpOnly", "SameSite=Strict", ...(production ? ["Secure"] : []) ]; export const createAdminSessionCookie = ( token, { now = Date.now, production = process.env.NODE_ENV === "production" } = {} ) => { const expires = new Date(now() + ADMIN_SESSION_TTL_SECONDS * 1000); return [ `${ADMIN_SESSION_COOKIE}=${encodeURIComponent(token)}`, `Max-Age=${ADMIN_SESSION_TTL_SECONDS}`, `Expires=${expires.toUTCString()}`, ...cookieSecurityAttributes(production) ].join("; "); }; export const createClearedAdminSessionCookie = ( { production = process.env.NODE_ENV === "production" } = {} ) => [ `${ADMIN_SESSION_COOKIE}=`, "Max-Age=0", "Expires=Thu, 01 Jan 1970 00:00:00 GMT", ...cookieSecurityAttributes(production) ].join("; "); export const readAdminSessionCookie = (req) => { const rawCookie = req?.headers?.cookie; if (typeof rawCookie !== "string") return null; for (const entry of rawCookie.split(";")) { const separator = entry.indexOf("="); if (separator === -1) continue; const name = entry.slice(0, separator).trim(); if (name !== ADMIN_SESSION_COOKIE) continue; try { return decodeURIComponent(entry.slice(separator + 1).trim()); } catch (_error) { return null; } } return null; }; export const hasTrustedSameOrigin = (req, { env = process.env } = {}) => { const requestOrigin = req?.headers?.origin; const configuredUrl = env.NEXTAUTH_URL; if ( typeof requestOrigin !== "string" || typeof configuredUrl !== "string" || configuredUrl.length === 0 ) { return false; } try { return new URL(requestOrigin).origin === new URL(configuredUrl).origin; } catch (_error) { return false; } }; export const setAdminAuthNoStore = (res) => { res.setHeader("Cache-Control", "no-store, max-age=0"); res.setHeader("Pragma", "no-cache"); };