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

@@ -8,9 +8,16 @@ function createAuthConsumerSession({
fetchJson,
supportsFirefoxCookieStoreTabs,
tabsCreate,
tabsUpdate,
tabsRemove,
responseHeaders,
cookieChanges,
inspectPage,
setTimeout,
clearTimeout,
wait
}) {
const sessionTabs = new Map();
async function requestConsumerTokenService(sender) {
const debugRequests = [];
@@ -150,7 +157,8 @@ function createAuthConsumerSession({
debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs);
}
await wait(retryDelayMs);
if (sessionTab) await waitForConsumerTokenSession(sessionTab, debugEntry, retryDelayMs);
else await wait(retryDelayMs);
}
}
@@ -160,7 +168,7 @@ function createAuthConsumerSession({
const debugEntry = debugRequests[debugRequests.length - 1];
try {
await tabsRemove(sessionTab.id);
await closeConsumerTokenSessionTab(sessionTab.id);
if (debugEntry) {
debugEntry.metadata.sessionBootstrapState = receivedValidJson
? "closed-after-valid-json"
@@ -212,7 +220,7 @@ function createAuthConsumerSession({
}
}
function openConsumerTokenSessionTab(sender) {
async function openConsumerTokenSessionTab(sender) {
const details = {
url: CUSTOMER_TOKEN_SESSION_URL,
active: false
@@ -226,8 +234,184 @@ function createAuthConsumerSession({
details.cookieStoreId = sender.tab.cookieStoreId;
}
return tabsCreate(details);
const tab = await tabsCreate(details);
if (Number.isInteger(tab?.id)) {
const state = { failedStatus: 0, reloads: 0, lastReloadAt: -Infinity, readyVersion: 0, consumedVersion: 0, closed: false };
const signal = reason => {
state.readyVersion++;
state.readyReason = reason;
state.wake?.();
};
state.listener = details => {
if (details.tabId !== tab.id
|| !/^https:\/\/spa\.oracle\.com\//.test(details.url)) return;
if (details.type === "main_frame") {
if (details.statusCode === 401 || details.statusCode === 403) {
state.failedStatus = details.statusCode;
state.consumedVersion = state.readyVersion;
state.wake?.();
} else if (details.statusCode >= 200 && details.statusCode < 300) {
state.failedStatus = 0;
if (!details.url.startsWith("https://spa.oracle.com/cloudgate/")) signal("spa-document-response");
}
} else if (details.type === "xmlhttprequest" && details.statusCode === 200
&& details.url.split("?")[0] === "https://spa.oracle.com/oalcrm/web/api/identity-management/users/current"
&& details.responseHeaders?.some(header => header.name.toLowerCase() === "content-type" && /application\/json/i.test(header.value))) {
state.failedStatus = 0;
signal("spa-identity-json-response");
}
};
state.cookieListener = change => {
const cookie = change.cookie;
if (change.removed || !cookie?.value || !cookie.secure || !cookie.httpOnly
|| cookie.domain?.replace(/^\./, "") !== "spa.oracle.com" || !/session/i.test(cookie.name)
|| (cookie.expirationDate != null && cookie.expirationDate <= Date.now() / 1000)
|| (sender?.tab?.cookieStoreId && cookie.storeId !== sender.tab.cookieStoreId)) return;
signal("spa-session-cookie-changed");
};
try {
responseHeaders?.addListener(state.listener, { urls: ["https://spa.oracle.com/*"], types: ["main_frame", "xmlhttprequest"] }, ["responseHeaders"]);
cookieChanges?.addListener(state.cookieListener);
sessionTabs.set(tab.id, state);
} catch (error) {
responseHeaders?.removeListener?.(state.listener);
cookieChanges?.removeListener(state.cookieListener);
await tabsRemove(tab.id).catch(() => {});
throw error;
}
}
return tab;
}
return { requestConsumerTokenService, fetchConsumerTokenServiceUntilJson, getConsumerTokenRetryDelay, openConsumerTokenSessionTabWithRetry, openConsumerTokenSessionTab };
async function recoverConsumerTokenSessionTab(tab, debugEntry, reason = "") {
const state = sessionTabs.get(tab.id);
if (!state || (!state.failedStatus && !reason) || Date.now() - state.lastReloadAt < 2000) return false;
const status = state.failedStatus;
state.failedStatus = 0;
state.consumedVersion = state.readyVersion;
state.lastReloadAt = Date.now();
state.reloads++;
if (debugEntry) {
debugEntry.metadata.sessionBootstrapReloadCount = String(state.reloads);
debugEntry.metadata.sessionBootstrapReloadReason = reason || `SPA navigation returned HTTP ${status}`;
}
try {
// Restart at the entry point; OAuth callback URLs may contain single-use state.
await tabsUpdate(tab.id, { url: CUSTOMER_TOKEN_SESSION_URL, active: false });
if (debugEntry) debugEntry.metadata.sessionBootstrapReloadState = "restarted-at-spa-home";
return true;
} catch (error) {
state.failedStatus = status;
if (debugEntry) debugEntry.metadata.sessionBootstrapReloadState = `restart-failed: ${error.message || "unknown error"}`;
return false;
}
}
async function waitForSpaPage(tab, debugEntry) {
const startedAt = Date.now();
const deadline = startedAt + 120000;
let restartedDocument = null;
let restarts = 0;
if (debugEntry) {
delete debugEntry.metadata.nextRetryDelayMs;
debugEntry.metadata.sessionWaitTimeoutMs = "120000";
debugEntry.metadata.sessionWaitReason = "inspecting-spa-document";
}
while (Date.now() < deadline) {
const state = sessionTabs.get(tab.id);
if (state?.closed) break;
let page;
try { page = await inspectPage(tab); } catch { /* Cross-origin SSO pages have no SPA receiver. */ }
if (debugEntry) debugEntry.metadata.spaPageState = page?.status || "loading-or-login";
const documentId = typeof page?.documentId === "string" ? page.documentId : null;
const alreadyRead = documentId && documentId === tab.spaDocumentId;
if (state?.failedStatus || page?.status === "error" || alreadyRead) {
if (restarts < 3 && (state?.failedStatus || documentId !== restartedDocument)) {
const previousReloads = state?.reloads || 0;
const restarted = await recoverConsumerTokenSessionTab(tab, debugEntry,
state?.failedStatus ? "" : alreadyRead ? "Identity rejected in this SPA document" : "SPA document displays an authorization error");
restarts += (state?.reloads || 0) - previousReloads;
if (restarted) restartedDocument = documentId;
}
} else if (page?.status === "ready" && documentId) {
// Confirm a stable, fully loaded document, not a transient SAML page.
await wait(500);
let confirmed;
try { confirmed = await inspectPage(tab); } catch { /* A redirect started during validation. */ }
if (confirmed?.status === "ready" && confirmed.documentId === documentId && !state?.failedStatus) {
tab.spaDocumentId = documentId;
if (debugEntry) {
debugEntry.metadata.sessionWaitReason = "spa-document-verified";
debugEntry.metadata.sessionWaitDurationMs = String(Date.now() - startedAt);
}
return;
}
}
await wait(1000);
}
const error = new Error("SPA page did not finish authentication. No further identity request was sent.");
error.code = "SPA_PAGE_NOT_READY";
throw error;
}
async function waitForConsumerTokenSession(tab, debugEntry, retryDelay) {
const state = sessionTabs.get(tab.id);
if (!state || !responseHeaders?.addListener || !setTimeout) return wait(retryDelay);
const startedAt = Date.now();
const deadline = startedAt + 20000;
if (debugEntry) {
delete debugEntry.metadata.nextRetryDelayMs;
debugEntry.metadata.sessionWaitTimeoutMs = "20000";
}
let restarts = 0;
let reason = "timeout-fallback";
// Do not issue API probes while SSO redirects are still establishing the session.
while (!state.closed && Date.now() < deadline) {
if (state.failedStatus) {
if (restarts >= 3) {
await wait(Math.max(0, deadline - Date.now()));
break;
}
await recoverConsumerTokenSessionTab(tab, debugEntry);
restarts++;
if (state.failedStatus) await wait(2000);
continue;
}
if (state.readyVersion > state.consumedVersion) {
await wait(250);
if (state.failedStatus) continue;
state.consumedVersion = state.readyVersion;
reason = state.readyReason;
break;
}
let timer;
try {
await new Promise(resolve => {
state.wake = resolve;
timer = setTimeout(resolve, Math.max(0, deadline - Date.now()));
});
} finally {
clearTimeout?.(timer);
state.wake = null;
}
}
if (debugEntry) {
debugEntry.metadata.sessionWaitReason = reason;
debugEntry.metadata.sessionWaitDurationMs = String(Date.now() - startedAt);
}
}
async function closeConsumerTokenSessionTab(id) {
const state = sessionTabs.get(id);
sessionTabs.delete(id);
if (state) {
state.closed = true;
state.wake?.();
responseHeaders?.removeListener?.(state.listener);
cookieChanges?.removeListener(state.cookieListener);
}
await tabsRemove(id);
}
return { requestConsumerTokenService, fetchConsumerTokenServiceUntilJson, getConsumerTokenRetryDelay, openConsumerTokenSessionTabWithRetry, openConsumerTokenSessionTab, recoverConsumerTokenSessionTab, waitForConsumerTokenSession, waitForSpaPage, closeConsumerTokenSessionTab };
}