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