Skip to content

feat: Auth state management: in-page modal + cross-tab propagation#941

Merged
chrischrischris merged 18 commits into
mainfrom
authbnnr
May 15, 2026
Merged

feat: Auth state management: in-page modal + cross-tab propagation#941
chrischrischris merged 18 commits into
mainfrom
authbnnr

Conversation

@chrischrischris
Copy link
Copy Markdown
Contributor

@chrischrischris chrischrischris commented May 15, 2026

Why

Three related gaps in how da-live handles auth state changes:

  1. Token failures used to navigate the user away. A daFetch 401 or an unrecoverable collab 4401 close called handleSignIn(), which redirects to IMS. Any unsaved editor state was lost in the round-trip.
  2. Cross-tab sign-out wasn't propagating. With nx2's handleSignOut (which doesn't touch nx-ims), signing out in Tab A left Tab B's editor fully functional. The user could keep typing — and da-collab kept persisting Tab B's edits to da-admin using the bearer it cached at WS handshake, since access tokens are stateless and survive IMS sign-out until their natural expiry.
  3. No coordinated UX for "session lost". Different code paths handled auth loss differently (or not at all): daFetch redirected to IMS, prose silently retried, etc.

What

  • <da-auth-banner> modal. Native <dialog> with showModal() that blocks page interaction; "Sign in" button triggers the standard IMS flow. Mounted via showAuthBanner() (idempotent — second call returns the existing instance). Replaces what used to be a redirect.
  • Cross-tab auth monitor. Single storage event listener (attached as a side-effect of the first initIms() call) that re-reads window.adobeIMS.getAccessToken() on every change. Works under both nx and nx2 because it inspects live imslib state instead of keying off nx-ims. On auth loss → mount the modal + wsProvider.disconnect() (so da-collab can't keep authoring writes with Tab B's cached bearer). On auth gain → window.location.reload() to pick up the new session cleanly.
  • daFetch refresh-and-retry on 401. Before any user-visible disruption, ask imslib to refresh the token and retry the failed request once. Rescues the cross-tab "just signed in, but this tab hasn't caught up yet" race. Falls through to the modal only if there's truly no token.
  • prose/index.js 4401 handling. When the collab WS reconnect handshake gets 4401 'auth' from da-collab (new in da-collab#149), refresh the token and retry. If unrecoverable and the user had a token, mount the modal.
  • Sign-out lands on home. scripts.js inspects the IMS callback URL (old_hash= present, access_token= absent → sign-out return) and window.location.replace('/') before loadArea() runs, so the sign-out tab doesn't briefly mount the editor + modal for the doc the user just left.

Trust model notes

da-collab's auth check is handshake-onlywebSocket.auth is cached and never re-verified. The only durable protections against post-sign-out writes are:

  • Server-side (da-collab/shareddoc.js:368): when da-admin returns 401 on a persist, da-collab calls closeConn on every connection in the room. So token rotation past expiry self-heals — every tab gets force-reconnected with whatever token getAuthToken() reads live (which imslib has rotated by then). This is what makes 24+ hour sessions work transparently.
  • Client-side (this PR): the auth monitor calls wsProvider.disconnect() the moment cross-tab sign-out is detected, so Tab B drops out of the room before any further yjs updates can flow through its still-authed connection.

chrischrischris and others added 13 commits May 15, 2026 07:04
A storage event listener watches nx-ims so disconnected tabs reconnect
automatically when the user signs back in elsewhere, instead of waiting
for the user to hit back. The inverse signal (sign-out elsewhere) drops
the connection so we stop hammering with a token that is about to be
revoked. The listener is detached on navigation-flagged disconnects so
it does not leak across editor instances.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously, a collab 4401 we couldn't recover from or a daFetch 401 with
no token both navigated the tab to IMS sign-in, destroying any unsaved
editor state. Now we mount a <da-auth-banner> at the top of the viewport
instead. The banner watches the nx-ims storage key and auto-dismisses
when another tab signs in, calling refreshToken so pending state can pick
up the new session. Manual "Sign in" still triggers handleSignIn for the
no-other-tab case. daFetch now also tries window.adobeIMS.refreshToken()
+ a single retry on 401 before any user-visible disruption, which covers
the cross-tab "just signed in" race.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three fixes addressing reported issues:

1. Global storage listener in scripts.js navigates every tab to '/' when
   any tab clears nx-ims, so an explicit sign-out is never followed by
   a session-expired dialog elsewhere.

2. The auth UI is now a modal <dialog> opened with showModal() instead
   of an unobtrusive banner, blocking page interaction until the user
   signs in or another tab takes them somewhere.

3. On cross-tab sign-in, the dialog now reloads the page rather than
   trying to refresh imslib state in place — that path didn't reliably
   update the dialog tab's session, leaving the dialog stuck open.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Reverts the global storage listener that navigated every tab to '/' on
nx-ims removal. Other tabs now stay on their page and show the session
dialog as normal. The originating tab is redirected to '/' by detecting
the IMS callback URL after sign-out: returning from IMS without an
nx-ims flag means the round-trip was a sign-out (sign-in would have
populated nx-ims), so we replace location with '/' before loading the
area that the user signed out from.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous logic redirected to '/' whenever a tab returned from IMS
without a token, which also fired when the user clicked "Sign in" on
the modal but IMS didn't grant a token (e.g. SSO cookie scrubbed by a
recent sign-out, or user cancelled the form). Snapshot nx-ims before
imsReady touches it so we can tell sign-in (was 'true') from sign-out
(was empty) and only redirect for the sign-out case.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1. Add type=\"button\" to the modal's Sign in button. Inside a native
   <dialog>, a <button> with no type defaults to type=\"submit\" and
   triggers the dialog's implicit form submission, closing the modal
   and potentially navigating the page before the click handler can run.

2. Detect sign-in vs sign-out callbacks by inspecting the URL hash for
   access_token= rather than tracking nx-ims across navigation. nx
   manages the flag inside handleSignIn/handleSignOut but nx2 does not,
   so any logic relying on nx-ims breaks under nx2. The access_token
   presence in the IMS callback URL is a reliable signal in both.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ents

The earlier per-feature storage listeners keyed off the nx-ims flag, but
nx2's handleSignOut doesn't touch nx-ims — only imslib's internal
session keys change. After imsReady, watch every storage event and
re-evaluate adobeIMS.getAccessToken(): if we just lost the token, show
the modal; if we just gained one, reload to pick up the fresh session.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three storage-event listeners were doing overlapping work: one in
createConnection (nx-ims), one in the modal (nx-ims), and one global
monitor in scripts.js (live adobeIMS state). The global monitor works
under both nx and nx2 and supersedes the other two:

- createConnection's listener and its disconnect monkey-patch are gone.
  The proactive provider.disconnect() it ran on sign-out only saved the
  brief window before the modal blocks the user anyway, and its sign-in
  reconnect was already dead code (the global monitor reloads first).
- The modal's _onStorage / _reload / _goHome (and the dead _dismiss
  helper + hideAuthBanner export) are gone. The global monitor handles
  the same transitions.
