224 lines
6.2 KiB
JavaScript
224 lines
6.2 KiB
JavaScript
#!/usr/bin/env node
|
|
import fs from "node:fs";
|
|
|
|
const DEFAULT_LOGIN_URL = "https://boiler.saas-ctrl.com/login";
|
|
const DEFAULT_COOKIE_NAME = "RJ_session";
|
|
|
|
function fail(message) {
|
|
console.error(`FAIL lighthouse cookie rotation: ${message}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
function requiredEnv(name) {
|
|
const value = process.env[name];
|
|
if (typeof value !== "string" || value.trim().length === 0) {
|
|
fail(`missing required env var ${name}`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function optionalEnv(name) {
|
|
const value = process.env[name];
|
|
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
|
}
|
|
|
|
function assertSafeEnvValue(name, value) {
|
|
if (/[\r\n]/.test(value)) {
|
|
fail(`${name} must not contain newline characters`);
|
|
}
|
|
}
|
|
|
|
function splitSetCookieHeader(headerValue) {
|
|
if (!headerValue) return [];
|
|
const cookies = [];
|
|
let start = 0;
|
|
|
|
for (let index = 0; index < headerValue.length; index += 1) {
|
|
if (headerValue[index] !== ",") continue;
|
|
const rest = headerValue.slice(index + 1);
|
|
if (/^\s*[^=;,\s]+=/.test(rest)) {
|
|
cookies.push(headerValue.slice(start, index).trim());
|
|
start = index + 1;
|
|
}
|
|
}
|
|
|
|
cookies.push(headerValue.slice(start).trim());
|
|
return cookies.filter(Boolean);
|
|
}
|
|
|
|
function getSetCookieHeaders(headers) {
|
|
if (typeof headers.getSetCookie === "function") {
|
|
return headers.getSetCookie();
|
|
}
|
|
return splitSetCookieHeader(headers.get("set-cookie"));
|
|
}
|
|
|
|
function parseCookiePair(setCookieHeader) {
|
|
const [pair] = setCookieHeader.split(";");
|
|
const separator = pair.indexOf("=");
|
|
if (separator <= 0) return null;
|
|
return {
|
|
name: pair.slice(0, separator).trim(),
|
|
value: pair.slice(separator + 1).trim(),
|
|
};
|
|
}
|
|
|
|
function collectCookies(headers, jar) {
|
|
for (const header of getSetCookieHeaders(headers)) {
|
|
const parsed = parseCookiePair(header);
|
|
if (parsed) {
|
|
jar.set(parsed.name, parsed.value);
|
|
}
|
|
}
|
|
}
|
|
|
|
function cookieHeader(jar) {
|
|
return [...jar.entries()].map(([name, value]) => `${name}=${value}`).join("; ");
|
|
}
|
|
|
|
function decodeHtmlAttribute(value) {
|
|
return value
|
|
.replaceAll(""", "\"")
|
|
.replaceAll(""", "\"")
|
|
.replaceAll(""", "\"")
|
|
.replaceAll("'", "'")
|
|
.replaceAll("'", "'")
|
|
.replaceAll("'", "'")
|
|
.replaceAll("&", "&");
|
|
}
|
|
|
|
function parseAttributes(tag) {
|
|
const attributes = new Map();
|
|
const attributePattern = /([A-Za-z_:][-A-Za-z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/g;
|
|
let match;
|
|
while ((match = attributePattern.exec(tag))) {
|
|
attributes.set(match[1].toLowerCase(), decodeHtmlAttribute(match[2] ?? match[3] ?? match[4] ?? ""));
|
|
}
|
|
return attributes;
|
|
}
|
|
|
|
function extractCsrfToken(html) {
|
|
const inputPattern = /<input\b[^>]*>/gi;
|
|
let match;
|
|
while ((match = inputPattern.exec(html))) {
|
|
const attributes = parseAttributes(match[0]);
|
|
if (attributes.get("name") === "csrf" && attributes.has("value")) {
|
|
return attributes.get("value");
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function appendGitHubEnv(values) {
|
|
const githubEnv = process.env.GITHUB_ENV;
|
|
if (!githubEnv) return;
|
|
|
|
const lines = [];
|
|
for (const [name, value] of Object.entries(values)) {
|
|
if (typeof value !== "string" || value.length === 0) continue;
|
|
assertSafeEnvValue(name, value);
|
|
lines.push(`${name}=${value}`);
|
|
}
|
|
|
|
if (lines.length > 0) {
|
|
fs.appendFileSync(githubEnv, `${lines.join("\n")}\n`, { mode: 0o600 });
|
|
}
|
|
}
|
|
|
|
function writeCookieOutput(cookieValue) {
|
|
const outputPath = process.env.LIGHTHOUSE_SESSION_COOKIE_OUTPUT;
|
|
if (!outputPath) return;
|
|
assertSafeEnvValue("LIGHTHOUSE_SESSION_COOKIE_OUTPUT", outputPath);
|
|
fs.writeFileSync(outputPath, `${cookieValue}\n`, { mode: 0o600 });
|
|
try {
|
|
fs.chmodSync(outputPath, 0o600);
|
|
} catch {
|
|
// Best-effort on filesystems that do not support chmod.
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const loginUrl = process.env.LIGHTHOUSE_LOGIN_URL || DEFAULT_LOGIN_URL;
|
|
const email = optionalEnv("LIGHTHOUSE_LOGIN_EMAIL");
|
|
const password = optionalEnv("LIGHTHOUSE_LOGIN_PASSWORD");
|
|
const cookieName = process.env.LIGHTHOUSE_SESSION_COOKIE_NAME || DEFAULT_COOKIE_NAME;
|
|
const cookieDomain = process.env.LIGHTHOUSE_COOKIE_DOMAIN || new URL(loginUrl).hostname;
|
|
const fallbackCookie = optionalEnv("LIGHTHOUSE_SESSION_COOKIE");
|
|
|
|
if (!email || !password) {
|
|
if (fallbackCookie) {
|
|
appendGitHubEnv({
|
|
LIGHTHOUSE_SESSION_COOKIE: fallbackCookie,
|
|
LIGHTHOUSE_SESSION_COOKIE_NAME: cookieName,
|
|
LIGHTHOUSE_COOKIE_DOMAIN: cookieDomain,
|
|
});
|
|
writeCookieOutput(fallbackCookie);
|
|
console.log(`Using pre-provisioned Lighthouse session cookie ${cookieName} for ${new URL(loginUrl).origin}.`);
|
|
return;
|
|
}
|
|
requiredEnv(!email ? "LIGHTHOUSE_LOGIN_EMAIL" : "LIGHTHOUSE_LOGIN_PASSWORD");
|
|
}
|
|
|
|
const jar = new Map();
|
|
|
|
const getResponse = await fetch(loginUrl, {
|
|
redirect: "manual",
|
|
headers: {
|
|
accept: "text/html,application/xhtml+xml",
|
|
},
|
|
});
|
|
|
|
if (!getResponse.ok) {
|
|
fail(`GET ${new URL(loginUrl).origin}/login returned HTTP ${getResponse.status}`);
|
|
}
|
|
|
|
collectCookies(getResponse.headers, jar);
|
|
const html = await getResponse.text();
|
|
const csrf = extractCsrfToken(html);
|
|
if (!csrf) {
|
|
fail("login page did not contain an input[name=csrf] token");
|
|
}
|
|
|
|
const form = new URLSearchParams({
|
|
action: "login",
|
|
email,
|
|
password,
|
|
redirectTo: "/",
|
|
csrf,
|
|
});
|
|
|
|
const postResponse = await fetch(loginUrl, {
|
|
method: "POST",
|
|
redirect: "manual",
|
|
headers: {
|
|
accept: "text/html,application/xhtml+xml",
|
|
"content-type": "application/x-www-form-urlencoded",
|
|
cookie: cookieHeader(jar),
|
|
"x-csrf-token": csrf,
|
|
},
|
|
body: form,
|
|
});
|
|
|
|
collectCookies(postResponse.headers, jar);
|
|
|
|
if (postResponse.status < 200 || postResponse.status >= 400) {
|
|
fail(`POST ${new URL(loginUrl).origin}/login returned HTTP ${postResponse.status}`);
|
|
}
|
|
|
|
const sessionCookie = jar.get(cookieName);
|
|
if (!sessionCookie) {
|
|
fail(`login did not return the ${cookieName} session cookie`);
|
|
}
|
|
|
|
appendGitHubEnv({
|
|
LIGHTHOUSE_SESSION_COOKIE: sessionCookie,
|
|
LIGHTHOUSE_SESSION_COOKIE_NAME: cookieName,
|
|
LIGHTHOUSE_COOKIE_DOMAIN: cookieDomain,
|
|
});
|
|
writeCookieOutput(sessionCookie);
|
|
|
|
console.log(`Minted Lighthouse session cookie ${cookieName} for ${new URL(loginUrl).origin}.`);
|
|
}
|
|
|
|
await main();
|