437 lines
13 KiB
JavaScript
437 lines
13 KiB
JavaScript
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);
|
|
});
|
|
} |