- The 4401 bail's signed-in check switched from localStorage.nx-ims
  (managed only by legacy nx) to lastSentToken, which is in scope and
  reliable under both nx and nx2.

Net: ~234 lines removed from the working tree.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two small refinements:

1. The cross-tab auth monitor moved from scripts.js into utils.js, where
   it sits next to initIms and getAuthToken. initIms attaches it once on
   first call as a side effect, so every page that touches auth gets the
   monitor for free without scripts.js needing to know about it.

2. The session-expired dialog now uses autofocus + tabindex=-1 on the
   dialog itself, so showModal() lands focus on the dialog rather than
   on the Sign-in button. Tab still reaches the button as before.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The auth monitor in utils.js detects cross-tab sign-out via storage
events and mounts the modal, but never touches the WS — and the server
only checks the token at WebSocket handshake, not on the established
connection. So the prose/index.js connection-close handler's 4401 path
never fires from cross-tab sign-out.

Dispatch a da-auth-lost custom event from the auth monitor; the
createConnection wires a one-line listener that disconnects + reconnects
the provider, forcing a fresh handshake that hits the now-401 token and
runs the 4401 branch (idempotent with the modal already shown).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The auth monitor in utils.js already covers cross-tab sign-out by
mounting the modal, and the prose 4401 handler fires naturally for the
scenarios it's designed for (network blip with revoked token, 10-min
blur+focus reconnect, initial connect with bad token). Forcing a WS
disconnect+reconnect on every cross-tab sign-out just to re-mount the
already-mounted modal added complexity without product value — the
server-side 4401 can be verified directly via DevTools network toggle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The modal blocks user input, but the underlying WebSocket stays open
and da-collab still uses the cached webSocket.auth (captured at
handshake) when writing to da-admin. Other users' edits flowing into
the ydoc would be persisted under an auth list that includes the
signed-out user's stale bearer.

Disconnecting the active provider when the auth monitor detects
sign-out closes that window without re-introducing the custom-event
or per-component listener layer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@aem-code-sync
Copy link
Copy Markdown

aem-code-sync Bot commented May 15, 2026

Hello, I'm the AEM Code Sync Bot and I will run some actions to deploy your branch.
In case there are problems, just click the checkbox below to rerun the respective action.

  • Re-sync branch
Commits

The "save and restore" afterEach pattern propagated any pre-existing
leak — if nx-ims was set when the describe started, restoring it on
the way out re-leaked it to subsequent test files whose getAuthToken
then tripped initIms()'s dynamic import with an unset getNx.

Always remove nx-ims in afterEach of the describe blocks I added or
touched (utils.test.js getAuthToken/daFetch, prose/index.test.js
createConnection, da-auth-banner.test.js). The flake reproduced in
~10% of full-suite runs; now stable across 15 consecutive runs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Member

@auniverseaway auniverseaway left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Looks like there's some test failurs.
  2. I'd make sure to use da-dialog if possible.

Replace the custom <da-auth-banner> Lit element + CSS with a thin
factory that creates a da-dialog (matching da-not-found's pattern):
title, body paragraph, action button. Removes ~60 lines of duplicated
modal scaffolding and the standalone CSS file.

Also wrap initIms's dynamic import inside its try/catch so a stray
nx-ims leak under a test session that hasn't called setNx no longer
throws "undefined/utils/ims.js". This was the last remaining flake in
the full-suite test runs (reproduced ~1-in-12 before, 0-in-30 after).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
chrischrischris and others added 2 commits May 15, 2026 16:37
da-dialog's close button lives inside its shadow root, so external CSS
can't reach it through the encapsulation boundary. Restore a
da-auth-banner.css with just the .da-dialog-close-btn { display: none }
rule and inject it into the dialog instance's shadow root via
adoptedStyleSheets after updateComplete.

The auth modal is intentionally blocking — the only escape is to sign
in (or be reloaded via the cross-tab auth monitor).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
One CSS declaration is shorter as a CSSStyleSheet literal at module
init than as a separate .css file routed through nx's loadStyle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@chrischrischris chrischrischris merged commit f9354bd into main May 15, 2026
3 of 4 checks passed
@chrischrischris chrischrischris deleted the authbnnr branch May 15, 2026 20:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants