Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
50a9ba3
Auto-recover collab connection on cross-tab IMS sign-in/out
chrischrischris May 15, 2026
d9709fe
Surface auth failures with an in-page banner instead of redirecting
chrischrischris May 15, 2026
bfc8d6f
Auth dialog: redirect home on sign-out, reload on cross-tab sign-in
chrischrischris May 15, 2026
bdd9f80
Redirect only the sign-out tab home, not every signed-in tab
chrischrischris May 15, 2026
6a8ecb9
Don't redirect home on a failed sign-in attempt
chrischrischris May 15, 2026
0b376de
Two fixes to the sign-in flow
chrischrischris May 15, 2026
ce6f452
Detect cross-tab sign-out by re-checking adobeIMS state on storage ev…
chrischrischris May 15, 2026
62ef986
Drop duplicate auth listeners — global monitor covers them
chrischrischris May 15, 2026
50033ae
Move auth monitor next to initIms; don't auto-focus modal button
chrischrischris May 15, 2026
1fca19e
Cycle the collab WS on cross-tab sign-out so the 4401 path engages
chrischrischris May 15, 2026
0de8ce7
Drop the da-auth-lost WS-cycle path
chrischrischris May 15, 2026
f3ffa48
Drop the collab WS on cross-tab sign-out
chrischrischris May 15, 2026
e3fa063
clean up comments
chrischrischris May 15, 2026
a46f787
Break the nx-ims leak chain in test cleanups
chrischrischris May 15, 2026
ec40bd8
Use da-dialog for the auth modal; harden initIms
chrischrischris May 15, 2026
d3a45a5
Hide da-dialog's close button on the auth modal
chrischrischris May 15, 2026
28435da
Inline the close-button-hide rule
chrischrischris May 15, 2026
fa26ad0
Add ability to hide da-dialog close button
chrischrischris May 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 7 additions & 12 deletions blocks/edit/prose/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down
39 changes: 39 additions & 0 deletions blocks/shared/da-auth-banner/da-auth-banner.js
Original file line number Diff line number Diff line change
@@ -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;
}
10 changes: 8 additions & 2 deletions blocks/shared/da-dialog/da-dialog.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -65,12 +71,12 @@ export default class DaDialog extends LitElement {
<div class="da-dialog-inner ${sizeClass} ${emphasisClass}" part="inner">
<div class="da-dialog-header" part="header">
<p class="sl-heading-m">${this.title}</p>
<button
${this.showCloseButton ? html`<button
class="da-dialog-close-btn"
@click=${this.close}
aria-label="Close dialog">
<svg class="icon"><use href="/blocks/browse/img/S2IconClose20N-icon.svg#S2IconClose20N-icon"></use></svg>
</button>
</button>` : nothing}
</div>
<hr/>
<div class="da-dialog-content" part="content">
Expand Down
73 changes: 54 additions & 19 deletions blocks/shared/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -35,32 +60,42 @@ 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`;
return { ok: false };
}
// 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();
}
}

Expand Down
14 changes: 11 additions & 3 deletions scripts/scripts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
46 changes: 21 additions & 25 deletions test/unit/blocks/edit/prose/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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();
});
Expand Down Expand Up @@ -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 {
Expand All @@ -138,23 +135,22 @@ describe('prose/index createConnection', () => {

expect(wsProvider.shouldConnect).to.equal(false);

wsProvider.disconnect();
wsProvider.disconnect({ data: 'Client navigation' });
wsProvider.destroy?.();
ydoc.destroy();
} finally {
if (savedIMS === undefined) delete window.adobeIMS; else window.adobeIMS = savedIMS;
}
});

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 {
Expand All @@ -164,42 +160,42 @@ 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 {
if (savedIMS === undefined) delete window.adobeIMS; else window.adobeIMS = savedIMS;
}
});

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 {
const { wsProvider, ydoc } = await createConnection('https://admin.da.live/source/org/repo/page.html');
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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
Loading
Loading