Add independent administrative authentication foundation

This commit is contained in:
2026-10-26 12:51:42 +00:00
parent fdeff5b898
commit d11c4a868e
6 changed files with 814 additions and 0 deletions
+56
View File
@@ -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.
+228
View File
@@ -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");
};
+39
View File
@@ -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 });
}
+30
View File
@@ -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 });
}
+24
View File
@@ -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);
});
}