diff --git a/dist/chromium/background.js b/dist/chromium/background.js index 1e5b984..c98694e 100644 --- a/dist/chromium/background.js +++ b/dist/chromium/background.js @@ -10,37 +10,181 @@ ]; const XSRF_COOKIE_NAME = "XSRF-TOKEN-US2DZ2V_F"; const XSRF_COOKIE_PREFIX = "XSRF-TOKEN-"; + const OPPORTUNITIES_LIST_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/opportunities/opportunities-list"; + const COOKIE_REFRESH_TIMEOUT_MS = 15000; + const COOKIE_REFRESH_POLL_MS = 250; const runtimeApi = typeof browser !== "undefined" ? browser : chrome; runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => { - if (!message || message.type !== "opportunitiesExtension.getXsrfToken") { + if (!message || !message.type) { return false; } - getXsrfTokenCookie() - .then((result) => { - sendResponse({ - ok: true, - cookieName: result.cookie ? result.cookie.name : "", - token: result.cookie ? result.cookie.value : "", - matchedCookieNames: result.matchedCookieNames, - lookupDetails: result.lookupDetails + if (message.type === "opportunitiesExtension.getXsrfToken") { + getXsrfTokenCookie() + .then((result) => { + sendResponse({ + ok: true, + cookieName: result.cookie ? result.cookie.name : "", + token: result.cookie ? result.cookie.value : "", + matchedCookieNames: result.matchedCookieNames, + lookupDetails: result.lookupDetails + }); + }) + .catch((error) => { + sendResponse({ + ok: false, + cookieName: "", + token: "", + matchedCookieNames: [], + lookupDetails: [], + error: error.message || "Unable to read cookies." + }); }); - }) - .catch((error) => { - sendResponse({ - ok: false, - cookieName: "", - token: "", - matchedCookieNames: [], - lookupDetails: [], - error: error.message || "Unable to read cookies." - }); - }); - return true; + return true; + } + + if (message.type === "opportunitiesExtension.refreshXsrfCookie") { + refreshXsrfCookie(sender) + .then(sendResponse) + .catch((error) => { + sendResponse({ + ok: false, + refreshed: false, + cookieName: "", + durationMs: 0, + error: error.message || "Unable to refresh XSRF cookie." + }); + }); + + return true; + } + + return false; }); + async function refreshXsrfCookie(sender) { + const startedAt = Date.now(); + const previous = (await getXsrfTokenCookie()).cookie; + const createProperties = { + url: OPPORTUNITIES_LIST_URL, + active: false + }; + + if (sender && sender.tab && Number.isInteger(sender.tab.windowId)) { + createProperties.windowId = sender.tab.windowId; + } + + if (supportsFirefoxCookieStoreTabs() && sender && sender.tab && sender.tab.cookieStoreId) { + createProperties.cookieStoreId = sender.tab.cookieStoreId; + } + + let refreshTab; + + try { + refreshTab = await tabsCreate(createProperties); + const current = await waitForXsrfCookieChange(previous); + const refreshed = hasCookieChanged(previous, current); + + return { + ok: Boolean(current), + refreshed, + cookieName: current ? current.name : "", + durationMs: Date.now() - startedAt, + error: refreshed ? "" : "XSRF cookie was not created or changed before timeout." + }; + } finally { + if (refreshTab && Number.isInteger(refreshTab.id)) { + try { + await tabsRemove(refreshTab.id); + } catch (error) { + // The temporary tab may already have been closed. + } + } + } + } + + async function waitForXsrfCookieChange(previousCookie) { + const deadline = Date.now() + COOKIE_REFRESH_TIMEOUT_MS; + let currentCookie = null; + + while (Date.now() < deadline) { + currentCookie = (await getXsrfTokenCookie()).cookie; + + if (hasCookieChanged(previousCookie, currentCookie)) { + return currentCookie; + } + + await wait(COOKIE_REFRESH_POLL_MS); + } + + return currentCookie; + } + + function hasCookieChanged(previousCookie, currentCookie) { + if (!currentCookie || !currentCookie.value) { + return false; + } + + if (!previousCookie) { + return true; + } + + return previousCookie.name !== currentCookie.name + || previousCookie.value !== currentCookie.value + || previousCookie.expirationDate !== currentCookie.expirationDate; + } + + function supportsFirefoxCookieStoreTabs() { + return Boolean( + runtimeApi.runtime + && typeof runtimeApi.runtime.getBrowserInfo === "function" + ); + } + + function tabsCreate(details) { + if (runtimeApi.tabs.create.length <= 1) { + return runtimeApi.tabs.create(details); + } + + return new Promise((resolve, reject) => { + runtimeApi.tabs.create(details, (tab) => { + const lastError = runtimeApi.runtime.lastError; + + if (lastError) { + reject(new Error(lastError.message)); + return; + } + + resolve(tab); + }); + }); + } + + function tabsRemove(tabId) { + if (runtimeApi.tabs.remove.length <= 1) { + return runtimeApi.tabs.remove(tabId); + } + + return new Promise((resolve, reject) => { + runtimeApi.tabs.remove(tabId, () => { + const lastError = runtimeApi.runtime.lastError; + + if (lastError) { + reject(new Error(lastError.message)); + return; + } + + resolve(); + }); + }); + } + + function wait(durationMs) { + return new Promise((resolve) => setTimeout(resolve, durationMs)); + } + async function getXsrfTokenCookie() { const lookupDetails = []; const allCookies = []; diff --git a/dist/chromium/content.js b/dist/chromium/content.js index cec9b0b..c37fae5 100644 --- a/dist/chromium/content.js +++ b/dist/chromium/content.js @@ -502,8 +502,98 @@ } async function requestTokenRelay() { - const xsrfTokenSource = await getXsrfToken(); - const requestEntry = logRequestStart({ + let refreshResult = null; + let refreshAttempted = false; + + while (true) { + const xsrfTokenSource = await getXsrfToken(); + + if (!xsrfTokenSource.token) { + if (refreshAttempted) { + const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult); + logRequestFailure(requestEntry, "XSRF token cookie not found after automatic refresh."); + throw new Error("XSRF token cookie not found after automatic refresh."); + } + + refreshResult = await refreshXsrfCookie("missing-cookie"); + refreshAttempted = true; + continue; + } + + const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult); + + try { + const response = await fetch(TOKEN_RELAY_URL, { + method: "GET", + credentials: "include", + headers: { + "x-xsrf-token": xsrfTokenSource.token + } + }); + + const responseBody = await readResponseBodyForDebug(response); + + if (!response.ok) { + logRequestSuccess(requestEntry, response, responseBody); + + if (!refreshAttempted && isInvalidXsrfStatus(response.status)) { + refreshResult = await refreshXsrfCookie(`tokenrelay-${response.status}`); + refreshAttempted = true; + requestEntry.metadata.xsrfRefresh = formatXsrfRefreshResult(refreshResult); + renderDebugPanel(); + continue; + } + + throw new Error(`Token relay failed with status ${response.status}.`); + } + + let responseData; + + try { + responseData = await response.json(); + } catch (error) { + logRequestSuccess(requestEntry, response, responseBody); + + if (!refreshAttempted) { + refreshResult = await refreshXsrfCookie("tokenrelay-invalid-response"); + refreshAttempted = true; + requestEntry.metadata.xsrfRefresh = formatXsrfRefreshResult(refreshResult); + renderDebugPanel(); + continue; + } + + throw error; + } + + if (!responseData || typeof responseData.access_token !== "string" || !responseData.access_token) { + logRequestSuccess(requestEntry, response, responseBody); + + if (!refreshAttempted) { + refreshResult = await refreshXsrfCookie("tokenrelay-missing-access-token"); + refreshAttempted = true; + requestEntry.metadata.xsrfRefresh = formatXsrfRefreshResult(refreshResult); + renderDebugPanel(); + continue; + } + + throw new Error("Token relay response does not contain access_token."); + } + + logRequestSuccess(requestEntry, response, createDebugBody(JSON.stringify({ + ...responseData, + access_token: maskToken(responseData.access_token) + }, null, 2))); + + return responseData.access_token; + } catch (error) { + logRequestFailure(requestEntry, error.message || "Request failed."); + throw error; + } + } + } + + function createTokenRelayRequestEntry(xsrfTokenSource, refreshResult) { + return logRequestStart({ name: "requestTokenRelay", method: "GET", url: TOKEN_RELAY_URL, @@ -519,56 +609,25 @@ xsrfLookupDetails: xsrfTokenSource.lookupDetails && xsrfTokenSource.lookupDetails.length ? xsrfTokenSource.lookupDetails.join(" | ") : "(none)", - xsrfTokenError: xsrfTokenSource.error || "" + xsrfTokenError: xsrfTokenSource.error || "", + xsrfRefresh: formatXsrfRefreshResult(refreshResult) } }); + } - if (!xsrfTokenSource.token) { - logRequestFailure(requestEntry, "XSRF token cookie not found."); - throw new Error("XSRF token cookie not found."); + function isInvalidXsrfStatus(status) { + return status === 400 || status === 401 || status === 403; + } + + function formatXsrfRefreshResult(result) { + if (!result) { + return "not-requested"; } - try { - const response = await fetch(TOKEN_RELAY_URL, { - method: "GET", - credentials: "include", - headers: { - "x-xsrf-token": xsrfTokenSource.token - } - }); - - const responseBody = await readResponseBodyForDebug(response); - - if (!response.ok) { - logRequestSuccess(requestEntry, response, responseBody); - throw new Error(`Token relay failed with status ${response.status}.`); - } - - let responseData; - - try { - responseData = await response.json(); - } catch (error) { - logRequestSuccess(requestEntry, response, responseBody); - throw error; - } - - if (!responseData || typeof responseData.access_token !== "string" || !responseData.access_token) { - logRequestSuccess(requestEntry, response, responseBody); - throw new Error("Token relay response does not contain access_token."); - } - - logRequestSuccess(requestEntry, response, createDebugBody(JSON.stringify({ - ...responseData, - access_token: maskToken(responseData.access_token) - }, null, 2))); - - return responseData.access_token; - } catch (error) { - logRequestFailure(requestEntry, error.message || "Request failed."); - - throw error; - } + const outcome = result.refreshed ? "refreshed" : "not-refreshed"; + const cookieName = result.cookieName || "(none)"; + const duration = Number.isFinite(result.durationMs) ? `${result.durationMs}ms` : "unknown"; + return `${result.reason}; ${outcome}; cookie=${cookieName}; duration=${duration}; ${result.error || ""}`.trim(); } async function requestOpportunitiesForPeriod(period) { @@ -2106,6 +2165,52 @@ }); } + function refreshXsrfCookie(reason) { + const runtimeApi = typeof browser !== "undefined" ? browser : chrome; + + if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.sendMessage) { + return Promise.resolve({ + ok: false, + refreshed: false, + cookieName: "", + durationMs: 0, + reason, + error: "Runtime messaging API unavailable." + }); + } + + return new Promise((resolve) => { + runtimeApi.runtime.sendMessage({ + type: "opportunitiesExtension.refreshXsrfCookie" + }, (response) => { + const lastError = runtimeApi.runtime.lastError; + + if (lastError) { + resolve({ + ok: false, + refreshed: false, + cookieName: "", + durationMs: 0, + reason, + error: lastError.message + }); + return; + } + + resolve({ + ...(response || { + ok: false, + refreshed: false, + cookieName: "", + durationMs: 0, + error: "Empty refresh response." + }), + reason + }); + }); + }); + } + function updateAuthBadge(status) { const badge = document.querySelector(`#${MODAL_ID} .opportunities-extension-auth-badge`); diff --git a/dist/firefox/background.js b/dist/firefox/background.js index 1e5b984..c98694e 100644 --- a/dist/firefox/background.js +++ b/dist/firefox/background.js @@ -10,37 +10,181 @@ ]; const XSRF_COOKIE_NAME = "XSRF-TOKEN-US2DZ2V_F"; const XSRF_COOKIE_PREFIX = "XSRF-TOKEN-"; + const OPPORTUNITIES_LIST_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/opportunities/opportunities-list"; + const COOKIE_REFRESH_TIMEOUT_MS = 15000; + const COOKIE_REFRESH_POLL_MS = 250; const runtimeApi = typeof browser !== "undefined" ? browser : chrome; runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => { - if (!message || message.type !== "opportunitiesExtension.getXsrfToken") { + if (!message || !message.type) { return false; } - getXsrfTokenCookie() - .then((result) => { - sendResponse({ - ok: true, - cookieName: result.cookie ? result.cookie.name : "", - token: result.cookie ? result.cookie.value : "", - matchedCookieNames: result.matchedCookieNames, - lookupDetails: result.lookupDetails + if (message.type === "opportunitiesExtension.getXsrfToken") { + getXsrfTokenCookie() + .then((result) => { + sendResponse({ + ok: true, + cookieName: result.cookie ? result.cookie.name : "", + token: result.cookie ? result.cookie.value : "", + matchedCookieNames: result.matchedCookieNames, + lookupDetails: result.lookupDetails + }); + }) + .catch((error) => { + sendResponse({ + ok: false, + cookieName: "", + token: "", + matchedCookieNames: [], + lookupDetails: [], + error: error.message || "Unable to read cookies." + }); }); - }) - .catch((error) => { - sendResponse({ - ok: false, - cookieName: "", - token: "", - matchedCookieNames: [], - lookupDetails: [], - error: error.message || "Unable to read cookies." - }); - }); - return true; + return true; + } + + if (message.type === "opportunitiesExtension.refreshXsrfCookie") { + refreshXsrfCookie(sender) + .then(sendResponse) + .catch((error) => { + sendResponse({ + ok: false, + refreshed: false, + cookieName: "", + durationMs: 0, + error: error.message || "Unable to refresh XSRF cookie." + }); + }); + + return true; + } + + return false; }); + async function refreshXsrfCookie(sender) { + const startedAt = Date.now(); + const previous = (await getXsrfTokenCookie()).cookie; + const createProperties = { + url: OPPORTUNITIES_LIST_URL, + active: false + }; + + if (sender && sender.tab && Number.isInteger(sender.tab.windowId)) { + createProperties.windowId = sender.tab.windowId; + } + + if (supportsFirefoxCookieStoreTabs() && sender && sender.tab && sender.tab.cookieStoreId) { + createProperties.cookieStoreId = sender.tab.cookieStoreId; + } + + let refreshTab; + + try { + refreshTab = await tabsCreate(createProperties); + const current = await waitForXsrfCookieChange(previous); + const refreshed = hasCookieChanged(previous, current); + + return { + ok: Boolean(current), + refreshed, + cookieName: current ? current.name : "", + durationMs: Date.now() - startedAt, + error: refreshed ? "" : "XSRF cookie was not created or changed before timeout." + }; + } finally { + if (refreshTab && Number.isInteger(refreshTab.id)) { + try { + await tabsRemove(refreshTab.id); + } catch (error) { + // The temporary tab may already have been closed. + } + } + } + } + + async function waitForXsrfCookieChange(previousCookie) { + const deadline = Date.now() + COOKIE_REFRESH_TIMEOUT_MS; + let currentCookie = null; + + while (Date.now() < deadline) { + currentCookie = (await getXsrfTokenCookie()).cookie; + + if (hasCookieChanged(previousCookie, currentCookie)) { + return currentCookie; + } + + await wait(COOKIE_REFRESH_POLL_MS); + } + + return currentCookie; + } + + function hasCookieChanged(previousCookie, currentCookie) { + if (!currentCookie || !currentCookie.value) { + return false; + } + + if (!previousCookie) { + return true; + } + + return previousCookie.name !== currentCookie.name + || previousCookie.value !== currentCookie.value + || previousCookie.expirationDate !== currentCookie.expirationDate; + } + + function supportsFirefoxCookieStoreTabs() { + return Boolean( + runtimeApi.runtime + && typeof runtimeApi.runtime.getBrowserInfo === "function" + ); + } + + function tabsCreate(details) { + if (runtimeApi.tabs.create.length <= 1) { + return runtimeApi.tabs.create(details); + } + + return new Promise((resolve, reject) => { + runtimeApi.tabs.create(details, (tab) => { + const lastError = runtimeApi.runtime.lastError; + + if (lastError) { + reject(new Error(lastError.message)); + return; + } + + resolve(tab); + }); + }); + } + + function tabsRemove(tabId) { + if (runtimeApi.tabs.remove.length <= 1) { + return runtimeApi.tabs.remove(tabId); + } + + return new Promise((resolve, reject) => { + runtimeApi.tabs.remove(tabId, () => { + const lastError = runtimeApi.runtime.lastError; + + if (lastError) { + reject(new Error(lastError.message)); + return; + } + + resolve(); + }); + }); + } + + function wait(durationMs) { + return new Promise((resolve) => setTimeout(resolve, durationMs)); + } + async function getXsrfTokenCookie() { const lookupDetails = []; const allCookies = []; diff --git a/dist/firefox/content.js b/dist/firefox/content.js index cec9b0b..c37fae5 100644 --- a/dist/firefox/content.js +++ b/dist/firefox/content.js @@ -502,8 +502,98 @@ } async function requestTokenRelay() { - const xsrfTokenSource = await getXsrfToken(); - const requestEntry = logRequestStart({ + let refreshResult = null; + let refreshAttempted = false; + + while (true) { + const xsrfTokenSource = await getXsrfToken(); + + if (!xsrfTokenSource.token) { + if (refreshAttempted) { + const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult); + logRequestFailure(requestEntry, "XSRF token cookie not found after automatic refresh."); + throw new Error("XSRF token cookie not found after automatic refresh."); + } + + refreshResult = await refreshXsrfCookie("missing-cookie"); + refreshAttempted = true; + continue; + } + + const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult); + + try { + const response = await fetch(TOKEN_RELAY_URL, { + method: "GET", + credentials: "include", + headers: { + "x-xsrf-token": xsrfTokenSource.token + } + }); + + const responseBody = await readResponseBodyForDebug(response); + + if (!response.ok) { + logRequestSuccess(requestEntry, response, responseBody); + + if (!refreshAttempted && isInvalidXsrfStatus(response.status)) { + refreshResult = await refreshXsrfCookie(`tokenrelay-${response.status}`); + refreshAttempted = true; + requestEntry.metadata.xsrfRefresh = formatXsrfRefreshResult(refreshResult); + renderDebugPanel(); + continue; + } + + throw new Error(`Token relay failed with status ${response.status}.`); + } + + let responseData; + + try { + responseData = await response.json(); + } catch (error) { + logRequestSuccess(requestEntry, response, responseBody); + + if (!refreshAttempted) { + refreshResult = await refreshXsrfCookie("tokenrelay-invalid-response"); + refreshAttempted = true; + requestEntry.metadata.xsrfRefresh = formatXsrfRefreshResult(refreshResult); + renderDebugPanel(); + continue; + } + + throw error; + } + + if (!responseData || typeof responseData.access_token !== "string" || !responseData.access_token) { + logRequestSuccess(requestEntry, response, responseBody); + + if (!refreshAttempted) { + refreshResult = await refreshXsrfCookie("tokenrelay-missing-access-token"); + refreshAttempted = true; + requestEntry.metadata.xsrfRefresh = formatXsrfRefreshResult(refreshResult); + renderDebugPanel(); + continue; + } + + throw new Error("Token relay response does not contain access_token."); + } + + logRequestSuccess(requestEntry, response, createDebugBody(JSON.stringify({ + ...responseData, + access_token: maskToken(responseData.access_token) + }, null, 2))); + + return responseData.access_token; + } catch (error) { + logRequestFailure(requestEntry, error.message || "Request failed."); + throw error; + } + } + } + + function createTokenRelayRequestEntry(xsrfTokenSource, refreshResult) { + return logRequestStart({ name: "requestTokenRelay", method: "GET", url: TOKEN_RELAY_URL, @@ -519,56 +609,25 @@ xsrfLookupDetails: xsrfTokenSource.lookupDetails && xsrfTokenSource.lookupDetails.length ? xsrfTokenSource.lookupDetails.join(" | ") : "(none)", - xsrfTokenError: xsrfTokenSource.error || "" + xsrfTokenError: xsrfTokenSource.error || "", + xsrfRefresh: formatXsrfRefreshResult(refreshResult) } }); + } - if (!xsrfTokenSource.token) { - logRequestFailure(requestEntry, "XSRF token cookie not found."); - throw new Error("XSRF token cookie not found."); + function isInvalidXsrfStatus(status) { + return status === 400 || status === 401 || status === 403; + } + + function formatXsrfRefreshResult(result) { + if (!result) { + return "not-requested"; } - try { - const response = await fetch(TOKEN_RELAY_URL, { - method: "GET", - credentials: "include", - headers: { - "x-xsrf-token": xsrfTokenSource.token - } - }); - - const responseBody = await readResponseBodyForDebug(response); - - if (!response.ok) { - logRequestSuccess(requestEntry, response, responseBody); - throw new Error(`Token relay failed with status ${response.status}.`); - } - - let responseData; - - try { - responseData = await response.json(); - } catch (error) { - logRequestSuccess(requestEntry, response, responseBody); - throw error; - } - - if (!responseData || typeof responseData.access_token !== "string" || !responseData.access_token) { - logRequestSuccess(requestEntry, response, responseBody); - throw new Error("Token relay response does not contain access_token."); - } - - logRequestSuccess(requestEntry, response, createDebugBody(JSON.stringify({ - ...responseData, - access_token: maskToken(responseData.access_token) - }, null, 2))); - - return responseData.access_token; - } catch (error) { - logRequestFailure(requestEntry, error.message || "Request failed."); - - throw error; - } + const outcome = result.refreshed ? "refreshed" : "not-refreshed"; + const cookieName = result.cookieName || "(none)"; + const duration = Number.isFinite(result.durationMs) ? `${result.durationMs}ms` : "unknown"; + return `${result.reason}; ${outcome}; cookie=${cookieName}; duration=${duration}; ${result.error || ""}`.trim(); } async function requestOpportunitiesForPeriod(period) { @@ -2106,6 +2165,52 @@ }); } + function refreshXsrfCookie(reason) { + const runtimeApi = typeof browser !== "undefined" ? browser : chrome; + + if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.sendMessage) { + return Promise.resolve({ + ok: false, + refreshed: false, + cookieName: "", + durationMs: 0, + reason, + error: "Runtime messaging API unavailable." + }); + } + + return new Promise((resolve) => { + runtimeApi.runtime.sendMessage({ + type: "opportunitiesExtension.refreshXsrfCookie" + }, (response) => { + const lastError = runtimeApi.runtime.lastError; + + if (lastError) { + resolve({ + ok: false, + refreshed: false, + cookieName: "", + durationMs: 0, + reason, + error: lastError.message + }); + return; + } + + resolve({ + ...(response || { + ok: false, + refreshed: false, + cookieName: "", + durationMs: 0, + error: "Empty refresh response." + }), + reason + }); + }); + }); + } + function updateAuthBadge(status) { const badge = document.querySelector(`#${MODAL_ID} .opportunities-extension-auth-badge`); diff --git a/src/background.js b/src/background.js index 1e5b984..c98694e 100644 --- a/src/background.js +++ b/src/background.js @@ -10,37 +10,181 @@ ]; const XSRF_COOKIE_NAME = "XSRF-TOKEN-US2DZ2V_F"; const XSRF_COOKIE_PREFIX = "XSRF-TOKEN-"; + const OPPORTUNITIES_LIST_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/opportunities/opportunities-list"; + const COOKIE_REFRESH_TIMEOUT_MS = 15000; + const COOKIE_REFRESH_POLL_MS = 250; const runtimeApi = typeof browser !== "undefined" ? browser : chrome; runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => { - if (!message || message.type !== "opportunitiesExtension.getXsrfToken") { + if (!message || !message.type) { return false; } - getXsrfTokenCookie() - .then((result) => { - sendResponse({ - ok: true, - cookieName: result.cookie ? result.cookie.name : "", - token: result.cookie ? result.cookie.value : "", - matchedCookieNames: result.matchedCookieNames, - lookupDetails: result.lookupDetails + if (message.type === "opportunitiesExtension.getXsrfToken") { + getXsrfTokenCookie() + .then((result) => { + sendResponse({ + ok: true, + cookieName: result.cookie ? result.cookie.name : "", + token: result.cookie ? result.cookie.value : "", + matchedCookieNames: result.matchedCookieNames, + lookupDetails: result.lookupDetails + }); + }) + .catch((error) => { + sendResponse({ + ok: false, + cookieName: "", + token: "", + matchedCookieNames: [], + lookupDetails: [], + error: error.message || "Unable to read cookies." + }); }); - }) - .catch((error) => { - sendResponse({ - ok: false, - cookieName: "", - token: "", - matchedCookieNames: [], - lookupDetails: [], - error: error.message || "Unable to read cookies." - }); - }); - return true; + return true; + } + + if (message.type === "opportunitiesExtension.refreshXsrfCookie") { + refreshXsrfCookie(sender) + .then(sendResponse) + .catch((error) => { + sendResponse({ + ok: false, + refreshed: false, + cookieName: "", + durationMs: 0, + error: error.message || "Unable to refresh XSRF cookie." + }); + }); + + return true; + } + + return false; }); + async function refreshXsrfCookie(sender) { + const startedAt = Date.now(); + const previous = (await getXsrfTokenCookie()).cookie; + const createProperties = { + url: OPPORTUNITIES_LIST_URL, + active: false + }; + + if (sender && sender.tab && Number.isInteger(sender.tab.windowId)) { + createProperties.windowId = sender.tab.windowId; + } + + if (supportsFirefoxCookieStoreTabs() && sender && sender.tab && sender.tab.cookieStoreId) { + createProperties.cookieStoreId = sender.tab.cookieStoreId; + } + + let refreshTab; + + try { + refreshTab = await tabsCreate(createProperties); + const current = await waitForXsrfCookieChange(previous); + const refreshed = hasCookieChanged(previous, current); + + return { + ok: Boolean(current), + refreshed, + cookieName: current ? current.name : "", + durationMs: Date.now() - startedAt, + error: refreshed ? "" : "XSRF cookie was not created or changed before timeout." + }; + } finally { + if (refreshTab && Number.isInteger(refreshTab.id)) { + try { + await tabsRemove(refreshTab.id); + } catch (error) { + // The temporary tab may already have been closed. + } + } + } + } + + async function waitForXsrfCookieChange(previousCookie) { + const deadline = Date.now() + COOKIE_REFRESH_TIMEOUT_MS; + let currentCookie = null; + + while (Date.now() < deadline) { + currentCookie = (await getXsrfTokenCookie()).cookie; + + if (hasCookieChanged(previousCookie, currentCookie)) { + return currentCookie; + } + + await wait(COOKIE_REFRESH_POLL_MS); + } + + return currentCookie; + } + + function hasCookieChanged(previousCookie, currentCookie) { + if (!currentCookie || !currentCookie.value) { + return false; + } + + if (!previousCookie) { + return true; + } + + return previousCookie.name !== currentCookie.name + || previousCookie.value !== currentCookie.value + || previousCookie.expirationDate !== currentCookie.expirationDate; + } + + function supportsFirefoxCookieStoreTabs() { + return Boolean( + runtimeApi.runtime + && typeof runtimeApi.runtime.getBrowserInfo === "function" + ); + } + + function tabsCreate(details) { + if (runtimeApi.tabs.create.length <= 1) { + return runtimeApi.tabs.create(details); + } + + return new Promise((resolve, reject) => { + runtimeApi.tabs.create(details, (tab) => { + const lastError = runtimeApi.runtime.lastError; + + if (lastError) { + reject(new Error(lastError.message)); + return; + } + + resolve(tab); + }); + }); + } + + function tabsRemove(tabId) { + if (runtimeApi.tabs.remove.length <= 1) { + return runtimeApi.tabs.remove(tabId); + } + + return new Promise((resolve, reject) => { + runtimeApi.tabs.remove(tabId, () => { + const lastError = runtimeApi.runtime.lastError; + + if (lastError) { + reject(new Error(lastError.message)); + return; + } + + resolve(); + }); + }); + } + + function wait(durationMs) { + return new Promise((resolve) => setTimeout(resolve, durationMs)); + } + async function getXsrfTokenCookie() { const lookupDetails = []; const allCookies = []; diff --git a/src/content.js b/src/content.js index cec9b0b..c37fae5 100644 --- a/src/content.js +++ b/src/content.js @@ -502,8 +502,98 @@ } async function requestTokenRelay() { - const xsrfTokenSource = await getXsrfToken(); - const requestEntry = logRequestStart({ + let refreshResult = null; + let refreshAttempted = false; + + while (true) { + const xsrfTokenSource = await getXsrfToken(); + + if (!xsrfTokenSource.token) { + if (refreshAttempted) { + const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult); + logRequestFailure(requestEntry, "XSRF token cookie not found after automatic refresh."); + throw new Error("XSRF token cookie not found after automatic refresh."); + } + + refreshResult = await refreshXsrfCookie("missing-cookie"); + refreshAttempted = true; + continue; + } + + const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult); + + try { + const response = await fetch(TOKEN_RELAY_URL, { + method: "GET", + credentials: "include", + headers: { + "x-xsrf-token": xsrfTokenSource.token + } + }); + + const responseBody = await readResponseBodyForDebug(response); + + if (!response.ok) { + logRequestSuccess(requestEntry, response, responseBody); + + if (!refreshAttempted && isInvalidXsrfStatus(response.status)) { + refreshResult = await refreshXsrfCookie(`tokenrelay-${response.status}`); + refreshAttempted = true; + requestEntry.metadata.xsrfRefresh = formatXsrfRefreshResult(refreshResult); + renderDebugPanel(); + continue; + } + + throw new Error(`Token relay failed with status ${response.status}.`); + } + + let responseData; + + try { + responseData = await response.json(); + } catch (error) { + logRequestSuccess(requestEntry, response, responseBody); + + if (!refreshAttempted) { + refreshResult = await refreshXsrfCookie("tokenrelay-invalid-response"); + refreshAttempted = true; + requestEntry.metadata.xsrfRefresh = formatXsrfRefreshResult(refreshResult); + renderDebugPanel(); + continue; + } + + throw error; + } + + if (!responseData || typeof responseData.access_token !== "string" || !responseData.access_token) { + logRequestSuccess(requestEntry, response, responseBody); + + if (!refreshAttempted) { + refreshResult = await refreshXsrfCookie("tokenrelay-missing-access-token"); + refreshAttempted = true; + requestEntry.metadata.xsrfRefresh = formatXsrfRefreshResult(refreshResult); + renderDebugPanel(); + continue; + } + + throw new Error("Token relay response does not contain access_token."); + } + + logRequestSuccess(requestEntry, response, createDebugBody(JSON.stringify({ + ...responseData, + access_token: maskToken(responseData.access_token) + }, null, 2))); + + return responseData.access_token; + } catch (error) { + logRequestFailure(requestEntry, error.message || "Request failed."); + throw error; + } + } + } + + function createTokenRelayRequestEntry(xsrfTokenSource, refreshResult) { + return logRequestStart({ name: "requestTokenRelay", method: "GET", url: TOKEN_RELAY_URL, @@ -519,56 +609,25 @@ xsrfLookupDetails: xsrfTokenSource.lookupDetails && xsrfTokenSource.lookupDetails.length ? xsrfTokenSource.lookupDetails.join(" | ") : "(none)", - xsrfTokenError: xsrfTokenSource.error || "" + xsrfTokenError: xsrfTokenSource.error || "", + xsrfRefresh: formatXsrfRefreshResult(refreshResult) } }); + } - if (!xsrfTokenSource.token) { - logRequestFailure(requestEntry, "XSRF token cookie not found."); - throw new Error("XSRF token cookie not found."); + function isInvalidXsrfStatus(status) { + return status === 400 || status === 401 || status === 403; + } + + function formatXsrfRefreshResult(result) { + if (!result) { + return "not-requested"; } - try { - const response = await fetch(TOKEN_RELAY_URL, { - method: "GET", - credentials: "include", - headers: { - "x-xsrf-token": xsrfTokenSource.token - } - }); - - const responseBody = await readResponseBodyForDebug(response); - - if (!response.ok) { - logRequestSuccess(requestEntry, response, responseBody); - throw new Error(`Token relay failed with status ${response.status}.`); - } - - let responseData; - - try { - responseData = await response.json(); - } catch (error) { - logRequestSuccess(requestEntry, response, responseBody); - throw error; - } - - if (!responseData || typeof responseData.access_token !== "string" || !responseData.access_token) { - logRequestSuccess(requestEntry, response, responseBody); - throw new Error("Token relay response does not contain access_token."); - } - - logRequestSuccess(requestEntry, response, createDebugBody(JSON.stringify({ - ...responseData, - access_token: maskToken(responseData.access_token) - }, null, 2))); - - return responseData.access_token; - } catch (error) { - logRequestFailure(requestEntry, error.message || "Request failed."); - - throw error; - } + const outcome = result.refreshed ? "refreshed" : "not-refreshed"; + const cookieName = result.cookieName || "(none)"; + const duration = Number.isFinite(result.durationMs) ? `${result.durationMs}ms` : "unknown"; + return `${result.reason}; ${outcome}; cookie=${cookieName}; duration=${duration}; ${result.error || ""}`.trim(); } async function requestOpportunitiesForPeriod(period) { @@ -2106,6 +2165,52 @@ }); } + function refreshXsrfCookie(reason) { + const runtimeApi = typeof browser !== "undefined" ? browser : chrome; + + if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.sendMessage) { + return Promise.resolve({ + ok: false, + refreshed: false, + cookieName: "", + durationMs: 0, + reason, + error: "Runtime messaging API unavailable." + }); + } + + return new Promise((resolve) => { + runtimeApi.runtime.sendMessage({ + type: "opportunitiesExtension.refreshXsrfCookie" + }, (response) => { + const lastError = runtimeApi.runtime.lastError; + + if (lastError) { + resolve({ + ok: false, + refreshed: false, + cookieName: "", + durationMs: 0, + reason, + error: lastError.message + }); + return; + } + + resolve({ + ...(response || { + ok: false, + refreshed: false, + cookieName: "", + durationMs: 0, + error: "Empty refresh response." + }), + reason + }); + }); + }); + } + function updateAuthBadge(status) { const badge = document.querySelector(`#${MODAL_ID} .opportunities-extension-auth-badge`);