diff --git a/background.js b/background.js index 43c1602..647d33f 100644 --- a/background.js +++ b/background.js @@ -59,7 +59,6 @@ async function installSpaFrameHeaderRules() { ], }, condition: { - initiatorDomains: ["eeho.fa.us2.oraclecloud.com"], requestDomains: ["spa.oracle.com"], resourceTypes: ["sub_frame"], urlFilter: "||spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html", diff --git a/content-script.js b/content-script.js index e7d7913..1aaed62 100644 --- a/content-script.js +++ b/content-script.js @@ -6,7 +6,7 @@ TARGET_PARAM_VALUE, TARGET_APP_URL, HCM_ORIGIN, - HCM_WELCOME_PATHNAME, + HCM_WELCOME_PATHNAMES, HCM_MY_INFORMATION_APPS_GROUP_ID, WORKLIST_ORIGIN, WORKLIST_SAASUI_PATHNAME, @@ -57,6 +57,8 @@ getOperationalWeekInfo, getCalendarRowWeekDate, parseDatePreservingDateOnly, + decodeJwtPayload, + isJwtExpired, getErrorMessage, escapeHtml, cssEscape, @@ -134,6 +136,7 @@ storageService, normalizeCachedExportPayload, cleanString, + isJwtExpired, }); const hcmPage = globalThis.ArchPanelHcmPage.create({ HCM_MY_INFORMATION_APPS_GROUP_ID, @@ -141,6 +144,7 @@ WORKLIST_SAASUI_PATHNAME, IDS, cleanString, + isJwtExpired, hcmTileTemplate: globalThis.ArchPanelTemplates.hcmTile, onOpenArchPanel: openModal, writeCachedWorklistToken, @@ -247,11 +251,15 @@ const currentUrl = new URL(window.location.href); return ( - currentUrl.origin === HCM_ORIGIN && - currentUrl.pathname === HCM_WELCOME_PATHNAME + isAllowedHcmOrigin(currentUrl) && + HCM_WELCOME_PATHNAMES.includes(currentUrl.pathname) ); } + function isAllowedHcmOrigin(url) { + return url?.origin === HCM_ORIGIN; + } + function scheduleSync() { if (syncFrame) { return; @@ -642,6 +650,7 @@ cleanString, firstNonEmptyString, getFieldValue, + decodeJwtPayload, toNumber, normalizeWorkload, isWonWorkload, diff --git a/manifest.json b/manifest.json index d14223b..32851b6 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Arch Panel", - "version": "1.0.7", + "version": "1.0.10", "description": "Insere o botao Arch panel no header do Oracle Workload Workbench.", "permissions": [ "declarativeNetRequestWithHostAccess", @@ -10,6 +10,7 @@ ], "host_permissions": [ "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*", + "https://*.oraclecloud.com/*", "https://eeho.fa.us2.oraclecloud.com/*", "https://spa.oracle.com/*", "https://worklist.oracle.com/*" @@ -31,8 +32,8 @@ }, { "matches": [ - "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html*", - "https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*" + "https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*", + "https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome*" ], "css": [ "src/content/styles.css" @@ -74,6 +75,7 @@ "page-bridge.js" ], "matches": [ + "https://*.oraclecloud.com/*", "https://eeho.fa.us2.oraclecloud.com/*", "https://spa.oracle.com/*" ] diff --git a/page-bridge.js b/page-bridge.js index a55f827..6b31514 100644 --- a/page-bridge.js +++ b/page-bridge.js @@ -81,7 +81,7 @@ } for (const origin of candidates) { - if (ALLOWED_TOP_MESSAGE_ORIGINS.has(origin)) { + if (isAllowedTopMessageOrigin(origin)) { return origin; } } @@ -89,6 +89,25 @@ return ""; } + function isAllowedTopMessageOrigin(origin) { + if (ALLOWED_TOP_MESSAGE_ORIGINS.has(origin)) { + return true; + } + + try { + const url = new URL(origin); + const hostname = url.hostname.toLowerCase(); + + return ( + url.protocol === "https:" && + hostname.endsWith(".oraclecloud.com") && + hostname.includes(".fa.") + ); + } catch { + return false; + } + } + function safePostToTop(payload) { const targetOrigin = getAllowedTopMessageOrigin(); diff --git a/src/content/hcm-page.js b/src/content/hcm-page.js index 185142e..23868ae 100644 --- a/src/content/hcm-page.js +++ b/src/content/hcm-page.js @@ -5,6 +5,7 @@ WORKLIST_SAASUI_PATHNAME, IDS, cleanString, + isJwtExpired, hcmTileTemplate, onOpenArchPanel, writeCachedWorklistToken, @@ -12,12 +13,9 @@ let lastPersistedWorklistIframeUrl = ""; function syncHcmTile() { - const appsGroup = document.getElementById(HCM_MY_INFORMATION_APPS_GROUP_ID); - const addTile = appsGroup?.querySelector( - ".flat-grid-cell.flat-grid-cell-addicon" - ); + const insertion = resolveHcmTileInsertion(); - if (!addTile?.parentElement) { + if (!insertion?.container) { document.getElementById(IDS.hcmTile)?.remove(); return; } @@ -25,21 +23,47 @@ const tile = document.getElementById(IDS.hcmTile) || createHcmTile(); if ( - tile.parentElement !== addTile.parentElement || - tile.nextElementSibling !== addTile + tile.parentElement !== insertion.container || + tile.nextElementSibling !== insertion.before ) { - addTile.parentElement.insertBefore(tile, addTile); + insertion.container.insertBefore(tile, insertion.before || null); } } + function resolveHcmTileInsertion() { + const appsGroup = document.getElementById(HCM_MY_INFORMATION_APPS_GROUP_ID); + + if (!appsGroup) { + return null; + } + + const addTile = appsGroup.querySelector( + ".flat-grid-cell.flat-grid-cell-addicon" + ); + + if (addTile?.parentElement && appsGroup.contains(addTile.parentElement)) { + return { + container: addTile.parentElement, + before: addTile, + }; + } + + return { + container: appsGroup, + before: null, + }; + } + function syncWorklistIframeToken(options = {}) { for (const frame of document.querySelectorAll("iframe")) { const tokenPayload = extractWorklistIframeToken(frame); - if (tokenPayload?.token) { - persistWorklistIframeToken(tokenPayload, options); - return tokenPayload; + if (!tokenPayload?.token || isWorklistTokenExpired(tokenPayload.token)) { + continue; } + + persistWorklistIframeToken(tokenPayload, options); + return tokenPayload; } return null; @@ -73,6 +97,10 @@ } } + function isWorklistTokenExpired(token) { + return typeof isJwtExpired === "function" && isJwtExpired(token, 120); + } + function persistWorklistIframeToken(tokenPayload, options = {}) { if (!options.force && tokenPayload.url === lastPersistedWorklistIframeUrl) { return; diff --git a/src/content/workbench-controller.js b/src/content/workbench-controller.js index c938d46..c9c3bb4 100644 --- a/src/content/workbench-controller.js +++ b/src/content/workbench-controller.js @@ -44,7 +44,8 @@ appState.errorMessage = ""; updateLoadingProgress("Requesting current user", 4); - const user = await fetchCurrentUser(); + let user = await fetchCurrentUser(); + user = await preserveCachedUserAvatar(user); updateLoadingProgress("Loading current resource user", 8); @@ -206,6 +207,48 @@ } } + async function preserveCachedUserAvatar(user) { + if (cleanString(user?.avatar)) { + return user; + } + + const currentDatasetAvatar = + getCachedUserAvatar(user, appState.dataset?.user) || + getCachedUserAvatar(user, appState.dataset?.exportPayload?.user); + + if (currentDatasetAvatar) { + return { + ...user, + avatar: currentDatasetAvatar, + }; + } + + const cachedPayload = await readCachedDataset().catch(() => null); + const persistedAvatar = getCachedUserAvatar(user, cachedPayload?.user); + + return persistedAvatar + ? { + ...user, + avatar: persistedAvatar, + } + : user; + } + + function getCachedUserAvatar(user, cachedUser) { + if (!cachedUser) { + return ""; + } + + const userEmail = normalizeString(user?.userEmail); + const cachedUserEmail = normalizeString(cachedUser?.userEmail); + + if (!userEmail || userEmail !== cachedUserEmail) { + return ""; + } + + return cleanString(cachedUser?.avatar || cachedUser?.imageUrl); + } + function updateLoadingProgress(message, progress) { appState.loadingMessage = message; appState.loadingProgress = Math.max( @@ -312,21 +355,49 @@ const worklistToken = currentFrameToken || (await readCachedWorklistToken()); - if (!worklistToken) { - throw new Error( - "Unable to resolve Worklist token. Reload the FuseWelcome page so the Worklist iframe token can be captured before refreshing data." - ); + appState.sessionAuthHeaders = {}; + + if (worklistToken) { + try { + const user = await workbenchRepository.fetchWorklistCurrentUser( + worklistToken + ); + + appState.sessionAuthHeaders = {}; + + return user; + } catch (error) { + appState.sessionAuthHeaders = {}; + + if (!isWorklistAuthError(error)) { + throw error; + } + } } - appState.sessionAuthHeaders = {}; + try { + const user = await workbenchRepository.fetchSpaCurrentUser(); - const user = await workbenchRepository.fetchWorklistCurrentUser( - worklistToken - ); + appState.sessionAuthHeaders = {}; - appState.sessionAuthHeaders = {}; + return user; + } catch (error) { + appState.sessionAuthHeaders = {}; - return user; + throw new Error( + worklistToken + ? `Unable to refresh current user. The Worklist token was rejected and the Workload Workbench current-user fallback also failed: ${getErrorMessage( + error + )}. Reload the FuseWelcome page and try Refresh data again.` + : `Unable to resolve the current user. Reload the FuseWelcome page and try Refresh data again. ${getErrorMessage( + error + )}` + ); + } + } + + function isWorklistAuthError(error) { + return /\b(401|403)\b/.test(getErrorMessage(error)); } function findWorkloadById(workloadId) { diff --git a/src/repositories/cache-repository.js b/src/repositories/cache-repository.js index 5ce9f68..f1b4819 100644 --- a/src/repositories/cache-repository.js +++ b/src/repositories/cache-repository.js @@ -4,6 +4,7 @@ storageService, normalizeCachedExportPayload, cleanString, + isJwtExpired, }) { async function readCachedDataset() { const record = await storageService.readRecord( @@ -60,7 +61,7 @@ async function writeCachedWorklistToken(tokenPayload) { const token = cleanString(tokenPayload?.token); - if (!token) { + if (!token || isCachedWorklistTokenExpired(token)) { return; } @@ -82,8 +83,13 @@ CACHE.worklistTokenKey, "Unable to read the cached Worklist token." ); + const token = cleanString(record?.token); - return cleanString(record?.token); + return isCachedWorklistTokenExpired(token) ? "" : token; + } + + function isCachedWorklistTokenExpired(token) { + return typeof isJwtExpired === "function" && isJwtExpired(token, 120); } return Object.freeze({ diff --git a/src/repositories/workbench-repository.js b/src/repositories/workbench-repository.js index c0c311b..9207bbc 100644 --- a/src/repositories/workbench-repository.js +++ b/src/repositories/workbench-repository.js @@ -8,6 +8,7 @@ cleanString, firstNonEmptyString, getFieldValue, + decodeJwtPayload, toNumber, normalizeWorkload, isWonWorkload, @@ -31,28 +32,45 @@ }).toString(); const payload = await fetchJson(url.toString(), { forceExtension: true }); - const userPayload = normalizeWorklistUserPayload(payload); + + return normalizeCurrentUserPayload(payload, "Worklist user"); + } + + async function fetchSpaCurrentUser() { + const payload = await fetchJson(API_ENDPOINTS.currentUser); + + return normalizeCurrentUserPayload(payload, "SPA current user"); + } + + function normalizeCurrentUserPayload(payload, sourceLabel) { + const userPayload = normalizeCurrentUserCandidate(payload); const firstName = cleanString( - getFieldValue(userPayload, ["FirstName", "firstName", "User.FirstName"]) - ); - const lastName = cleanString( - getFieldValue(userPayload, ["LastName", "lastName", "User.LastName"]) - ); - const userEmail = cleanString( getFieldValue(userPayload, [ - "Email", - "email", - "EmailAddress", - "emailAddress", - "User.Email", - "User.email", - "User.EmailAddress", + "FirstName", + "firstName", + "GivenName", + "givenName", + "User.FirstName", ]) ); + const lastName = cleanString( + getFieldValue(userPayload, [ + "LastName", + "lastName", + "FamilyName", + "familyName", + "User.LastName", + ]) + ); + const userEmail = resolveUserEmail(payload, userPayload); const displayName = firstNonEmptyString([ `${firstName} ${lastName}`.trim(), userPayload?.DisplayName, userPayload?.displayName, + userPayload?.FullName, + userPayload?.fullName, + userPayload?.Name, + userPayload?.name, userEmail, ]); @@ -62,7 +80,7 @@ ? Object.keys(userPayload).join(", ") : ""; throw new Error( - `Unable to resolve userEmail from Worklist user response. Available top-level keys: ${Object.keys( + `Unable to resolve userEmail from ${sourceLabel} response. Available top-level keys: ${Object.keys( payload || {} ).join(", ")}${userKeys ? ` | User keys: ${userKeys}` : ""}` ); @@ -71,9 +89,7 @@ return { userEmail, name: displayName || userEmail, - avatar: cleanString( - getFieldValue(userPayload, ["ImageURL", "imageUrl", "User.ImageURL"]) - ), + avatar: resolveUserAvatar(payload, userPayload), administrator: Boolean( getFieldValue(userPayload, [ "Administrator", @@ -84,20 +100,178 @@ }; } - function normalizeWorklistUserPayload(payload) { - const candidate = payload?.User ?? payload?.user ?? payload; + function normalizeCurrentUserCandidate(payload) { + const candidate = + getObjectCandidate(payload?.User) ?? + getObjectCandidate(payload?.user) ?? + getObjectCandidate(payload?.currentUser) ?? + getObjectCandidate(payload?.CurrentUser) ?? + getObjectCandidate(payload?.data) ?? + getObjectCandidate(payload?.result) ?? + getObjectCandidate(payload?.items?.[0]) ?? + payload; if (Array.isArray(candidate)) { return candidate[0] || {}; } - if (candidate?.User) { - return normalizeWorklistUserPayload(candidate); + const nestedUser = + getObjectCandidate(candidate?.User) ?? getObjectCandidate(candidate?.user); + + if (nestedUser) { + return normalizeCurrentUserCandidate(nestedUser); } return candidate || {}; } + function getObjectCandidate(value) { + return value && typeof value === "object" ? value : null; + } + + function resolveUserEmail(payload, userPayload) { + return firstEmailLikeString([ + getFieldValue(userPayload, [ + "Email", + "email", + "EmailAddress", + "emailAddress", + "User.Email", + "User.email", + "User.EmailAddress", + "UserName", + "userName", + "username", + "mail", + "Mail", + "userEmail", + "UserEmail", + "PrimaryEmail.EmailAddress", + "primaryEmail.emailAddress", + "ResourceEmailAddress", + "resourceEmailAddress", + "id", + "Id", + ]), + ...getJwtEmailCandidates(payload?.token), + ...getJwtEmailCandidates(payload?.accessToken), + ...getJwtEmailCandidates(payload?.idToken), + ...getJwtEmailCandidates(userPayload?.token), + ]); + } + + function resolveUserAvatar(payload, userPayload) { + return firstNonEmptyString([ + getFieldValue(userPayload, [ + "ImageURL", + "imageUrl", + "imageURL", + "User.ImageURL", + "Picture", + "picture", + "avatar", + "Avatar", + "avatarUrl", + "avatarURL", + "photo", + "photoUrl", + "photoURL", + "profileImageUrl", + "profileImageURL", + "thumbnailUrl", + "thumbnailURL", + "image", + "userImage", + "userImageUrl", + "profile.picture", + "profile.avatar", + "profile.photoUrl", + ]), + ...getJwtAvatarCandidates(payload?.token), + ...getJwtAvatarCandidates(payload?.accessToken), + ...getJwtAvatarCandidates(payload?.idToken), + ...getJwtAvatarCandidates(userPayload?.token), + ]); + } + + function getJwtAvatarCandidates(token) { + const tokenPayload = decodeTokenPayload(token); + + if (!tokenPayload) { + return []; + } + + return [ + getFieldValue(tokenPayload, [ + "picture", + "avatar", + "avatar_url", + "image", + "photo", + "photo_url", + "thumbnail", + "thumbnail_url", + ]), + ]; + } + + function getJwtEmailCandidates(token) { + const tokenPayload = decodeTokenPayload(token); + + if (!tokenPayload) { + return []; + } + + return [ + getFieldValue(tokenPayload, [ + "email", + "Email", + "sub", + "prn", + "upn", + "user_name", + "username", + "preferred_username", + "unique_name", + "mail", + "userEmail", + "UserEmail", + ]), + ]; + } + + function decodeTokenPayload(token) { + const normalizedToken = cleanTokenValue(token); + + if (!normalizedToken || typeof decodeJwtPayload !== "function") { + return null; + } + + return decodeJwtPayload(normalizedToken); + } + + function cleanTokenValue(value) { + const normalized = cleanString(value); + + if (!normalized) { + return ""; + } + + return normalized.replace(/^Bearer\s+/i, ""); + } + + function firstEmailLikeString(values) { + for (const value of values) { + const normalized = cleanString(value); + + if (/@/.test(normalized)) { + return normalized; + } + } + + return ""; + } + async function fetchAllCustomers(userEmail) { const limit = 49; let offset = 0; @@ -277,6 +451,7 @@ return { fetchWorklistCurrentUser, + fetchSpaCurrentUser, fetchAllCustomers, fetchCustomerWorkloads, fetchWorkloadActions, diff --git a/src/services/transport-service.js b/src/services/transport-service.js index a9ffb02..fc81fd3 100644 --- a/src/services/transport-service.js +++ b/src/services/transport-service.js @@ -172,7 +172,9 @@ const response = await new Promise((resolve, reject) => { const timeout = window.setTimeout(() => { pendingBridgeRequests.delete(requestId); - reject(new Error(`Timed out while requesting ${requestUrl}`)); + reject( + new Error(`Timed out while requesting ${redactUrlForError(requestUrl)}`) + ); }, 60000); pendingBridgeRequests.set(requestId, { @@ -229,7 +231,9 @@ `${ payloadMessage || response.error || - `Request failed (${response.status}) for ${requestUrl}` + `Request failed (${response.status}) for ${redactUrlForError( + requestUrl + )}` }${debugSuffix}` ); } @@ -297,7 +301,9 @@ }); if (!response || typeof response !== "object") { - throw new Error(`No response received for ${requestUrl}`); + throw new Error( + `No response received for ${redactUrlForError(requestUrl)}` + ); } if ( @@ -328,8 +334,8 @@ const debugFrameUrl = cleanString(response.debug?.frameUrl); const debugUrl = cleanString(response.debug?.url); const debugParts = [ - debugUrl ? `url: ${debugUrl}` : "", - debugFrameUrl ? `frame: ${debugFrameUrl}` : "", + debugUrl ? `url: ${redactUrlForError(debugUrl)}` : "", + debugFrameUrl ? `frame: ${redactUrlForError(debugFrameUrl)}` : "", debugHeaderKeys ? `captured headers: ${debugHeaderKeys}` : "", ].filter(Boolean); const debugSuffix = @@ -339,7 +345,9 @@ `${ payloadMessage || response.error || - `Request failed (${response.status}) for ${requestUrl}` + `Request failed (${response.status}) for ${redactUrlForError( + requestUrl + )}` }${debugSuffix}` ); } @@ -493,7 +501,9 @@ }); if (!response || typeof response !== "object") { - throw new Error(`No response received for ${requestUrl}`); + throw new Error( + `No response received for ${redactUrlForError(requestUrl)}` + ); } if (!response.ok) { @@ -511,7 +521,7 @@ : ""; const debugUrl = cleanString(response.debug?.url); const debugParts = [ - debugUrl ? `url: ${debugUrl}` : "", + debugUrl ? `url: ${redactUrlForError(debugUrl)}` : "", debugHeaderKeys ? `captured headers: ${debugHeaderKeys}` : "", ].filter(Boolean); const debugSuffix = @@ -521,7 +531,9 @@ `${ payloadMessage || response.error || - `Request failed (${response.status}) for ${requestUrl}` + `Request failed (${response.status}) for ${redactUrlForError( + requestUrl + )}` }${debugSuffix}` ); } @@ -631,6 +643,42 @@ } } + function redactUrlForError(value) { + const normalized = cleanString(value); + + if (!normalized) { + return ""; + } + + try { + const parsedUrl = new URL(normalized, window.location.origin); + const sensitiveQueryPattern = + /(token|authorization|auth|jwt|id_token|access_token)/i; + + for (const key of Array.from(parsedUrl.searchParams.keys())) { + if (sensitiveQueryPattern.test(key)) { + parsedUrl.searchParams.set(key, "[redacted]"); + } + } + + const redactedUrl = parsedUrl.toString(); + + if ( + parsedUrl.origin === window.location.origin && + !/^[a-z][a-z0-9+.-]*:/i.test(normalized) + ) { + return `${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`; + } + + return redactedUrl; + } catch { + return normalized.replace( + /([?&][^=\s|]*(?:token|authorization|auth|jwt|id_token|access_token)[^=\s|]*=)[^&\s|]+/gi, + "$1[redacted]" + ); + } + } + function handleBridgeMessage(event) { const message = event.data; diff --git a/src/shared/config.js b/src/shared/config.js index 72f2f86..fcc5ccc 100644 --- a/src/shared/config.js +++ b/src/shared/config.js @@ -7,7 +7,10 @@ const TARGET_APP_URL = "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html?ojr=workload_workbench"; const HCM_ORIGIN = "https://eeho.fa.us2.oraclecloud.com"; - const HCM_WELCOME_PATHNAME = "/hcmUI/faces/FuseWelcome"; + const HCM_WELCOME_PATHNAMES = Object.freeze([ + "/hcmUI/faces/FuseWelcome", + "/fscmUI/faces/FuseWelcome", + ]); const HCM_MY_INFORMATION_APPS_GROUP_ID = "yourapps_groupNode_my_information"; const WORKLIST_ORIGIN = "https://worklist.oracle.com"; const WORKLIST_SAASUI_PATHNAME = "/oalapp/pub/worklist/saasui/index.html"; @@ -167,7 +170,7 @@ TARGET_PARAM_VALUE, TARGET_APP_URL, HCM_ORIGIN, - HCM_WELCOME_PATHNAME, + HCM_WELCOME_PATHNAMES, HCM_MY_INFORMATION_APPS_GROUP_ID, WORKLIST_ORIGIN, WORKLIST_SAASUI_PATHNAME, diff --git a/src/shared/utils.js b/src/shared/utils.js index d739ab6..18e59f5 100644 --- a/src/shared/utils.js +++ b/src/shared/utils.js @@ -402,6 +402,54 @@ return new Date(value); } + function decodeJwtPayload(token) { + const normalizedToken = cleanString(token); + + if ( + !/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test( + normalizedToken + ) + ) { + return null; + } + + try { + const payloadPart = normalizedToken.split(".")[1]; + const base64 = payloadPart.replace(/-/g, "+").replace(/_/g, "/"); + const paddedBase64 = base64.padEnd(Math.ceil(base64.length / 4) * 4, "="); + const json = decodeURIComponent( + Array.from(atob(paddedBase64), (char) => { + return `%${char.charCodeAt(0).toString(16).padStart(2, "0")}`; + }).join("") + ); + + return JSON.parse(json); + } catch { + return null; + } + } + + function getJwtExpiresAt(token) { + const payload = decodeJwtPayload(token); + const expiresAtSeconds = Number(payload?.exp); + + return Number.isFinite(expiresAtSeconds) && expiresAtSeconds > 0 + ? expiresAtSeconds * 1000 + : 0; + } + + function isJwtExpired(token, skewSeconds = 60) { + const expiresAt = getJwtExpiresAt(token); + + if (!expiresAt) { + return false; + } + + const skewMilliseconds = Math.max(Number(skewSeconds) || 0, 0) * 1000; + + return expiresAt <= Date.now() + skewMilliseconds; + } + function getErrorMessage(error) { if (error instanceof Error) { return error.message; @@ -456,6 +504,9 @@ getOperationalWeekInfo, getCalendarRowWeekDate, parseDatePreservingDateOnly, + decodeJwtPayload, + getJwtExpiresAt, + isJwtExpired, getErrorMessage, escapeHtml, cssEscape,