#!/usr/bin/env node
import fs from "node:fs/promises";
import path from "node:path";
import vm from "node:vm";
import { createInterface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
import {
DISPLAY_META_PATH,
DISPLAY_RUNTIME_HOME,
DISPLAY_STATE_PATH,
ensureRuntimeTree,
resolveMetaWritePath,
resolveRuntimeHome,
resolveStateWritePath,
} from "./lib/runtime-paths.mjs";
import { parseArgs } from "./lib/cli-shared.mjs";
import {
extractClientBin,
extractCsrfToken,
normalizeSameSite,
splitSetCookieHeader,
} from "./lib/auth-shared.mjs";
const SIGNON_URL = "https://signon-int.oracle.com/signin";
const SIGNON_ORIGIN = "https://signon-int.oracle.com";
const SIGNON_PUBLIC_URL = "https://signon.oracle.com/signin";
const SIGNON_PUBLIC_ORIGIN = "https://signon.oracle.com";
const TARGET_URL =
"https://salesintelligence-dv.oracle.com/ui/dv/ui/project.jsp?pageid=visualAnalyzer&reportmode=presentation&reportpath=%2F%40Catalog%2Fshared%2FTeam%20Sharing%20Area%2FLAD%2FLAD%20Sales%20Operations%2FLAD%20Production%2FLAD%20Knowledge%20One%20View";
const SESSION_INFO_PATH = "/ui/dv/ui/api/v1/sessioninfo/ext";
const SUPPORTED_FACTORS = new Set([
"PUSH",
"SMS",
"EMAIL",
"PHONE_CALL",
"TOTP",
"BYPASSCODE",
"PASSWORD",
"FIDO_PASSKEY",
]);
function usage() {
return `Direct Oracle Sales Intelligence authentication without browser.
Usage:
node refresh-auth-direct.mjs [options]
Options:
--runtime-home
Override workspace runtime root (default: ${DISPLAY_RUNTIME_HOME})
--state-path Override auth-state.json output path (default: ${DISPLAY_STATE_PATH})
--meta-path Override session-meta.json output path (default: ${DISPLAY_META_PATH})
--username Username to use (prompted if omitted; also supports ORACLE_SI_USERNAME)
--factor Prefer one MFA factor (for example: BYPASSCODE, TOTP, PASSWORD)
--url Target Oracle DV workbook URL
--help Show this help
`;
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function decodeHtml(value) {
return String(value || "")
.replace(/&/g, "&")
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/</g, "<")
.replace(/>/g, ">");
}
function defaultCookiePath(url) {
const pathname = url.pathname || "/";
if (!pathname.startsWith("/") || pathname === "/") return "/";
const lastSlash = pathname.lastIndexOf("/");
return lastSlash <= 0 ? "/" : pathname.slice(0, lastSlash);
}
function domainMatches(hostname, cookieDomain) {
const normalized = String(cookieDomain || "").replace(/^\./, "").toLowerCase();
const host = String(hostname || "").toLowerCase();
return host === normalized || host.endsWith(`.${normalized}`);
}
function normalizeFactor(value) {
const source = String(value || "").trim().toUpperCase().replace(/[\s-]+/g, "_");
if (!source) return null;
if (source.includes("PHONE")) return "PHONE_CALL";
if (source.includes("BYPASS")) return "BYPASSCODE";
if (source.includes("FIDO") || source.includes("PASSKEY") || source.includes("WEBAUTHN")) {
return "FIDO_PASSKEY";
}
if (source === "PASSWORD" || source === "USERNAME_PASSWORD") return "PASSWORD";
if (source.includes("PUSH")) return "PUSH";
if (source.includes("TOTP")) return "TOTP";
if (source.includes("SMS")) return "SMS";
if (source.includes("EMAIL")) return "EMAIL";
return SUPPORTED_FACTORS.has(source) ? source : null;
}
function uniqBy(items, keyFn) {
const seen = new Set();
const result = [];
for (const item of items) {
const key = keyFn(item);
if (seen.has(key)) continue;
seen.add(key);
result.push(item);
}
return result;
}
function mergeFactorChoices(...groups) {
return uniqBy(
groups.flat().filter(Boolean),
(item) => `${item.factor}|${item.deviceId || ""}|${item.label || ""}`
);
}
function rankFactorChoice(choice, preferredFactor) {
if (preferredFactor && choice.factor === preferredFactor) return 0;
const order = {
BYPASSCODE: 1,
TOTP: 2,
PUSH: 3,
SMS: 4,
EMAIL: 5,
PHONE_CALL: 6,
PASSWORD: 7,
FIDO_PASSKEY: 8,
};
return order[choice.factor] ?? 99;
}
function prioritizeFactorChoices(choices, preferredFactor) {
return [...choices].sort((left, right) => {
const rankDiff = rankFactorChoice(left, preferredFactor) - rankFactorChoice(right, preferredFactor);
if (rankDiff !== 0) return rankDiff;
return `${left.factor}|${left.label || ""}|${left.deviceId || ""}`.localeCompare(
`${right.factor}|${right.label || ""}|${right.deviceId || ""}`
);
});
}
function formatFactorChoice(choice, duplicateFactors) {
const label = String(choice.label || "").trim();
if (!label) return choice.factor;
if (choice.deviceId || duplicateFactors.has(choice.factor) || label !== choice.factor) {
return `${choice.factor} - ${label}`;
}
return choice.factor;
}
function attachChoiceState(choices, state, source) {
return (choices || []).map((choice) => ({
...choice,
state,
source,
requestState: extractTopLevelRequestState(state),
}));
}
class CookieJar {
constructor(initialCookies = []) {
this.cookies = [];
for (const cookie of initialCookies) {
this.upsert(cookie);
}
}
upsert(cookie) {
if (!cookie?.name || !cookie?.domain) return;
const normalized = {
path: "/",
expires: -1,
httpOnly: false,
secure: false,
sameSite: "Lax",
...cookie,
domain: String(cookie.domain).toLowerCase(),
path: cookie.path || "/",
expires:
Number.isFinite(cookie.expires) || cookie.expires === -1
? cookie.expires
: Number(cookie.expires) || -1,
sameSite: normalizeSameSite(cookie.sameSite),
};
const key = `${normalized.name}|${normalized.domain}|${normalized.path}`;
const index = this.cookies.findIndex(
(item) => `${item.name}|${item.domain}|${item.path}` === key
);
if (normalized.expires === 0 || normalized.maxAge === 0) {
if (index >= 0) this.cookies.splice(index, 1);
return;
}
if (index >= 0) {
this.cookies[index] = normalized;
return;
}
this.cookies.push(normalized);
}
storeFromResponse(response, requestUrl) {
const url = new URL(requestUrl);
const rawSetCookies =
typeof response.headers.getSetCookie === "function"
? response.headers.getSetCookie()
: splitSetCookieHeader(response.headers.get("set-cookie"));
for (const header of rawSetCookies) {
const cookie = this.parseSetCookie(header, url);
if (cookie) this.upsert(cookie);
}
}
parseSetCookie(header, url) {
if (!header) return null;
const segments = header.split(";").map((part) => part.trim()).filter(Boolean);
const [nameValue, ...attributes] = segments;
const separator = nameValue.indexOf("=");
if (separator <= 0) return null;
const cookie = {
name: nameValue.slice(0, separator),
value: nameValue.slice(separator + 1),
domain: url.hostname.toLowerCase(),
path: defaultCookiePath(url),
expires: -1,
httpOnly: false,
secure: false,
sameSite: "Lax",
};
for (const attribute of attributes) {
const eq = attribute.indexOf("=");
const key = (eq >= 0 ? attribute.slice(0, eq) : attribute).trim().toLowerCase();
const value = eq >= 0 ? attribute.slice(eq + 1).trim() : "";
if (key === "domain") {
cookie.domain = value.toLowerCase();
} else if (key === "path") {
cookie.path = value || "/";
} else if (key === "expires") {
const seconds = Math.floor(new Date(value).getTime() / 1000);
cookie.expires = Number.isFinite(seconds) ? seconds : -1;
} else if (key === "max-age") {
const maxAge = Number(value);
cookie.expires = Number.isFinite(maxAge)
? Math.floor(Date.now() / 1000) + maxAge
: cookie.expires;
cookie.maxAge = maxAge;
} else if (key === "httponly") {
cookie.httpOnly = true;
} else if (key === "secure") {
cookie.secure = true;
} else if (key === "samesite") {
cookie.sameSite = normalizeSameSite(value);
}
}
return cookie;
}
headerFor(urlString) {
const url = new URL(urlString);
const now = Math.floor(Date.now() / 1000);
return this.cookies
.filter((cookie) => {
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("; ");
}
toStorageState(username = "") {
return {
cookies: this.cookies
.map((cookie) => ({
name: cookie.name,
value: cookie.value,
domain: cookie.domain,
path: cookie.path,
expires: cookie.expires ?? -1,
httpOnly: Boolean(cookie.httpOnly),
secure: Boolean(cookie.secure),
sameSite: normalizeSameSite(cookie.sameSite),
}))
.sort((left, right) =>
`${left.domain}|${left.path}|${left.name}`.localeCompare(
`${right.domain}|${right.path}|${right.name}`
)
),
origins: [
{
origin: "https://signon.oracle.com",
localStorage: [{ name: "idcs-clp-username", value: username }],
},
{
origin: "https://signon-int.oracle.com",
localStorage: [{ name: "idcs-clp-username", value: username }],
},
],
};
}
}
async function requestWithJar(jar, url, options = {}) {
let currentUrl = url;
let method = options.method || "GET";
let body = options.body;
let headers = { ...(options.headers || {}) };
const maxRedirects = options.maxRedirects ?? 10;
for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount += 1) {
const requestHeaders = { ...headers };
const cookieHeader = jar.headerFor(currentUrl);
if (cookieHeader) {
requestHeaders.cookie = cookieHeader;
}
const response = await fetch(currentUrl, {
method,
headers: requestHeaders,
body,
redirect: "manual",
});
jar.storeFromResponse(response, currentUrl);
if ([301, 302, 303, 307, 308].includes(response.status)) {
const location = response.headers.get("location");
if (!location) {
return {
url: currentUrl,
response,
text: await response.text(),
};
}
currentUrl = new URL(location, currentUrl).toString();
if (response.status === 303 || ((response.status === 301 || response.status === 302) && method === "POST")) {
method = "GET";
body = undefined;
headers = Object.fromEntries(
Object.entries(headers).filter(([key]) => !/^content-(type|length)$/i.test(key))
);
}
continue;
}
return {
url: currentUrl,
response,
text: await response.text(),
};
}
throw new Error(`Too many redirects while requesting ${url}`);
}
async function requestJson(jar, url, payload, extraHeaders = {}) {
const { response, text } = await requestWithJar(jar, url, {
method: "POST",
headers: {
accept: "application/json, text/plain, */*",
"content-type": "application/json; charset=UTF-8",
origin: SIGNON_ORIGIN,
referer: SIGNON_URL,
"x-requested-with": "XMLHttpRequest",
...extraHeaders,
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`Auth request failed (${response.status}) ${url}: ${text.slice(0, 500)}`);
}
try {
return JSON.parse(text);
} catch (error) {
throw new Error(`Could not parse JSON from ${url}: ${error.message}`);
}
}
function parseHiddenInputs(html) {
const formMatch = html.match(/