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 }; }