From bd65790ebbfdd925b3a57b7a4da30278eda61bac Mon Sep 17 00:00:00 2001 From: David Nuescheler Date: Mon, 6 Jul 2026 15:39:51 -0700 Subject: [PATCH] fix: handle expired DA token without raw IPC errors Clear the stored IMS token and notify the renderer when da.live returns 401 so browse, sync, and document views sign out gracefully instead of surfacing "Error invoking remote method 'da:list'". Co-authored-by: Cursor --- src/main/content-api-client.js | 21 +++++++++++---------- src/main/content-api-shared.js | 12 ++++++++++++ src/main/index.js | 21 ++++++++++++++++++--- src/preload/index.cjs | 5 +++++ src/renderer/renderer.js | 27 +++++++++++++++++++++++++++ test/content-api-shared.test.js | 11 +++++++++++ 6 files changed, 84 insertions(+), 13 deletions(-) diff --git a/src/main/content-api-client.js b/src/main/content-api-client.js index 671dce5..d4c0d26 100644 --- a/src/main/content-api-client.js +++ b/src/main/content-api-client.js @@ -22,6 +22,7 @@ import { API_BACKEND_AEM_API, API_BACKEND_DA_LIVE, buildPostUploadRequest, + DA_UNAUTHORIZED_MESSAGE, isValidApiBackend, normalizeDaPath, } from './content-api-shared.js'; @@ -87,7 +88,7 @@ export class ContentApiClient { } const res = await this.fetch(url, { headers, cache: 'reload' }); // eslint-disable-line no-await-in-loop if (res.status === 401) { - throw new Error('Unauthorized: invalid or expired token'); + throw new Error(DA_UNAUTHORIZED_MESSAGE); } if (!res.ok) { throw await buildHttpError('GET', url, res, `List failed for ${normalized}`); // eslint-disable-line no-await-in-loop @@ -119,7 +120,7 @@ export class ContentApiClient { const url = buildAemApiListUrl(org, repo, normalized); const res = await this.fetch(url, { headers: this.authHeader, cache: 'reload' }); if (res.status === 401) { - throw new Error('Unauthorized: invalid or expired token'); + throw new Error(DA_UNAUTHORIZED_MESSAGE); } if (!res.ok) { throw await buildHttpError('GET', url, res, `List failed for ${normalized}`); @@ -146,7 +147,7 @@ export class ContentApiClient { : buildDaLiveSourceUrl(org, repo, normalized); const res = await this.fetch(url, { headers: this.authHeader, cache: 'reload' }); if (res.status === 401) { - throw new Error('Unauthorized: invalid or expired token'); + throw new Error(DA_UNAUTHORIZED_MESSAGE); } if (res.status === 404) { return null; @@ -175,7 +176,7 @@ export class ContentApiClient { : buildDaLiveSourceUrl(org, repo, normalized); const res = await this.fetch(url, { headers: this.authHeader, cache: 'reload' }); if (res.status === 401) { - throw new Error('Unauthorized: invalid or expired token'); + throw new Error(DA_UNAUTHORIZED_MESSAGE); } if (res.status === 404) { return null; @@ -221,7 +222,7 @@ export class ContentApiClient { body, }); if (putRes.status === 401) { - throw new Error('Unauthorized: invalid or expired token'); + throw new Error(DA_UNAUTHORIZED_MESSAGE); } if (putRes.ok) { return; @@ -234,7 +235,7 @@ export class ContentApiClient { body: post.body, }); if (postRes.status === 401) { - throw new Error('Unauthorized: invalid or expired token'); + throw new Error(DA_UNAUTHORIZED_MESSAGE); } if (postRes.ok) { return; @@ -262,7 +263,7 @@ export class ContentApiClient { headers: this.authHeader, }); if (res.status === 401) { - throw new Error('Unauthorized: invalid or expired token'); + throw new Error(DA_UNAUTHORIZED_MESSAGE); } if (!res.ok && res.status !== 404) { throw await buildHttpError('DELETE', url, res, `Delete failed for ${normalized}`); @@ -284,7 +285,7 @@ export class ContentApiClient { body: JSON.stringify({ paths, forceAsync: true }), }); if (res.status === 401) { - throw new Error('Unauthorized: invalid or expired token'); + throw new Error(DA_UNAUTHORIZED_MESSAGE); } if (!res.ok && res.status !== 202) { throw await buildHttpError('POST', url, res, 'Bulk preview failed'); @@ -307,7 +308,7 @@ export class ContentApiClient { body: JSON.stringify({ paths, forceAsync: true }), }); if (res.status === 401) { - throw new Error('Unauthorized: invalid or expired token'); + throw new Error(DA_UNAUTHORIZED_MESSAGE); } if (!res.ok && res.status !== 202) { throw await buildHttpError('POST', url, res, 'Bulk publish failed'); @@ -327,7 +328,7 @@ export class ContentApiClient { const url = buildAemApiJobUrl(org, repo, topic, jobName); const res = await this.fetch(url, { headers: this.authHeader }); if (res.status === 401) { - throw new Error('Unauthorized: invalid or expired token'); + throw new Error(DA_UNAUTHORIZED_MESSAGE); } if (!res.ok && res.status !== 202) { throw await buildHttpError('GET', url, res, 'Job status failed'); diff --git a/src/main/content-api-shared.js b/src/main/content-api-shared.js index b8bf2dc..0998c66 100644 --- a/src/main/content-api-shared.js +++ b/src/main/content-api-shared.js @@ -13,6 +13,18 @@ export const API_BACKEND_DA_LIVE = 'da.live'; export const API_BACKEND_AEM_API = 'api.aem.live'; +/** Thrown when da.live / api.aem.live rejects the IMS bearer token. */ +export const DA_UNAUTHORIZED_MESSAGE = 'Unauthorized: invalid or expired token'; + +/** + * @param {unknown} err + * @returns {boolean} + */ +export function isDaUnauthorizedError(err) { + const message = err instanceof Error ? err.message : String(err); + return message.includes(DA_UNAUTHORIZED_MESSAGE); +} + /** * @param {string} backend * @returns {boolean} diff --git a/src/main/index.js b/src/main/index.js index 3d67907..716ae87 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -20,11 +20,15 @@ import { createWindowOptions } from './window-options.js'; import { initAutoUpdater } from './updater.js'; import { screenshotFilename } from './dev-config.js'; import { toDaPath } from './aem-page-url.js'; -import { API_BACKEND_AEM_API, API_BACKEND_DA_LIVE } from './content-api-shared.js'; +import { + API_BACKEND_AEM_API, + API_BACKEND_DA_LIVE, + isDaUnauthorizedError, +} from './content-api-shared.js'; import { ContentApiClient } from './content-api-client.js'; import { HttpRequestError } from './http-request-error.js'; import { - DA_TOKEN_FILENAME, getAuthStatus, getValidToken, logout, + DA_TOKEN_FILENAME, clearStoredToken, getAuthStatus, getValidToken, logout, } from './da-auth.js'; import { isSiteTokenExpired, @@ -230,7 +234,18 @@ async function withContentClient(site, fn) { openBrowser: (url) => shell.openExternal(url), }); const backend = site.apiBackend || API_BACKEND_DA_LIVE; - return fn(new ContentApiClient(accessToken, backend)); + try { + return await fn(new ContentApiClient(accessToken, backend)); + } catch (err) { + if (isDaUnauthorizedError(err)) { + await clearStoredToken(tokenPath()); + contentDaLiveAuth?.clearCache(); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('da:session-expired'); + } + } + throw err; + } } async function createWindow() { diff --git a/src/preload/index.cjs b/src/preload/index.cjs index d280574..85b3481 100644 --- a/src/preload/index.cjs +++ b/src/preload/index.cjs @@ -29,6 +29,11 @@ contextBridge.exposeInMainWorld('aemDesktop', { getDaAuthStatus: () => ipcRenderer.invoke('da:auth-status'), loginDa: () => ipcRenderer.invoke('da:login'), logoutDa: () => ipcRenderer.invoke('da:logout'), + onDaSessionExpired: (callback) => { + const handler = () => callback(); + ipcRenderer.on('da:session-expired', handler); + return () => ipcRenderer.removeListener('da:session-expired', handler); + }, listDa: (siteId, daPath) => ipcRenderer.invoke('da:list', { siteId, daPath }), getDaSource: (siteId, daPath) => ipcRenderer.invoke('da:get-source', { siteId, daPath }), parseDocumentView: (html) => ipcRenderer.invoke('document:parse', { html }), diff --git a/src/renderer/renderer.js b/src/renderer/renderer.js index 685cdc9..0c45701 100644 --- a/src/renderer/renderer.js +++ b/src/renderer/renderer.js @@ -234,11 +234,27 @@ function hide(element) { element.hidden = true; } +/** Matches {@link DA_UNAUTHORIZED_MESSAGE} in content-api-shared.js. */ +const DA_UNAUTHORIZED_SNIPPET = 'Unauthorized: invalid or expired token'; + +/** + * @param {unknown} err + * @returns {boolean} + */ +function isDaUnauthorizedError(err) { + const message = err instanceof Error ? err.message : String(err); + return message.includes(DA_UNAUTHORIZED_SNIPPET); +} + /** * @param {string} title * @param {{ message?: string, xError?: string|null, detail?: string }|Error|string} error */ async function showRequestErrorDialog(title, error) { + if (isDaUnauthorizedError(error)) { + await refreshAuthStatus(); + return; + } const payload = typeof error === 'string' ? { message: error } : error; @@ -809,6 +825,10 @@ async function loadDocumentViewForPath(daPath, pane) { const model = await window.aemDesktop.parseDocumentView(source.text); renderDocumentView(pane, model); } catch (err) { + if (isDaUnauthorizedError(err)) { + await refreshAuthStatus(); + return; + } setPanePlaceholder(pane, err.message || 'Failed to load document.'); } } @@ -1008,6 +1028,10 @@ async function loadFolder(daPath) { state.tree.cache[daPath] = items; await refreshLocalBadgesForFolder(daPath); } catch (err) { + if (isDaUnauthorizedError(err)) { + await refreshAuthStatus(); + return; + } if (daPath === '/') { state.tree.error = err.message || 'Failed to list folder'; } @@ -2898,6 +2922,9 @@ async function init() { await refreshAuthStatus(); await loadSites(); window.aemDesktop.onPreviewAuthRequired(handlePreviewAuthRequired); + window.aemDesktop.onDaSessionExpired(() => { + refreshAuthStatus(); + }); showView('home'); } diff --git a/test/content-api-shared.test.js b/test/content-api-shared.test.js index 594c5d2..486b1de 100644 --- a/test/content-api-shared.test.js +++ b/test/content-api-shared.test.js @@ -14,6 +14,8 @@ import assert from 'node:assert/strict'; import { API_BACKEND_DA_LIVE, buildPostUploadRequest, + DA_UNAUTHORIZED_MESSAGE, + isDaUnauthorizedError, normalizeDaPath, toApiRelativePath, } from '../src/main/content-api-shared.js'; @@ -34,3 +36,12 @@ test('buildPostUploadRequest sets filename from daPath for da.live', () => { ); assert.ok(body instanceof FormData); }); + +test('isDaUnauthorizedError detects API and IPC-wrapped unauthorized errors', () => { + assert.equal(isDaUnauthorizedError(new Error(DA_UNAUTHORIZED_MESSAGE)), true); + assert.equal( + isDaUnauthorizedError(new Error("Error invoking remote method 'da:list': Error: Unauthorized: invalid or expired token")), + true, + ); + assert.equal(isDaUnauthorizedError(new Error('List failed for /')), false); +});