Files
knowledge-one-view-cli/scripts/refresh-auth-direct.mjs

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(/&amp;/g, "&")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, "<")
.replace(/&gt;/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;
});