From ba61f91c53c7b809a44fb8a8b0f8da09276676b6 Mon Sep 17 00:00:00 2001 From: Paulo Porto Date: Wed, 21 Oct 2026 15:55:29 -0300 Subject: [PATCH] Fixed the SPA authentication flow --- README.md | 18 +- dist/chromium/background.js | 360 +++++++++++++++++- dist/chromium/content.js | 34 +- dist/chromium/manifest.json | 10 + dist/chromium/spa-page.js | 58 +++ dist/firefox/background.js | 360 +++++++++++++++++- dist/firefox/content.js | 34 +- dist/firefox/manifest.json | 10 + dist/firefox/spa-page.js | 58 +++ docs/architecture.md | 5 + scripts/build.mjs | 10 +- scripts/compose.cjs | 2 +- scripts/sources.cjs | 2 + src/auth/consumer-session.js | 194 +++++++++- src/auth/current-user.js | 33 +- src/auth/spa-page.js | 52 +++ src/background/application.js | 22 +- src/background/contracts.js | 2 +- src/background/messages.js | 2 +- src/content/application.js | 1 + src/platform/background-api.js | 15 +- src/services/spa/current-user.js | 89 ++++- src/services/spa/page-client.js | 34 ++ tests/activityFilters.test.cjs | 2 +- tests/characterization.test.cjs | 5 + ...onsumerTokenAuthorizationRecovery.test.cjs | 2 +- tests/consumerTokenSessionRetry.test.cjs | 2 +- tests/currentUser.test.cjs | 189 ++++++++- tests/spaPage.test.cjs | 119 ++++++ tests/spaSessionReload.test.cjs | 87 +++++ tests/spaSessionWait.test.cjs | 181 +++++++++ tests/support/characterization.cjs | 47 ++- tests/workloadActionForm.test.cjs | 2 +- 33 files changed, 1924 insertions(+), 117 deletions(-) create mode 100644 dist/chromium/spa-page.js create mode 100644 dist/firefox/spa-page.js create mode 100644 src/auth/spa-page.js create mode 100644 src/services/spa/page-client.js create mode 100644 tests/spaPage.test.cjs create mode 100644 tests/spaSessionReload.test.cjs create mode 100644 tests/spaSessionWait.test.cjs diff --git a/README.md b/README.md index c6d611e..bbe25e5 100644 --- a/README.md +++ b/README.md @@ -52,11 +52,25 @@ On the first Opportunities modal opening in a page session, `requestCurrentUser` fetches SPA identity before tokenrelay or opportunity requests. Concurrent calls, Reference changes and modal reopenings reuse that result. Users with `SALES_REP` do not trigger CRM Service Request or Workload Actions queries or see their badges. -If identity or roles cannot be validated, only these two features stay disabled; -other opportunity features continue. Reload the page to refresh identity/roles. +Identity must contain a valid user (id, name, roles) and token before dependent +requests start. Failed identity validation keeps those requests waiting and retries +with backoff. Reload the page to refresh identity/roles. The identity token is retained in debug responses and hidden by default; the explicit sensitive-values toggle reveals the original value. +After an initial identity failure, recovery opens `https://spa.oracle.com/` in one +inactive auxiliary tab. The top-frame-only `spa-page.js` content script inspects +the page without sending HTTP requests. It rejects error pages, SAML/login forms, +OAuth callbacks and incomplete documents; cookies, HTTP 200 and elapsed time alone +do not establish readiness. After a stable loaded document is verified, identity +is fetched in that tab's same-origin context, at most once per document. A failed +read requires a fresh verified navigation before retrying. Rejected callbacks +restart at the SPA home URL, never at the one-use callback URL. A two-minute +readiness timeout ends the cycle without an additional API probe. The owned tab +and observers are cleaned up on success or failure. Debug metadata records the +page state, wait reason and `spa-tab` execution context; sensitive values remain +hidden by default. Reload the extension after changes to the manifest or script. + Consumer token recovery also handles HTTP 401/403, in addition to HTTP 200 with invalid JSON. It opens the existing SPA session URL in an inactive temporary tab, preserves the originating window and Firefox cookie store, and retries within diff --git a/dist/chromium/background.js b/dist/chromium/background.js index e1eae9a..28f64d1 100644 --- a/dist/chromium/background.js +++ b/dist/chromium/background.js @@ -222,7 +222,7 @@ function createBackgroundContracts() { const OPPTY_PRODUCTS_MAX_PAGES = 250; const OPPTY_PRODUCTS_CONCURRENCY = 4; const CUSTOMER_TOKEN_SERVICE_URL = "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/consumerTokenService"; - const CUSTOMER_TOKEN_SESSION_URL = "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html"; + const CUSTOMER_TOKEN_SESSION_URL = "https://spa.oracle.com/"; const CUSTOMER_TOKEN_MAX_ATTEMPTS = 5; const CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS = 750; const CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS = 4000; @@ -294,9 +294,16 @@ function createAuthConsumerSession({ fetchJson, supportsFirefoxCookieStoreTabs, tabsCreate, + tabsUpdate, tabsRemove, + responseHeaders, + cookieChanges, + inspectPage, + setTimeout, + clearTimeout, wait }) { + const sessionTabs = new Map(); async function requestConsumerTokenService(sender) { const debugRequests = []; @@ -436,7 +443,8 @@ function createAuthConsumerSession({ debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs); } - await wait(retryDelayMs); + if (sessionTab) await waitForConsumerTokenSession(sessionTab, debugEntry, retryDelayMs); + else await wait(retryDelayMs); } } @@ -446,7 +454,7 @@ function createAuthConsumerSession({ const debugEntry = debugRequests[debugRequests.length - 1]; try { - await tabsRemove(sessionTab.id); + await closeConsumerTokenSessionTab(sessionTab.id); if (debugEntry) { debugEntry.metadata.sessionBootstrapState = receivedValidJson ? "closed-after-valid-json" @@ -498,7 +506,7 @@ function createAuthConsumerSession({ } } - function openConsumerTokenSessionTab(sender) { + async function openConsumerTokenSessionTab(sender) { const details = { url: CUSTOMER_TOKEN_SESSION_URL, active: false @@ -512,10 +520,186 @@ function createAuthConsumerSession({ details.cookieStoreId = sender.tab.cookieStoreId; } - return tabsCreate(details); + 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; } - return { requestConsumerTokenService, fetchConsumerTokenServiceUntilJson, getConsumerTokenRetryDelay, openConsumerTokenSessionTabWithRetry, openConsumerTokenSessionTab }; + 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 }; } // Source: src/auth/spa-credentials.js @@ -1558,28 +1742,131 @@ function createFusionSrCreate({ fetchJson, tokenHeaders, previewTokenHeaders, in } // Source: src/services/spa/current-user.js -function createSpaCurrentUser({ fetchJson }) { - async function requestCurrentUser() { +function createSpaCurrentUser({ fetchJson, fetchInTab, openSessionTab, waitForSession, closeSessionTab, sessionUrl }) { + const pending = new Map(); + function requestCurrentUser(sender) { + const key = JSON.stringify([sender?.tab?.windowId ?? null, sender?.tab?.cookieStoreId ?? "", Boolean(sender?.tab?.incognito)]); + if (!pending.has(key)) { + pending.set(key, loadCurrentUser(sender).finally(() => pending.delete(key))); + } + return pending.get(key); + } + + async function loadCurrentUser(sender) { const debugRequests = []; - let user = null; + let sessionTab = null; + let receivedValidJson = false; try { - const result = await fetchJson("https://spa.oracle.com/oalcrm/web/api/identity-management/users/current", { - method: "GET", credentials: "include", cache: "no-store", headers: { Accept: "application/json" } - }, "requestCurrentUser", debugRequests); - if (!result?.user || !Array.isArray(result.user.roles) || result.user.roles.some(role => typeof role !== "string")) { - throw new Error("requestCurrentUser returned invalid user roles."); + // Bound each bootstrap cycle so a failed sign-in never leaves an orphan tab. + // The content-side gate can start another cycle without releasing dependent requests. + for (let attempt = 1; attempt <= 6; attempt++) { + try { + const result = sessionTab ? await fetchInTab(sessionTab, debugRequests) : await fetchJson("https://spa.oracle.com/oalcrm/web/api/identity-management/users/current", { + method: "GET", credentials: "include", cache: "no-store", headers: { Accept: "application/json" } + }, "requestCurrentUser", debugRequests); + const validText = value => typeof value === "string" && value.trim().length > 0; + if (!result || typeof result !== "object" || Array.isArray(result) + || !result.user || typeof result.user !== "object" || Array.isArray(result.user) + || !validText(result.user.id) || !validText(result.user.name) || !validText(result.token) + || !Array.isArray(result.user.roles) || result.user.roles.some(role => !validText(role)) + || ["onWhiteList", "provisioned", "administrator"].some(key => key in result.user && typeof result.user[key] !== "boolean")) { + const error = new Error("requestCurrentUser returned an invalid user/token response."); + error.code = "INVALID_CURRENT_USER_RESPONSE"; + throw error; + } + const entry = debugRequests[debugRequests.length - 1]; + if (entry) entry.metadata.attempt = String(attempt); + receivedValidJson = true; + const user = { id: result.user.id, name: result.user.name, roles: result.user.roles }; + return { ok: true, user, debugRequests }; + } catch (error) { + const entry = debugRequests[debugRequests.length - 1]; + if (entry) { + entry.status = "failed"; + entry.error = error.message; + entry.metadata.attempt = String(attempt); + } + const needsSession = error.code === "INVALID_JSON_RESPONSE" || error.code === "INVALID_CURRENT_USER_RESPONSE" + || error.httpStatus === 401 || error.httpStatus === 403 || !error.httpStatus; + if (!needsSession || attempt === 6) { + if (entry && needsSession) entry.metadata.retryLimitReached = "true"; + throw error; + } + if (entry) { + entry.metadata.sessionBootstrapUrl = sessionUrl; + entry.metadata.retryReason = "Current user requires a valid SPA session"; + } + if (!sessionTab) { + try { + const tab = await openSessionTab(sender); + if (!Number.isInteger(tab?.id)) throw new Error("SPA session tab was not created."); + sessionTab = tab; + if (entry) entry.metadata.sessionBootstrapState = "opened-inactive-tab"; + } catch (sessionError) { + if (entry) entry.metadata.sessionBootstrapState = `tab-open-failed: ${sessionError.message || "unknown error"}`; + throw sessionError; + } + } else if (entry) { + entry.metadata.sessionBootstrapState = "waiting-for-valid-session"; + } + await waitForSession(sessionTab, entry); + } } - user = { id: result.user.id, name: result.user.name, roles: result.user.roles }; - return { ok: true, user, debugRequests }; } catch (error) { - for (const entry of debugRequests) { entry.status = "failed"; entry.error = error.message; } error.debugRequests = debugRequests; throw error; + } finally { + if (sessionTab) { + const entry = debugRequests[debugRequests.length - 1]; + try { + await closeSessionTab(sessionTab.id); + if (entry) entry.metadata.sessionBootstrapState = receivedValidJson + ? "closed-after-valid-json" : "closed-after-retry-finished"; + } catch (error) { + if (entry) entry.metadata.sessionBootstrapState = `tab-close-failed: ${error.message || "unknown error"}`; + } + } } } return { requestCurrentUser }; } +// Source: src/services/spa/page-client.js +function createSpaPageClient({ sendMessage, getHttp }) { + const options = { frameId: 0 }; + const inspect = tab => sendMessage(tab.id, { type: "opportunitiesExtension.inspectSpaPage" }, options); + async function requestIdentity(tab, debugRequests) { + const http = getHttp(); + const url = "https://spa.oracle.com/oalcrm/web/api/identity-management/users/current"; + const entry = http.createBackgroundDebugEntry("requestCurrentUser", url, + { method: "GET", headers: { Accept: "application/json" } }, new Date()); + entry.metadata.executionContext = "spa-tab"; + try { + const result = await sendMessage(tab.id, { type: "opportunitiesExtension.readSpaIdentity", documentId: tab.spaDocumentId }, options); + if (!result || result.error || !Number.isInteger(result.status) || typeof result.body !== "string") { + throw new Error(result?.error || "Invalid SPA identity response."); + } + const response = { status: result.status, statusText: result.statusText || "", ok: result.status >= 200 && result.status < 300, + headers: { forEach: callback => Object.entries(result.headers || { "content-type": result.contentType || "" }).forEach(([key, value]) => callback(value, key)) } }; + http.completeBackgroundDebugEntry(entry, response, http.createDebugBody(result.body), ""); + entry.metadata.responseContentType = result.contentType || "(not provided)"; + entry.metadata.responseLength = String(result.body.length); + if (result.status !== 200) { + const error = new Error(`requestCurrentUser failed with status ${result.status}.`); + error.httpStatus = result.status; + throw error; + } + return http.parseJsonResponse(result.body, "requestCurrentUser"); + } catch (error) { + if (!entry.completedAt) http.completeBackgroundDebugEntry(entry, null, { value: "", truncated: false }, error.message); + entry.status = "failed"; + entry.error = error.message; + throw error; + } finally { debugRequests.push(entry); } + } + return { inspect, requestIdentity }; +} + // Source: src/services/spa/consumption.js function createSpaConsumption({ CONSUMPTION_TREND_REQUESTS, @@ -2471,6 +2758,19 @@ function createPlatformBackgroundApi({ }); } + function tabsUpdate(tabId, details) { + if (runtimeApi.tabs.update.length <= 2) { + return runtimeApi.tabs.update(tabId, details); + } + return new Promise((resolve, reject) => { + runtimeApi.tabs.update(tabId, details, tab => { + const lastError = runtimeApi.runtime.lastError; + if (lastError) reject(new Error(lastError.message)); + else resolve(tab); + }); + }); + } + function tabsRemove(tabId) { if (runtimeApi.tabs.remove.length <= 1) { return runtimeApi.tabs.remove(tabId); @@ -2628,7 +2928,7 @@ function createPlatformBackgroundApi({ ); } - return { supportsFirefoxCookieStoreTabs, tabsCreate, tabsRemove, tabsSendMessage, storageLocalGet, storageLocalSet, wait, cookiesGetAll, cookiesGet, cookiesGetAllCookieStores, removeUndefinedValues }; + return { supportsFirefoxCookieStoreTabs, tabsCreate, tabsUpdate, tabsRemove, tabsSendMessage, storageLocalGet, storageLocalSet, wait, cookiesGetAll, cookiesGet, cookiesGetAllCookieStores, removeUndefinedValues }; } // Source: src/background/main.js @@ -2809,7 +3109,7 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => { } if (message.type === messageTypes.requestCurrentUser) { - requestCurrentUser().then(sendResponse).catch(error => sendResponse({ + requestCurrentUser(sender).then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message || "Unable to load current user.", debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : [] })); @@ -2913,13 +3213,25 @@ function createBackgroundApplication(overrides = {}) { browser: globalThis.browser, chrome: globalThis.chrome, setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout, ...overrides }; const messageTypes = createMessageTypes(); const timeEntries = createTimeEntries({ dates: createTimeDates(), fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), getResourceUser: message => fusionSrCreate.getResourceUser(message), saveHeaders: url => srRequestHeaders.timeEntriesHeaders(url) }); - const spaCurrentUser = createSpaCurrentUser({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) }); - const requestCurrentUser = () => spaCurrentUser.requestCurrentUser(); const contracts = createBackgroundContracts(); + const spaPageClient = createSpaPageClient({ + sendMessage: (...args) => platformBackgroundApi.tabsSendMessage(...args), + getHttp: () => httpBackgroundJson + }); + const spaCurrentUser = createSpaCurrentUser({ + fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), + fetchInTab: (tab, logs) => spaPageClient.requestIdentity(tab, logs), + openSessionTab: sender => authConsumerSession.openConsumerTokenSessionTab(sender), + waitForSession: (tab, entry) => authConsumerSession.waitForSpaPage(tab, entry), + closeSessionTab: id => authConsumerSession.closeConsumerTokenSessionTab(id), + sessionUrl: contracts.CUSTOMER_TOKEN_SESSION_URL + }); + const requestCurrentUser = sender => spaCurrentUser.requestCurrentUser(sender); const productsCacheState = createProductsCacheState(); const authConsumerSession = createAuthConsumerSession({ CUSTOMER_TOKEN_MAX_ATTEMPTS: contracts.CUSTOMER_TOKEN_MAX_ATTEMPTS, @@ -2931,7 +3243,13 @@ function createBackgroundApplication(overrides = {}) { fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), supportsFirefoxCookieStoreTabs: (...args) => platformBackgroundApi.supportsFirefoxCookieStoreTabs(...args), tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args), + tabsUpdate: (...args) => platformBackgroundApi.tabsUpdate(...args), tabsRemove: (...args) => platformBackgroundApi.tabsRemove(...args), + responseHeaders: (environment.browser || environment.chrome)?.webRequest?.onHeadersReceived, + cookieChanges: (environment.browser || environment.chrome)?.cookies?.onChanged, + inspectPage: tab => spaPageClient.inspect(tab), + setTimeout: environment.setTimeout, + clearTimeout: environment.clearTimeout, wait: (...args) => platformBackgroundApi.wait(...args) }); const authSpaCredentials = createAuthSpaCredentials({ diff --git a/dist/chromium/content.js b/dist/chromium/content.js index e2f6c4e..3b73fd6 100644 --- a/dist/chromium/content.js +++ b/dist/chromium/content.js @@ -1990,20 +1990,35 @@ function createAuthContentSession({ } // Source: src/auth/current-user.js -function createCurrentUser({ sendRuntimeMessage, appendBackgroundRequestLog, messageTypes }) { +function createCurrentUser({ sendRuntimeMessage, appendBackgroundRequestLog, messageTypes, environment }) { + const { setTimeout } = environment; let pending = null; let allowed = false; let userId = ""; function ensure() { if (!pending) { - pending = sendRuntimeMessage({ type: messageTypes.requestCurrentUser }).then(response => { - appendBackgroundRequestLog(response?.debugRequests); - const roles = response?.user?.roles; - userId = response?.ok === true && typeof response.user?.id === "string" ? response.user.id : ""; - allowed = response?.ok === true && Array.isArray(roles) - && roles.every(role => typeof role === "string") && !roles.includes("SALES_REP"); - return allowed; - }).catch(() => { allowed = false; return false; }); + pending = (async () => { + let retryDelay = 1000; + // Keep a single pending identity request until the response is usable. + for (;;) { + try { + const response = await sendRuntimeMessage({ type: messageTypes.requestCurrentUser }); + appendBackgroundRequestLog(response?.debugRequests); + const user = response?.user; + if (response?.ok === true && typeof user?.id === "string" && user.id.trim() + && typeof user.name === "string" && user.name.trim() + && Array.isArray(user.roles) && user.roles.every(role => typeof role === "string" && role.trim())) { + userId = user.id; + allowed = !user.roles.includes("SALES_REP"); + return allowed; + } + } catch (error) { + appendBackgroundRequestLog(error?.debugRequests); + } + await new Promise(resolve => setTimeout(resolve, retryDelay)); + retryDelay = Math.min(retryDelay * 2, 10000); + } + })(); } return pending; } @@ -10056,6 +10071,7 @@ function createContentApplication(overrides = {}) { }; const messageTypes = createMessageTypes(); const currentUser = createCurrentUser({ + environment, messageTypes, sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args), appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args) diff --git a/dist/chromium/manifest.json b/dist/chromium/manifest.json index e085555..c93b2eb 100644 --- a/dist/chromium/manifest.json +++ b/dist/chromium/manifest.json @@ -25,6 +25,16 @@ ], "all_frames": true, "run_at": "document_idle" + }, + { + "matches": [ + "https://spa.oracle.com/*" + ], + "js": [ + "spa-page.js" + ], + "all_frames": false, + "run_at": "document_idle" } ], "background": { diff --git a/dist/chromium/spa-page.js b/dist/chromium/spa-page.js new file mode 100644 index 0000000..37c0ef4 --- /dev/null +++ b/dist/chromium/spa-page.js @@ -0,0 +1,58 @@ +(function () { + "use strict"; +// Source: src/auth/spa-page.js +function createSpaPage() { + function start() { + if (location.origin !== "https://spa.oracle.com" || window.top !== window) return; + const runtime = (globalThis.browser || globalThis.chrome)?.runtime; + if (!runtime) return; + const documentId = `${performance.timeOrigin}:${location.pathname}`; + let identityRequest = null; + + function inspect() { + const text = `${document.title}\n${document.body?.innerText || ""}`.trim(); + let status = "loading"; + if (/\b(?:401\s+(?:authorization required|unauthorized)|403\s+forbidden|access denied|authorization required|authentication failed|internal server error|service unavailable|bad gateway)\b/i.test(text)) status = "error"; + else if (/^\/(?:cloudgate|login|signin|sso|oauth)(?:\/|$)/i.test(location.pathname) + || /\b(?:sign(?:ing)? in|log in|single sign.on|oracle access manager)\b/i.test(document.title) + || document.querySelector('input[name="SAMLResponse"], input[name="SAMLRequest"], input[type="password"], form[action*="/sso"], form[action*="/login"], meta[http-equiv="refresh" i]')) status = "login"; + else if (document.readyState === "complete" && text + && document.querySelector('a[href], button, input, [role="main"], main, nav')) status = "ready"; + return { status, documentId }; + } + + runtime.onMessage.addListener((message, sender, reply) => { + if (sender.id !== runtime.id) return false; + if (message?.type === "opportunitiesExtension.inspectSpaPage") { + reply(inspect()); + return false; + } + if (message?.type !== "opportunitiesExtension.readSpaIdentity") return false; + const page = inspect(); + if (page.status !== "ready" || page.documentId !== message.documentId) { + reply({ error: "SPA page is not ready or has navigated." }); + return false; + } + // A document is probed at most once; failed identity reads require a fresh navigation. + if (!identityRequest) identityRequest = (async () => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 15000); + try { + const response = await fetch("https://spa.oracle.com/oalcrm/web/api/identity-management/users/current", { + method: "GET", credentials: "include", cache: "no-store", headers: { Accept: "application/json" }, signal: controller.signal + }); + return { status: response.status, statusText: response.statusText, headers: Object.fromEntries(response.headers), + contentType: response.headers.get("content-type") || "", body: await response.text() }; + } catch (error) { + return { error: error.message || "Unable to read SPA identity." }; + } finally { clearTimeout(timer); } + })(); + identityRequest.then(reply); + return true; + }); + } + return { start }; +} + +createSpaPage().start(); +})(); diff --git a/dist/firefox/background.js b/dist/firefox/background.js index e1eae9a..28f64d1 100644 --- a/dist/firefox/background.js +++ b/dist/firefox/background.js @@ -222,7 +222,7 @@ function createBackgroundContracts() { const OPPTY_PRODUCTS_MAX_PAGES = 250; const OPPTY_PRODUCTS_CONCURRENCY = 4; const CUSTOMER_TOKEN_SERVICE_URL = "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/consumerTokenService"; - const CUSTOMER_TOKEN_SESSION_URL = "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html"; + const CUSTOMER_TOKEN_SESSION_URL = "https://spa.oracle.com/"; const CUSTOMER_TOKEN_MAX_ATTEMPTS = 5; const CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS = 750; const CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS = 4000; @@ -294,9 +294,16 @@ function createAuthConsumerSession({ fetchJson, supportsFirefoxCookieStoreTabs, tabsCreate, + tabsUpdate, tabsRemove, + responseHeaders, + cookieChanges, + inspectPage, + setTimeout, + clearTimeout, wait }) { + const sessionTabs = new Map(); async function requestConsumerTokenService(sender) { const debugRequests = []; @@ -436,7 +443,8 @@ function createAuthConsumerSession({ debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs); } - await wait(retryDelayMs); + if (sessionTab) await waitForConsumerTokenSession(sessionTab, debugEntry, retryDelayMs); + else await wait(retryDelayMs); } } @@ -446,7 +454,7 @@ function createAuthConsumerSession({ const debugEntry = debugRequests[debugRequests.length - 1]; try { - await tabsRemove(sessionTab.id); + await closeConsumerTokenSessionTab(sessionTab.id); if (debugEntry) { debugEntry.metadata.sessionBootstrapState = receivedValidJson ? "closed-after-valid-json" @@ -498,7 +506,7 @@ function createAuthConsumerSession({ } } - function openConsumerTokenSessionTab(sender) { + async function openConsumerTokenSessionTab(sender) { const details = { url: CUSTOMER_TOKEN_SESSION_URL, active: false @@ -512,10 +520,186 @@ function createAuthConsumerSession({ details.cookieStoreId = sender.tab.cookieStoreId; } - return tabsCreate(details); + 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; } - return { requestConsumerTokenService, fetchConsumerTokenServiceUntilJson, getConsumerTokenRetryDelay, openConsumerTokenSessionTabWithRetry, openConsumerTokenSessionTab }; + 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 }; } // Source: src/auth/spa-credentials.js @@ -1558,28 +1742,131 @@ function createFusionSrCreate({ fetchJson, tokenHeaders, previewTokenHeaders, in } // Source: src/services/spa/current-user.js -function createSpaCurrentUser({ fetchJson }) { - async function requestCurrentUser() { +function createSpaCurrentUser({ fetchJson, fetchInTab, openSessionTab, waitForSession, closeSessionTab, sessionUrl }) { + const pending = new Map(); + function requestCurrentUser(sender) { + const key = JSON.stringify([sender?.tab?.windowId ?? null, sender?.tab?.cookieStoreId ?? "", Boolean(sender?.tab?.incognito)]); + if (!pending.has(key)) { + pending.set(key, loadCurrentUser(sender).finally(() => pending.delete(key))); + } + return pending.get(key); + } + + async function loadCurrentUser(sender) { const debugRequests = []; - let user = null; + let sessionTab = null; + let receivedValidJson = false; try { - const result = await fetchJson("https://spa.oracle.com/oalcrm/web/api/identity-management/users/current", { - method: "GET", credentials: "include", cache: "no-store", headers: { Accept: "application/json" } - }, "requestCurrentUser", debugRequests); - if (!result?.user || !Array.isArray(result.user.roles) || result.user.roles.some(role => typeof role !== "string")) { - throw new Error("requestCurrentUser returned invalid user roles."); + // Bound each bootstrap cycle so a failed sign-in never leaves an orphan tab. + // The content-side gate can start another cycle without releasing dependent requests. + for (let attempt = 1; attempt <= 6; attempt++) { + try { + const result = sessionTab ? await fetchInTab(sessionTab, debugRequests) : await fetchJson("https://spa.oracle.com/oalcrm/web/api/identity-management/users/current", { + method: "GET", credentials: "include", cache: "no-store", headers: { Accept: "application/json" } + }, "requestCurrentUser", debugRequests); + const validText = value => typeof value === "string" && value.trim().length > 0; + if (!result || typeof result !== "object" || Array.isArray(result) + || !result.user || typeof result.user !== "object" || Array.isArray(result.user) + || !validText(result.user.id) || !validText(result.user.name) || !validText(result.token) + || !Array.isArray(result.user.roles) || result.user.roles.some(role => !validText(role)) + || ["onWhiteList", "provisioned", "administrator"].some(key => key in result.user && typeof result.user[key] !== "boolean")) { + const error = new Error("requestCurrentUser returned an invalid user/token response."); + error.code = "INVALID_CURRENT_USER_RESPONSE"; + throw error; + } + const entry = debugRequests[debugRequests.length - 1]; + if (entry) entry.metadata.attempt = String(attempt); + receivedValidJson = true; + const user = { id: result.user.id, name: result.user.name, roles: result.user.roles }; + return { ok: true, user, debugRequests }; + } catch (error) { + const entry = debugRequests[debugRequests.length - 1]; + if (entry) { + entry.status = "failed"; + entry.error = error.message; + entry.metadata.attempt = String(attempt); + } + const needsSession = error.code === "INVALID_JSON_RESPONSE" || error.code === "INVALID_CURRENT_USER_RESPONSE" + || error.httpStatus === 401 || error.httpStatus === 403 || !error.httpStatus; + if (!needsSession || attempt === 6) { + if (entry && needsSession) entry.metadata.retryLimitReached = "true"; + throw error; + } + if (entry) { + entry.metadata.sessionBootstrapUrl = sessionUrl; + entry.metadata.retryReason = "Current user requires a valid SPA session"; + } + if (!sessionTab) { + try { + const tab = await openSessionTab(sender); + if (!Number.isInteger(tab?.id)) throw new Error("SPA session tab was not created."); + sessionTab = tab; + if (entry) entry.metadata.sessionBootstrapState = "opened-inactive-tab"; + } catch (sessionError) { + if (entry) entry.metadata.sessionBootstrapState = `tab-open-failed: ${sessionError.message || "unknown error"}`; + throw sessionError; + } + } else if (entry) { + entry.metadata.sessionBootstrapState = "waiting-for-valid-session"; + } + await waitForSession(sessionTab, entry); + } } - user = { id: result.user.id, name: result.user.name, roles: result.user.roles }; - return { ok: true, user, debugRequests }; } catch (error) { - for (const entry of debugRequests) { entry.status = "failed"; entry.error = error.message; } error.debugRequests = debugRequests; throw error; + } finally { + if (sessionTab) { + const entry = debugRequests[debugRequests.length - 1]; + try { + await closeSessionTab(sessionTab.id); + if (entry) entry.metadata.sessionBootstrapState = receivedValidJson + ? "closed-after-valid-json" : "closed-after-retry-finished"; + } catch (error) { + if (entry) entry.metadata.sessionBootstrapState = `tab-close-failed: ${error.message || "unknown error"}`; + } + } } } return { requestCurrentUser }; } +// Source: src/services/spa/page-client.js +function createSpaPageClient({ sendMessage, getHttp }) { + const options = { frameId: 0 }; + const inspect = tab => sendMessage(tab.id, { type: "opportunitiesExtension.inspectSpaPage" }, options); + async function requestIdentity(tab, debugRequests) { + const http = getHttp(); + const url = "https://spa.oracle.com/oalcrm/web/api/identity-management/users/current"; + const entry = http.createBackgroundDebugEntry("requestCurrentUser", url, + { method: "GET", headers: { Accept: "application/json" } }, new Date()); + entry.metadata.executionContext = "spa-tab"; + try { + const result = await sendMessage(tab.id, { type: "opportunitiesExtension.readSpaIdentity", documentId: tab.spaDocumentId }, options); + if (!result || result.error || !Number.isInteger(result.status) || typeof result.body !== "string") { + throw new Error(result?.error || "Invalid SPA identity response."); + } + const response = { status: result.status, statusText: result.statusText || "", ok: result.status >= 200 && result.status < 300, + headers: { forEach: callback => Object.entries(result.headers || { "content-type": result.contentType || "" }).forEach(([key, value]) => callback(value, key)) } }; + http.completeBackgroundDebugEntry(entry, response, http.createDebugBody(result.body), ""); + entry.metadata.responseContentType = result.contentType || "(not provided)"; + entry.metadata.responseLength = String(result.body.length); + if (result.status !== 200) { + const error = new Error(`requestCurrentUser failed with status ${result.status}.`); + error.httpStatus = result.status; + throw error; + } + return http.parseJsonResponse(result.body, "requestCurrentUser"); + } catch (error) { + if (!entry.completedAt) http.completeBackgroundDebugEntry(entry, null, { value: "", truncated: false }, error.message); + entry.status = "failed"; + entry.error = error.message; + throw error; + } finally { debugRequests.push(entry); } + } + return { inspect, requestIdentity }; +} + // Source: src/services/spa/consumption.js function createSpaConsumption({ CONSUMPTION_TREND_REQUESTS, @@ -2471,6 +2758,19 @@ function createPlatformBackgroundApi({ }); } + function tabsUpdate(tabId, details) { + if (runtimeApi.tabs.update.length <= 2) { + return runtimeApi.tabs.update(tabId, details); + } + return new Promise((resolve, reject) => { + runtimeApi.tabs.update(tabId, details, tab => { + const lastError = runtimeApi.runtime.lastError; + if (lastError) reject(new Error(lastError.message)); + else resolve(tab); + }); + }); + } + function tabsRemove(tabId) { if (runtimeApi.tabs.remove.length <= 1) { return runtimeApi.tabs.remove(tabId); @@ -2628,7 +2928,7 @@ function createPlatformBackgroundApi({ ); } - return { supportsFirefoxCookieStoreTabs, tabsCreate, tabsRemove, tabsSendMessage, storageLocalGet, storageLocalSet, wait, cookiesGetAll, cookiesGet, cookiesGetAllCookieStores, removeUndefinedValues }; + return { supportsFirefoxCookieStoreTabs, tabsCreate, tabsUpdate, tabsRemove, tabsSendMessage, storageLocalGet, storageLocalSet, wait, cookiesGetAll, cookiesGet, cookiesGetAllCookieStores, removeUndefinedValues }; } // Source: src/background/main.js @@ -2809,7 +3109,7 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => { } if (message.type === messageTypes.requestCurrentUser) { - requestCurrentUser().then(sendResponse).catch(error => sendResponse({ + requestCurrentUser(sender).then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message || "Unable to load current user.", debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : [] })); @@ -2913,13 +3213,25 @@ function createBackgroundApplication(overrides = {}) { browser: globalThis.browser, chrome: globalThis.chrome, setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout, ...overrides }; const messageTypes = createMessageTypes(); const timeEntries = createTimeEntries({ dates: createTimeDates(), fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), getResourceUser: message => fusionSrCreate.getResourceUser(message), saveHeaders: url => srRequestHeaders.timeEntriesHeaders(url) }); - const spaCurrentUser = createSpaCurrentUser({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) }); - const requestCurrentUser = () => spaCurrentUser.requestCurrentUser(); const contracts = createBackgroundContracts(); + const spaPageClient = createSpaPageClient({ + sendMessage: (...args) => platformBackgroundApi.tabsSendMessage(...args), + getHttp: () => httpBackgroundJson + }); + const spaCurrentUser = createSpaCurrentUser({ + fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), + fetchInTab: (tab, logs) => spaPageClient.requestIdentity(tab, logs), + openSessionTab: sender => authConsumerSession.openConsumerTokenSessionTab(sender), + waitForSession: (tab, entry) => authConsumerSession.waitForSpaPage(tab, entry), + closeSessionTab: id => authConsumerSession.closeConsumerTokenSessionTab(id), + sessionUrl: contracts.CUSTOMER_TOKEN_SESSION_URL + }); + const requestCurrentUser = sender => spaCurrentUser.requestCurrentUser(sender); const productsCacheState = createProductsCacheState(); const authConsumerSession = createAuthConsumerSession({ CUSTOMER_TOKEN_MAX_ATTEMPTS: contracts.CUSTOMER_TOKEN_MAX_ATTEMPTS, @@ -2931,7 +3243,13 @@ function createBackgroundApplication(overrides = {}) { fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), supportsFirefoxCookieStoreTabs: (...args) => platformBackgroundApi.supportsFirefoxCookieStoreTabs(...args), tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args), + tabsUpdate: (...args) => platformBackgroundApi.tabsUpdate(...args), tabsRemove: (...args) => platformBackgroundApi.tabsRemove(...args), + responseHeaders: (environment.browser || environment.chrome)?.webRequest?.onHeadersReceived, + cookieChanges: (environment.browser || environment.chrome)?.cookies?.onChanged, + inspectPage: tab => spaPageClient.inspect(tab), + setTimeout: environment.setTimeout, + clearTimeout: environment.clearTimeout, wait: (...args) => platformBackgroundApi.wait(...args) }); const authSpaCredentials = createAuthSpaCredentials({ diff --git a/dist/firefox/content.js b/dist/firefox/content.js index e2f6c4e..3b73fd6 100644 --- a/dist/firefox/content.js +++ b/dist/firefox/content.js @@ -1990,20 +1990,35 @@ function createAuthContentSession({ } // Source: src/auth/current-user.js -function createCurrentUser({ sendRuntimeMessage, appendBackgroundRequestLog, messageTypes }) { +function createCurrentUser({ sendRuntimeMessage, appendBackgroundRequestLog, messageTypes, environment }) { + const { setTimeout } = environment; let pending = null; let allowed = false; let userId = ""; function ensure() { if (!pending) { - pending = sendRuntimeMessage({ type: messageTypes.requestCurrentUser }).then(response => { - appendBackgroundRequestLog(response?.debugRequests); - const roles = response?.user?.roles; - userId = response?.ok === true && typeof response.user?.id === "string" ? response.user.id : ""; - allowed = response?.ok === true && Array.isArray(roles) - && roles.every(role => typeof role === "string") && !roles.includes("SALES_REP"); - return allowed; - }).catch(() => { allowed = false; return false; }); + pending = (async () => { + let retryDelay = 1000; + // Keep a single pending identity request until the response is usable. + for (;;) { + try { + const response = await sendRuntimeMessage({ type: messageTypes.requestCurrentUser }); + appendBackgroundRequestLog(response?.debugRequests); + const user = response?.user; + if (response?.ok === true && typeof user?.id === "string" && user.id.trim() + && typeof user.name === "string" && user.name.trim() + && Array.isArray(user.roles) && user.roles.every(role => typeof role === "string" && role.trim())) { + userId = user.id; + allowed = !user.roles.includes("SALES_REP"); + return allowed; + } + } catch (error) { + appendBackgroundRequestLog(error?.debugRequests); + } + await new Promise(resolve => setTimeout(resolve, retryDelay)); + retryDelay = Math.min(retryDelay * 2, 10000); + } + })(); } return pending; } @@ -10056,6 +10071,7 @@ function createContentApplication(overrides = {}) { }; const messageTypes = createMessageTypes(); const currentUser = createCurrentUser({ + environment, messageTypes, sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args), appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args) diff --git a/dist/firefox/manifest.json b/dist/firefox/manifest.json index b25c18b..38fcc9b 100644 --- a/dist/firefox/manifest.json +++ b/dist/firefox/manifest.json @@ -25,6 +25,16 @@ ], "all_frames": true, "run_at": "document_idle" + }, + { + "matches": [ + "https://spa.oracle.com/*" + ], + "js": [ + "spa-page.js" + ], + "all_frames": false, + "run_at": "document_idle" } ], "background": { diff --git a/dist/firefox/spa-page.js b/dist/firefox/spa-page.js new file mode 100644 index 0000000..37c0ef4 --- /dev/null +++ b/dist/firefox/spa-page.js @@ -0,0 +1,58 @@ +(function () { + "use strict"; +// Source: src/auth/spa-page.js +function createSpaPage() { + function start() { + if (location.origin !== "https://spa.oracle.com" || window.top !== window) return; + const runtime = (globalThis.browser || globalThis.chrome)?.runtime; + if (!runtime) return; + const documentId = `${performance.timeOrigin}:${location.pathname}`; + let identityRequest = null; + + function inspect() { + const text = `${document.title}\n${document.body?.innerText || ""}`.trim(); + let status = "loading"; + if (/\b(?:401\s+(?:authorization required|unauthorized)|403\s+forbidden|access denied|authorization required|authentication failed|internal server error|service unavailable|bad gateway)\b/i.test(text)) status = "error"; + else if (/^\/(?:cloudgate|login|signin|sso|oauth)(?:\/|$)/i.test(location.pathname) + || /\b(?:sign(?:ing)? in|log in|single sign.on|oracle access manager)\b/i.test(document.title) + || document.querySelector('input[name="SAMLResponse"], input[name="SAMLRequest"], input[type="password"], form[action*="/sso"], form[action*="/login"], meta[http-equiv="refresh" i]')) status = "login"; + else if (document.readyState === "complete" && text + && document.querySelector('a[href], button, input, [role="main"], main, nav')) status = "ready"; + return { status, documentId }; + } + + runtime.onMessage.addListener((message, sender, reply) => { + if (sender.id !== runtime.id) return false; + if (message?.type === "opportunitiesExtension.inspectSpaPage") { + reply(inspect()); + return false; + } + if (message?.type !== "opportunitiesExtension.readSpaIdentity") return false; + const page = inspect(); + if (page.status !== "ready" || page.documentId !== message.documentId) { + reply({ error: "SPA page is not ready or has navigated." }); + return false; + } + // A document is probed at most once; failed identity reads require a fresh navigation. + if (!identityRequest) identityRequest = (async () => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 15000); + try { + const response = await fetch("https://spa.oracle.com/oalcrm/web/api/identity-management/users/current", { + method: "GET", credentials: "include", cache: "no-store", headers: { Accept: "application/json" }, signal: controller.signal + }); + return { status: response.status, statusText: response.statusText, headers: Object.fromEntries(response.headers), + contentType: response.headers.get("content-type") || "", body: await response.text() }; + } catch (error) { + return { error: error.message || "Unable to read SPA identity." }; + } finally { clearTimeout(timer); } + })(); + identityRequest.then(reply); + return true; + }); + } + return { start }; +} + +createSpaPage().start(); +})(); diff --git a/docs/architecture.md b/docs/architecture.md index e7261c5..870600e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -14,6 +14,11 @@ preexisting risks are tracked in [refactoring.md](refactoring.md). uses `background.scripts`. Neither entry point is declared as a module. - Content scripts run at `document_idle`, in all matching frames on the two configured Fusion welcome pages. Their code also checks the hostname/path. +- `src/auth/spa-page.js` is a separate top-frame-only SPA content script, copied + into both distributions. It handles only extension messages for DOM readiness + inspection and a fixed same-origin identity GET; it sends no requests on its own. + `services/spa/page-client.js` connects this receiver to the background recovery + flow and the existing debug envelope. No page-world script is injected. - There are no extension UI pages, injected page-context scripts, custom CSP, declared execution world, or web-accessible resources. - Permissions remain `cookies` and `storage`, with host access limited to the diff --git a/scripts/build.mjs b/scripts/build.mjs index c574cad..601c911 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -8,6 +8,7 @@ const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") const distDir = path.join(rootDir, "dist"); const contentScript = composition.composeScript("content"); const backgroundScript = composition.composeScript("background"); +const spaPageScript = composition.composeScript("spa-page"); const checkOnly = process.argv.includes("--check"); const baseManifest = { @@ -35,6 +36,12 @@ const baseManifest = { js: ["content.js"], all_frames: true, run_at: "document_idle" + }, + { + matches: ["https://spa.oracle.com/*"], + js: ["spa-page.js"], + all_frames: false, + run_at: "document_idle" } ] }; @@ -70,7 +77,7 @@ for (const target of targets) { JSON.parse(manifestJson); if (checkOnly) { - const expected = { "manifest.json": manifestJson, "content.js": contentScript, "background.js": backgroundScript }; + const expected = { "manifest.json": manifestJson, "content.js": contentScript, "background.js": backgroundScript, "spa-page.js": spaPageScript }; for (const [file, contents] of Object.entries(expected)) { const actual = await readFile(path.join(distDir, target.name, file), "utf8"); if (actual !== contents) throw new Error(`Stale output: dist/${target.name}/${file}. Run npm run build.`); @@ -82,6 +89,7 @@ for (const target of targets) { await mkdir(outputDir, { recursive: true }); await output.writeOutput(path.join(outputDir, "content.js"), contentScript); await output.writeOutput(path.join(outputDir, "background.js"), backgroundScript); + await output.writeOutput(path.join(outputDir, "spa-page.js"), spaPageScript); await output.writeOutput(path.join(outputDir, "manifest.json"), manifestJson); } diff --git a/scripts/compose.cjs b/scripts/compose.cjs index 116aacd..b812344 100644 --- a/scripts/compose.cjs +++ b/scripts/compose.cjs @@ -25,7 +25,7 @@ function composeScript(target, { bootstrap = true } = {}) { .map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n"); parts.unshift(`const redwoodStyles = ${JSON.stringify(css)};`); } - const factory = target === "content" ? "createContentApplication" : "createBackgroundApplication"; + const factory = { content: "createContentApplication", background: "createBackgroundApplication", "spa-page": "createSpaPage" }[target]; const ending = bootstrap ? `${factory}().start();` : `return ${factory};`; const output = `(function () {\n "use strict";\n${parts.join("\n")}\n${ending}\n})();\n`; new vm.Script(output, { filename: `${target}.js` }); diff --git a/scripts/sources.cjs b/scripts/sources.cjs index 76ac187..e707033 100644 --- a/scripts/sources.cjs +++ b/scripts/sources.cjs @@ -1,4 +1,5 @@ module.exports = { + "spa-page": ["src/auth/spa-page.js"], "content": [ "src/shared/time-dates.js", "src/features/manage-time/view.js", @@ -107,6 +108,7 @@ module.exports = { "src/services/fusion/sr-details.js", "src/services/fusion/sr-create.js", "src/services/spa/current-user.js", + "src/services/spa/page-client.js", "src/services/spa/consumption.js", "src/services/spa/response-shapes.js", "src/services/http/background-json.js", diff --git a/src/auth/consumer-session.js b/src/auth/consumer-session.js index c9e0a59..806893d 100644 --- a/src/auth/consumer-session.js +++ b/src/auth/consumer-session.js @@ -8,9 +8,16 @@ function createAuthConsumerSession({ fetchJson, supportsFirefoxCookieStoreTabs, tabsCreate, + tabsUpdate, tabsRemove, + responseHeaders, + cookieChanges, + inspectPage, + setTimeout, + clearTimeout, wait }) { + const sessionTabs = new Map(); async function requestConsumerTokenService(sender) { const debugRequests = []; @@ -150,7 +157,8 @@ function createAuthConsumerSession({ debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs); } - await wait(retryDelayMs); + if (sessionTab) await waitForConsumerTokenSession(sessionTab, debugEntry, retryDelayMs); + else await wait(retryDelayMs); } } @@ -160,7 +168,7 @@ function createAuthConsumerSession({ const debugEntry = debugRequests[debugRequests.length - 1]; try { - await tabsRemove(sessionTab.id); + await closeConsumerTokenSessionTab(sessionTab.id); if (debugEntry) { debugEntry.metadata.sessionBootstrapState = receivedValidJson ? "closed-after-valid-json" @@ -212,7 +220,7 @@ function createAuthConsumerSession({ } } - function openConsumerTokenSessionTab(sender) { + async function openConsumerTokenSessionTab(sender) { const details = { url: CUSTOMER_TOKEN_SESSION_URL, active: false @@ -226,8 +234,184 @@ function createAuthConsumerSession({ details.cookieStoreId = sender.tab.cookieStoreId; } - return tabsCreate(details); + 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; } - return { requestConsumerTokenService, fetchConsumerTokenServiceUntilJson, getConsumerTokenRetryDelay, openConsumerTokenSessionTabWithRetry, openConsumerTokenSessionTab }; + 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 }; } diff --git a/src/auth/current-user.js b/src/auth/current-user.js index 48c5dd7..822d271 100644 --- a/src/auth/current-user.js +++ b/src/auth/current-user.js @@ -1,17 +1,32 @@ -function createCurrentUser({ sendRuntimeMessage, appendBackgroundRequestLog, messageTypes }) { +function createCurrentUser({ sendRuntimeMessage, appendBackgroundRequestLog, messageTypes, environment }) { + const { setTimeout } = environment; let pending = null; let allowed = false; let userId = ""; function ensure() { if (!pending) { - pending = sendRuntimeMessage({ type: messageTypes.requestCurrentUser }).then(response => { - appendBackgroundRequestLog(response?.debugRequests); - const roles = response?.user?.roles; - userId = response?.ok === true && typeof response.user?.id === "string" ? response.user.id : ""; - allowed = response?.ok === true && Array.isArray(roles) - && roles.every(role => typeof role === "string") && !roles.includes("SALES_REP"); - return allowed; - }).catch(() => { allowed = false; return false; }); + pending = (async () => { + let retryDelay = 1000; + // Keep a single pending identity request until the response is usable. + for (;;) { + try { + const response = await sendRuntimeMessage({ type: messageTypes.requestCurrentUser }); + appendBackgroundRequestLog(response?.debugRequests); + const user = response?.user; + if (response?.ok === true && typeof user?.id === "string" && user.id.trim() + && typeof user.name === "string" && user.name.trim() + && Array.isArray(user.roles) && user.roles.every(role => typeof role === "string" && role.trim())) { + userId = user.id; + allowed = !user.roles.includes("SALES_REP"); + return allowed; + } + } catch (error) { + appendBackgroundRequestLog(error?.debugRequests); + } + await new Promise(resolve => setTimeout(resolve, retryDelay)); + retryDelay = Math.min(retryDelay * 2, 10000); + } + })(); } return pending; } diff --git a/src/auth/spa-page.js b/src/auth/spa-page.js new file mode 100644 index 0000000..3f38351 --- /dev/null +++ b/src/auth/spa-page.js @@ -0,0 +1,52 @@ +function createSpaPage() { + function start() { + if (location.origin !== "https://spa.oracle.com" || window.top !== window) return; + const runtime = (globalThis.browser || globalThis.chrome)?.runtime; + if (!runtime) return; + const documentId = `${performance.timeOrigin}:${location.pathname}`; + let identityRequest = null; + + function inspect() { + const text = `${document.title}\n${document.body?.innerText || ""}`.trim(); + let status = "loading"; + if (/\b(?:401\s+(?:authorization required|unauthorized)|403\s+forbidden|access denied|authorization required|authentication failed|internal server error|service unavailable|bad gateway)\b/i.test(text)) status = "error"; + else if (/^\/(?:cloudgate|login|signin|sso|oauth)(?:\/|$)/i.test(location.pathname) + || /\b(?:sign(?:ing)? in|log in|single sign.on|oracle access manager)\b/i.test(document.title) + || document.querySelector('input[name="SAMLResponse"], input[name="SAMLRequest"], input[type="password"], form[action*="/sso"], form[action*="/login"], meta[http-equiv="refresh" i]')) status = "login"; + else if (document.readyState === "complete" && text + && document.querySelector('a[href], button, input, [role="main"], main, nav')) status = "ready"; + return { status, documentId }; + } + + runtime.onMessage.addListener((message, sender, reply) => { + if (sender.id !== runtime.id) return false; + if (message?.type === "opportunitiesExtension.inspectSpaPage") { + reply(inspect()); + return false; + } + if (message?.type !== "opportunitiesExtension.readSpaIdentity") return false; + const page = inspect(); + if (page.status !== "ready" || page.documentId !== message.documentId) { + reply({ error: "SPA page is not ready or has navigated." }); + return false; + } + // A document is probed at most once; failed identity reads require a fresh navigation. + if (!identityRequest) identityRequest = (async () => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 15000); + try { + const response = await fetch("https://spa.oracle.com/oalcrm/web/api/identity-management/users/current", { + method: "GET", credentials: "include", cache: "no-store", headers: { Accept: "application/json" }, signal: controller.signal + }); + return { status: response.status, statusText: response.statusText, headers: Object.fromEntries(response.headers), + contentType: response.headers.get("content-type") || "", body: await response.text() }; + } catch (error) { + return { error: error.message || "Unable to read SPA identity." }; + } finally { clearTimeout(timer); } + })(); + identityRequest.then(reply); + return true; + }); + } + return { start }; +} diff --git a/src/background/application.js b/src/background/application.js index f08ff1f..8b8f5ea 100644 --- a/src/background/application.js +++ b/src/background/application.js @@ -4,13 +4,25 @@ function createBackgroundApplication(overrides = {}) { browser: globalThis.browser, chrome: globalThis.chrome, setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout, ...overrides }; const messageTypes = createMessageTypes(); const timeEntries = createTimeEntries({ dates: createTimeDates(), fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), getResourceUser: message => fusionSrCreate.getResourceUser(message), saveHeaders: url => srRequestHeaders.timeEntriesHeaders(url) }); - const spaCurrentUser = createSpaCurrentUser({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) }); - const requestCurrentUser = () => spaCurrentUser.requestCurrentUser(); const contracts = createBackgroundContracts(); + const spaPageClient = createSpaPageClient({ + sendMessage: (...args) => platformBackgroundApi.tabsSendMessage(...args), + getHttp: () => httpBackgroundJson + }); + const spaCurrentUser = createSpaCurrentUser({ + fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), + fetchInTab: (tab, logs) => spaPageClient.requestIdentity(tab, logs), + openSessionTab: sender => authConsumerSession.openConsumerTokenSessionTab(sender), + waitForSession: (tab, entry) => authConsumerSession.waitForSpaPage(tab, entry), + closeSessionTab: id => authConsumerSession.closeConsumerTokenSessionTab(id), + sessionUrl: contracts.CUSTOMER_TOKEN_SESSION_URL + }); + const requestCurrentUser = sender => spaCurrentUser.requestCurrentUser(sender); const productsCacheState = createProductsCacheState(); const authConsumerSession = createAuthConsumerSession({ CUSTOMER_TOKEN_MAX_ATTEMPTS: contracts.CUSTOMER_TOKEN_MAX_ATTEMPTS, @@ -22,7 +34,13 @@ function createBackgroundApplication(overrides = {}) { fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), supportsFirefoxCookieStoreTabs: (...args) => platformBackgroundApi.supportsFirefoxCookieStoreTabs(...args), tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args), + tabsUpdate: (...args) => platformBackgroundApi.tabsUpdate(...args), tabsRemove: (...args) => platformBackgroundApi.tabsRemove(...args), + responseHeaders: (environment.browser || environment.chrome)?.webRequest?.onHeadersReceived, + cookieChanges: (environment.browser || environment.chrome)?.cookies?.onChanged, + inspectPage: tab => spaPageClient.inspect(tab), + setTimeout: environment.setTimeout, + clearTimeout: environment.clearTimeout, wait: (...args) => platformBackgroundApi.wait(...args) }); const authSpaCredentials = createAuthSpaCredentials({ diff --git a/src/background/contracts.js b/src/background/contracts.js index 426917c..c53b03b 100644 --- a/src/background/contracts.js +++ b/src/background/contracts.js @@ -16,7 +16,7 @@ function createBackgroundContracts() { const OPPTY_PRODUCTS_MAX_PAGES = 250; const OPPTY_PRODUCTS_CONCURRENCY = 4; const CUSTOMER_TOKEN_SERVICE_URL = "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/consumerTokenService"; - const CUSTOMER_TOKEN_SESSION_URL = "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html"; + const CUSTOMER_TOKEN_SESSION_URL = "https://spa.oracle.com/"; const CUSTOMER_TOKEN_MAX_ATTEMPTS = 5; const CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS = 750; const CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS = 4000; diff --git a/src/background/messages.js b/src/background/messages.js index a4abb98..19ac950 100644 --- a/src/background/messages.js +++ b/src/background/messages.js @@ -170,7 +170,7 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => { } if (message.type === messageTypes.requestCurrentUser) { - requestCurrentUser().then(sendResponse).catch(error => sendResponse({ + requestCurrentUser(sender).then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message || "Unable to load current user.", debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : [] })); diff --git a/src/content/application.js b/src/content/application.js index 0d7da84..15bf8aa 100644 --- a/src/content/application.js +++ b/src/content/application.js @@ -19,6 +19,7 @@ function createContentApplication(overrides = {}) { }; const messageTypes = createMessageTypes(); const currentUser = createCurrentUser({ + environment, messageTypes, sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args), appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args) diff --git a/src/platform/background-api.js b/src/platform/background-api.js index 53eacf0..ecf8d2c 100644 --- a/src/platform/background-api.js +++ b/src/platform/background-api.js @@ -29,6 +29,19 @@ function createPlatformBackgroundApi({ }); } + function tabsUpdate(tabId, details) { + if (runtimeApi.tabs.update.length <= 2) { + return runtimeApi.tabs.update(tabId, details); + } + return new Promise((resolve, reject) => { + runtimeApi.tabs.update(tabId, details, tab => { + const lastError = runtimeApi.runtime.lastError; + if (lastError) reject(new Error(lastError.message)); + else resolve(tab); + }); + }); + } + function tabsRemove(tabId) { if (runtimeApi.tabs.remove.length <= 1) { return runtimeApi.tabs.remove(tabId); @@ -186,5 +199,5 @@ function createPlatformBackgroundApi({ ); } - return { supportsFirefoxCookieStoreTabs, tabsCreate, tabsRemove, tabsSendMessage, storageLocalGet, storageLocalSet, wait, cookiesGetAll, cookiesGet, cookiesGetAllCookieStores, removeUndefinedValues }; + return { supportsFirefoxCookieStoreTabs, tabsCreate, tabsUpdate, tabsRemove, tabsSendMessage, storageLocalGet, storageLocalSet, wait, cookiesGetAll, cookiesGet, cookiesGetAllCookieStores, removeUndefinedValues }; } diff --git a/src/services/spa/current-user.js b/src/services/spa/current-user.js index 4a0eade..0719877 100644 --- a/src/services/spa/current-user.js +++ b/src/services/spa/current-user.js @@ -1,20 +1,87 @@ -function createSpaCurrentUser({ fetchJson }) { - async function requestCurrentUser() { +function createSpaCurrentUser({ fetchJson, fetchInTab, openSessionTab, waitForSession, closeSessionTab, sessionUrl }) { + const pending = new Map(); + function requestCurrentUser(sender) { + const key = JSON.stringify([sender?.tab?.windowId ?? null, sender?.tab?.cookieStoreId ?? "", Boolean(sender?.tab?.incognito)]); + if (!pending.has(key)) { + pending.set(key, loadCurrentUser(sender).finally(() => pending.delete(key))); + } + return pending.get(key); + } + + async function loadCurrentUser(sender) { const debugRequests = []; - let user = null; + let sessionTab = null; + let receivedValidJson = false; try { - const result = await fetchJson("https://spa.oracle.com/oalcrm/web/api/identity-management/users/current", { - method: "GET", credentials: "include", cache: "no-store", headers: { Accept: "application/json" } - }, "requestCurrentUser", debugRequests); - if (!result?.user || !Array.isArray(result.user.roles) || result.user.roles.some(role => typeof role !== "string")) { - throw new Error("requestCurrentUser returned invalid user roles."); + // Bound each bootstrap cycle so a failed sign-in never leaves an orphan tab. + // The content-side gate can start another cycle without releasing dependent requests. + for (let attempt = 1; attempt <= 6; attempt++) { + try { + const result = sessionTab ? await fetchInTab(sessionTab, debugRequests) : await fetchJson("https://spa.oracle.com/oalcrm/web/api/identity-management/users/current", { + method: "GET", credentials: "include", cache: "no-store", headers: { Accept: "application/json" } + }, "requestCurrentUser", debugRequests); + const validText = value => typeof value === "string" && value.trim().length > 0; + if (!result || typeof result !== "object" || Array.isArray(result) + || !result.user || typeof result.user !== "object" || Array.isArray(result.user) + || !validText(result.user.id) || !validText(result.user.name) || !validText(result.token) + || !Array.isArray(result.user.roles) || result.user.roles.some(role => !validText(role)) + || ["onWhiteList", "provisioned", "administrator"].some(key => key in result.user && typeof result.user[key] !== "boolean")) { + const error = new Error("requestCurrentUser returned an invalid user/token response."); + error.code = "INVALID_CURRENT_USER_RESPONSE"; + throw error; + } + const entry = debugRequests[debugRequests.length - 1]; + if (entry) entry.metadata.attempt = String(attempt); + receivedValidJson = true; + const user = { id: result.user.id, name: result.user.name, roles: result.user.roles }; + return { ok: true, user, debugRequests }; + } catch (error) { + const entry = debugRequests[debugRequests.length - 1]; + if (entry) { + entry.status = "failed"; + entry.error = error.message; + entry.metadata.attempt = String(attempt); + } + const needsSession = error.code === "INVALID_JSON_RESPONSE" || error.code === "INVALID_CURRENT_USER_RESPONSE" + || error.httpStatus === 401 || error.httpStatus === 403 || !error.httpStatus; + if (!needsSession || attempt === 6) { + if (entry && needsSession) entry.metadata.retryLimitReached = "true"; + throw error; + } + if (entry) { + entry.metadata.sessionBootstrapUrl = sessionUrl; + entry.metadata.retryReason = "Current user requires a valid SPA session"; + } + if (!sessionTab) { + try { + const tab = await openSessionTab(sender); + if (!Number.isInteger(tab?.id)) throw new Error("SPA session tab was not created."); + sessionTab = tab; + if (entry) entry.metadata.sessionBootstrapState = "opened-inactive-tab"; + } catch (sessionError) { + if (entry) entry.metadata.sessionBootstrapState = `tab-open-failed: ${sessionError.message || "unknown error"}`; + throw sessionError; + } + } else if (entry) { + entry.metadata.sessionBootstrapState = "waiting-for-valid-session"; + } + await waitForSession(sessionTab, entry); + } } - user = { id: result.user.id, name: result.user.name, roles: result.user.roles }; - return { ok: true, user, debugRequests }; } catch (error) { - for (const entry of debugRequests) { entry.status = "failed"; entry.error = error.message; } error.debugRequests = debugRequests; throw error; + } finally { + if (sessionTab) { + const entry = debugRequests[debugRequests.length - 1]; + try { + await closeSessionTab(sessionTab.id); + if (entry) entry.metadata.sessionBootstrapState = receivedValidJson + ? "closed-after-valid-json" : "closed-after-retry-finished"; + } catch (error) { + if (entry) entry.metadata.sessionBootstrapState = `tab-close-failed: ${error.message || "unknown error"}`; + } + } } } return { requestCurrentUser }; diff --git a/src/services/spa/page-client.js b/src/services/spa/page-client.js new file mode 100644 index 0000000..661f841 --- /dev/null +++ b/src/services/spa/page-client.js @@ -0,0 +1,34 @@ +function createSpaPageClient({ sendMessage, getHttp }) { + const options = { frameId: 0 }; + const inspect = tab => sendMessage(tab.id, { type: "opportunitiesExtension.inspectSpaPage" }, options); + async function requestIdentity(tab, debugRequests) { + const http = getHttp(); + const url = "https://spa.oracle.com/oalcrm/web/api/identity-management/users/current"; + const entry = http.createBackgroundDebugEntry("requestCurrentUser", url, + { method: "GET", headers: { Accept: "application/json" } }, new Date()); + entry.metadata.executionContext = "spa-tab"; + try { + const result = await sendMessage(tab.id, { type: "opportunitiesExtension.readSpaIdentity", documentId: tab.spaDocumentId }, options); + if (!result || result.error || !Number.isInteger(result.status) || typeof result.body !== "string") { + throw new Error(result?.error || "Invalid SPA identity response."); + } + const response = { status: result.status, statusText: result.statusText || "", ok: result.status >= 200 && result.status < 300, + headers: { forEach: callback => Object.entries(result.headers || { "content-type": result.contentType || "" }).forEach(([key, value]) => callback(value, key)) } }; + http.completeBackgroundDebugEntry(entry, response, http.createDebugBody(result.body), ""); + entry.metadata.responseContentType = result.contentType || "(not provided)"; + entry.metadata.responseLength = String(result.body.length); + if (result.status !== 200) { + const error = new Error(`requestCurrentUser failed with status ${result.status}.`); + error.httpStatus = result.status; + throw error; + } + return http.parseJsonResponse(result.body, "requestCurrentUser"); + } catch (error) { + if (!entry.completedAt) http.completeBackgroundDebugEntry(entry, null, { value: "", truncated: false }, error.message); + entry.status = "failed"; + entry.error = error.message; + throw error; + } finally { debugRequests.push(entry); } + } + return { inspect, requestIdentity }; +} diff --git a/tests/activityFilters.test.cjs b/tests/activityFilters.test.cjs index f1c64cf..1d52fc2 100644 --- a/tests/activityFilters.test.cjs +++ b/tests/activityFilters.test.cjs @@ -154,7 +154,7 @@ for (const [roles, allowed] of [[ ["SALES_REP"], false ], [ ["SIMPLE_USER", "SAL assert.equal(controls.children[0].hidden, true, "hidden before identity resolves"); assert.equal(controls.children[1].hidden, true); const identity = app.currentUser.ensure(); - finish({ ok: true, user: { roles } }); + finish({ ok: true, user: { id: "synthetic@example.invalid", name: "Synthetic User", roles } }); await identity; app.activityFilters.render(); assert.equal(controls.children[0].hidden, !allowed); diff --git a/tests/characterization.test.cjs b/tests/characterization.test.cjs index 3242713..b4c9bbc 100644 --- a/tests/characterization.test.cjs +++ b/tests/characterization.test.cjs @@ -45,6 +45,8 @@ test("distribution contracts: manifests, execution boundaries and deterministic content_scripts: [{ matches: ["https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*", "https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome*"], js: ["content.js"], all_frames: true, run_at: "document_idle" + }, { + matches: ["https://spa.oracle.com/*"], js: ["spa-page.js"], all_frames: false, run_at: "document_idle" }] }; for (const target of ["chromium", "firefox"]) { @@ -57,6 +59,9 @@ test("distribution contracts: manifests, execution boundaries and deterministic assert.equal(output, composeScript(file.replace(".js", ""))); assert.doesNotThrow(() => new vm.Script(output, { filename: `${target}/${file}` })); } + const pageScript = fs.readFileSync(path.join(root, "dist", target, "spa-page.js"), "utf8"); + assert.equal(pageScript, composeScript("spa-page")); + assert.doesNotThrow(() => new vm.Script(pageScript)); } }); diff --git a/tests/consumerTokenAuthorizationRecovery.test.cjs b/tests/consumerTokenAuthorizationRecovery.test.cjs index c28fd62..4423ca6 100644 --- a/tests/consumerTokenAuthorizationRecovery.test.cjs +++ b/tests/consumerTokenAuthorizationRecovery.test.cjs @@ -26,7 +26,7 @@ for (const family of ["chrome", "firefox"]) { assert.equal(h.tabs[0].active, false); assert.equal(h.tabs[0].windowId, 3); assert.equal(h.tabs[0].cookieStoreId, family === "firefox" ? "test-store" : undefined); - assert.equal(h.tabs[0].url, "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html"); + assert.equal(h.tabs[0].url, "https://spa.oracle.com/"); assert.deepEqual(h.removedTabs, [91]); assert.equal(result.debugRequests[0].httpStatus, status); assert.match(result.debugRequests[0].metadata.retryReason, /SPA session recovery/); diff --git a/tests/consumerTokenSessionRetry.test.cjs b/tests/consumerTokenSessionRetry.test.cjs index 289c6b9..c5e5768 100644 --- a/tests/consumerTokenSessionRetry.test.cjs +++ b/tests/consumerTokenSessionRetry.test.cjs @@ -109,7 +109,7 @@ async function run() { assert.equal(createdTabs[0].active, false); assert.equal( createdTabs[0].url, - "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html" + "https://spa.oracle.com/" ); assert.deepEqual(removedTabs, [27]); assert.equal(response.debugRequests.length, 2); diff --git a/tests/currentUser.test.cjs b/tests/currentUser.test.cjs index 5ec0411..9e2b332 100644 --- a/tests/currentUser.test.cjs +++ b/tests/currentUser.test.cjs @@ -3,13 +3,18 @@ const { test } = require("node:test"); const { loadBackground, response } = require("./support/characterization.cjs"); const { loadApplication } = require("./support/application.cjs"); const type = "opportunitiesExtension.requestCurrentUser"; +const validIdentity = { + user: { id: "synthetic@example.invalid", name: "Synthetic User", onWhiteList: false, + provisioned: true, administrator: false, roles: ["SALES_REP", "SIMPLE_USER"] }, + token: "synthetic-secret" +}; for (const family of ["chrome", "firefox"]) { test(`${family}: current user GET preserves identity token only in debug data for explicit reveal`, async () => { const calls = []; const app = loadBackground({ family, fetch: async (url, options) => { calls.push({ url, options }); - return response({ user: { id: "synthetic@example.invalid", roles: ["SALES_REP", "SIMPLE_USER"] }, token: "synthetic-secret" }); + return response(validIdentity); } }); const result = await app.dispatch({ type }); assert.equal(result.ok, true); @@ -26,6 +31,8 @@ for (const family of ["chrome", "firefox"]) { content.debugState.debugSecretsVisible = true; assert.match(content.debugContentLog.formatDebugBody(result.debugRequests[0].responseBody, "requestCurrentUser"), /synthetic-secret/); assert.equal(result.token, undefined); + assert.equal(app.tabs.length, 0, "a valid existing session must not open a tab"); + assert.equal(app.removedTabs.length, 0); }); } @@ -46,8 +53,116 @@ test("identity failures retain diagnostics but hide response tokens by default", } }); -function content(identity) { - const app = loadApplication("content", { document: { createElement: () => ({ setAttribute() {} }) } }); +test("retry timer is called without the environment object as its receiver", async () => { + let attempts = 0; + let timers = 0; + const { app } = content(() => ++attempts === 1 ? { ok: false } : { ok: true, user: validIdentity.user }, { + setTimeout: function(callback) { + "use strict"; + assert.equal(this, undefined, "Window timers reject an arbitrary receiver in Edge"); + timers++; + setImmediate(callback); + } + }); + await app.currentUser.ensure(); + assert.equal(attempts, 2); + assert.equal(timers, 1); +}); + +for (const family of ["chrome", "firefox"]) { + test(`${family}: current-user SSO recovery shares one verified SPA tab and closes on valid JSON`, async () => { + let attempts = 0; + const app = loadBackground({ family, fetch: async (url, options) => { + assert.equal(url, "https://spa.oracle.com/oalcrm/web/api/identity-management/users/current"); + assert.equal(options.credentials, "include"); + attempts++; + if (attempts <= 2) return new Response('
'); + assert.equal(app.tabs.length, 1, "SSO must be started before retry can succeed"); + assert.equal(app.removedTabs.length, 0, "do not close before a valid identity response"); + return response(validIdentity); + } }); + const [first, second] = await Promise.all([app.dispatch({ type }), app.dispatch({ type })]); + assert.equal(first.ok, true); + assert.deepEqual(first, second); + assert.equal(attempts, 3); + assert.deepEqual(app.tabs, [{ + url: "https://spa.oracle.com/", + active: false, windowId: 3, ...(family === "firefox" ? { cookieStoreId: "test-store" } : {}) + }]); + assert.deepEqual(app.removedTabs, [91]); + assert.equal(first.debugRequests[0].metadata.sessionBootstrapState, "opened-inactive-tab"); + assert.equal(first.debugRequests[1].metadata.sessionBootstrapState, "waiting-for-valid-session"); + assert.equal(first.debugRequests[2].metadata.sessionBootstrapState, "closed-after-valid-json"); + }); +} + +test("expired SPA session recovery handles 401, 403 and network errors, then releases its in-flight request", async () => { + let attempts = 0; + const app = loadBackground({ fetch: async () => { + attempts++; + if (attempts === 1) return response({}, 401); + if (attempts === 2) return response({}, 403); + if (attempts === 3) throw new TypeError("Failed to fetch"); + return response(validIdentity); + } }); + const first = await app.dispatch({ type }); + assert.equal(first.ok, true); + assert.equal(app.tabs.length, 1); + assert.deepEqual(first.debugRequests.map(entry => entry.httpStatus), [401, 403, null, 200]); + assert.deepEqual(app.removedTabs, [91]); + assert.equal((await app.dispatch({ type })).ok, true); + assert.equal(attempts, 5, "background must not persist identity after the shared request settles"); + assert.equal(app.tabs.length, 1); +}); + +test("failed recovery closes its own tab and allows another cycle; server errors do not open SPA", async () => { + let ready = false; + const app = loadBackground({ fetch: async () => ready ? response(validIdentity) : new Response("Sign in") }); + const failure = await app.dispatch({ type }); + assert.equal(failure.ok, false); + assert.equal(failure.debugRequests.length, 6); + assert.deepEqual(app.removedTabs, [91]); + assert.equal(failure.debugRequests.at(-1).metadata.retryLimitReached, "true"); + ready = true; + assert.equal((await app.dispatch({ type })).ok, true); + const unavailable = loadBackground({ fetch: async () => response({}, 503) }); + assert.equal((await unavailable.dispatch({ type })).ok, false); + assert.equal(unavailable.tabs.length, 0); +}); + +test("SPA tab opening failures stop API probes; a later cycle and cleanup failure preserve valid identity", async () => { + let listener; + let attempts = 0; + let opens = 0; + const app = loadApplication("background", { + chrome: { runtime: { onMessage: { addListener: callback => { listener = callback; } } } }, URL, + fetch: async () => { attempts++; return new Response("SSO"); } + }); + app.platformBackgroundApi.tabsCreate = async details => { + assert.equal(details.active, false); + if (++opens === 1) throw new Error("Temporary tab error"); + return { id: 92 }; + }; + app.platformBackgroundApi.wait = async () => {}; + app.platformBackgroundApi.tabsSendMessage = async (id, message) => message.type === "opportunitiesExtension.inspectSpaPage" + ? { status: "ready", documentId: "test-document" } + : { status: 200, contentType: "application/json", body: JSON.stringify(validIdentity) }; + app.platformBackgroundApi.tabsRemove = async id => { assert.equal(id, 92); throw new Error("Tab already closed"); }; + app.start(); + const dispatch = () => new Promise(resolve => listener({ type }, { tab: { windowId: 3 } }, resolve)); + const failure = await dispatch(); + assert.equal(failure.ok, false); + assert.equal(attempts, 1); + assert.match(failure.debugRequests[0].metadata.sessionBootstrapState, /tab-open-failed/); + const result = await dispatch(); + assert.equal(result.ok, true); + assert.equal(opens, 2); + assert.equal(attempts, 2); + assert.match(result.debugRequests[1].metadata.sessionBootstrapState, /tab-close-failed/); +}); + +function content(identity, overrides = {}) { + const app = loadApplication("content", { document: { createElement: () => ({ setAttribute() {} }) }, ...overrides }); app.debugContentView.renderDebugPanel = () => {}; const calls = []; app.platformContentMessages.sendRuntimeMessage = async message => { @@ -76,7 +191,7 @@ for (const [roles, allowed] of [[ ["SALES_REP", "SIMPLE_USER"], false ], [ ["SOL assert.equal(app.currentUser.allowsCrmActivities(), false); const same = app.currentUser.ensure(); assert.equal(same, app.currentUser.ensure()); - finish({ ok: true, user: { roles } }); + finish({ ok: true, user: { ...validIdentity.user, roles } }); await first; assert.deepEqual(calls, [type, "tokenrelay", "opportunities"]); app.productsContentState.currentProductsRequestId = "products-cycle"; @@ -98,15 +213,63 @@ for (const [roles, allowed] of [[ ["SALES_REP", "SIMPLE_USER"], false ], [ ["SOL }); } -test("identity failure disables only CRM/Actions and does not retry on Reference changes", async () => { +test("identity failures retry with shared backoff instead of being cached as a final result", async () => { for (const identity of [async () => ({ ok: false }), async () => ({ ok: true, user: {} }), async () => { throw new Error("Offline"); }]) { - const { app, calls } = content(identity); - await app.opportunitiesController.requestOpportunitiesForPeriod("Current Quarter"); - await app.opportunitiesController.requestOpportunitiesForPeriod("Next Quarter"); - assert.equal(calls.filter(value => value === type).length, 1); - assert.equal(calls.filter(value => value === "opportunities").length, 2); - assert.equal(calls.includes("crm"), false); - app.opportunityWorkloadActions.loadProducts({ items: [{ RevnId: "123" }] }); - assert.equal(calls.includes("opportunitiesExtension.requestWlActions"), false); + const timers = []; + const delays = []; + let attempts = 0; + const { app, calls } = content(() => ++attempts <= 6 ? identity() : { ok: true, user: validIdentity.user }, { + setTimeout: (callback, delay) => { timers.push(callback); delays.push(delay); } + }); + const first = app.opportunitiesController.requestOpportunitiesForPeriod("Current Quarter"); + const second = app.opportunitiesController.requestOpportunitiesForPeriod("Next Quarter"); + assert.equal(app.currentUser.ensure(), app.currentUser.ensure()); + for (let i = 0; i < 6; i++) { + await new Promise(resolve => setImmediate(resolve)); + assert.ok(calls.every(value => value === type)); + assert.equal(timers.length, 1); + assert.equal(app.currentUser.getUserId(), ""); + timers.shift()(); + } + await Promise.all([first, second]); + assert.deepEqual(delays, [1000, 2000, 4000, 8000, 10000, 10000]); + assert.equal(attempts, 7); + assert.deepEqual(calls.slice(-2), ["tokenrelay", "opportunities"]); + await app.currentUser.ensure(); + assert.equal(attempts, 7); + assert.equal(app.currentUser.getUserId(), validIdentity.user.id); } }); + +for (const family of ["chrome", "firefox"]) { + test(`${family}: HTML, malformed JSON and invalid identity objects retry until valid user/token JSON`, async () => { + const payloads = [ + () => new Response("Sign in"), + () => new Response('{"user":'), + () => response(null), + () => response([]), + () => response({}), + () => response({ ...validIdentity, token: "" }), + () => response({ ...validIdentity, user: { ...validIdentity.user, id: "" } }), + () => response({ ...validIdentity, user: { ...validIdentity.user, name: null } }), + () => response({ ...validIdentity, user: { ...validIdentity.user, roles: [null] } }), + () => response({ ...validIdentity, user: { ...validIdentity.user, provisioned: "true" } }), + () => response(validIdentity) + ]; + let attempts = 0; + const background = loadBackground({ family, fetch: async () => payloads[attempts++]() }); + const { app } = content(() => background.dispatch({ type }), { setTimeout: callback => setImmediate(callback) }); + const logs = []; + app.debugContentLog.appendBackgroundRequestLog = entries => logs.push(...entries); + const pending = app.currentUser.ensure(); + assert.equal(pending, app.currentUser.ensure()); + await pending; + assert.equal(attempts, payloads.length); + assert.equal(app.currentUser.getUserId(), validIdentity.user.id); + assert.equal(app.currentUser.allowsCrmActivities(), false, "valid SALES_REP identity must not retry"); + assert.equal(logs.filter(entry => entry.status === "failed").length, payloads.length - 1); + assert.equal(logs.at(-1).status, "success"); + await app.currentUser.ensure(); + assert.equal(attempts, payloads.length); + }); +} diff --git a/tests/spaPage.test.cjs b/tests/spaPage.test.cjs new file mode 100644 index 0000000..b6e8ca1 --- /dev/null +++ b/tests/spaPage.test.cjs @@ -0,0 +1,119 @@ +const assert = require("node:assert/strict"); +const { test } = require("node:test"); +const vm = require("node:vm"); +const { composeScript } = require("../scripts/compose.cjs"); +const script = new vm.Script(composeScript("spa-page")); +const inspect = "opportunitiesExtension.inspectSpaPage"; +const read = "opportunitiesExtension.readSpaIdentity"; +const validIdentity = { user: { id: "test@example.invalid", name: "Test User", roles: [] }, token: "synthetic-token" }; + +function harness({ family = "chrome", origin = "https://spa.oracle.com", subframe = false, fetchResponse } = {}) { + let listener; + const requests = []; + const timers = new Map(); + const dom = { pathname: "/oalcrm/", title: "SPA", body: "Sales Planning and Analytics", readyState: "complete", controls: true, loginSelector: "" }; + const window = {}; + window.top = subframe ? {} : window; + const document = { + get title() { return dom.title; }, + get body() { return { innerText: dom.body }; }, + get readyState() { return dom.readyState; }, + querySelector: selector => selector.startsWith("a[href]") ? dom.controls : Boolean(dom.loginSelector && selector.includes(dom.loginSelector)) + }; + script.runInNewContext({ + window, document, location: { origin, get pathname() { return dom.pathname; } }, performance: { timeOrigin: 123456 }, + [family]: { runtime: { id: "test-extension", onMessage: { addListener: callback => { listener = callback; } } } }, + AbortController, + setTimeout: callback => { const id = {}; timers.set(id, callback); return id; }, + clearTimeout: id => timers.delete(id), + fetch: async (url, options) => { + requests.push({ url, options }); + return fetchResponse ? fetchResponse(options) : new Response(JSON.stringify(validIdentity), { headers: { "Content-Type": "application/json", "X-Request-Id": "synthetic" } }); + } + }); + const send = (type, documentId = "123456:/oalcrm/") => new Promise(resolve => listener({ type, documentId }, { id: "test-extension" }, resolve)); + return { dom, send, requests, timers, get listener() { return listener; } }; +} + +test("SPA inspector is restricted to the top-level SPA origin and trusted extension messages", () => { + assert.equal(harness({ origin: "https://other.example.invalid" }).listener, undefined); + assert.equal(harness({ subframe: true }).listener, undefined); + const h = harness(); + assert.equal(h.listener({ type: inspect }, { id: "other-extension" }, () => assert.fail("Must not reply")), false); + assert.equal(h.listener({ type: "unknown" }, { id: "test-extension" }, () => assert.fail("Must not reply")), false); + assert.equal(h.requests.length, 0, "installing the inspector never starts an API request"); +}); + +test("error, SAML/login, blank and incomplete pages never authorize a fetch", async () => { + const cases = [ + [{ body: "401 Authorization Required" }, "error"], + [{ title: "403 Forbidden" }, "error"], + [{ body: "Internal Server Error" }, "error"], + [{ pathname: "/cloudgate/v1/oauth2/callback" }, "login"], + [{ title: "Oracle Sign In" }, "login"], + [{ loginSelector: 'input[name="SAMLResponse"]' }, "login"], + [{ loginSelector: 'input[name="SAMLRequest"]' }, "login"], + [{ loginSelector: 'input[type="password"]' }, "login"], + [{ loginSelector: 'meta[http-equiv="refresh" i]' }, "login"], + [{ readyState: "interactive" }, "loading"], + [{ readyState: "loading" }, "loading"], + [{ title: "", body: "" }, "loading"], + [{ controls: false }, "loading"] + ]; + for (const [dom, expected] of cases) { + const h = harness(); + Object.assign(h.dom, dom); + assert.equal((await h.send(inspect)).status, expected, JSON.stringify(dom)); + assert.match((await h.send(read)).error, /not ready/); + assert.equal(h.requests.length, 0); + } +}); + +for (const family of ["chrome", "browser"]) { + test(`${family}: a loaded application reads identity once per document in its own authenticated context`, async () => { + const h = harness({ family }); + const page = await h.send(inspect); + assert.equal(page.status, "ready"); + const [first, second] = await Promise.all([h.send(read, page.documentId), h.send(read, page.documentId)]); + assert.deepEqual(first, second); + assert.deepEqual(JSON.parse(first.body), validIdentity); + assert.equal(first.headers["x-request-id"], "synthetic"); + assert.equal((await h.send(read)).body, first.body); + assert.equal(h.requests.length, 1); + assert.equal(h.requests[0].url, "https://spa.oracle.com/oalcrm/web/api/identity-management/users/current"); + assert.equal(h.requests[0].options.method, "GET"); + assert.equal(h.requests[0].options.credentials, "include"); + assert.equal(h.requests[0].options.cache, "no-store"); + assert.equal(h.timers.size, 0); + assert.match((await h.send(read, "old-document")).error, /navigated/); + h.dom.pathname = "/login"; + assert.match((await h.send(read)).error, /not ready/); + assert.equal(h.requests.length, 1); + }); +} + +test("network or HTML failures are not retried in the same SPA document", async () => { + for (const fetchResponse of [ + () => { throw new Error("Network failure"); }, + () => new Response('
', { headers: { "Content-Type": "text/html" } }) + ]) { + const h = harness({ fetchResponse }); + const first = await h.send(read); + for (let i = 0; i < 20; i++) assert.deepEqual(await h.send(read), first); + assert.equal(h.requests.length, 1); + assert.equal(h.timers.size, 0); + } +}); + +test("a stalled identity fetch is aborted and never leaves a retry timer", async () => { + const h = harness({ fetchResponse: options => new Promise((resolve, reject) => { + options.signal.addEventListener("abort", () => reject(new Error("Timed out"))); + }) }); + const pending = h.send(read); + assert.equal(h.timers.size, 1); + [...h.timers.values()][0](); + assert.equal((await pending).error, "Timed out"); + assert.equal(h.timers.size, 0); + await h.send(read); + assert.equal(h.requests.length, 1); +}); diff --git a/tests/spaSessionReload.test.cjs b/tests/spaSessionReload.test.cjs new file mode 100644 index 0000000..54d3621 --- /dev/null +++ b/tests/spaSessionReload.test.cjs @@ -0,0 +1,87 @@ +const assert = require("node:assert/strict"); +const { test } = require("node:test"); +const { loadBackground, response } = require("./support/characterization.cjs"); +const currentUser = "opportunitiesExtension.requestCurrentUser"; +const consumer = "opportunitiesExtension.requestConsumerTokenService"; +const identity = { user: { id: "test@example.invalid", name: "Test User", roles: [] }, token: "synthetic-token" }; +const tokens = [ + { tokenService: "ACCP-TS", uiaasHeader: { value: "synthetic-account" } }, + { tokenService: "OCICONS-TS", uiaasHeader: { value: "synthetic-consumption" } } +]; +const html = () => new Response("Sign in"); +function navigation(app, overrides = {}) { + const observer = app.responseHeaderListeners.find(item => item.filter.types.includes("main_frame")); + assert.ok(observer); + observer.listener({ tabId: 91, type: "main_frame", statusCode: 401, + url: "https://spa.oracle.com/cloudgate/v1/oauth2/callback?code=do-not-replay", ...overrides }); +} + +for (const family of ["chrome", "firefox"]) { + for (const type of [currentUser, consumer]) { + test(`${family}: ${type} restarts a rejected callback at SPA home in the same inactive tab`, async () => { + let calls = 0; + const app = loadBackground({ family, fetch: async () => { + calls++; + if (calls === 1) return html(); + if (calls === 2) { navigation(app); return html(); } + assert.deepEqual(app.updatedTabs, [{ id: 91, details: { url: "https://spa.oracle.com/", active: false } }]); + return response(type === currentUser ? identity : tokens); + } }); + const result = await app.dispatch({ type }); + assert.equal(result.ok, true); + assert.equal(app.tabs.length, 1); + assert.deepEqual(app.removedTabs, [91]); + assert.equal(result.debugRequests[1].metadata.sessionBootstrapReloadState, "restarted-at-spa-home"); + assert.match(result.debugRequests[1].metadata.sessionBootstrapReloadReason, /401/); + assert.doesNotMatch(JSON.stringify(result.debugRequests), /do-not-replay/); + assert.equal(app.responseHeaderListeners.filter(item => item.filter.types.includes("main_frame")).length, 0); + }); + } + test(`${family}: failed navigation is recorded and retried after the cooldown without opening another tab`, async () => { + let calls = 0; + const app = loadBackground({ family, updateTabError: true, fetch: async () => { + calls++; + if (calls === 2) navigation(app, { statusCode: 403 }); + return calls < 5 ? html() : response(identity); + } }); + const result = await app.dispatch({ type: currentUser }); + assert.equal(result.ok, false); + assert.equal(calls, 2, "failed navigation must never release another identity probe"); + assert.equal(app.updatedTabs.length, 3, "at most three restart attempts per session wait"); + assert.equal(app.tabs.length, 1); + assert.match(result.debugRequests[1].metadata.sessionBootstrapReloadState, /restart-failed/); + assert.equal(result.debugRequests[1].metadata.sessionBootstrapReloadCount, "3"); + assert.deepEqual(app.removedTabs, [91]); + }); +} + +test("only errors in the owned SPA document trigger navigation, never other tabs, frames or hosts", async () => { + let calls = 0; + const app = loadBackground({ fetch: async () => { + if (++calls === 1) return html(); + if (calls === 2) { + navigation(app, { tabId: 7 }); + navigation(app, { type: "sub_frame" }); + navigation(app, { type: "xmlhttprequest" }); + navigation(app, { url: "https://other.example.invalid/callback" }); + navigation(app, { statusCode: 302 }); + assert.equal(app.updatedTabs.length, 0); + } + return response(identity); + } }); + assert.equal((await app.dispatch({ type: currentUser })).ok, true); +}); + +test("a successful SPA navigation clears an earlier failure before it can be restarted", async () => { + let calls = 0; + const app = loadBackground({ fetch: async () => { + if (++calls === 1) return html(); + if (calls === 2) { + navigation(app); + navigation(app, { url: "https://spa.oracle.com/", statusCode: 200 }); + assert.equal(app.updatedTabs.length, 0); + } + return response(identity); + } }); + assert.equal((await app.dispatch({ type: currentUser })).ok, true); +}); diff --git a/tests/spaSessionWait.test.cjs b/tests/spaSessionWait.test.cjs new file mode 100644 index 0000000..1e90a86 --- /dev/null +++ b/tests/spaSessionWait.test.cjs @@ -0,0 +1,181 @@ +const assert = require("node:assert/strict"); +const { test } = require("node:test"); +const { loadApplication } = require("./support/application.cjs"); +const type = "opportunitiesExtension.requestCurrentUser"; +const identity = { user: { id: "test@example.invalid", name: "Test User", roles: [] }, token: "synthetic-token" }; +const flush = () => new Promise(resolve => setImmediate(resolve)); + +function harness({ validAfter = 0 } = {}) { + let now = Date.UTC(2026, 9, 21); + let sequence = 0; + let backgroundCalls = 0; + let tabCalls = 0; + let listener; + let page = { status: "loading", documentId: "first-document" }; + const timers = new Map(); + const tabs = []; + const updates = []; + const removed = []; + function event() { + const listeners = new Set(); + return { listeners, addListener: callback => listeners.add(callback), removeListener: callback => listeners.delete(callback), + emit: value => [...listeners].forEach(callback => callback(value)) }; + } + const headers = event(); + const cookies = event(); + class ClockDate extends Date { + constructor(...args) { super(...(args.length ? args : [now])); } + static now() { return now; } + } + const app = loadApplication("background", { + chrome: { runtime: { onMessage: { addListener: callback => { listener = callback; } } }, + webRequest: { onHeadersReceived: headers }, cookies: { onChanged: cookies } }, URL, + Date: ClockDate, + setTimeout: function(callback, delay) { + "use strict"; + assert.equal(this, undefined); + const id = ++sequence; + timers.set(id, { callback, at: now + delay }); + return id; + }, + clearTimeout: id => timers.delete(id), + fetch: async () => { backgroundCalls++; return new Response("SSO"); } + }); + app.platformBackgroundApi.tabsCreate = async details => { tabs.push(details); return { id: 91 }; }; + app.platformBackgroundApi.tabsUpdate = async (id, details) => { updates.push({ id, details }); return { id }; }; + app.platformBackgroundApi.tabsRemove = async id => { removed.push(id); }; + app.platformBackgroundApi.tabsSendMessage = async (id, message, options) => { + assert.equal(id, 91); + assert.equal(options.frameId, 0); + if (message.type === "opportunitiesExtension.inspectSpaPage") return page; + assert.equal(message.type, "opportunitiesExtension.readSpaIdentity"); + assert.equal(message.documentId, page.documentId); + assert.equal(page.status, "ready"); + const valid = ++tabCalls > validAfter; + return { status: 200, contentType: valid ? "application/json" : "text/html", body: valid ? JSON.stringify(identity) : "SSO" }; + }; + app.start(); + function dispatch() { + return new Promise(resolve => listener({ type }, { tab: { id: 7, windowId: 3, cookieStoreId: "store-a" } }, resolve)); + } + async function advance(ms) { + const until = now + ms; + for (;;) { + const next = [...timers].filter(([, timer]) => timer.at <= until).sort((a, b) => a[1].at - b[1].at)[0]; + if (!next) break; + now = next[1].at; + timers.delete(next[0]); + next[1].callback(); + await flush(); + } + now = until; + await flush(); + } + function navigation(overrides = {}) { + headers.emit({ tabId: 91, type: "main_frame", statusCode: 200, url: "https://spa.oracle.com/", ...overrides }); + } + function cookie() { + cookies.emit({ removed: false, cookie: { name: "synthetic-session", value: "never-log-this-cookie", domain: "spa.oracle.com", + storeId: "store-a", secure: true, httpOnly: true } }); + } + return { dispatch, advance, navigation, cookie, tabs, updates, removed, timers, cookies, headers, + setPage: (status, documentId = "first-document") => { page = { status, documentId }; }, + get backgroundCalls() { return backgroundCalls; }, get tabCalls() { return tabCalls; } }; +} + +test("HTTP 200, cookies and sixty seconds do not release identity GETs on a login page", async () => { + const h = harness(); + const first = h.dispatch(); + const second = h.dispatch(); + await flush(); + h.setPage("login"); + h.navigation(); + h.cookie(); + await h.advance(60000); + assert.equal(h.backgroundCalls, 1); + assert.equal(h.tabCalls, 0); + h.setPage("ready"); + await h.advance(1500); + const [result, shared] = await Promise.all([first, second]); + assert.equal(result.ok, true); + assert.deepEqual(result, shared); + assert.equal(h.backgroundCalls, 1); + assert.equal(h.tabCalls, 1, "identity is read in the verified SPA tab, not in the background"); + assert.equal(h.tabs.length, 1); + assert.deepEqual(h.removed, [91]); + assert.equal(h.timers.size, 0); + assert.equal(h.cookies.listeners.size, 0); + assert.equal(h.headers.listeners.size, 1, "only the unrelated SR observer remains"); + assert.equal(result.debugRequests[0].metadata.sessionWaitReason, "spa-document-verified"); + assert.equal(result.debugRequests[1].metadata.executionContext, "spa-tab"); + assert.doesNotMatch(JSON.stringify(result), /never-log-this-cookie/); +}); + +test("callback 401 restarts only the owned SPA home; another HTTP 200 error page still blocks GETs", async () => { + const h = harness(); + const pending = h.dispatch(); + await flush(); + h.setPage("error"); + h.navigation({ statusCode: 401, url: "https://spa.oracle.com/cloudgate/v1/oauth2/callback?code=never-replay" }); + await h.advance(1000); + assert.equal(h.updates.length, 1); + assert.equal(h.updates[0].details.url, "https://spa.oracle.com/"); + assert.equal(h.updates[0].details.active, false); + h.navigation(); + await h.advance(10000); + assert.equal(h.tabCalls, 0); + assert.equal(h.updates.length, 1, "do not repeatedly restart a document already being navigated away from"); + h.setPage("ready", "new-document"); + await h.advance(1500); + assert.equal((await pending).ok, true); + assert.equal(h.tabCalls, 1); +}); + +test("timeout fails closed with no fallback API request", async () => { + const h = harness(); + const pending = h.dispatch(); + await flush(); + h.navigation(); + h.cookie(); + await h.advance(120000); + const result = await pending; + assert.equal(result.ok, false); + assert.match(result.error, /did not finish authentication/); + assert.equal(result.debugRequests.length, 1); + assert.equal(h.backgroundCalls, 1); + assert.equal(h.tabCalls, 0); + assert.deepEqual(h.removed, [91]); + assert.equal(h.timers.size, 0); +}); + +test("invalid identity requires a new verified document; the old ready document is never probed twice", async () => { + const h = harness({ validAfter: 1 }); + h.setPage("ready"); + const pending = h.dispatch(); + await flush(); + await h.advance(500); + assert.equal(h.tabCalls, 1); + assert.equal(h.updates.length, 1); + await h.advance(30000); + assert.equal(h.tabCalls, 1); + assert.equal(h.updates.length, 1); + h.setPage("ready", "reloaded-document"); + await h.advance(1500); + assert.equal((await pending).ok, true); + assert.equal(h.tabCalls, 2); + assert.equal(h.backgroundCalls, 1); +}); + +test("a redirect during stable-document confirmation does not trigger a GET", async () => { + const h = harness(); + h.setPage("ready"); + const pending = h.dispatch(); + await flush(); + h.setPage("login", "redirect-document"); + await h.advance(2000); + assert.equal(h.tabCalls, 0); + h.setPage("ready", "final-document"); + await h.advance(2000); + assert.equal((await pending).ok, true); + assert.equal(h.tabCalls, 1); +}); diff --git a/tests/support/characterization.cjs b/tests/support/characterization.cjs index c7a7977..5bb127c 100644 --- a/tests/support/characterization.cjs +++ b/tests/support/characterization.cjs @@ -57,30 +57,53 @@ function loadContent({ fetch = unexpectedFetch, storage = new Map(), xsrf = null }; } -function loadBackground({ family = "chrome", fetch = unexpectedFetch, storage = {}, sessionStorage = {}, cookies = () => [], storageError = false, ruleError = false, messageTimeout = 2000 } = {}) { +function loadBackground({ family = "chrome", fetch = unexpectedFetch, storage = {}, sessionStorage = {}, cookies = () => [], storageError = false, ruleError = false, updateTabError = false, messageTimeout = 2000 } = {}) { const time = clock(); const listeners = []; const progress = []; const requestDebug = []; const tabs = []; const removedTabs = []; + const updatedTabs = []; const cookieQueries = []; const headerListeners = []; const responseHeaderListeners = []; const headerRules = []; + let spaDocument = 0; + let identityRequest; const api = { runtime: { onMessage: { addListener: fn => listeners.push(fn) }, getURL: path => `${family === "firefox" ? "moz" : "chrome"}-extension://synthetic-extension${path}` }, webRequest: { onBeforeSendHeaders: { addListener: (listener, filter, options) => headerListeners.push({ listener, filter: plain(filter), options: plain(options) }) }, - onHeadersReceived: { addListener: (listener, filter, options) => responseHeaderListeners.push({ listener, filter: plain(filter), options: plain(options) }) } + onHeadersReceived: { + addListener: (listener, filter, options) => responseHeaderListeners.push({ listener, filter: plain(filter), options: plain(options) }), + removeListener: listener => { + const index = responseHeaderListeners.findIndex(item => item.listener === listener); + if (index >= 0) responseHeaderListeners.splice(index, 1); + } + } }, declarativeNetRequest: { updateSessionRules: async rules => { if (ruleError) throw new Error("Rule permission denied"); headerRules.push(plain(rules)); } } }; const readCookies = details => { cookieQueries.push(plain(details)); return cookies(details); }; const getStored = key => ({ [key]: storage[key] === undefined ? undefined : plain(storage[key]) }); const store = value => Object.assign(storage, plain(value)); - const openTab = details => { tabs.push(plain(details)); return { id: 91 }; }; + const newSpaDocument = () => { spaDocument++; identityRequest = null; }; + const openTab = details => { tabs.push(plain(details)); newSpaDocument(); return { id: 91 }; }; const send = (tabId, message, options) => { + if (message.type === "opportunitiesExtension.inspectSpaPage") return { status: "ready", documentId: String(spaDocument) }; + if (message.type === "opportunitiesExtension.readSpaIdentity") { + assert.equal(message.documentId, String(spaDocument)); + if (!identityRequest) identityRequest = (async () => { + try { + const result = await fetch("https://spa.oracle.com/oalcrm/web/api/identity-management/users/current", { + method: "GET", credentials: "include", cache: "no-store", headers: { Accept: "application/json" } + }); + return { status: result.status, statusText: result.statusText, contentType: result.headers.get("content-type"), body: await result.text() }; + } catch (error) { return { error: error.message }; } + })(); + return identityRequest; + } const events = message.type === "opportunitiesExtension.opptyProductsRequestDebug" ? requestDebug : progress; events.push({ tabId, message: plain(message), options: plain(options || {}) }); }; @@ -91,6 +114,12 @@ function loadBackground({ family = "chrome", fetch = unexpectedFetch, storage = set: async value => { if (storageError) throw new Error("Storage unavailable"); store(value); } } }; api.tabs = { create: async details => openTab(details), remove: async id => removedTabs.push(id), + update: async (id, details) => { + updatedTabs.push({ id, details: plain(details) }); + if (updateTabError) throw new Error("Synthetic navigation failure"); + newSpaDocument(); + return { id }; + }, sendMessage: async (tabId, message, options) => send(tabId, message, options) }; api.cookies = { get: async details => readCookies(details).find(cookie => cookie.name === details.name) || null, @@ -107,11 +136,17 @@ function loadBackground({ family = "chrome", fetch = unexpectedFetch, storage = set(value, callback) { withStorageError(() => { if (!storageError) store(value); callback(); }); } } }; api.tabs = { + update(id, details, callback) { + updatedTabs.push({ id, details: plain(details) }); + api.runtime.lastError = updateTabError ? { message: "Synthetic navigation failure" } : undefined; + if (!updateTabError) newSpaDocument(); + try { callback({ id }); } finally { delete api.runtime.lastError; } + }, create(details, callback) { callback(openTab(details)); }, remove(id, callback) { removedTabs.push(id); callback(); }, sendMessage(tabId, message, options, callback) { if (typeof options === "function") { callback = options; options = undefined; } - send(tabId, message, options); callback(); + Promise.resolve(send(tabId, message, options)).then(callback); } }; api.cookies = { @@ -126,7 +161,7 @@ function loadBackground({ family = "chrome", fetch = unexpectedFetch, storage = }; const context = { [family === "firefox" ? "browser" : "chrome"]: api, - fetch, URL, Date: time.Date, + fetch, URL, Date: time.Date, clearTimeout, setTimeout: (callback, ms) => { time.advance(ms); return setTimeout(callback, 0); } }; new vm.Script(composeScript("background"), { filename: "background.js" }) @@ -146,7 +181,7 @@ function loadBackground({ family = "chrome", fetch = unexpectedFetch, storage = if (pending !== true) { clearTimeout(timer); reject(new Error("Expected asynchronous response contract")); } }); } - return { dispatch, listener: listeners[0], progress, requestDebug, tabs, removedTabs, cookieQueries, storage, sessionStorage, headerRules, headerListeners, responseHeaderListeners, advance: time.advance }; + return { dispatch, listener: listeners[0], progress, requestDebug, tabs, removedTabs, updatedTabs, cookieQueries, storage, sessionStorage, headerRules, headerListeners, responseHeaderListeners, advance: time.advance }; } module.exports = { root, plain, response, loadContent, loadBackground }; diff --git a/tests/workloadActionForm.test.cjs b/tests/workloadActionForm.test.cjs index fd1fa58..b2ff0db 100644 --- a/tests/workloadActionForm.test.cjs +++ b/tests/workloadActionForm.test.cjs @@ -342,7 +342,7 @@ test("both options requests must succeed before fields enable; email and lookup test("Action defaults use current user and lookup labels; only Notes is optional", async () => { const h = optionsSetup(); const identity = h.app.currentUser.ensure(); - h.calls[0].resolve({ ok: true, user: { id: "OWNER@example.invalid", roles: ["SIMPLE_USER"] } }); + h.calls[0].resolve({ ok: true, user: { id: "OWNER@example.invalid", name: "Synthetic Owner", roles: ["SIMPLE_USER"] } }); await identity; assert.equal(h.app.currentUser.getUserId(), "OWNER@example.invalid"); h.calls.length = 0;