Add independent administrative authentication foundation
This commit is contained in:
@@ -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.
|
||||
@@ -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");
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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)
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user