Fixed the SPA authentication flow
This commit is contained in:
18
README.md
18
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
|
||||
|
||||
360
dist/chromium/background.js
vendored
360
dist/chromium/background.js
vendored
@@ -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({
|
||||
|
||||
34
dist/chromium/content.js
vendored
34
dist/chromium/content.js
vendored
@@ -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)
|
||||
|
||||
10
dist/chromium/manifest.json
vendored
10
dist/chromium/manifest.json
vendored
@@ -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": {
|
||||
|
||||
58
dist/chromium/spa-page.js
vendored
Normal file
58
dist/chromium/spa-page.js
vendored
Normal file
@@ -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();
|
||||
})();
|
||||
360
dist/firefox/background.js
vendored
360
dist/firefox/background.js
vendored
@@ -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({
|
||||
|
||||
34
dist/firefox/content.js
vendored
34
dist/firefox/content.js
vendored
@@ -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)
|
||||
|
||||
10
dist/firefox/manifest.json
vendored
10
dist/firefox/manifest.json
vendored
@@ -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": {
|
||||
|
||||
58
dist/firefox/spa-page.js
vendored
Normal file
58
dist/firefox/spa-page.js
vendored
Normal file
@@ -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();
|
||||
})();
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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` });
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
52
src/auth/spa-page.js
Normal file
52
src/auth/spa-page.js
Normal file
@@ -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 };
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 : []
|
||||
}));
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
34
src/services/spa/page-client.js
Normal file
34
src/services/spa/page-client.js
Normal file
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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('<html><body onload="document.forms[0].submit()"><form method="POST"></form></body></html>');
|
||||
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("<html>Sign in</html>") });
|
||||
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("<html>SSO</html>"); }
|
||||
});
|
||||
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("<html>Sign in</html>"),
|
||||
() => 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);
|
||||
});
|
||||
}
|
||||
|
||||
119
tests/spaPage.test.cjs
Normal file
119
tests/spaPage.test.cjs
Normal file
@@ -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('<html><form><input name="SAMLResponse"></form></html>', { 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);
|
||||
});
|
||||
87
tests/spaSessionReload.test.cjs
Normal file
87
tests/spaSessionReload.test.cjs
Normal file
@@ -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("<html>Sign in</html>");
|
||||
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);
|
||||
});
|
||||
181
tests/spaSessionWait.test.cjs
Normal file
181
tests/spaSessionWait.test.cjs
Normal file
@@ -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("<html>SSO</html>"); }
|
||||
});
|
||||
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) : "<html>SSO</html>" };
|
||||
};
|
||||
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);
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user