1202 lines
35 KiB
JavaScript
1202 lines
35 KiB
JavaScript
#!/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 <dir> Override workspace runtime root (default: ${DISPLAY_RUNTIME_HOME})
|
|
--state-path <file> Override auth-state.json output path (default: ${DISPLAY_STATE_PATH})
|
|
--meta-path <file> Override session-meta.json output path (default: ${DISPLAY_META_PATH})
|
|
--username <email> Username to use (prompted if omitted; also supports ORACLE_SI_USERNAME)
|
|
--factor <name> Prefer one MFA factor (for example: BYPASSCODE, TOTP, PASSWORD)
|
|
--url <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(/<form[^>]*action="([^"]+)"[\s\S]*?<\/form>/i);
|
|
if (!formMatch) {
|
|
throw new Error("Could not find Oracle redirect form in sign-in response.");
|
|
}
|
|
const inputRegex = /<input[^>]*name="([^"]+)"[^>]*value="([^"]*)"[^>]*>/gi;
|
|
const fields = {};
|
|
let match = inputRegex.exec(html);
|
|
while (match) {
|
|
fields[match[1]] = decodeHtml(match[2]);
|
|
match = inputRegex.exec(html);
|
|
}
|
|
return {
|
|
action: decodeHtml(formMatch[1]),
|
|
fields,
|
|
};
|
|
}
|
|
|
|
function tryParseHiddenInputs(html) {
|
|
try {
|
|
return parseHiddenInputs(html);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function isPostFormRedirectPage(html) {
|
|
return /PostFormRedirect|submitFormOnLoad\(/i.test(String(html || ""));
|
|
}
|
|
|
|
function parseGlobalConfig(html) {
|
|
const match = html.match(/(?:var|window\.)\s*GlobalConfig\s*=\s*({[\s\S]*?});/i);
|
|
if (match) {
|
|
try {
|
|
return vm.runInNewContext(`(${match[1]})`);
|
|
} catch {
|
|
// fall through to regex extraction
|
|
}
|
|
}
|
|
|
|
const accessToken = html.match(/["']accessToken["']\s*:\s*["']([^"']+)["']/i)?.[1];
|
|
const idcsURL = html.match(/["']idcsURL["']\s*:\s*["']([^"']+)["']/i)?.[1];
|
|
const loginCtx = html.match(/["']loginCtx["']\s*:\s*["']([^"']+)["']/i)?.[1];
|
|
if (!accessToken || !idcsURL || !loginCtx) {
|
|
throw new Error("Could not extract GlobalConfig from Oracle sign-in page.");
|
|
}
|
|
return { accessToken, idcsURL, loginCtx };
|
|
}
|
|
|
|
function walk(value, visitor, context = {}) {
|
|
if (Array.isArray(value)) {
|
|
for (const item of value) {
|
|
walk(item, visitor, context);
|
|
}
|
|
return;
|
|
}
|
|
if (!value || typeof value !== "object") {
|
|
return;
|
|
}
|
|
visitor(value, context);
|
|
for (const [key, child] of Object.entries(value)) {
|
|
const nextContext = { ...context };
|
|
if (key.toLowerCase().includes("factor")) {
|
|
nextContext.factor = normalizeFactor(
|
|
typeof child === "string" ? child : value.authFactor || value.type || value.factor
|
|
);
|
|
}
|
|
walk(child, visitor, nextContext);
|
|
}
|
|
}
|
|
|
|
function findFirstString(node, keys) {
|
|
let result = null;
|
|
walk(node, (value) => {
|
|
if (result) return;
|
|
for (const key of keys) {
|
|
if (typeof value[key] === "string" && value[key]) {
|
|
result = value[key];
|
|
return;
|
|
}
|
|
}
|
|
});
|
|
return result;
|
|
}
|
|
|
|
function extractRequestState(value) {
|
|
if (!value) return null;
|
|
if (typeof value === "string") {
|
|
try {
|
|
const decoded = Buffer.from(value, "base64").toString("utf8");
|
|
const parsed = JSON.parse(decoded);
|
|
return (
|
|
parsed.requestState ||
|
|
parsed.nextRequestState ||
|
|
findFirstString(parsed, ["requestState", "nextRequestState", "state"])
|
|
);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
return findFirstString(value, ["requestState", "nextRequestState", "state"]);
|
|
}
|
|
|
|
function extractTopLevelRequestState(value) {
|
|
if (value && typeof value === "object" && typeof value.requestState === "string" && value.requestState) {
|
|
return value.requestState;
|
|
}
|
|
return extractRequestState(value);
|
|
}
|
|
|
|
function extractAuthnToken(value) {
|
|
return findFirstString(value, ["authnToken"]);
|
|
}
|
|
|
|
function extractMessage(value) {
|
|
return (
|
|
findFirstString(value, ["displayMessage", "message", "errorMessage", "localizedMessage"]) ||
|
|
null
|
|
);
|
|
}
|
|
|
|
function extractFactorChoices(value) {
|
|
const choices = [];
|
|
if (Array.isArray(value?.nextAuthFactors)) {
|
|
for (const rawFactor of value.nextAuthFactors) {
|
|
const factor = normalizeFactor(rawFactor);
|
|
if (!factor || !SUPPORTED_FACTORS.has(factor)) {
|
|
continue;
|
|
}
|
|
choices.push({
|
|
factor,
|
|
deviceId: null,
|
|
label:
|
|
factor === "FIDO_PASSKEY"
|
|
? value.displayName || "Windows Hello / passkey"
|
|
: value.displayName || factor,
|
|
});
|
|
}
|
|
}
|
|
walk(
|
|
value,
|
|
(node, context) => {
|
|
const factor = normalizeFactor(
|
|
node.authFactor || node.factorName || node.factorType || node.type || context.factor
|
|
);
|
|
if (!factor || !SUPPORTED_FACTORS.has(factor)) {
|
|
return;
|
|
}
|
|
|
|
if (Array.isArray(node.devices)) {
|
|
for (const device of node.devices) {
|
|
choices.push({
|
|
factor,
|
|
deviceId: device.deviceId || device.id || null,
|
|
label:
|
|
device.displayName ||
|
|
device.deviceName ||
|
|
device.name ||
|
|
device.email ||
|
|
device.phoneNumber ||
|
|
device.mobileNumber ||
|
|
"default device",
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
const deviceId = node.deviceId || node.id || null;
|
|
const label =
|
|
node.displayName ||
|
|
node.deviceName ||
|
|
node.name ||
|
|
node.email ||
|
|
node.phoneNumber ||
|
|
node.mobileNumber ||
|
|
(deviceId ? "selected device" : factor);
|
|
|
|
if (deviceId || factor === "BYPASSCODE") {
|
|
choices.push({ factor, deviceId, label });
|
|
}
|
|
},
|
|
{}
|
|
);
|
|
|
|
if (choices.length > 0) {
|
|
return uniqBy(choices, (item) => `${item.factor}|${item.deviceId || ""}|${item.label}`);
|
|
}
|
|
|
|
const factors = [];
|
|
walk(value, (node) => {
|
|
const factor = normalizeFactor(node.authFactor || node.factorName || node.factorType || node.type);
|
|
if (factor && SUPPORTED_FACTORS.has(factor)) {
|
|
factors.push({ factor, deviceId: null, label: factor });
|
|
}
|
|
});
|
|
return uniqBy(factors, (item) => `${item.factor}|${item.deviceId || ""}`);
|
|
}
|
|
|
|
function toFormBody(values) {
|
|
const form = new URLSearchParams();
|
|
for (const [key, value] of Object.entries(values)) {
|
|
if (value === undefined || value === null) continue;
|
|
form.set(key, String(value));
|
|
}
|
|
return form.toString();
|
|
}
|
|
|
|
async function promptLine(rl, message) {
|
|
return rl.question(message);
|
|
}
|
|
|
|
async function promptSecret(message) {
|
|
const secretRl = createInterface({ input, output, terminal: true, historySize: 0 });
|
|
const originalWriteToOutput =
|
|
typeof secretRl._writeToOutput === "function"
|
|
? secretRl._writeToOutput.bind(secretRl)
|
|
: null;
|
|
secretRl._writeToOutput = (chunk) => {
|
|
const text = String(chunk);
|
|
if (text.includes("\n") || text.includes("\r")) {
|
|
output.write(text);
|
|
return;
|
|
}
|
|
if (/^\x1B\[[0-9;]*[A-Za-z]$/.test(text)) {
|
|
output.write(text);
|
|
return;
|
|
}
|
|
output.write("*");
|
|
};
|
|
try {
|
|
return await secretRl.question(message);
|
|
} finally {
|
|
if (originalWriteToOutput) {
|
|
secretRl._writeToOutput = originalWriteToOutput;
|
|
}
|
|
secretRl.close();
|
|
output.write("\n");
|
|
}
|
|
}
|
|
|
|
async function authenticateUsername({ jar, authEndpoint, requestState, username, accessToken }) {
|
|
return requestJson(jar, authEndpoint, {
|
|
op: "credSubmit",
|
|
origin: SIGNON_ORIGIN,
|
|
credentials: {
|
|
username,
|
|
keepMeSignedIn: false,
|
|
},
|
|
authFactor: "USERNAME",
|
|
isFidoSupportedPlatform: true,
|
|
requestState,
|
|
}, {
|
|
authorization: `Bearer ${accessToken}`,
|
|
});
|
|
}
|
|
|
|
async function authenticatePrimary({ jar, authEndpoint, requestState, username, password, accessToken }) {
|
|
return requestJson(jar, authEndpoint, {
|
|
op: "credSubmit",
|
|
origin: SIGNON_ORIGIN,
|
|
credentials: {
|
|
username,
|
|
keepMeSignedIn: false,
|
|
password,
|
|
browserFingerPrintParameters: "",
|
|
},
|
|
authFactor: "USERNAME_PASSWORD",
|
|
requestState,
|
|
}, {
|
|
authorization: `Bearer ${accessToken}`,
|
|
});
|
|
}
|
|
|
|
async function submitPasswordCredential({ jar, authEndpoint, currentState, username, password, accessToken }) {
|
|
return authenticatePrimary({
|
|
jar,
|
|
authEndpoint,
|
|
requestState: extractRequestState(currentState),
|
|
username,
|
|
password,
|
|
accessToken,
|
|
});
|
|
}
|
|
|
|
async function submitFactorSelection({ jar, authEndpoint, requestState, factor, deviceId, accessToken }) {
|
|
const credentials = {};
|
|
if (deviceId) credentials.deviceId = deviceId;
|
|
return requestJson(jar, authEndpoint, {
|
|
op: "credSubmit",
|
|
origin: SIGNON_ORIGIN,
|
|
authFactor: factor,
|
|
requestState,
|
|
credentials,
|
|
}, {
|
|
authorization: `Bearer ${accessToken}`,
|
|
});
|
|
}
|
|
|
|
async function submitOtpCode({ jar, authEndpoint, requestState, factor, code, deviceId, accessToken }) {
|
|
const payload = {
|
|
op: "credSubmit",
|
|
origin: SIGNON_ORIGIN,
|
|
requestState,
|
|
credentials: {
|
|
otpCode: code,
|
|
},
|
|
};
|
|
if (factor) payload.authFactor = factor;
|
|
if (deviceId) payload.credentials.deviceId = deviceId;
|
|
return requestJson(jar, authEndpoint, payload, {
|
|
authorization: `Bearer ${accessToken}`,
|
|
});
|
|
}
|
|
|
|
async function resendOtp({ jar, authEndpoint, requestState, accessToken }) {
|
|
return requestJson(jar, authEndpoint, {
|
|
op: "resendCode",
|
|
requestState,
|
|
}, {
|
|
authorization: `Bearer ${accessToken}`,
|
|
});
|
|
}
|
|
|
|
async function fetchBackupFactors({ jar, authEndpoint, requestState, accessToken }) {
|
|
return requestJson(
|
|
jar,
|
|
authEndpoint,
|
|
{
|
|
op: "getBackupFactors",
|
|
origin: SIGNON_ORIGIN,
|
|
requestState,
|
|
},
|
|
{
|
|
authorization: `Bearer ${accessToken}`,
|
|
}
|
|
);
|
|
}
|
|
|
|
async function handleOtpFlow({ rl, jar, authEndpoint, currentState, factor, deviceId, accessToken }) {
|
|
let state = currentState;
|
|
|
|
if (["SMS", "EMAIL", "PHONE_CALL"].includes(factor)) {
|
|
state = await submitFactorSelection({
|
|
jar,
|
|
authEndpoint,
|
|
requestState: extractRequestState(state),
|
|
factor,
|
|
deviceId,
|
|
accessToken,
|
|
});
|
|
}
|
|
|
|
while (true) {
|
|
const code = (await promptLine(rl, `Codigo ${factor}: `)).trim();
|
|
if (!code) continue;
|
|
state = await submitOtpCode({
|
|
jar,
|
|
authEndpoint,
|
|
requestState: extractRequestState(state),
|
|
factor: factor === "SMS" || factor === "EMAIL" || factor === "PHONE_CALL" ? undefined : factor,
|
|
code,
|
|
deviceId,
|
|
accessToken,
|
|
});
|
|
if (extractAuthnToken(state)) {
|
|
return state;
|
|
}
|
|
const message = extractMessage(state);
|
|
if (message) {
|
|
console.log(message);
|
|
}
|
|
const nextAction = (
|
|
await promptLine(rl, "Codigo invalido. Pressione Enter para tentar de novo ou digite resend: ")
|
|
)
|
|
.trim()
|
|
.toLowerCase();
|
|
if (nextAction === "resend" && ["SMS", "EMAIL", "PHONE_CALL"].includes(factor)) {
|
|
state = await resendOtp({
|
|
jar,
|
|
authEndpoint,
|
|
requestState: extractRequestState(state),
|
|
accessToken,
|
|
});
|
|
console.log("Novo codigo solicitado.");
|
|
}
|
|
}
|
|
}
|
|
|
|
async function handlePushFlow({ jar, authEndpoint, currentState, deviceId, accessToken }) {
|
|
let state = await submitFactorSelection({
|
|
jar,
|
|
authEndpoint,
|
|
requestState: extractRequestState(currentState),
|
|
factor: "PUSH",
|
|
deviceId,
|
|
accessToken,
|
|
});
|
|
|
|
console.log("Aprove a solicitacao de push no seu dispositivo.");
|
|
for (let attempt = 1; attempt <= 60; attempt += 1) {
|
|
if (extractAuthnToken(state)) {
|
|
return state;
|
|
}
|
|
await sleep(5000);
|
|
state = await requestJson(jar, authEndpoint, {
|
|
op: "credSubmit",
|
|
origin: SIGNON_ORIGIN,
|
|
requestState: extractRequestState(state),
|
|
}, {
|
|
authorization: `Bearer ${accessToken}`,
|
|
});
|
|
const message = extractMessage(state);
|
|
if (message && attempt % 3 === 0) {
|
|
console.log(message);
|
|
}
|
|
}
|
|
|
|
throw new Error("Timeout aguardando aprovacao do push MFA.");
|
|
}
|
|
|
|
async function handleMfa({ rl, jar, authEndpoint, response, username, accessToken, preferredFactor }) {
|
|
let choices = attachChoiceState(extractFactorChoices(response), response, "primary");
|
|
let backupChoices = [];
|
|
if (Array.isArray(response?.nextOp) && response.nextOp.includes("getBackupFactors")) {
|
|
try {
|
|
const backupResponse = await fetchBackupFactors({
|
|
jar,
|
|
authEndpoint,
|
|
requestState: extractRequestState(response),
|
|
accessToken,
|
|
});
|
|
backupChoices = attachChoiceState(extractFactorChoices(backupResponse), backupResponse, "backup");
|
|
choices = mergeFactorChoices(
|
|
choices,
|
|
backupChoices
|
|
);
|
|
} catch (error) {
|
|
console.log(`Nao foi possivel carregar fatores alternativos: ${error.message}`);
|
|
}
|
|
}
|
|
choices = prioritizeFactorChoices(choices, preferredFactor);
|
|
if (choices.length === 0) {
|
|
throw new Error(
|
|
`MFA exigido, mas o script nao conseguiu identificar os fatores disponiveis. Resposta: ${JSON.stringify(
|
|
response,
|
|
null,
|
|
2
|
|
).slice(0, 2000)}`
|
|
);
|
|
}
|
|
|
|
let selected = choices[0];
|
|
const duplicateFactors = new Set(
|
|
choices
|
|
.map((choice) => choice.factor)
|
|
.filter((factor, index, factors) => factors.indexOf(factor) !== index)
|
|
);
|
|
const preferredChoice = preferredFactor
|
|
? backupChoices.find((choice) => choice.factor === preferredFactor) ||
|
|
choices.find((choice) => choice.factor === preferredFactor)
|
|
: null;
|
|
if (preferredChoice) {
|
|
selected = preferredChoice;
|
|
console.log(`Usando MFA ${formatFactorChoice(selected, duplicateFactors)} (preferido por argumento).`);
|
|
} else if (choices.length > 1) {
|
|
console.log("Fatores MFA disponiveis:");
|
|
choices.forEach((choice, index) => {
|
|
console.log(` ${index + 1}. ${formatFactorChoice(choice, duplicateFactors)}`);
|
|
});
|
|
while (true) {
|
|
const raw = (await promptLine(rl, `Escolha um fator [1-${choices.length}]: `)).trim();
|
|
const index = Number(raw) - 1;
|
|
if (Number.isInteger(index) && index >= 0 && index < choices.length) {
|
|
selected = choices[index];
|
|
break;
|
|
}
|
|
}
|
|
} else {
|
|
console.log(`Usando MFA ${formatFactorChoice(selected, duplicateFactors)}.`);
|
|
}
|
|
|
|
if (selected.factor === "PUSH") {
|
|
return handlePushFlow({
|
|
jar,
|
|
authEndpoint,
|
|
currentState: selected.requestState ? { requestState: selected.requestState } : selected.state || response,
|
|
deviceId: selected.deviceId,
|
|
accessToken,
|
|
});
|
|
}
|
|
|
|
if (selected.factor === "TOTP" || selected.factor === "BYPASSCODE") {
|
|
return handleOtpFlow({
|
|
rl,
|
|
jar,
|
|
authEndpoint,
|
|
currentState: selected.requestState ? { requestState: selected.requestState } : selected.state || response,
|
|
factor: selected.factor,
|
|
deviceId: selected.deviceId,
|
|
accessToken,
|
|
});
|
|
}
|
|
|
|
if (["SMS", "EMAIL", "PHONE_CALL"].includes(selected.factor)) {
|
|
return handleOtpFlow({
|
|
rl,
|
|
jar,
|
|
authEndpoint,
|
|
currentState: selected.requestState ? { requestState: selected.requestState } : selected.state || response,
|
|
factor: selected.factor,
|
|
deviceId: selected.deviceId,
|
|
accessToken,
|
|
});
|
|
}
|
|
|
|
if (selected.factor === "PASSWORD") {
|
|
const password = await promptSecret("Senha Oracle: ");
|
|
if (!password) {
|
|
throw new Error("Senha vazia.");
|
|
}
|
|
return submitPasswordCredential({
|
|
jar,
|
|
authEndpoint,
|
|
currentState: selected.requestState ? { requestState: selected.requestState } : selected.state || response,
|
|
username,
|
|
password,
|
|
accessToken,
|
|
});
|
|
}
|
|
|
|
if (selected.factor === "FIDO_PASSKEY") {
|
|
throw new Error(
|
|
"Este tenant exige passkey/FIDO com Windows Hello. O fluxo direto por terminal ainda nao consegue acionar a janela nativa do Windows Hello; use refresh-auth-state.mjs no browser para concluir a autenticacao."
|
|
);
|
|
}
|
|
|
|
throw new Error(`Fator MFA nao suportado automaticamente: ${selected.factor}`);
|
|
}
|
|
|
|
async function createSecureSession({ jar, idcsUrl, accessToken, authnToken }) {
|
|
const targets = uniqBy(
|
|
[
|
|
{ url: new URL("/sso/v1/sdk/secure/session", idcsUrl).toString(), origin: new URL(idcsUrl).origin },
|
|
{ url: new URL("/sso/v1/sdk/secure/session", SIGNON_ORIGIN).toString(), origin: SIGNON_ORIGIN },
|
|
{ url: new URL("/sso/v1/sdk/secure/session", SIGNON_PUBLIC_ORIGIN).toString(), origin: SIGNON_PUBLIC_ORIGIN },
|
|
],
|
|
(entry) => entry.url
|
|
);
|
|
const errors = [];
|
|
let successCount = 0;
|
|
|
|
for (const target of targets) {
|
|
const { response, text } = await requestWithJar(jar, target.url, {
|
|
method: "POST",
|
|
headers: {
|
|
accept: "*/*",
|
|
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
|
|
origin: target.origin,
|
|
referer: `${target.origin}/signin`,
|
|
},
|
|
body: toFormBody({
|
|
authnToken,
|
|
authorization: accessToken,
|
|
}),
|
|
});
|
|
|
|
if (response.ok) {
|
|
successCount += 1;
|
|
continue;
|
|
}
|
|
|
|
errors.push(`${target.url} -> ${response.status}: ${text.slice(0, 200)}`);
|
|
}
|
|
|
|
if (successCount === 0) {
|
|
throw new Error(`Could not establish Oracle secure session: ${errors.join(" | ")}`);
|
|
}
|
|
}
|
|
|
|
async function loadBrowserLikePage({ jar, targetUrl, maxSteps = 12 }) {
|
|
let nextRequest = {
|
|
url: targetUrl,
|
|
options: {
|
|
headers: {
|
|
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
},
|
|
maxRedirects: 12,
|
|
},
|
|
};
|
|
|
|
for (let step = 1; step <= maxSteps; step += 1) {
|
|
const page = await requestWithJar(jar, nextRequest.url, nextRequest.options);
|
|
const url = page.url;
|
|
|
|
if (
|
|
(url.includes("signon.oracle.com") || url.includes("signon-int.oracle.com")) &&
|
|
!isPostFormRedirectPage(page.text)
|
|
) {
|
|
throw new Error("Oracle DV redirectou de volta para a pagina de login. A autenticacao direta nao fechou a sessao.");
|
|
}
|
|
|
|
const form = tryParseHiddenInputs(page.text);
|
|
if (form && isPostFormRedirectPage(page.text)) {
|
|
const formUrl = new URL(form.action, url).toString();
|
|
nextRequest = {
|
|
url: formUrl,
|
|
options: {
|
|
method: "POST",
|
|
headers: {
|
|
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
|
|
origin: new URL(formUrl).origin,
|
|
referer: url,
|
|
},
|
|
body: toFormBody(form.fields),
|
|
maxRedirects: 12,
|
|
},
|
|
};
|
|
continue;
|
|
}
|
|
|
|
return page;
|
|
}
|
|
|
|
throw new Error("Oracle DV browserless bootstrap exceeded the maximum number of redirects/forms.");
|
|
}
|
|
|
|
async function loadDvSession({ jar, targetUrl }) {
|
|
const page = await loadBrowserLikePage({ jar, targetUrl });
|
|
|
|
if (page.url.includes("signon.oracle.com") || page.url.includes("signon-int.oracle.com")) {
|
|
throw new Error("Oracle DV redirectou de volta para o sign-in. A autenticacao direta nao fechou a sessao.");
|
|
}
|
|
if (!page.response.ok) {
|
|
throw new Error(`Could not load Oracle DV workbook (${page.response.status}).`);
|
|
}
|
|
|
|
let clientBin = extractClientBin(page.text, page.response.headers);
|
|
const sessionInfoUrl = new URL(SESSION_INFO_PATH, targetUrl).toString();
|
|
const sessionInfo = await requestWithJar(jar, sessionInfoUrl, {
|
|
headers: {
|
|
accept: "application/json, text/plain, */*",
|
|
authorization: "session",
|
|
"x-requested-with": "XMLHttpRequest",
|
|
referer: targetUrl,
|
|
...(clientBin ? { "x-bitech-clientbin": clientBin } : {}),
|
|
},
|
|
});
|
|
|
|
if (sessionInfo.url.includes("signon.oracle.com") || sessionInfo.url.includes("signon-int.oracle.com")) {
|
|
throw new Error("Oracle DV session info redirecionou para o sign-in.");
|
|
}
|
|
if (!sessionInfo.response.ok) {
|
|
throw new Error(
|
|
`Could not read Oracle DV session info (${sessionInfo.response.status}): ${sessionInfo.text.slice(0, 500)}`
|
|
);
|
|
}
|
|
|
|
let sessionInfoJson;
|
|
try {
|
|
sessionInfoJson = JSON.parse(sessionInfo.text);
|
|
} catch (error) {
|
|
throw new Error(`Could not parse Oracle DV session info JSON: ${error.message}`);
|
|
}
|
|
|
|
const csrfToken = extractCsrfToken(sessionInfoJson);
|
|
clientBin ||= extractClientBin(sessionInfo.text, sessionInfo.response.headers);
|
|
|
|
if (!csrfToken) {
|
|
throw new Error("Could not extract csrftoken from Oracle DV session info.");
|
|
}
|
|
if (!clientBin) {
|
|
throw new Error("Could not extract x-bitech-clientbin from Oracle DV responses.");
|
|
}
|
|
|
|
return {
|
|
pageUrl: page.url,
|
|
xCsrfToken: csrfToken,
|
|
xBitechClientbin: clientBin,
|
|
};
|
|
}
|
|
|
|
async function writeJson(filePath, value) {
|
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
}
|
|
|
|
async function main() {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
if (args.help === "true") {
|
|
console.log(usage());
|
|
return;
|
|
}
|
|
|
|
const configuredUsername = args.username || process.env.ORACLE_SI_USERNAME || "";
|
|
const runtimeHome = resolveRuntimeHome(args["runtime-home"]);
|
|
await ensureRuntimeTree(runtimeHome);
|
|
const statePath = resolveStateWritePath({ explicitPath: args["state-path"], runtimeHome });
|
|
const metaPath = resolveMetaWritePath({ explicitPath: args["meta-path"], runtimeHome });
|
|
const targetUrl = args.url || process.env.ORACLE_SI_TARGET_URL || TARGET_URL;
|
|
const preferredFactor = args.factor ? normalizeFactor(args.factor) : null;
|
|
const jar = new CookieJar();
|
|
const startedAt = Date.now();
|
|
let rl = null;
|
|
|
|
try {
|
|
rl = createInterface({ input, output });
|
|
const username =
|
|
configuredUsername.trim() || (await promptLine(rl, "Oracle username/email: ")).trim();
|
|
if (!username) {
|
|
throw new Error("Oracle username/email is required. Pass --username or set ORACLE_SI_USERNAME.");
|
|
}
|
|
|
|
console.log(`Autenticando diretamente no Oracle IDCS para ${username}.`);
|
|
console.log("O fluxo nao abre browser. Ele tenta primeiro o caminho passwordless do tenant e so pede senha se o Oracle realmente solicitar.");
|
|
|
|
const initial = await requestWithJar(jar, SIGNON_URL, { maxRedirects: 8 });
|
|
const redirectForm = parseHiddenInputs(initial.text);
|
|
const signInApp = await requestWithJar(jar, redirectForm.action, {
|
|
method: "POST",
|
|
headers: {
|
|
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
|
|
origin: SIGNON_ORIGIN,
|
|
referer: SIGNON_URL,
|
|
},
|
|
body: toFormBody(redirectForm.fields),
|
|
maxRedirects: 8,
|
|
});
|
|
|
|
const globalConfig = parseGlobalConfig(signInApp.text);
|
|
const requestState = extractRequestState(globalConfig.loginCtx);
|
|
if (!requestState) {
|
|
throw new Error("Could not extract requestState from GlobalConfig loginCtx.");
|
|
}
|
|
|
|
const authEndpoint = new URL("/sso/v1/sdk/authenticate", globalConfig.idcsURL).toString();
|
|
let authResponse = await authenticateUsername({
|
|
jar,
|
|
authEndpoint,
|
|
requestState,
|
|
username,
|
|
accessToken: globalConfig.accessToken,
|
|
});
|
|
|
|
if (!extractAuthnToken(authResponse)) {
|
|
authResponse = await handleMfa({
|
|
rl,
|
|
jar,
|
|
authEndpoint,
|
|
response: authResponse,
|
|
username,
|
|
accessToken: globalConfig.accessToken,
|
|
preferredFactor,
|
|
});
|
|
}
|
|
|
|
const authnToken = extractAuthnToken(authResponse);
|
|
if (!authnToken) {
|
|
throw new Error(
|
|
`Oracle login nao retornou authnToken. Resposta: ${JSON.stringify(authResponse, null, 2).slice(0, 2000)}`
|
|
);
|
|
}
|
|
|
|
await createSecureSession({
|
|
jar,
|
|
idcsUrl: globalConfig.idcsURL,
|
|
accessToken: globalConfig.accessToken,
|
|
authnToken,
|
|
});
|
|
|
|
const dvSession = await loadDvSession({
|
|
jar,
|
|
targetUrl,
|
|
});
|
|
|
|
const storageState = jar.toStorageState(username);
|
|
const sessionMeta = {
|
|
xCsrfToken: dvSession.xCsrfToken,
|
|
xBitechClientbin: dvSession.xBitechClientbin,
|
|
referer: targetUrl,
|
|
targetUrl,
|
|
capturedAt: new Date().toISOString(),
|
|
};
|
|
|
|
await writeJson(statePath, storageState);
|
|
await writeJson(metaPath, sessionMeta);
|
|
|
|
console.log(
|
|
JSON.stringify(
|
|
{
|
|
ok: true,
|
|
username,
|
|
statePath,
|
|
metaPath,
|
|
cookies: storageState.cookies.length,
|
|
xBitechClientbin: sessionMeta.xBitechClientbin,
|
|
elapsedMs: Date.now() - startedAt,
|
|
},
|
|
null,
|
|
2
|
|
)
|
|
);
|
|
} finally {
|
|
rl?.close();
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error.message);
|
|
process.exitCode = 1;
|
|
});
|