418 lines
17 KiB
JavaScript
418 lines
17 KiB
JavaScript
function createAuthConsumerSession({
|
|
CUSTOMER_TOKEN_MAX_ATTEMPTS,
|
|
CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS,
|
|
CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS,
|
|
CUSTOMER_TOKEN_SERVICE_URL,
|
|
CUSTOMER_TOKEN_SESSION_URL,
|
|
extractTokenServiceAuthorization,
|
|
fetchJson,
|
|
supportsFirefoxCookieStoreTabs,
|
|
tabsCreate,
|
|
tabsUpdate,
|
|
tabsRemove,
|
|
responseHeaders,
|
|
cookieChanges,
|
|
inspectPage,
|
|
setTimeout,
|
|
clearTimeout,
|
|
wait
|
|
}) {
|
|
const sessionTabs = new Map();
|
|
async function requestConsumerTokenService(sender) {
|
|
const debugRequests = [];
|
|
|
|
try {
|
|
const tokenResponse = await fetchConsumerTokenServiceUntilJson(sender, debugRequests);
|
|
const accountSummaryAuthorization = extractTokenServiceAuthorization(
|
|
tokenResponse,
|
|
"ACCP-TS",
|
|
"account-ti-authorization"
|
|
);
|
|
const accountPlansAuthorization = extractTokenServiceAuthorization(
|
|
tokenResponse,
|
|
"OCICONS-TS",
|
|
"g2m-authorization"
|
|
);
|
|
const tokenRequestDebugEntry = debugRequests[debugRequests.length - 1];
|
|
|
|
if (tokenRequestDebugEntry) {
|
|
tokenRequestDebugEntry.metadata = {
|
|
...tokenRequestDebugEntry.metadata,
|
|
accountSummaryTokenService: "ACCP-TS",
|
|
accountSummaryAuthorizationHeader: accountSummaryAuthorization.name || "(missing)",
|
|
accountSummaryAuthorizationValue: accountSummaryAuthorization.value
|
|
? `(present, ${accountSummaryAuthorization.value.length} characters)`
|
|
: "(missing)",
|
|
accountSummaryAuthorizationExpires: accountSummaryAuthorization.expires || "(not provided)",
|
|
accountPlansTokenService: "OCICONS-TS",
|
|
accountPlansAuthorizationHeader: accountPlansAuthorization.name || "(missing)",
|
|
accountPlansAuthorizationValue: accountPlansAuthorization.value
|
|
? `(present, ${accountPlansAuthorization.value.length} characters)`
|
|
: "(missing)",
|
|
accountPlansAuthorizationExpires: accountPlansAuthorization.expires || "(not provided)"
|
|
};
|
|
}
|
|
|
|
if (!accountSummaryAuthorization.value) {
|
|
throw new Error("ACCP-TS uiaasHeader.value was not found in requestConsumerTokenService.");
|
|
}
|
|
|
|
if (!accountPlansAuthorization.value) {
|
|
throw new Error("OCICONS-TS uiaasHeader.value was not found in requestConsumerTokenService.");
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
authorizations: {
|
|
accountSummary: accountSummaryAuthorization,
|
|
accountPlans: accountPlansAuthorization,
|
|
installBase: extractTokenServiceAuthorization(tokenResponse, "SAIB-TS", ""),
|
|
customerServiceRequests: extractTokenServiceAuthorization(tokenResponse, "SPA-ORANGE", ""),
|
|
workloadActions: extractTokenServiceAuthorization(tokenResponse, "WWB-TS", "Wwb-Provider-Authorization")
|
|
},
|
|
debugRequests
|
|
};
|
|
} catch (error) {
|
|
error.debugRequests = debugRequests;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function fetchConsumerTokenServiceUntilJson(sender, debugRequests) {
|
|
let attempt = 0;
|
|
let sessionTab = null;
|
|
let receivedValidJson = false;
|
|
|
|
try {
|
|
while (attempt < CUSTOMER_TOKEN_MAX_ATTEMPTS) {
|
|
attempt += 1;
|
|
|
|
try {
|
|
const tokenResponse = await fetchJson(CUSTOMER_TOKEN_SERVICE_URL, {
|
|
method: "GET",
|
|
credentials: "include",
|
|
cache: "no-store",
|
|
headers: {
|
|
Accept: "application/json",
|
|
"Cache-Control": "no-cache, no-store, max-age=0",
|
|
Pragma: "no-cache"
|
|
}
|
|
}, "requestConsumerTokenService", debugRequests);
|
|
const debugEntry = debugRequests[debugRequests.length - 1];
|
|
|
|
if (debugEntry) {
|
|
debugEntry.metadata.attempt = String(attempt);
|
|
}
|
|
|
|
receivedValidJson = true;
|
|
return tokenResponse;
|
|
} catch (error) {
|
|
const isHtmlFallback = error
|
|
&& error.code === "INVALID_JSON_RESPONSE"
|
|
&& error.httpStatus === 200;
|
|
const isSessionUnauthorized = error && (error.httpStatus === 401 || error.httpStatus === 403);
|
|
const needsSessionRecovery = isHtmlFallback || isSessionUnauthorized;
|
|
const retryReason = isSessionUnauthorized
|
|
? `HTTP ${error.httpStatus} response requires SPA session recovery`
|
|
: "HTTP 200 response did not contain valid JSON";
|
|
const debugEntry = debugRequests[debugRequests.length - 1];
|
|
|
|
if (!needsSessionRecovery || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) {
|
|
if (debugEntry) {
|
|
debugEntry.metadata.attempt = String(attempt);
|
|
if (needsSessionRecovery) {
|
|
debugEntry.metadata.retryLimitReached = "true";
|
|
debugEntry.metadata.retryReason = retryReason;
|
|
debugEntry.error = isHtmlFallback
|
|
? `requestConsumerTokenService returned invalid JSON after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`
|
|
: `requestConsumerTokenService failed with status ${error.httpStatus} after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`;
|
|
error.message = debugEntry.error;
|
|
}
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
|
|
if (!sessionTab) {
|
|
try {
|
|
sessionTab = await openConsumerTokenSessionTabWithRetry(sender, debugEntry);
|
|
if (debugEntry) {
|
|
debugEntry.metadata.sessionBootstrapUrl = CUSTOMER_TOKEN_SESSION_URL;
|
|
debugEntry.metadata.sessionBootstrapState = "opened-inactive-tab";
|
|
}
|
|
} catch (sessionError) {
|
|
if (debugEntry) {
|
|
debugEntry.metadata.sessionBootstrapUrl = CUSTOMER_TOKEN_SESSION_URL;
|
|
debugEntry.metadata.sessionBootstrapState = `tab-open-failed: ${sessionError.message || "unknown error"}`;
|
|
}
|
|
}
|
|
} else if (debugEntry) {
|
|
debugEntry.metadata.sessionBootstrapState = "waiting-for-valid-session";
|
|
}
|
|
|
|
const retryDelayMs = getConsumerTokenRetryDelay(attempt);
|
|
if (debugEntry) {
|
|
debugEntry.metadata.attempt = String(attempt);
|
|
debugEntry.metadata.retryReason = retryReason;
|
|
debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs);
|
|
}
|
|
|
|
if (sessionTab) await waitForConsumerTokenSession(sessionTab, debugEntry, retryDelayMs);
|
|
else await wait(retryDelayMs);
|
|
}
|
|
}
|
|
|
|
throw new Error("requestConsumerTokenService retry loop ended unexpectedly.");
|
|
} finally {
|
|
if (sessionTab && Number.isInteger(sessionTab.id)) {
|
|
const debugEntry = debugRequests[debugRequests.length - 1];
|
|
|
|
try {
|
|
await closeConsumerTokenSessionTab(sessionTab.id);
|
|
if (debugEntry) {
|
|
debugEntry.metadata.sessionBootstrapState = receivedValidJson
|
|
? "closed-after-valid-json"
|
|
: "closed-after-retry-finished";
|
|
}
|
|
} catch (error) {
|
|
if (debugEntry) {
|
|
debugEntry.metadata.sessionBootstrapState = `tab-close-failed: ${error.message || "unknown error"}`;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function getConsumerTokenRetryDelay(attempt) {
|
|
return Math.min(
|
|
CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS * (2 ** Math.max(attempt - 1, 0)),
|
|
CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS
|
|
);
|
|
}
|
|
|
|
async function openConsumerTokenSessionTabWithRetry(sender, debugEntry) {
|
|
let attempt = 0;
|
|
|
|
// Opening failures must not consume the token service's five attempts.
|
|
while (true) {
|
|
attempt += 1;
|
|
if (debugEntry) {
|
|
debugEntry.metadata.sessionBootstrapUrl = CUSTOMER_TOKEN_SESSION_URL;
|
|
debugEntry.metadata.sessionTabOpenAttempt = String(attempt);
|
|
}
|
|
|
|
try {
|
|
const tab = await openConsumerTokenSessionTab(sender);
|
|
if (debugEntry) {
|
|
debugEntry.metadata.sessionBootstrapState = "opened-inactive-tab";
|
|
delete debugEntry.metadata.sessionTabNextRetryDelayMs;
|
|
}
|
|
return tab;
|
|
} catch (error) {
|
|
const retryDelayMs = getConsumerTokenRetryDelay(Math.min(attempt, 5));
|
|
if (debugEntry) {
|
|
debugEntry.metadata.sessionBootstrapState = "retrying-tab-open";
|
|
debugEntry.metadata.sessionTabLastOpenError = error.message || "unknown error";
|
|
debugEntry.metadata.sessionTabNextRetryDelayMs = String(retryDelayMs);
|
|
}
|
|
await wait(retryDelayMs);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function openConsumerTokenSessionTab(sender) {
|
|
const details = {
|
|
url: CUSTOMER_TOKEN_SESSION_URL,
|
|
active: false
|
|
};
|
|
|
|
if (sender && sender.tab && Number.isInteger(sender.tab.windowId)) {
|
|
details.windowId = sender.tab.windowId;
|
|
}
|
|
|
|
if (supportsFirefoxCookieStoreTabs() && sender && sender.tab && sender.tab.cookieStoreId) {
|
|
details.cookieStoreId = sender.tab.cookieStoreId;
|
|
}
|
|
|
|
const tab = await tabsCreate(details);
|
|
if (Number.isInteger(tab?.id)) {
|
|
const state = { failedStatus: 0, reloads: 0, lastReloadAt: -Infinity, readyVersion: 0, consumedVersion: 0, closed: false };
|
|
const signal = reason => {
|
|
state.readyVersion++;
|
|
state.readyReason = reason;
|
|
state.wake?.();
|
|
};
|
|
state.listener = details => {
|
|
if (details.tabId !== tab.id
|
|
|| !/^https:\/\/spa\.oracle\.com\//.test(details.url)) return;
|
|
if (details.type === "main_frame") {
|
|
if (details.statusCode === 401 || details.statusCode === 403) {
|
|
state.failedStatus = details.statusCode;
|
|
state.consumedVersion = state.readyVersion;
|
|
state.wake?.();
|
|
} else if (details.statusCode >= 200 && details.statusCode < 300) {
|
|
state.failedStatus = 0;
|
|
if (!details.url.startsWith("https://spa.oracle.com/cloudgate/")) signal("spa-document-response");
|
|
}
|
|
} else if (details.type === "xmlhttprequest" && details.statusCode === 200
|
|
&& details.url.split("?")[0] === "https://spa.oracle.com/oalcrm/web/api/identity-management/users/current"
|
|
&& details.responseHeaders?.some(header => header.name.toLowerCase() === "content-type" && /application\/json/i.test(header.value))) {
|
|
state.failedStatus = 0;
|
|
signal("spa-identity-json-response");
|
|
}
|
|
};
|
|
state.cookieListener = change => {
|
|
const cookie = change.cookie;
|
|
if (change.removed || !cookie?.value || !cookie.secure || !cookie.httpOnly
|
|
|| cookie.domain?.replace(/^\./, "") !== "spa.oracle.com" || !/session/i.test(cookie.name)
|
|
|| (cookie.expirationDate != null && cookie.expirationDate <= Date.now() / 1000)
|
|
|| (sender?.tab?.cookieStoreId && cookie.storeId !== sender.tab.cookieStoreId)) return;
|
|
signal("spa-session-cookie-changed");
|
|
};
|
|
try {
|
|
responseHeaders?.addListener(state.listener, { urls: ["https://spa.oracle.com/*"], types: ["main_frame", "xmlhttprequest"] }, ["responseHeaders"]);
|
|
cookieChanges?.addListener(state.cookieListener);
|
|
sessionTabs.set(tab.id, state);
|
|
} catch (error) {
|
|
responseHeaders?.removeListener?.(state.listener);
|
|
cookieChanges?.removeListener(state.cookieListener);
|
|
await tabsRemove(tab.id).catch(() => {});
|
|
throw error;
|
|
}
|
|
}
|
|
return tab;
|
|
}
|
|
|
|
async function recoverConsumerTokenSessionTab(tab, debugEntry, reason = "") {
|
|
const state = sessionTabs.get(tab.id);
|
|
if (!state || (!state.failedStatus && !reason) || Date.now() - state.lastReloadAt < 2000) return false;
|
|
const status = state.failedStatus;
|
|
state.failedStatus = 0;
|
|
state.consumedVersion = state.readyVersion;
|
|
state.lastReloadAt = Date.now();
|
|
state.reloads++;
|
|
if (debugEntry) {
|
|
debugEntry.metadata.sessionBootstrapReloadCount = String(state.reloads);
|
|
debugEntry.metadata.sessionBootstrapReloadReason = reason || `SPA navigation returned HTTP ${status}`;
|
|
}
|
|
try {
|
|
// Restart at the entry point; OAuth callback URLs may contain single-use state.
|
|
await tabsUpdate(tab.id, { url: CUSTOMER_TOKEN_SESSION_URL, active: false });
|
|
if (debugEntry) debugEntry.metadata.sessionBootstrapReloadState = "restarted-at-spa-home";
|
|
return true;
|
|
} catch (error) {
|
|
state.failedStatus = status;
|
|
if (debugEntry) debugEntry.metadata.sessionBootstrapReloadState = `restart-failed: ${error.message || "unknown error"}`;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function waitForSpaPage(tab, debugEntry) {
|
|
const startedAt = Date.now();
|
|
const deadline = startedAt + 120000;
|
|
let restartedDocument = null;
|
|
let restarts = 0;
|
|
if (debugEntry) {
|
|
delete debugEntry.metadata.nextRetryDelayMs;
|
|
debugEntry.metadata.sessionWaitTimeoutMs = "120000";
|
|
debugEntry.metadata.sessionWaitReason = "inspecting-spa-document";
|
|
}
|
|
while (Date.now() < deadline) {
|
|
const state = sessionTabs.get(tab.id);
|
|
if (state?.closed) break;
|
|
let page;
|
|
try { page = await inspectPage(tab); } catch { /* Cross-origin SSO pages have no SPA receiver. */ }
|
|
if (debugEntry) debugEntry.metadata.spaPageState = page?.status || "loading-or-login";
|
|
const documentId = typeof page?.documentId === "string" ? page.documentId : null;
|
|
const alreadyRead = documentId && documentId === tab.spaDocumentId;
|
|
if (state?.failedStatus || page?.status === "error" || alreadyRead) {
|
|
if (restarts < 3 && (state?.failedStatus || documentId !== restartedDocument)) {
|
|
const previousReloads = state?.reloads || 0;
|
|
const restarted = await recoverConsumerTokenSessionTab(tab, debugEntry,
|
|
state?.failedStatus ? "" : alreadyRead ? "Identity rejected in this SPA document" : "SPA document displays an authorization error");
|
|
restarts += (state?.reloads || 0) - previousReloads;
|
|
if (restarted) restartedDocument = documentId;
|
|
}
|
|
} else if (page?.status === "ready" && documentId) {
|
|
// Confirm a stable, fully loaded document, not a transient SAML page.
|
|
await wait(500);
|
|
let confirmed;
|
|
try { confirmed = await inspectPage(tab); } catch { /* A redirect started during validation. */ }
|
|
if (confirmed?.status === "ready" && confirmed.documentId === documentId && !state?.failedStatus) {
|
|
tab.spaDocumentId = documentId;
|
|
if (debugEntry) {
|
|
debugEntry.metadata.sessionWaitReason = "spa-document-verified";
|
|
debugEntry.metadata.sessionWaitDurationMs = String(Date.now() - startedAt);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
await wait(1000);
|
|
}
|
|
const error = new Error("SPA page did not finish authentication. No further identity request was sent.");
|
|
error.code = "SPA_PAGE_NOT_READY";
|
|
throw error;
|
|
}
|
|
|
|
async function waitForConsumerTokenSession(tab, debugEntry, retryDelay) {
|
|
const state = sessionTabs.get(tab.id);
|
|
if (!state || !responseHeaders?.addListener || !setTimeout) return wait(retryDelay);
|
|
const startedAt = Date.now();
|
|
const deadline = startedAt + 20000;
|
|
if (debugEntry) {
|
|
delete debugEntry.metadata.nextRetryDelayMs;
|
|
debugEntry.metadata.sessionWaitTimeoutMs = "20000";
|
|
}
|
|
let restarts = 0;
|
|
let reason = "timeout-fallback";
|
|
// Do not issue API probes while SSO redirects are still establishing the session.
|
|
while (!state.closed && Date.now() < deadline) {
|
|
if (state.failedStatus) {
|
|
if (restarts >= 3) {
|
|
await wait(Math.max(0, deadline - Date.now()));
|
|
break;
|
|
}
|
|
await recoverConsumerTokenSessionTab(tab, debugEntry);
|
|
restarts++;
|
|
if (state.failedStatus) await wait(2000);
|
|
continue;
|
|
}
|
|
if (state.readyVersion > state.consumedVersion) {
|
|
await wait(250);
|
|
if (state.failedStatus) continue;
|
|
state.consumedVersion = state.readyVersion;
|
|
reason = state.readyReason;
|
|
break;
|
|
}
|
|
let timer;
|
|
try {
|
|
await new Promise(resolve => {
|
|
state.wake = resolve;
|
|
timer = setTimeout(resolve, Math.max(0, deadline - Date.now()));
|
|
});
|
|
} finally {
|
|
clearTimeout?.(timer);
|
|
state.wake = null;
|
|
}
|
|
}
|
|
if (debugEntry) {
|
|
debugEntry.metadata.sessionWaitReason = reason;
|
|
debugEntry.metadata.sessionWaitDurationMs = String(Date.now() - startedAt);
|
|
}
|
|
}
|
|
|
|
async function closeConsumerTokenSessionTab(id) {
|
|
const state = sessionTabs.get(id);
|
|
sessionTabs.delete(id);
|
|
if (state) {
|
|
state.closed = true;
|
|
state.wake?.();
|
|
responseHeaders?.removeListener?.(state.listener);
|
|
cookieChanges?.removeListener(state.cookieListener);
|
|
}
|
|
await tabsRemove(id);
|
|
}
|
|
|
|
return { requestConsumerTokenService, fetchConsumerTokenServiceUntilJson, getConsumerTokenRetryDelay, openConsumerTokenSessionTabWithRetry, openConsumerTokenSessionTab, recoverConsumerTokenSessionTab, waitForConsumerTokenSession, waitForSpaPage, closeConsumerTokenSessionTab };
|
|
}
|