228 lines
6.2 KiB
JavaScript
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");
|
|
}; |