Files
pedwfrontend/lib/admin/adminAuth.js

228 lines
6.2 KiB
JavaScript

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");
};