Files
knowledge-one-view-cli/scripts/lib/auth-shared.mjs

257 lines
8.6 KiB
JavaScript

import fs from "node:fs/promises";
import path from "node:path";
function domainMatches(hostname, cookieDomain) {
const normalized = String(cookieDomain || "").replace(/^\./, "").toLowerCase();
const host = String(hostname || "").toLowerCase();
if (!normalized || !host) return false;
if (normalized === host) return true;
if (normalized === "localhost") return host === "localhost";
return host.endsWith(`.${normalized}`);
}
export function normalizeSameSite(value) {
const normalized = String(value || "").trim().toLowerCase();
if (normalized === "strict") return "Strict";
if (normalized === "none") return "None";
return "Lax";
}
export function normalizeCookie(cookie) {
if (!cookie?.name || !cookie?.domain) return null;
return {
path: "/",
expires: -1,
httpOnly: false,
secure: false,
sameSite: "Lax",
...cookie,
domain: String(cookie.domain).replace(/^\./, "").toLowerCase(),
path: cookie.path || "/",
expires:
Number.isFinite(cookie.expires) || cookie.expires === -1
? cookie.expires
: Number(cookie.expires) || -1,
httpOnly: Boolean(cookie.httpOnly),
secure: Boolean(cookie.secure),
sameSite: normalizeSameSite(cookie.sameSite),
};
}
function findCookieIndex(cookies, candidate) {
return cookies.findIndex(
(cookie) =>
cookie.name === candidate.name &&
String(cookie.domain || "").replace(/^\./, "").toLowerCase() === candidate.domain &&
(cookie.path || "/") === candidate.path
);
}
export function cookieHeaderFor(urlString, cookies) {
const url = new URL(urlString);
const now = Math.floor(Date.now() / 1000);
return cookies
.map(normalizeCookie)
.filter((cookie) => {
if (!cookie) return false;
if (!domainMatches(url.hostname, cookie.domain)) return false;
if (!url.pathname.startsWith(cookie.path || "/")) return false;
if (cookie.secure && url.protocol !== "https:") return false;
if (cookie.expires && cookie.expires !== -1 && cookie.expires < now) {
return false;
}
return true;
})
.sort((left, right) => (right.path?.length || 0) - (left.path?.length || 0))
.map((cookie) => `${cookie.name}=${cookie.value}`)
.join("; ");
}
export function splitSetCookieHeader(headerValue) {
if (!headerValue) return [];
const parts = [];
let current = "";
let inExpires = false;
for (let index = 0; index < headerValue.length; index += 1) {
const char = headerValue[index];
const next = headerValue.slice(index);
if (!inExpires && next.toLowerCase().startsWith("expires=")) {
inExpires = true;
}
if (char === "," && !inExpires) {
const trimmed = current.trim();
if (trimmed) parts.push(trimmed);
current = "";
continue;
}
current += char;
if (inExpires && char === ";") {
inExpires = false;
}
}
const trimmed = current.trim();
if (trimmed) parts.push(trimmed);
return parts;
}
function defaultCookiePath(url) {
const pathname = url.pathname || "/";
if (!pathname.startsWith("/") || pathname === "/") return "/";
const lastSlash = pathname.lastIndexOf("/");
return lastSlash <= 0 ? "/" : pathname.slice(0, lastSlash);
}
export function mergeCookiesFromHeader(state, urlString, headerValue) {
const url = new URL(urlString);
const parts = splitSetCookieHeader(headerValue);
let changed = false;
for (const part of parts) {
const segments = part.split(";").map((segment) => segment.trim()).filter(Boolean);
const [nameValue, ...attributes] = segments;
const eqIndex = nameValue?.indexOf("=") ?? -1;
if (eqIndex <= 0) continue;
const name = nameValue.slice(0, eqIndex);
const value = nameValue.slice(eqIndex + 1);
const nextCookie = {
name,
value,
domain: url.hostname.toLowerCase(),
path: defaultCookiePath(url),
expires: -1,
httpOnly: false,
secure: url.protocol === "https:",
sameSite: "Lax",
};
for (const attribute of attributes) {
const separator = attribute.indexOf("=");
const rawKey = (separator >= 0 ? attribute.slice(0, separator) : attribute).trim();
const rawValue = separator >= 0 ? attribute.slice(separator + 1).trim() : "";
const key = rawKey.toLowerCase();
if (key === "domain") nextCookie.domain = rawValue.replace(/^\./, "").toLowerCase() || url.hostname.toLowerCase();
if (key === "path") nextCookie.path = rawValue || "/";
if (key === "secure") nextCookie.secure = true;
if (key === "httponly") nextCookie.httpOnly = true;
if (key === "samesite") nextCookie.sameSite = normalizeSameSite(rawValue);
if (key === "expires") {
const seconds = Math.floor(new Date(rawValue).getTime() / 1000);
nextCookie.expires = Number.isFinite(seconds) ? seconds : -1;
}
if (key === "max-age") {
const maxAge = Number(rawValue);
nextCookie.maxAge = maxAge;
nextCookie.expires = Number.isFinite(maxAge)
? Math.floor(Date.now() / 1000) + maxAge
: nextCookie.expires;
}
}
const normalized = normalizeCookie(nextCookie);
if (!normalized) continue;
const existingIndex = findCookieIndex(state.cookies, normalized);
if (normalized.expires === 0 || normalized.maxAge === 0) {
if (existingIndex >= 0) {
state.cookies.splice(existingIndex, 1);
changed = true;
}
continue;
}
if (existingIndex >= 0) {
const previous = JSON.stringify(normalizeCookie(state.cookies[existingIndex]));
state.cookies[existingIndex] = { ...state.cookies[existingIndex], ...normalized };
if (previous !== JSON.stringify(normalizeCookie(state.cookies[existingIndex]))) {
changed = true;
}
} else {
state.cookies.push(normalized);
changed = true;
}
}
return changed;
}
function getHeaderValue(headers, name) {
if (!headers) return null;
if (typeof headers.get === "function") {
return headers.get(name) || headers.get(name.toLowerCase()) || headers.get(name.toUpperCase()) || null;
}
return headers[name] || headers[name.toLowerCase()] || headers[name.toUpperCase()] || null;
}
export function extractClientBin(html, headers = null) {
const fromHeader = getHeaderValue(headers, "x-bitech-svrbin");
if (fromHeader) return fromHeader;
return String(html || "").match(/oracle-jet-bundles\/[^.]+\.([a-z0-9]+)\//i)?.[1] || null;
}
export function extractCsrfToken(value) {
if (!value || typeof value !== "object") return null;
for (const [key, candidate] of Object.entries(value)) {
if (typeof candidate === "string" && key.toLowerCase() === "csrftoken") {
return candidate;
}
const nested = extractCsrfToken(candidate);
if (nested) return nested;
}
return null;
}
export async function readJson(filePath, label) {
let raw;
try {
raw = await fs.readFile(filePath, "utf8");
} catch (error) {
if (error?.code === "ENOENT") {
throw new Error(
`Could not find ${label} at ${filePath}. Run scripts/refresh-auth-direct.mjs first to create the session files.`
);
}
throw error;
}
try {
return JSON.parse(raw);
} catch (error) {
throw new Error(`Could not parse ${label} at ${filePath}: ${error.message}`);
}
}
export async function persistSessionArtifacts(statePath, metaPath, state, meta, { ensureParentDir } = {}) {
if (typeof ensureParentDir === "function") {
await ensureParentDir(statePath);
await ensureParentDir(metaPath);
}
await fs.writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
await fs.writeFile(metaPath, `${JSON.stringify(meta, null, 2)}\n`, "utf8");
}
export function replaceObjectContents(target, nextValue) {
for (const key of Object.keys(target)) {
delete target[key];
}
Object.assign(target, nextValue);
return target;
}
export function inferRuntimeHome(statePath, metaPath) {
const stateDir = statePath ? path.dirname(statePath) : null;
const metaDir = metaPath ? path.dirname(metaPath) : null;
if (stateDir && metaDir && stateDir === metaDir) {
return stateDir;
}
return stateDir || metaDir || null;
}
export function isAuthRefreshRequiredError(error) {
const message = String(error?.message || "");
return [
"Refresh auth state first",
"Request redirected",
"Session redirected to Oracle sign-in",
"Oracle DV session restore redirected to Oracle sign-in",
"Request redirected while restoring Oracle DV session",
"Oracle DV session is not valid anymore",
"Could not find auth state",
"Could not find session metadata",
"Session metadata at",
"Expected JSON",
].some((fragment) => message.includes(fragment));
}