From d11c4a868e54fd8598257a1b842211ad9749a36f Mon Sep 17 00:00:00 2001 From: robbond Date: Mon, 26 Oct 2026 12:51:42 +0000 Subject: [PATCH] Add independent administrative authentication foundation --- docs/administrative-authentication.md | 56 +++ lib/admin/adminAuth.js | 228 ++++++++++ pages/api/admin/auth/login.js | 39 ++ pages/api/admin/auth/logout.js | 30 ++ pages/api/admin/auth/session.js | 24 + tests/phase21/admin-auth-foundation.test.cjs | 437 +++++++++++++++++++ 6 files changed, 814 insertions(+) create mode 100644 docs/administrative-authentication.md create mode 100644 lib/admin/adminAuth.js create mode 100644 pages/api/admin/auth/login.js create mode 100644 pages/api/admin/auth/logout.js create mode 100644 pages/api/admin/auth/session.js create mode 100644 tests/phase21/admin-auth-foundation.test.cjs diff --git a/docs/administrative-authentication.md b/docs/administrative-authentication.md new file mode 100644 index 00000000..e71f25a4 --- /dev/null +++ b/docs/administrative-authentication.md @@ -0,0 +1,56 @@ +# Administrative authentication foundation + +## Stage 1 scope + +This foundation is independent of NextAuth, Prisma, CRM, the existing `/admin` +page restriction, and draft deletion. It provides shared-credential login, +logout, and session-status API handlers only. It is not wired into `/admin` or +any storage API in this stage. + +## Required server-side configuration + +The deployment must provide all of the following server-side values: + +- `ADMIN_AUTH_SECRET`: a unique high-entropy administrative passphrase of at + least 32 characters. +- `ADMIN_SESSION_SECRET`: a different unique high-entropy signing key of at + least 32 characters. +- `NEXTAUTH_URL`: the existing canonical application URL. Administrative login + and logout compare the request `Origin` with this server-configured origin. + +The administrative values must never use a `NEXT_PUBLIC_` prefix or be exposed +to browser JavaScript. Do not commit real values. Missing, short, or identical +administrative secrets fail closed. + +## Security properties and limitations + +- Credentials are derived with Node `scrypt` and compared with + `timingSafeEqual`. +- Sessions are stateless, versioned HMAC-SHA256 tokens with a 20-minute fixed + lifetime and a random nonce. +- The cookie is `HttpOnly`, `SameSite=Strict`, scoped to `/`, explicitly + expires, and is `Secure` in production. +- Login and logout require an exact same-origin `Origin` match against + `NEXTAUTH_URL`; missing or malformed origins fail closed. +- Authentication responses use `Cache-Control: no-store`. +- Logout clears the browser cookie. Because sessions are stateless, a stolen + token can remain valid until its short expiry; later protected routes must + continue verifying signature and expiry on every request. +- This is shared-credential authentication. A valid token proves knowledge of + the shared credential, not the identity of a named administrator. + +## Deployment requirements before production use + +The repository does not provide a demonstrated distributed rate-limit service. +Administrative login must therefore be protected by an ingress, gateway, WAF, +or shared backing service that enforces rate limits consistently across all +deployed instances. A process-local limiter is intentionally not used because +it would reset on restart and could be bypassed across instances. + +The deployment must also ensure `NEXTAUTH_URL` is the correct canonical HTTPS +origin. Login and logout intentionally do not derive trust from `Host` or +forwarding headers. + +Any future administrative state-changing API must require more than possession +of the cookie: it must independently validate the administrative session and +enforce same-origin/CSRF protection before performing side effects. \ No newline at end of file diff --git a/lib/admin/adminAuth.js b/lib/admin/adminAuth.js new file mode 100644 index 00000000..47063b41 --- /dev/null +++ b/lib/admin/adminAuth.js @@ -0,0 +1,228 @@ +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"); +}; \ No newline at end of file diff --git a/pages/api/admin/auth/login.js b/pages/api/admin/auth/login.js new file mode 100644 index 00000000..facba52c --- /dev/null +++ b/pages/api/admin/auth/login.js @@ -0,0 +1,39 @@ +import { + createAdminSessionCookie, + hasTrustedSameOrigin, + issueAdminSession, + setAdminAuthNoStore, + validateAdminPassphrase +} from "../../../../lib/admin/adminAuth"; +import { respondError, respondSuccess } from "../../middleware/apiResponse"; + +const forbidden = (res) => + respondError(res, { + status: 403, + code: "FORBIDDEN", + message: "Request not permitted" + }); + +export default async function handler(req, res) { + setAdminAuthNoStore(res); + res.setHeader("Allow", "POST"); + + if (req.method !== "POST") { + return respondError(res, { + status: 405, + code: "METHOD_NOT_ALLOWED", + message: "Method not allowed" + }); + } + + if (!hasTrustedSameOrigin(req)) return forbidden(res); + + const passphrase = req.body?.passphrase; + if (!validateAdminPassphrase(passphrase)) return forbidden(res); + + const token = issueAdminSession(); + if (!token) return forbidden(res); + + res.setHeader("Set-Cookie", createAdminSessionCookie(token)); + return respondSuccess(res, { authenticated: true }); +} \ No newline at end of file diff --git a/pages/api/admin/auth/logout.js b/pages/api/admin/auth/logout.js new file mode 100644 index 00000000..5d276e11 --- /dev/null +++ b/pages/api/admin/auth/logout.js @@ -0,0 +1,30 @@ +import { + createClearedAdminSessionCookie, + hasTrustedSameOrigin, + setAdminAuthNoStore +} from "../../../../lib/admin/adminAuth"; +import { respondError, respondSuccess } from "../../middleware/apiResponse"; + +export default async function handler(req, res) { + setAdminAuthNoStore(res); + res.setHeader("Allow", "POST"); + + if (req.method !== "POST") { + return respondError(res, { + status: 405, + code: "METHOD_NOT_ALLOWED", + message: "Method not allowed" + }); + } + + if (!hasTrustedSameOrigin(req)) { + return respondError(res, { + status: 403, + code: "FORBIDDEN", + message: "Request not permitted" + }); + } + + res.setHeader("Set-Cookie", createClearedAdminSessionCookie()); + return respondSuccess(res, { authenticated: false }); +} \ No newline at end of file diff --git a/pages/api/admin/auth/session.js b/pages/api/admin/auth/session.js new file mode 100644 index 00000000..d80d231d --- /dev/null +++ b/pages/api/admin/auth/session.js @@ -0,0 +1,24 @@ +import { + readAdminSessionCookie, + setAdminAuthNoStore, + validateAdminSession +} from "../../../../lib/admin/adminAuth"; +import { respondError, respondSuccess } from "../../middleware/apiResponse"; + +export default async function handler(req, res) { + setAdminAuthNoStore(res); + res.setHeader("Allow", "GET"); + + if (req.method !== "GET") { + return respondError(res, { + status: 405, + code: "METHOD_NOT_ALLOWED", + message: "Method not allowed" + }); + } + + const token = readAdminSessionCookie(req); + return respondSuccess(res, { + authenticated: validateAdminSession(token) + }); +} \ No newline at end of file diff --git a/tests/phase21/admin-auth-foundation.test.cjs b/tests/phase21/admin-auth-foundation.test.cjs new file mode 100644 index 00000000..e4691931 --- /dev/null +++ b/tests/phase21/admin-auth-foundation.test.cjs @@ -0,0 +1,437 @@ +const assert = require("assert"); +const crypto = require("crypto"); +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); +const { + createRes, + respondErrorMock, + respondSuccessMock +} = require("./_shared.cjs"); + +const rootDir = path.resolve(__dirname, "..", ".."); + +const syntheticEnv = { + ADMIN_AUTH_SECRET: "synthetic-admin-credential-value-0001", + ADMIN_SESSION_SECRET: "synthetic-session-signing-value-0002", + NEXTAUTH_URL: "https://admin.test.local" +}; + +const fixedNow = () => Date.parse("2026-10-26T12:00:00.000Z"); +const fixedNonce = () => Buffer.alloc(16, 7); + +const loadAdminAuthModule = () => { + const filePath = path.join(rootDir, "lib", "admin", "adminAuth.js"); + let source = fs.readFileSync(filePath, "utf8"); + + source = source.replace(/import[\s\S]*?from\s+"[^"]+";\n?/g, ""); + source = source.replace(/export const\s+/g, "const "); + source += ` +module.exports = { + ADMIN_SESSION_COOKIE, + ADMIN_SESSION_TTL_SECONDS, + ADMIN_PASSPHRASE_MAX_LENGTH, + validateAdminPassphrase, + issueAdminSession, + validateAdminSession, + createAdminSessionCookie, + createClearedAdminSessionCookie, + readAdminSessionCookie, + hasTrustedSameOrigin, + setAdminAuthNoStore +};`; + + const context = { + module: { exports: {} }, + exports: {}, + require, + process, + Buffer, + URL, + Date, + encodeURIComponent, + decodeURIComponent, + ...crypto + }; + + vm.runInNewContext(source, context, { filename: filePath }); + return context.module.exports; +}; + +const loadHandler = (relativePath, injected) => { + const filePath = path.join(rootDir, relativePath); + let source = fs.readFileSync(filePath, "utf8"); + + source = source.replace(/import[\s\S]*?from\s+"[^"]+";\n?/g, ""); + source = source.replace( + /export default async function\s+(\w+)\s*\(/, + "async function $1(" + ); + source += "\nmodule.exports.default = handler;\n"; + + const context = { + module: { exports: {} }, + exports: {}, + require, + process, + ...injected + }; + + vm.runInNewContext(source, context, { filename: filePath }); + return context.module.exports.default; +}; + +const tests = []; +const test = (name, fn) => tests.push({ name, fn }); + +test("correct administrative credential succeeds", () => { + const auth = loadAdminAuthModule(); + assert.strictEqual( + auth.validateAdminPassphrase(syntheticEnv.ADMIN_AUTH_SECRET, { + env: syntheticEnv + }), + true + ); +}); + +test("incorrect administrative credential fails", () => { + const auth = loadAdminAuthModule(); + assert.strictEqual( + auth.validateAdminPassphrase("incorrect-synthetic-credential", { + env: syntheticEnv + }), + false + ); + assert.strictEqual( + auth.validateAdminPassphrase( + "x".repeat(auth.ADMIN_PASSPHRASE_MAX_LENGTH + 1), + { env: syntheticEnv } + ), + false + ); +}); + +test("missing, short, or reused configuration fails closed", () => { + const auth = loadAdminAuthModule(); + + assert.strictEqual( + auth.validateAdminPassphrase("anything", { env: {} }), + false + ); + assert.strictEqual( + auth.issueAdminSession({ + env: { + ...syntheticEnv, + ADMIN_SESSION_SECRET: "short" + }, + now: fixedNow, + nonce: fixedNonce + }), + null + ); + assert.strictEqual( + auth.issueAdminSession({ + env: { + ...syntheticEnv, + ADMIN_SESSION_SECRET: syntheticEnv.ADMIN_AUTH_SECRET + }, + now: fixedNow, + nonce: fixedNonce + }), + null + ); +}); + +test("session signing and validation succeed with a deterministic clock", () => { + const auth = loadAdminAuthModule(); + const token = auth.issueAdminSession({ + env: syntheticEnv, + now: fixedNow, + nonce: fixedNonce + }); + + assert.match(token, /^v1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/); + assert.strictEqual( + auth.validateAdminSession(token, { env: syntheticEnv, now: fixedNow }), + true + ); +}); + +test("tampered session fails validation", () => { + const auth = loadAdminAuthModule(); + const token = auth.issueAdminSession({ + env: syntheticEnv, + now: fixedNow, + nonce: fixedNonce + }); + const tampered = token.slice(0, -1) + (token.endsWith("A") ? "B" : "A"); + + assert.strictEqual( + auth.validateAdminSession(tampered, { + env: syntheticEnv, + now: fixedNow + }), + false + ); +}); + +test("expired session fails validation", () => { + const auth = loadAdminAuthModule(); + const token = auth.issueAdminSession({ + env: syntheticEnv, + now: fixedNow, + nonce: fixedNonce + }); + const expiredNow = () => + fixedNow() + (auth.ADMIN_SESSION_TTL_SECONDS + 1) * 1000; + + assert.strictEqual( + auth.validateAdminSession(token, { + env: syntheticEnv, + now: expiredNow + }), + false + ); +}); + +test("malformed and unsigned sessions fail validation", () => { + const auth = loadAdminAuthModule(); + + for (const value of [null, "", "unsigned", "v1.payload", "v2.a.b"]){ + assert.strictEqual( + auth.validateAdminSession(value, { + env: syntheticEnv, + now: fixedNow + }), + false + ); + } +}); + +test("login sets production secure cookie attributes and no-store headers", async () => { + const auth = loadAdminAuthModule(); + const token = auth.issueAdminSession({ + env: syntheticEnv, + now: fixedNow, + nonce: fixedNonce + }); + const handler = loadHandler("pages/api/admin/auth/login.js", { + validateAdminPassphrase: () => true, + issueAdminSession: () => token, + hasTrustedSameOrigin: () => true, + createAdminSessionCookie: (value) => + auth.createAdminSessionCookie(value, { + now: fixedNow, + production: true + }), + setAdminAuthNoStore: auth.setAdminAuthNoStore, + respondError: respondErrorMock, + respondSuccess: respondSuccessMock + }); + const req = { + method: "POST", + headers: { origin: syntheticEnv.NEXTAUTH_URL }, + body: { passphrase: syntheticEnv.ADMIN_AUTH_SECRET } + }; + const res = createRes(); + + await handler(req, res); + + const cookie = res.state.headers["set-cookie"]; + assert.strictEqual(res.state.statusCode, 200); + assert.match(cookie, /^pedw_admin_session=/); + assert.match(cookie, /Max-Age=1200/); + assert.match(cookie, /Expires=/); + assert.match(cookie, /Path=\//); + assert.match(cookie, /HttpOnly/); + assert.match(cookie, /SameSite=Strict/); + assert.match(cookie, /Secure/); + assert.strictEqual( + res.state.headers["cache-control"], + "no-store, max-age=0" + ); + assert.strictEqual(res.state.headers.pragma, "no-cache"); +}); + +test("logout clears the cookie", async () => { + const auth = loadAdminAuthModule(); + const handler = loadHandler("pages/api/admin/auth/logout.js", { + hasTrustedSameOrigin: () => true, + createClearedAdminSessionCookie: () => + auth.createClearedAdminSessionCookie({ production: true }), + setAdminAuthNoStore: auth.setAdminAuthNoStore, + respondError: respondErrorMock, + respondSuccess: respondSuccessMock + }); + const res = createRes(); + + await handler( + { method: "POST", headers: { origin: syntheticEnv.NEXTAUTH_URL } }, + res + ); + + const cookie = res.state.headers["set-cookie"]; + assert.strictEqual(res.state.statusCode, 200); + assert.match(cookie, /^pedw_admin_session=;/); + assert.match(cookie, /Max-Age=0/); + assert.match(cookie, /Expires=Thu, 01 Jan 1970 00:00:00 GMT/); + assert.match(cookie, /HttpOnly/); + assert.match(cookie, /SameSite=Strict/); + assert.match(cookie, /Secure/); +}); + +test("session-status route reports only authentication state", async () => { + const auth = loadAdminAuthModule(); + const handler = loadHandler("pages/api/admin/auth/session.js", { + readAdminSessionCookie: () => "synthetic-token", + validateAdminSession: () => true, + setAdminAuthNoStore: auth.setAdminAuthNoStore, + respondError: respondErrorMock, + respondSuccess: respondSuccessMock + }); + const res = createRes(); + + await handler({ method: "GET", headers: {} }, res); + + assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), { + authenticated: true + }); + const serialized = JSON.stringify(res.state.jsonBody); + assert.ok(!serialized.includes(syntheticEnv.ADMIN_AUTH_SECRET)); + assert.ok(!serialized.includes(syntheticEnv.ADMIN_SESSION_SECRET)); +}); + +test("invalid request methods are rejected", async () => { + const auth = loadAdminAuthModule(); + const shared = { + hasTrustedSameOrigin: () => true, + validateAdminPassphrase: () => true, + issueAdminSession: () => "token", + createAdminSessionCookie: () => "cookie", + createClearedAdminSessionCookie: () => "cookie", + readAdminSessionCookie: () => "token", + validateAdminSession: () => true, + setAdminAuthNoStore: auth.setAdminAuthNoStore, + respondError: respondErrorMock, + respondSuccess: respondSuccessMock + }; + + for (const [route, method] of [ + ["pages/api/admin/auth/login.js", "GET"], + ["pages/api/admin/auth/logout.js", "GET"], + ["pages/api/admin/auth/session.js", "POST"] + ]) { + const handler = loadHandler(route, shared); + const res = createRes(); + await handler({ method, headers: {}, body: {} }, res); + assert.strictEqual(res.state.statusCode, 405); + assert.strictEqual(res.state.jsonBody.error.code, "METHOD_NOT_ALLOWED"); + } +}); + +test("missing or invalid trusted origin rejects state-changing requests", async () => { + const auth = loadAdminAuthModule(); + assert.strictEqual( + auth.hasTrustedSameOrigin( + { headers: { origin: syntheticEnv.NEXTAUTH_URL } }, + { env: syntheticEnv } + ), + true + ); + const login = loadHandler("pages/api/admin/auth/login.js", { + hasTrustedSameOrigin: (req) => + auth.hasTrustedSameOrigin(req, { env: syntheticEnv }), + validateAdminPassphrase: () => true, + issueAdminSession: () => "token", + createAdminSessionCookie: () => "cookie", + setAdminAuthNoStore: auth.setAdminAuthNoStore, + respondError: respondErrorMock, + respondSuccess: respondSuccessMock + }); + const logout = loadHandler("pages/api/admin/auth/logout.js", { + hasTrustedSameOrigin: (req) => + auth.hasTrustedSameOrigin(req, { env: syntheticEnv }), + createClearedAdminSessionCookie: () => "cookie", + setAdminAuthNoStore: auth.setAdminAuthNoStore, + respondError: respondErrorMock, + respondSuccess: respondSuccessMock + }); + + for (const handler of [login, logout]) { + for (const origin of [undefined, "https://attacker.test", "not-a-url"]) { + const res = createRes(); + await handler( + { + method: "POST", + headers: origin ? { origin } : {}, + body: { passphrase: syntheticEnv.ADMIN_AUTH_SECRET } + }, + res + ); + assert.strictEqual(res.state.statusCode, 403); + assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN"); + } + } +}); + +test("authentication foundation has no Prisma, CRM, or NextAuth dependency", () => { + const files = [ + "lib/admin/adminAuth.js", + "pages/api/admin/auth/login.js", + "pages/api/admin/auth/logout.js", + "pages/api/admin/auth/session.js" + ]; + + for (const relativePath of files) { + const source = fs.readFileSync(path.join(rootDir, relativePath), "utf8"); + assert.doesNotMatch(source, /@prisma|Prisma|getPortalLogin|CRM|next-auth/i); + } +}); + +test("administrative session verification is independent of portal cookies", () => { + const auth = loadAdminAuthModule(); + const token = auth.issueAdminSession({ + env: syntheticEnv, + now: fixedNow, + nonce: fixedNonce + }); + const req = { + headers: { + cookie: `next-auth.session-token=portal-token; ${auth.ADMIN_SESSION_COOKIE}=${encodeURIComponent( + token + )}` + } + }; + + const extracted = auth.readAdminSessionCookie(req); + assert.strictEqual(extracted, token); + assert.strictEqual( + auth.validateAdminSession(extracted, { + env: syntheticEnv, + now: fixedNow + }), + true + ); +}); + +const run = async () => { + let passed = 0; + + for (const currentTest of tests) { + await currentTest.fn(); + passed += 1; + } + + console.log( + `Administrative authentication foundation tests passed (${passed}/${tests.length}).` + ); +}; + +module.exports = run; + +if (require.main === module) { + run().catch((error) => { + console.error(error); + process.exit(1); + }); +} \ No newline at end of file