diff --git a/blocks/edit/prose/index.js b/blocks/edit/prose/index.js index a2459a60..ebe8cf4e 100644 --- a/blocks/edit/prose/index.js +++ b/blocks/edit/prose/index.js @@ -23,7 +23,6 @@ import { } from 'da-y-wrapper'; import { getSchema } from 'da-parser'; -import { getNx } from '../../../scripts/utils.js'; import { COLLAB_ORIGIN, DA_ORIGIN } from '../../shared/constants.js'; import { daFetch, getAuthToken } from '../../shared/utils.js'; import { getDiffClass, checkForLocNodes, addActiveView } from './diff/diff-utils.js'; @@ -55,11 +54,9 @@ export async function createConnection(path) { // (exponential backoff starting with 100ms) and then every 30s. provider.maxBackoffTime = 30000; - // y-websocket re-reads provider.protocols on each reconnect, so swapping in a - // fresh IMS token here is enough; no provider rebuild needed. - // Server close codes: 4401 = token expired (refresh + retry), 4403 = forbidden (stop). let lastSentToken = token || null; provider.on('connection-close', async (event) => { + // Server close codes: 4401 = token expired (refresh + retry), 4403 = forbidden (stop). if (event?.code === 4403) { provider.shouldConnect = false; return; @@ -69,16 +66,14 @@ export async function createConnection(path) { try { await window.adobeIMS?.refreshToken?.(); } catch { /* ignore */ } const fresh = await getAuthToken(); if (!fresh || fresh === lastSentToken) { - // No new token to try — retrying would loop on the same 4401 forever. + // No new token to try — retrying would loop on the same 4401. Stop + // the reconnect loop, and surface the modal if the user was signed in. provider.shouldConnect = false; - // If the user expected to be signed in, route them through IMS sign-in - // so collab can recover. Matches daFetch's 401 handling. Anonymous users - // (no nx-ims flag) hitting a private doc are left disconnected. - if (localStorage.getItem('nx-ims')) { + if (lastSentToken) { try { - const { handleSignIn } = await import(`${getNx()}/utils/ims.js`); - handleSignIn(); - } catch { /* nothing to do */ } + const { showAuthBanner } = await import('../../shared/da-auth-banner/da-auth-banner.js'); + showAuthBanner(); + } catch { /* ignore */ } } return; } diff --git a/blocks/shared/da-auth-banner/da-auth-banner.js b/blocks/shared/da-auth-banner/da-auth-banner.js new file mode 100644 index 00000000..ec986000 --- /dev/null +++ b/blocks/shared/da-auth-banner/da-auth-banner.js @@ -0,0 +1,39 @@ +import { getNx } from '../../../scripts/utils.js'; +import '../da-dialog/da-dialog.js'; + +let mountedInstance = null; + +async function triggerSignIn() { + const { loadIms, handleSignIn } = await import(`${getNx()}/utils/ims.js`); + await loadIms(); + handleSignIn(); +} + +export function showAuthBanner() { + if (mountedInstance?.isConnected) return mountedInstance; + + const dialog = document.createElement('da-dialog'); + dialog.title = 'Your session has expired'; + dialog.classList.add('da-auth-banner'); + dialog.showCloseButton = false; + + const msg = document.createElement('p'); + msg.textContent = 'Sign in again to continue.'; + dialog.appendChild(msg); + + dialog.action = { + label: 'Sign in', + style: 'accent', + click: triggerSignIn, + }; + + dialog.addEventListener('close', () => { + if (mountedInstance === dialog) mountedInstance = null; + dialog.remove(); + }); + + document.body.appendChild(dialog); + mountedInstance = dialog; + + return dialog; +} diff --git a/blocks/shared/da-dialog/da-dialog.js b/blocks/shared/da-dialog/da-dialog.js index 8fb7eb68..e4ab1dba 100644 --- a/blocks/shared/da-dialog/da-dialog.js +++ b/blocks/shared/da-dialog/da-dialog.js @@ -17,9 +17,15 @@ export default class DaDialog extends LitElement { action: { state: true }, emphasis: { type: String }, // quiet size: { type: String }, // 'small', 'medium', 'large', 'auto' + showCloseButton: { type: Boolean, attribute: 'show-close-button' }, _showLazyModal: { state: true }, }; + constructor() { + super(); + this.showCloseButton = true; + } + connectedCallback() { super.connectedCallback(); this.shadowRoot.adoptedStyleSheets = [STYLE]; @@ -65,12 +71,12 @@ export default class DaDialog extends LitElement {

${this.title}

- + ` : nothing}

diff --git a/blocks/shared/utils.js b/blocks/shared/utils.js index a8a0ea4a..f540d51f 100644 --- a/blocks/shared/utils.js +++ b/blocks/shared/utils.js @@ -7,13 +7,38 @@ const ETC_ORIGINS = ['https://stage-content.da.live', 'https://helix-snapshot-sc const ALLOWED_TOKEN = [...DA_ORIGINS, ...AEM_ORIGINS, ...ETC_ORIGINS]; let imsDetails; +let authMonitorAttached = false; + +// Watch imslib's session for cross-tab sign-in/out. imslib persists its +// token in localStorage and other tabs' writes fire storage events here; +// re-check the live auth state on every storage change so we react +// regardless of which keys (nx-ims, imslib's own) flipped. +function attachAuthMonitor() { + if (authMonitorAttached) return; + authMonitorAttached = true; + let wasAuthed = !!window.adobeIMS?.getAccessToken(); + window.addEventListener('storage', async () => { + const isAuthed = !!window.adobeIMS?.getAccessToken(); + if (wasAuthed && !isAuthed) { + const { showAuthBanner } = await import('./da-auth-banner/da-auth-banner.js'); + showAuthBanner(); + // Drop any open collab WS so the stale auth cached on the server side + // can't keep authorizing persistence after the user signed out elsewhere. + document.querySelector('da-content')?.wsProvider?.disconnect(); + } else if (!wasAuthed && isAuthed) { + // Another tab signed back in — reload to pick up the fresh session. + window.location.reload(); + } + wasAuthed = isAuthed; + }); +} export async function initIms() { if (imsDetails) return imsDetails; - const { loadIms } = await import(`${getNx()}/utils/ims.js`); - try { + const { loadIms } = await import(`${getNx()}/utils/ims.js`); imsDetails = await loadIms(); + attachAuthMonitor(); return imsDetails; } catch { return null; @@ -35,22 +60,33 @@ export async function getAuthToken() { export const daFetch = async (url, opts = {}) => { opts.headers = opts.headers || {}; - const accessToken = await getAuthToken(); - if (accessToken) { + const setBearer = (tok) => { const canToken = ALLOWED_TOKEN.some((origin) => new URL(url).origin === origin); - if (canToken) { - opts.headers.Authorization = `Bearer ${accessToken}`; - if (AEM_ORIGINS.some((origin) => new URL(url).origin === origin)) { - opts.headers['x-content-source-authorization'] = `Bearer ${accessToken}`; - } + if (!canToken) return; + opts.headers.Authorization = `Bearer ${tok}`; + if (AEM_ORIGINS.some((origin) => new URL(url).origin === origin)) { + opts.headers['x-content-source-authorization'] = `Bearer ${tok}`; } - } - const resp = await fetch(url, opts); - if (resp.status === 401 && opts.noRedirect !== true) { - // Only attempt sign-in if the request is for DA. - if (DA_ORIGINS.some((origin) => url.startsWith(origin))) { - // If the user has an access token, but are not permitted, redirect them to not found. - if (accessToken) { + }; + + const accessToken = await getAuthToken(); + if (accessToken) setBearer(accessToken); + + let resp = await fetch(url, opts); + + if (resp.status === 401 && opts.noRedirect !== true + && DA_ORIGINS.some((origin) => url.startsWith(origin))) { + // Silent recovery: another tab may have just refreshed/signed in. Ask imslib + // for a fresh token and retry once before any user-visible disruption. + let refreshed = null; + try { await window.adobeIMS?.refreshToken?.(); } catch { /* ignore */ } + refreshed = await getAuthToken(); + if (refreshed && refreshed !== accessToken) { + setBearer(refreshed); + resp = await fetch(url, opts); + } + if (resp.status === 401) { + if (refreshed || accessToken) { // eslint-disable-next-line no-console console.warn('You see the 404 page because you have no access to this page', url); window.location = `${window.location.origin}/not-found`; @@ -58,9 +94,8 @@ export const daFetch = async (url, opts = {}) => { } // eslint-disable-next-line no-console console.warn('You need to sign in because you are not authorized to access this page', url); - const { loadIms, handleSignIn } = await import(`${getNx()}/utils/ims.js`); - await loadIms(); - handleSignIn(); + const { showAuthBanner } = await import('./da-auth-banner/da-auth-banner.js'); + showAuthBanner(); } } diff --git a/scripts/scripts.js b/scripts/scripts.js index 4dee529c..88af3df2 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -63,13 +63,21 @@ export default async function loadPage() { document.body.classList.remove('light-scheme', 'dark-scheme'); document.body.classList.add('light-scheme'); } + + const { hash } = window.location; + const hadAccessToken = hash.includes('access_token='); + const isImsCallback = hadAccessToken || hash.includes('old_hash='); + const imsReady = initIms(); await setConfig(CONFIG); - // Only block on IMS for OAuth-callback loads - const { hash } = window.location; - if (hash.includes('access_token=') || hash.includes('old_hash=')) { + if (isImsCallback) { await imsReady; + if (!hadAccessToken) { + // User explicitly signed out — redirect to home + window.location.replace('/'); + return; + } } await loadArea(); diff --git a/test/unit/blocks/edit/prose/index.test.js b/test/unit/blocks/edit/prose/index.test.js index 60bd0329..8ad80a88 100644 --- a/test/unit/blocks/edit/prose/index.test.js +++ b/test/unit/blocks/edit/prose/index.test.js @@ -68,17 +68,14 @@ function buildFakeWsProvider({ withSynced = false } = {}) { } describe('prose/index createConnection', () => { - let savedNxIms; beforeEach(() => { - savedNxIms = window.localStorage.getItem('nx-ims'); window.localStorage.removeItem('nx-ims'); }); afterEach(() => { - if (savedNxIms) { - window.localStorage.setItem('nx-ims', savedNxIms); - } else { - window.localStorage.removeItem('nx-ims'); - } + // Always remove rather than restoring a prior value — if a leak entered + // this block, restoring it would propagate the leak to later test files. + window.localStorage.removeItem('nx-ims'); + document.querySelectorAll('da-dialog.da-auth-banner').forEach((el) => el.remove()); }); it('Returns a wsProvider and a Y.Doc with maxBackoffTime configured', async () => { @@ -87,7 +84,7 @@ describe('prose/index createConnection', () => { expect(result.ydoc).to.exist; expect(result.wsProvider.maxBackoffTime).to.equal(30000); // Clean up the underlying WS connection - result.wsProvider.disconnect(); + result.wsProvider.disconnect({ data: 'Client navigation' }); result.wsProvider.destroy?.(); result.ydoc.destroy(); }); @@ -116,7 +113,7 @@ describe('prose/index createConnection', () => { expect(wsProvider.protocols).to.deep.equal(['yjs', 'T-rotated']); expect(wsProvider.shouldConnect).to.equal(true); - wsProvider.disconnect(); + wsProvider.disconnect({ data: 'Client navigation' }); wsProvider.destroy?.(); ydoc.destroy(); } finally { @@ -138,7 +135,7 @@ describe('prose/index createConnection', () => { expect(wsProvider.shouldConnect).to.equal(false); - wsProvider.disconnect(); + wsProvider.disconnect({ data: 'Client navigation' }); wsProvider.destroy?.(); ydoc.destroy(); } finally { @@ -146,15 +143,14 @@ describe('prose/index createConnection', () => { } }); - it('Stops reconnecting on 4401 when imslib cannot produce a token, triggers sign-in', async () => { + it('Stops reconnecting on 4401 when imslib cannot produce a token, shows banner', async () => { window.localStorage.setItem('nx-ims', 'true'); const savedIMS = window.adobeIMS; let refreshCalls = 0; - let signInCalls = 0; window.adobeIMS = { getAccessToken: () => ({ token: 'T-initial' }), refreshToken: async () => { refreshCalls += 1; }, - signIn: () => { signInCalls += 1; }, + signIn: () => {}, }; try { @@ -164,14 +160,15 @@ describe('prose/index createConnection', () => { window.adobeIMS.getAccessToken = () => null; wsProvider.emit('connection-close', [{ code: 4401, reason: 'auth' }, wsProvider]); - // Allow the dynamic import + signIn call to settle - await new Promise((r) => { setTimeout(r, 50); }); + // Allow the dynamic banner import + mount to settle + await new Promise((r) => { setTimeout(r, 80); }); expect(refreshCalls).to.equal(1); expect(wsProvider.shouldConnect).to.equal(false); - expect(signInCalls).to.equal(1); + expect(document.querySelector('da-dialog.da-auth-banner')).to.exist; - wsProvider.disconnect(); + document.querySelector('da-dialog.da-auth-banner')?.remove(); + wsProvider.disconnect({ data: 'Client navigation' }); wsProvider.destroy?.(); ydoc.destroy(); } finally { @@ -179,14 +176,13 @@ describe('prose/index createConnection', () => { } }); - it('Anonymous user hitting a private doc bails on 4401 without sign-in redirect', async () => { + it('Anonymous user hitting a private doc bails on 4401 without showing banner', async () => { window.localStorage.removeItem('nx-ims'); const savedIMS = window.adobeIMS; - let signInCalls = 0; window.adobeIMS = { getAccessToken: () => null, refreshToken: async () => {}, - signIn: () => { signInCalls += 1; }, + signIn: () => {}, }; try { @@ -194,12 +190,12 @@ describe('prose/index createConnection', () => { expect(wsProvider.protocols).to.deep.equal(['yjs']); wsProvider.emit('connection-close', [{ code: 4401, reason: 'auth' }, wsProvider]); - await new Promise((r) => { setTimeout(r, 50); }); + await new Promise((r) => { setTimeout(r, 80); }); expect(wsProvider.shouldConnect).to.equal(false); - expect(signInCalls).to.equal(0); + expect(document.querySelector('da-dialog.da-auth-banner')).to.not.exist; - wsProvider.disconnect(); + wsProvider.disconnect({ data: 'Client navigation' }); wsProvider.destroy?.(); ydoc.destroy(); } finally { @@ -225,7 +221,7 @@ describe('prose/index createConnection', () => { // No new token to try — don't loop. expect(wsProvider.shouldConnect).to.equal(false); - wsProvider.disconnect(); + wsProvider.disconnect({ data: 'Client navigation' }); wsProvider.destroy?.(); ydoc.destroy(); } finally { @@ -249,7 +245,7 @@ describe('prose/index createConnection', () => { expect(wsProvider.protocols).to.deep.equal(['yjs']); expect(wsProvider.shouldConnect).to.equal(true); - wsProvider.disconnect(); + wsProvider.disconnect({ data: 'Client navigation' }); wsProvider.destroy?.(); ydoc.destroy(); } finally { diff --git a/test/unit/blocks/shared/da-auth-banner.test.js b/test/unit/blocks/shared/da-auth-banner.test.js new file mode 100644 index 00000000..0cc2a000 --- /dev/null +++ b/test/unit/blocks/shared/da-auth-banner.test.js @@ -0,0 +1,51 @@ +import { expect } from '@esm-bundle/chai'; +import { setNx } from '../../../../scripts/utils.js'; + +setNx('/test/fixtures/nx', { hostname: 'example.com' }); + +const { showAuthBanner } = await import('../../../../blocks/shared/da-auth-banner/da-auth-banner.js'); + +const wait = (ms) => new Promise((r) => { setTimeout(r, ms); }); + +describe('da-auth-banner', () => { + let savedIMS; + + beforeEach(() => { + savedIMS = window.adobeIMS; + window.localStorage.removeItem('nx-ims'); + document.querySelectorAll('da-dialog.da-auth-banner').forEach((el) => el.remove()); + }); + + afterEach(() => { + if (savedIMS === undefined) delete window.adobeIMS; else window.adobeIMS = savedIMS; + // The Sign-in button calls handleSignIn which sets nx-ims; always remove + // it so the leaked flag doesn't trip later tests that don't configure setNx. + window.localStorage.removeItem('nx-ims'); + document.querySelectorAll('da-dialog.da-auth-banner').forEach((el) => el.remove()); + }); + + it('showAuthBanner mounts a single banner element', () => { + const a = showAuthBanner(); + const b = showAuthBanner(); + expect(a).to.equal(b); + expect(document.querySelectorAll('da-dialog.da-auth-banner').length).to.equal(1); + }); + + it('Mounts a da-dialog with the expected title and action label', async () => { + const banner = showAuthBanner(); + await banner.updateComplete; + expect(banner.title).to.equal('Your session has expired'); + expect(banner.action?.label).to.equal('Sign in'); + }); + + it('Sign-in action calls handleSignIn which invokes adobeIMS.signIn', async () => { + let signInCalls = 0; + window.adobeIMS = { signIn: () => { signInCalls += 1; } }; + + const banner = showAuthBanner(); + await banner.updateComplete; + await banner.action.click(); + await wait(50); + expect(signInCalls).to.equal(1); + }); +}); diff --git a/test/unit/blocks/shared/utils.test.js b/test/unit/blocks/shared/utils.test.js index f14e5115..16a72b71 100644 --- a/test/unit/blocks/shared/utils.test.js +++ b/test/unit/blocks/shared/utils.test.js @@ -1,4 +1,5 @@ import { expect } from '@esm-bundle/chai'; +import { setNx } from '../../../../scripts/utils.js'; import { daFetch, etcFetch, @@ -14,6 +15,11 @@ import { getAuthToken, } from '../../../../blocks/shared/utils.js'; +// daFetch's 401-no-token path lazy-loads the banner module, which resolves +// `${getNx()}/utils/utils.js`. Configure nx for the test environment so that +// import works. +setNx('/test/fixtures/nx', { hostname: 'example.com' }); + describe('getSheetByIndex', () => { it('Returns data directly for non-multi-sheet', () => { const json = { ':type': 'sheet', data: [{ a: 1 }] }; @@ -180,20 +186,17 @@ describe('sanitizeName', () => { }); describe('getAuthToken', () => { - let savedLocalStorage; let savedAdobeIMS; beforeEach(() => { - savedLocalStorage = window.localStorage.getItem('nx-ims'); savedAdobeIMS = window.adobeIMS; + window.localStorage.removeItem('nx-ims'); }); afterEach(() => { - if (savedLocalStorage) { - window.localStorage.setItem('nx-ims', savedLocalStorage); - } else { - window.localStorage.removeItem('nx-ims'); - } + // Always remove rather than restoring — preserving a leaked value would + // propagate it to later tests/files whose getAuthToken can't survive it. + window.localStorage.removeItem('nx-ims'); if (savedAdobeIMS === undefined) { delete window.adobeIMS; } else { @@ -232,20 +235,15 @@ describe('getAuthToken', () => { describe('daFetch', () => { let savedFetch; - let savedLocalStorage; beforeEach(() => { savedFetch = window.fetch; - savedLocalStorage = window.localStorage.getItem('nx-ims'); + window.localStorage.removeItem('nx-ims'); }); afterEach(() => { window.fetch = savedFetch; - if (savedLocalStorage) { - window.localStorage.setItem('nx-ims', savedLocalStorage); - } else { - window.localStorage.removeItem('nx-ims'); - } + window.localStorage.removeItem('nx-ims'); }); it('Defaults headers when none are provided', async () => { @@ -303,6 +301,61 @@ describe('daFetch', () => { expect(resp.permissions).to.deep.equal(['read', 'write']); }); + it('On 401 with no token, dispatches banner instead of redirecting to IMS', async () => { + window.localStorage.removeItem('nx-ims'); + const savedIMS = window.adobeIMS; + delete window.adobeIMS; + + window.fetch = () => Promise.resolve(new Response('nope', { status: 401 })); + + try { + // DA_ORIGINS in utils.js includes http://localhost:8787 — use that so the + // 401 path triggers without us needing to monkey-patch the origin list. + const resp = await daFetch('http://localhost:8787/source/o/r/p.html'); + // Wait for the dynamic banner import to settle. + await new Promise((r) => { setTimeout(r, 80); }); + + expect(resp.ok).to.equal(false); + expect(document.querySelector('da-dialog.da-auth-banner')).to.exist; + } finally { + document.querySelector('da-dialog.da-auth-banner')?.remove(); + if (savedIMS === undefined) delete window.adobeIMS; else window.adobeIMS = savedIMS; + } + }); + + it('On 401 with a token, refreshToken-and-retry recovers without redirect', async () => { + window.localStorage.setItem('nx-ims', 'true'); + const savedIMS = window.adobeIMS; + let getCalls = 0; + const tokens = ['stale', 'fresh']; + window.adobeIMS = { + getAccessToken: () => { + const t = tokens[Math.min(getCalls, tokens.length - 1)]; + getCalls += 1; + return { token: t }; + }, + refreshToken: async () => {}, + }; + + let fetchCalls = 0; + const capturedAuth = []; + window.fetch = (url, opts) => { + fetchCalls += 1; + capturedAuth.push(opts?.headers?.Authorization); + // First call returns 401; second returns 200. + return Promise.resolve(new Response('ok', { status: fetchCalls === 1 ? 401 : 200 })); + }; + + try { + const resp = await daFetch('http://localhost:8787/source/o/r/p.html'); + expect(resp.ok).to.equal(true); + expect(fetchCalls).to.equal(2); + expect(capturedAuth).to.deep.equal(['Bearer stale', 'Bearer fresh']); + } finally { + if (savedIMS === undefined) delete window.adobeIMS; else window.adobeIMS = savedIMS; + } + }); + it('Returns 403 response directly', async () => { window.localStorage.removeItem('nx-ims'); window.fetch = () => Promise.resolve(new Response('forbidden', { status: 403 }));