Fixed the SPA authentication flow

This commit is contained in:
2026-10-21 15:55:29 -03:00
parent 5ef4c22c59
commit ba61f91c53
33 changed files with 1924 additions and 117 deletions

View File

@@ -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, fetches SPA identity before tokenrelay or opportunity requests. Concurrent calls,
Reference changes and modal reopenings reuse that result. Users with `SALES_REP` 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. 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; Identity must contain a valid user (id, name, roles) and token before dependent
other opportunity features continue. Reload the page to refresh identity/roles. 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 The identity token is retained in debug responses and hidden by default; the
explicit sensitive-values toggle reveals the original value. 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 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, 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 preserves the originating window and Firefox cookie store, and retries within

View File

@@ -222,7 +222,7 @@ function createBackgroundContracts() {
const OPPTY_PRODUCTS_MAX_PAGES = 250; const OPPTY_PRODUCTS_MAX_PAGES = 250;
const OPPTY_PRODUCTS_CONCURRENCY = 4; const OPPTY_PRODUCTS_CONCURRENCY = 4;
const CUSTOMER_TOKEN_SERVICE_URL = "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/consumerTokenService"; 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_MAX_ATTEMPTS = 5;
const CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS = 750; const CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS = 750;
const CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS = 4000; const CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS = 4000;
@@ -294,9 +294,16 @@ function createAuthConsumerSession({
fetchJson, fetchJson,
supportsFirefoxCookieStoreTabs, supportsFirefoxCookieStoreTabs,
tabsCreate, tabsCreate,
tabsUpdate,
tabsRemove, tabsRemove,
responseHeaders,
cookieChanges,
inspectPage,
setTimeout,
clearTimeout,
wait wait
}) { }) {
const sessionTabs = new Map();
async function requestConsumerTokenService(sender) { async function requestConsumerTokenService(sender) {
const debugRequests = []; const debugRequests = [];
@@ -436,7 +443,8 @@ function createAuthConsumerSession({
debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs); 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]; const debugEntry = debugRequests[debugRequests.length - 1];
try { try {
await tabsRemove(sessionTab.id); await closeConsumerTokenSessionTab(sessionTab.id);
if (debugEntry) { if (debugEntry) {
debugEntry.metadata.sessionBootstrapState = receivedValidJson debugEntry.metadata.sessionBootstrapState = receivedValidJson
? "closed-after-valid-json" ? "closed-after-valid-json"
@@ -498,7 +506,7 @@ function createAuthConsumerSession({
} }
} }
function openConsumerTokenSessionTab(sender) { async function openConsumerTokenSessionTab(sender) {
const details = { const details = {
url: CUSTOMER_TOKEN_SESSION_URL, url: CUSTOMER_TOKEN_SESSION_URL,
active: false active: false
@@ -512,10 +520,186 @@ function createAuthConsumerSession({
details.cookieStoreId = sender.tab.cookieStoreId; 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 // Source: src/auth/spa-credentials.js
@@ -1558,28 +1742,131 @@ function createFusionSrCreate({ fetchJson, tokenHeaders, previewTokenHeaders, in
} }
// Source: src/services/spa/current-user.js // Source: src/services/spa/current-user.js
function createSpaCurrentUser({ fetchJson }) { function createSpaCurrentUser({ fetchJson, fetchInTab, openSessionTab, waitForSession, closeSessionTab, sessionUrl }) {
async function requestCurrentUser() { 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 = []; const debugRequests = [];
let user = null; let sessionTab = null;
let receivedValidJson = false;
try { try {
const result = await fetchJson("https://spa.oracle.com/oalcrm/web/api/identity-management/users/current", { // Bound each bootstrap cycle so a failed sign-in never leaves an orphan tab.
method: "GET", credentials: "include", cache: "no-store", headers: { Accept: "application/json" } // The content-side gate can start another cycle without releasing dependent requests.
}, "requestCurrentUser", debugRequests); for (let attempt = 1; attempt <= 6; attempt++) {
if (!result?.user || !Array.isArray(result.user.roles) || result.user.roles.some(role => typeof role !== "string")) { try {
throw new Error("requestCurrentUser returned invalid user roles."); 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) { } catch (error) {
for (const entry of debugRequests) { entry.status = "failed"; entry.error = error.message; }
error.debugRequests = debugRequests; error.debugRequests = debugRequests;
throw error; 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 }; 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 // Source: src/services/spa/consumption.js
function createSpaConsumption({ function createSpaConsumption({
CONSUMPTION_TREND_REQUESTS, 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) { function tabsRemove(tabId) {
if (runtimeApi.tabs.remove.length <= 1) { if (runtimeApi.tabs.remove.length <= 1) {
return runtimeApi.tabs.remove(tabId); 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 // Source: src/background/main.js
@@ -2809,7 +3109,7 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
} }
if (message.type === messageTypes.requestCurrentUser) { 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.", ok: false, error: error.message || "Unable to load current user.",
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : [] debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
})); }));
@@ -2913,13 +3213,25 @@ function createBackgroundApplication(overrides = {}) {
browser: globalThis.browser, browser: globalThis.browser,
chrome: globalThis.chrome, chrome: globalThis.chrome,
setTimeout: globalThis.setTimeout, setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout,
...overrides ...overrides
}; };
const messageTypes = createMessageTypes(); const messageTypes = createMessageTypes();
const timeEntries = createTimeEntries({ dates: createTimeDates(), fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), getResourceUser: message => fusionSrCreate.getResourceUser(message), saveHeaders: url => srRequestHeaders.timeEntriesHeaders(url) }); 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 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 productsCacheState = createProductsCacheState();
const authConsumerSession = createAuthConsumerSession({ const authConsumerSession = createAuthConsumerSession({
CUSTOMER_TOKEN_MAX_ATTEMPTS: contracts.CUSTOMER_TOKEN_MAX_ATTEMPTS, CUSTOMER_TOKEN_MAX_ATTEMPTS: contracts.CUSTOMER_TOKEN_MAX_ATTEMPTS,
@@ -2931,7 +3243,13 @@ function createBackgroundApplication(overrides = {}) {
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
supportsFirefoxCookieStoreTabs: (...args) => platformBackgroundApi.supportsFirefoxCookieStoreTabs(...args), supportsFirefoxCookieStoreTabs: (...args) => platformBackgroundApi.supportsFirefoxCookieStoreTabs(...args),
tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args), tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args),
tabsUpdate: (...args) => platformBackgroundApi.tabsUpdate(...args),
tabsRemove: (...args) => platformBackgroundApi.tabsRemove(...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) wait: (...args) => platformBackgroundApi.wait(...args)
}); });
const authSpaCredentials = createAuthSpaCredentials({ const authSpaCredentials = createAuthSpaCredentials({

View File

@@ -1990,20 +1990,35 @@ function createAuthContentSession({
} }
// Source: src/auth/current-user.js // Source: src/auth/current-user.js
function createCurrentUser({ sendRuntimeMessage, appendBackgroundRequestLog, messageTypes }) { function createCurrentUser({ sendRuntimeMessage, appendBackgroundRequestLog, messageTypes, environment }) {
const { setTimeout } = environment;
let pending = null; let pending = null;
let allowed = false; let allowed = false;
let userId = ""; let userId = "";
function ensure() { function ensure() {
if (!pending) { if (!pending) {
pending = sendRuntimeMessage({ type: messageTypes.requestCurrentUser }).then(response => { pending = (async () => {
appendBackgroundRequestLog(response?.debugRequests); let retryDelay = 1000;
const roles = response?.user?.roles; // Keep a single pending identity request until the response is usable.
userId = response?.ok === true && typeof response.user?.id === "string" ? response.user.id : ""; for (;;) {
allowed = response?.ok === true && Array.isArray(roles) try {
&& roles.every(role => typeof role === "string") && !roles.includes("SALES_REP"); const response = await sendRuntimeMessage({ type: messageTypes.requestCurrentUser });
return allowed; appendBackgroundRequestLog(response?.debugRequests);
}).catch(() => { allowed = false; return false; }); 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; return pending;
} }
@@ -10056,6 +10071,7 @@ function createContentApplication(overrides = {}) {
}; };
const messageTypes = createMessageTypes(); const messageTypes = createMessageTypes();
const currentUser = createCurrentUser({ const currentUser = createCurrentUser({
environment,
messageTypes, messageTypes,
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args), sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args) appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args)

View File

@@ -25,6 +25,16 @@
], ],
"all_frames": true, "all_frames": true,
"run_at": "document_idle" "run_at": "document_idle"
},
{
"matches": [
"https://spa.oracle.com/*"
],
"js": [
"spa-page.js"
],
"all_frames": false,
"run_at": "document_idle"
} }
], ],
"background": { "background": {

58
dist/chromium/spa-page.js vendored Normal file
View 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();
})();

View File

@@ -222,7 +222,7 @@ function createBackgroundContracts() {
const OPPTY_PRODUCTS_MAX_PAGES = 250; const OPPTY_PRODUCTS_MAX_PAGES = 250;
const OPPTY_PRODUCTS_CONCURRENCY = 4; const OPPTY_PRODUCTS_CONCURRENCY = 4;
const CUSTOMER_TOKEN_SERVICE_URL = "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/consumerTokenService"; 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_MAX_ATTEMPTS = 5;
const CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS = 750; const CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS = 750;
const CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS = 4000; const CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS = 4000;
@@ -294,9 +294,16 @@ function createAuthConsumerSession({
fetchJson, fetchJson,
supportsFirefoxCookieStoreTabs, supportsFirefoxCookieStoreTabs,
tabsCreate, tabsCreate,
tabsUpdate,
tabsRemove, tabsRemove,
responseHeaders,
cookieChanges,
inspectPage,
setTimeout,
clearTimeout,
wait wait
}) { }) {
const sessionTabs = new Map();
async function requestConsumerTokenService(sender) { async function requestConsumerTokenService(sender) {
const debugRequests = []; const debugRequests = [];
@@ -436,7 +443,8 @@ function createAuthConsumerSession({
debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs); 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]; const debugEntry = debugRequests[debugRequests.length - 1];
try { try {
await tabsRemove(sessionTab.id); await closeConsumerTokenSessionTab(sessionTab.id);
if (debugEntry) { if (debugEntry) {
debugEntry.metadata.sessionBootstrapState = receivedValidJson debugEntry.metadata.sessionBootstrapState = receivedValidJson
? "closed-after-valid-json" ? "closed-after-valid-json"
@@ -498,7 +506,7 @@ function createAuthConsumerSession({
} }
} }
function openConsumerTokenSessionTab(sender) { async function openConsumerTokenSessionTab(sender) {
const details = { const details = {
url: CUSTOMER_TOKEN_SESSION_URL, url: CUSTOMER_TOKEN_SESSION_URL,
active: false active: false
@@ -512,10 +520,186 @@ function createAuthConsumerSession({
details.cookieStoreId = sender.tab.cookieStoreId; 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 // Source: src/auth/spa-credentials.js
@@ -1558,28 +1742,131 @@ function createFusionSrCreate({ fetchJson, tokenHeaders, previewTokenHeaders, in
} }
// Source: src/services/spa/current-user.js // Source: src/services/spa/current-user.js
function createSpaCurrentUser({ fetchJson }) { function createSpaCurrentUser({ fetchJson, fetchInTab, openSessionTab, waitForSession, closeSessionTab, sessionUrl }) {
async function requestCurrentUser() { 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 = []; const debugRequests = [];
let user = null; let sessionTab = null;
let receivedValidJson = false;
try { try {
const result = await fetchJson("https://spa.oracle.com/oalcrm/web/api/identity-management/users/current", { // Bound each bootstrap cycle so a failed sign-in never leaves an orphan tab.
method: "GET", credentials: "include", cache: "no-store", headers: { Accept: "application/json" } // The content-side gate can start another cycle without releasing dependent requests.
}, "requestCurrentUser", debugRequests); for (let attempt = 1; attempt <= 6; attempt++) {
if (!result?.user || !Array.isArray(result.user.roles) || result.user.roles.some(role => typeof role !== "string")) { try {
throw new Error("requestCurrentUser returned invalid user roles."); 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) { } catch (error) {
for (const entry of debugRequests) { entry.status = "failed"; entry.error = error.message; }
error.debugRequests = debugRequests; error.debugRequests = debugRequests;
throw error; 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 }; 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 // Source: src/services/spa/consumption.js
function createSpaConsumption({ function createSpaConsumption({
CONSUMPTION_TREND_REQUESTS, 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) { function tabsRemove(tabId) {
if (runtimeApi.tabs.remove.length <= 1) { if (runtimeApi.tabs.remove.length <= 1) {
return runtimeApi.tabs.remove(tabId); 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 // Source: src/background/main.js
@@ -2809,7 +3109,7 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
} }
if (message.type === messageTypes.requestCurrentUser) { 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.", ok: false, error: error.message || "Unable to load current user.",
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : [] debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
})); }));
@@ -2913,13 +3213,25 @@ function createBackgroundApplication(overrides = {}) {
browser: globalThis.browser, browser: globalThis.browser,
chrome: globalThis.chrome, chrome: globalThis.chrome,
setTimeout: globalThis.setTimeout, setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout,
...overrides ...overrides
}; };
const messageTypes = createMessageTypes(); const messageTypes = createMessageTypes();
const timeEntries = createTimeEntries({ dates: createTimeDates(), fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), getResourceUser: message => fusionSrCreate.getResourceUser(message), saveHeaders: url => srRequestHeaders.timeEntriesHeaders(url) }); 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 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 productsCacheState = createProductsCacheState();
const authConsumerSession = createAuthConsumerSession({ const authConsumerSession = createAuthConsumerSession({
CUSTOMER_TOKEN_MAX_ATTEMPTS: contracts.CUSTOMER_TOKEN_MAX_ATTEMPTS, CUSTOMER_TOKEN_MAX_ATTEMPTS: contracts.CUSTOMER_TOKEN_MAX_ATTEMPTS,
@@ -2931,7 +3243,13 @@ function createBackgroundApplication(overrides = {}) {
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
supportsFirefoxCookieStoreTabs: (...args) => platformBackgroundApi.supportsFirefoxCookieStoreTabs(...args), supportsFirefoxCookieStoreTabs: (...args) => platformBackgroundApi.supportsFirefoxCookieStoreTabs(...args),
tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args), tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args),
tabsUpdate: (...args) => platformBackgroundApi.tabsUpdate(...args),
tabsRemove: (...args) => platformBackgroundApi.tabsRemove(...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) wait: (...args) => platformBackgroundApi.wait(...args)
}); });
const authSpaCredentials = createAuthSpaCredentials({ const authSpaCredentials = createAuthSpaCredentials({

View File

@@ -1990,20 +1990,35 @@ function createAuthContentSession({
} }
// Source: src/auth/current-user.js // Source: src/auth/current-user.js
function createCurrentUser({ sendRuntimeMessage, appendBackgroundRequestLog, messageTypes }) { function createCurrentUser({ sendRuntimeMessage, appendBackgroundRequestLog, messageTypes, environment }) {
const { setTimeout } = environment;
let pending = null; let pending = null;
let allowed = false; let allowed = false;
let userId = ""; let userId = "";
function ensure() { function ensure() {
if (!pending) { if (!pending) {
pending = sendRuntimeMessage({ type: messageTypes.requestCurrentUser }).then(response => { pending = (async () => {
appendBackgroundRequestLog(response?.debugRequests); let retryDelay = 1000;
const roles = response?.user?.roles; // Keep a single pending identity request until the response is usable.
userId = response?.ok === true && typeof response.user?.id === "string" ? response.user.id : ""; for (;;) {
allowed = response?.ok === true && Array.isArray(roles) try {
&& roles.every(role => typeof role === "string") && !roles.includes("SALES_REP"); const response = await sendRuntimeMessage({ type: messageTypes.requestCurrentUser });
return allowed; appendBackgroundRequestLog(response?.debugRequests);
}).catch(() => { allowed = false; return false; }); 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; return pending;
} }
@@ -10056,6 +10071,7 @@ function createContentApplication(overrides = {}) {
}; };
const messageTypes = createMessageTypes(); const messageTypes = createMessageTypes();
const currentUser = createCurrentUser({ const currentUser = createCurrentUser({
environment,
messageTypes, messageTypes,
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args), sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args) appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args)

View File

@@ -25,6 +25,16 @@
], ],
"all_frames": true, "all_frames": true,
"run_at": "document_idle" "run_at": "document_idle"
},
{
"matches": [
"https://spa.oracle.com/*"
],
"js": [
"spa-page.js"
],
"all_frames": false,
"run_at": "document_idle"
} }
], ],
"background": { "background": {

58
dist/firefox/spa-page.js vendored Normal file
View 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();
})();

View File

@@ -14,6 +14,11 @@ preexisting risks are tracked in [refactoring.md](refactoring.md).
uses `background.scripts`. Neither entry point is declared as a module. uses `background.scripts`. Neither entry point is declared as a module.
- Content scripts run at `document_idle`, in all matching frames on the two - Content scripts run at `document_idle`, in all matching frames on the two
configured Fusion welcome pages. Their code also checks the hostname/path. 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, - There are no extension UI pages, injected page-context scripts, custom CSP,
declared execution world, or web-accessible resources. declared execution world, or web-accessible resources.
- Permissions remain `cookies` and `storage`, with host access limited to the - Permissions remain `cookies` and `storage`, with host access limited to the

View File

@@ -8,6 +8,7 @@ const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
const distDir = path.join(rootDir, "dist"); const distDir = path.join(rootDir, "dist");
const contentScript = composition.composeScript("content"); const contentScript = composition.composeScript("content");
const backgroundScript = composition.composeScript("background"); const backgroundScript = composition.composeScript("background");
const spaPageScript = composition.composeScript("spa-page");
const checkOnly = process.argv.includes("--check"); const checkOnly = process.argv.includes("--check");
const baseManifest = { const baseManifest = {
@@ -35,6 +36,12 @@ const baseManifest = {
js: ["content.js"], js: ["content.js"],
all_frames: true, all_frames: true,
run_at: "document_idle" 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); JSON.parse(manifestJson);
if (checkOnly) { 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)) { for (const [file, contents] of Object.entries(expected)) {
const actual = await readFile(path.join(distDir, target.name, file), "utf8"); 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.`); 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 mkdir(outputDir, { recursive: true });
await output.writeOutput(path.join(outputDir, "content.js"), contentScript); await output.writeOutput(path.join(outputDir, "content.js"), contentScript);
await output.writeOutput(path.join(outputDir, "background.js"), backgroundScript); 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); await output.writeOutput(path.join(outputDir, "manifest.json"), manifestJson);
} }

View File

@@ -25,7 +25,7 @@ function composeScript(target, { bootstrap = true } = {}) {
.map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n"); .map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n");
parts.unshift(`const redwoodStyles = ${JSON.stringify(css)};`); 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 ending = bootstrap ? `${factory}().start();` : `return ${factory};`;
const output = `(function () {\n "use strict";\n${parts.join("\n")}\n${ending}\n})();\n`; const output = `(function () {\n "use strict";\n${parts.join("\n")}\n${ending}\n})();\n`;
new vm.Script(output, { filename: `${target}.js` }); new vm.Script(output, { filename: `${target}.js` });

View File

@@ -1,4 +1,5 @@
module.exports = { module.exports = {
"spa-page": ["src/auth/spa-page.js"],
"content": [ "content": [
"src/shared/time-dates.js", "src/shared/time-dates.js",
"src/features/manage-time/view.js", "src/features/manage-time/view.js",
@@ -107,6 +108,7 @@ module.exports = {
"src/services/fusion/sr-details.js", "src/services/fusion/sr-details.js",
"src/services/fusion/sr-create.js", "src/services/fusion/sr-create.js",
"src/services/spa/current-user.js", "src/services/spa/current-user.js",
"src/services/spa/page-client.js",
"src/services/spa/consumption.js", "src/services/spa/consumption.js",
"src/services/spa/response-shapes.js", "src/services/spa/response-shapes.js",
"src/services/http/background-json.js", "src/services/http/background-json.js",

View File

@@ -8,9 +8,16 @@ function createAuthConsumerSession({
fetchJson, fetchJson,
supportsFirefoxCookieStoreTabs, supportsFirefoxCookieStoreTabs,
tabsCreate, tabsCreate,
tabsUpdate,
tabsRemove, tabsRemove,
responseHeaders,
cookieChanges,
inspectPage,
setTimeout,
clearTimeout,
wait wait
}) { }) {
const sessionTabs = new Map();
async function requestConsumerTokenService(sender) { async function requestConsumerTokenService(sender) {
const debugRequests = []; const debugRequests = [];
@@ -150,7 +157,8 @@ function createAuthConsumerSession({
debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs); 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]; const debugEntry = debugRequests[debugRequests.length - 1];
try { try {
await tabsRemove(sessionTab.id); await closeConsumerTokenSessionTab(sessionTab.id);
if (debugEntry) { if (debugEntry) {
debugEntry.metadata.sessionBootstrapState = receivedValidJson debugEntry.metadata.sessionBootstrapState = receivedValidJson
? "closed-after-valid-json" ? "closed-after-valid-json"
@@ -212,7 +220,7 @@ function createAuthConsumerSession({
} }
} }
function openConsumerTokenSessionTab(sender) { async function openConsumerTokenSessionTab(sender) {
const details = { const details = {
url: CUSTOMER_TOKEN_SESSION_URL, url: CUSTOMER_TOKEN_SESSION_URL,
active: false active: false
@@ -226,8 +234,184 @@ function createAuthConsumerSession({
details.cookieStoreId = sender.tab.cookieStoreId; 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 };
} }

View File

@@ -1,17 +1,32 @@
function createCurrentUser({ sendRuntimeMessage, appendBackgroundRequestLog, messageTypes }) { function createCurrentUser({ sendRuntimeMessage, appendBackgroundRequestLog, messageTypes, environment }) {
const { setTimeout } = environment;
let pending = null; let pending = null;
let allowed = false; let allowed = false;
let userId = ""; let userId = "";
function ensure() { function ensure() {
if (!pending) { if (!pending) {
pending = sendRuntimeMessage({ type: messageTypes.requestCurrentUser }).then(response => { pending = (async () => {
appendBackgroundRequestLog(response?.debugRequests); let retryDelay = 1000;
const roles = response?.user?.roles; // Keep a single pending identity request until the response is usable.
userId = response?.ok === true && typeof response.user?.id === "string" ? response.user.id : ""; for (;;) {
allowed = response?.ok === true && Array.isArray(roles) try {
&& roles.every(role => typeof role === "string") && !roles.includes("SALES_REP"); const response = await sendRuntimeMessage({ type: messageTypes.requestCurrentUser });
return allowed; appendBackgroundRequestLog(response?.debugRequests);
}).catch(() => { allowed = false; return false; }); 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; return pending;
} }

52
src/auth/spa-page.js Normal file
View 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 };
}

View File

@@ -4,13 +4,25 @@ function createBackgroundApplication(overrides = {}) {
browser: globalThis.browser, browser: globalThis.browser,
chrome: globalThis.chrome, chrome: globalThis.chrome,
setTimeout: globalThis.setTimeout, setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout,
...overrides ...overrides
}; };
const messageTypes = createMessageTypes(); const messageTypes = createMessageTypes();
const timeEntries = createTimeEntries({ dates: createTimeDates(), fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), getResourceUser: message => fusionSrCreate.getResourceUser(message), saveHeaders: url => srRequestHeaders.timeEntriesHeaders(url) }); 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 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 productsCacheState = createProductsCacheState();
const authConsumerSession = createAuthConsumerSession({ const authConsumerSession = createAuthConsumerSession({
CUSTOMER_TOKEN_MAX_ATTEMPTS: contracts.CUSTOMER_TOKEN_MAX_ATTEMPTS, CUSTOMER_TOKEN_MAX_ATTEMPTS: contracts.CUSTOMER_TOKEN_MAX_ATTEMPTS,
@@ -22,7 +34,13 @@ function createBackgroundApplication(overrides = {}) {
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
supportsFirefoxCookieStoreTabs: (...args) => platformBackgroundApi.supportsFirefoxCookieStoreTabs(...args), supportsFirefoxCookieStoreTabs: (...args) => platformBackgroundApi.supportsFirefoxCookieStoreTabs(...args),
tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args), tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args),
tabsUpdate: (...args) => platformBackgroundApi.tabsUpdate(...args),
tabsRemove: (...args) => platformBackgroundApi.tabsRemove(...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) wait: (...args) => platformBackgroundApi.wait(...args)
}); });
const authSpaCredentials = createAuthSpaCredentials({ const authSpaCredentials = createAuthSpaCredentials({

View File

@@ -16,7 +16,7 @@ function createBackgroundContracts() {
const OPPTY_PRODUCTS_MAX_PAGES = 250; const OPPTY_PRODUCTS_MAX_PAGES = 250;
const OPPTY_PRODUCTS_CONCURRENCY = 4; const OPPTY_PRODUCTS_CONCURRENCY = 4;
const CUSTOMER_TOKEN_SERVICE_URL = "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/consumerTokenService"; 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_MAX_ATTEMPTS = 5;
const CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS = 750; const CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS = 750;
const CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS = 4000; const CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS = 4000;

View File

@@ -170,7 +170,7 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
} }
if (message.type === messageTypes.requestCurrentUser) { 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.", ok: false, error: error.message || "Unable to load current user.",
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : [] debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
})); }));

View File

@@ -19,6 +19,7 @@ function createContentApplication(overrides = {}) {
}; };
const messageTypes = createMessageTypes(); const messageTypes = createMessageTypes();
const currentUser = createCurrentUser({ const currentUser = createCurrentUser({
environment,
messageTypes, messageTypes,
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args), sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args) appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args)

View File

@@ -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) { function tabsRemove(tabId) {
if (runtimeApi.tabs.remove.length <= 1) { if (runtimeApi.tabs.remove.length <= 1) {
return runtimeApi.tabs.remove(tabId); 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 };
} }

View File

@@ -1,20 +1,87 @@
function createSpaCurrentUser({ fetchJson }) { function createSpaCurrentUser({ fetchJson, fetchInTab, openSessionTab, waitForSession, closeSessionTab, sessionUrl }) {
async function requestCurrentUser() { 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 = []; const debugRequests = [];
let user = null; let sessionTab = null;
let receivedValidJson = false;
try { try {
const result = await fetchJson("https://spa.oracle.com/oalcrm/web/api/identity-management/users/current", { // Bound each bootstrap cycle so a failed sign-in never leaves an orphan tab.
method: "GET", credentials: "include", cache: "no-store", headers: { Accept: "application/json" } // The content-side gate can start another cycle without releasing dependent requests.
}, "requestCurrentUser", debugRequests); for (let attempt = 1; attempt <= 6; attempt++) {
if (!result?.user || !Array.isArray(result.user.roles) || result.user.roles.some(role => typeof role !== "string")) { try {
throw new Error("requestCurrentUser returned invalid user roles."); 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) { } catch (error) {
for (const entry of debugRequests) { entry.status = "failed"; entry.error = error.message; }
error.debugRequests = debugRequests; error.debugRequests = debugRequests;
throw error; 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 }; return { requestCurrentUser };

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

View File

@@ -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[0].hidden, true, "hidden before identity resolves");
assert.equal(controls.children[1].hidden, true); assert.equal(controls.children[1].hidden, true);
const identity = app.currentUser.ensure(); const identity = app.currentUser.ensure();
finish({ ok: true, user: { roles } }); finish({ ok: true, user: { id: "synthetic@example.invalid", name: "Synthetic User", roles } });
await identity; await identity;
app.activityFilters.render(); app.activityFilters.render();
assert.equal(controls.children[0].hidden, !allowed); assert.equal(controls.children[0].hidden, !allowed);

View File

@@ -45,6 +45,8 @@ test("distribution contracts: manifests, execution boundaries and deterministic
content_scripts: [{ content_scripts: [{
matches: ["https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*", "https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome*"], 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" 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"]) { 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.equal(output, composeScript(file.replace(".js", "")));
assert.doesNotThrow(() => new vm.Script(output, { filename: `${target}/${file}` })); 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));
} }
}); });

View File

@@ -26,7 +26,7 @@ for (const family of ["chrome", "firefox"]) {
assert.equal(h.tabs[0].active, false); assert.equal(h.tabs[0].active, false);
assert.equal(h.tabs[0].windowId, 3); assert.equal(h.tabs[0].windowId, 3);
assert.equal(h.tabs[0].cookieStoreId, family === "firefox" ? "test-store" : undefined); 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.deepEqual(h.removedTabs, [91]);
assert.equal(result.debugRequests[0].httpStatus, status); assert.equal(result.debugRequests[0].httpStatus, status);
assert.match(result.debugRequests[0].metadata.retryReason, /SPA session recovery/); assert.match(result.debugRequests[0].metadata.retryReason, /SPA session recovery/);

View File

@@ -109,7 +109,7 @@ async function run() {
assert.equal(createdTabs[0].active, false); assert.equal(createdTabs[0].active, false);
assert.equal( assert.equal(
createdTabs[0].url, 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.deepEqual(removedTabs, [27]);
assert.equal(response.debugRequests.length, 2); assert.equal(response.debugRequests.length, 2);

View File

@@ -3,13 +3,18 @@ const { test } = require("node:test");
const { loadBackground, response } = require("./support/characterization.cjs"); const { loadBackground, response } = require("./support/characterization.cjs");
const { loadApplication } = require("./support/application.cjs"); const { loadApplication } = require("./support/application.cjs");
const type = "opportunitiesExtension.requestCurrentUser"; 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"]) { for (const family of ["chrome", "firefox"]) {
test(`${family}: current user GET preserves identity token only in debug data for explicit reveal`, async () => { test(`${family}: current user GET preserves identity token only in debug data for explicit reveal`, async () => {
const calls = []; const calls = [];
const app = loadBackground({ family, fetch: async (url, options) => { const app = loadBackground({ family, fetch: async (url, options) => {
calls.push({ 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 }); const result = await app.dispatch({ type });
assert.equal(result.ok, true); assert.equal(result.ok, true);
@@ -26,6 +31,8 @@ for (const family of ["chrome", "firefox"]) {
content.debugState.debugSecretsVisible = true; content.debugState.debugSecretsVisible = true;
assert.match(content.debugContentLog.formatDebugBody(result.debugRequests[0].responseBody, "requestCurrentUser"), /synthetic-secret/); assert.match(content.debugContentLog.formatDebugBody(result.debugRequests[0].responseBody, "requestCurrentUser"), /synthetic-secret/);
assert.equal(result.token, undefined); 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) { test("retry timer is called without the environment object as its receiver", async () => {
const app = loadApplication("content", { document: { createElement: () => ({ setAttribute() {} }) } }); 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 = () => {}; app.debugContentView.renderDebugPanel = () => {};
const calls = []; const calls = [];
app.platformContentMessages.sendRuntimeMessage = async message => { 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); assert.equal(app.currentUser.allowsCrmActivities(), false);
const same = app.currentUser.ensure(); const same = app.currentUser.ensure();
assert.equal(same, app.currentUser.ensure()); assert.equal(same, app.currentUser.ensure());
finish({ ok: true, user: { roles } }); finish({ ok: true, user: { ...validIdentity.user, roles } });
await first; await first;
assert.deepEqual(calls, [type, "tokenrelay", "opportunities"]); assert.deepEqual(calls, [type, "tokenrelay", "opportunities"]);
app.productsContentState.currentProductsRequestId = "products-cycle"; 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"); }]) { for (const identity of [async () => ({ ok: false }), async () => ({ ok: true, user: {} }), async () => { throw new Error("Offline"); }]) {
const { app, calls } = content(identity); const timers = [];
await app.opportunitiesController.requestOpportunitiesForPeriod("Current Quarter"); const delays = [];
await app.opportunitiesController.requestOpportunitiesForPeriod("Next Quarter"); let attempts = 0;
assert.equal(calls.filter(value => value === type).length, 1); const { app, calls } = content(() => ++attempts <= 6 ? identity() : { ok: true, user: validIdentity.user }, {
assert.equal(calls.filter(value => value === "opportunities").length, 2); setTimeout: (callback, delay) => { timers.push(callback); delays.push(delay); }
assert.equal(calls.includes("crm"), false); });
app.opportunityWorkloadActions.loadProducts({ items: [{ RevnId: "123" }] }); const first = app.opportunitiesController.requestOpportunitiesForPeriod("Current Quarter");
assert.equal(calls.includes("opportunitiesExtension.requestWlActions"), false); 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
View 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);
});

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

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

View File

@@ -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 time = clock();
const listeners = []; const listeners = [];
const progress = []; const progress = [];
const requestDebug = []; const requestDebug = [];
const tabs = []; const tabs = [];
const removedTabs = []; const removedTabs = [];
const updatedTabs = [];
const cookieQueries = []; const cookieQueries = [];
const headerListeners = []; const headerListeners = [];
const responseHeaderListeners = []; const responseHeaderListeners = [];
const headerRules = []; const headerRules = [];
let spaDocument = 0;
let identityRequest;
const api = { runtime: { onMessage: { addListener: fn => listeners.push(fn) }, const api = { runtime: { onMessage: { addListener: fn => listeners.push(fn) },
getURL: path => `${family === "firefox" ? "moz" : "chrome"}-extension://synthetic-extension${path}` }, getURL: path => `${family === "firefox" ? "moz" : "chrome"}-extension://synthetic-extension${path}` },
webRequest: { webRequest: {
onBeforeSendHeaders: { addListener: (listener, filter, options) => headerListeners.push({ listener, filter: plain(filter), options: plain(options) }) }, 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)); } } 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 readCookies = details => { cookieQueries.push(plain(details)); return cookies(details); };
const getStored = key => ({ [key]: storage[key] === undefined ? undefined : plain(storage[key]) }); const getStored = key => ({ [key]: storage[key] === undefined ? undefined : plain(storage[key]) });
const store = value => Object.assign(storage, plain(value)); 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) => { 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; const events = message.type === "opportunitiesExtension.opptyProductsRequestDebug" ? requestDebug : progress;
events.push({ tabId, message: plain(message), options: plain(options || {}) }); 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); } 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), 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) }; sendMessage: async (tabId, message, options) => send(tabId, message, options) };
api.cookies = { api.cookies = {
get: async details => readCookies(details).find(cookie => cookie.name === details.name) || null, 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(); }); } set(value, callback) { withStorageError(() => { if (!storageError) store(value); callback(); }); }
} }; } };
api.tabs = { 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)); }, create(details, callback) { callback(openTab(details)); },
remove(id, callback) { removedTabs.push(id); callback(); }, remove(id, callback) { removedTabs.push(id); callback(); },
sendMessage(tabId, message, options, callback) { sendMessage(tabId, message, options, callback) {
if (typeof options === "function") { callback = options; options = undefined; } if (typeof options === "function") { callback = options; options = undefined; }
send(tabId, message, options); callback(); Promise.resolve(send(tabId, message, options)).then(callback);
} }
}; };
api.cookies = { api.cookies = {
@@ -126,7 +161,7 @@ function loadBackground({ family = "chrome", fetch = unexpectedFetch, storage =
}; };
const context = { const context = {
[family === "firefox" ? "browser" : "chrome"]: api, [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); } setTimeout: (callback, ms) => { time.advance(ms); return setTimeout(callback, 0); }
}; };
new vm.Script(composeScript("background"), { filename: "background.js" }) 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")); } 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 }; module.exports = { root, plain, response, loadContent, loadBackground };

View File

@@ -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 () => { test("Action defaults use current user and lookup labels; only Notes is optional", async () => {
const h = optionsSetup(); const h = optionsSetup();
const identity = h.app.currentUser.ensure(); 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; await identity;
assert.equal(h.app.currentUser.getUserId(), "OWNER@example.invalid"); assert.equal(h.app.currentUser.getUserId(), "OWNER@example.invalid");
h.calls.length = 0; h.calls.length = 0;