Skip to content

Session staleness - #328

Open
bourgeoa wants to merge 13 commits into
stagingfrom
session-staleness
Open

bourgeoa wants to merge 13 commits into
stagingfrom
session-staleness

Conversation

@bourgeoa

Copy link
Copy Markdown
Contributor

No description provided.

bourgeoa and others added 2 commits September 15, 2026 19:11
Legacy consumers (and the tests) assign authSession.info; a getter-only property throws on assignment in strict mode. Keep the derived value behind an assignable accessor: an assigned object wins until it is cleared back to undefined.
editable() reads responses cached under fetcher.appNode; they are not keyed by identity, so an anonymous (pre-restore) or previous-identity response keeps answering after a login/logout. Mark every recorded response out-of-date on login/sessionRestore/logout and on any identity change (new sessionChange event, also noticed on refocus so a login made in another tab is caught). The next load of each document then re-fetches it with current credentials.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Unresolved critical and moderate findings affect identity transitions and authorization-cache freshness.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds session-transition detection and invalidates cached rdflib authorization metadata to prevent stale editability results.

Changes:

  • Detects login, logout, refocus, and identity transitions.
  • Emits sessionChange and flags authorization metadata.
  • Adds compatibility wiring and transition/contract tests.
File summaries
File Summary and final review note
test/transitions.test.ts Tests session transition behavior.
test/rdflibEditableFlagContract.test.ts Tests rdflib metadata behavior. Nit (1 vote): does not exercise a real fresh-response load path.
test/flagAuthorizationOnTransitions.test.ts Tests invalidation wiring.
src/logic/solidLogic.ts Connects session transitions to store invalidation.
src/authSession/transitions.ts Detects identity transitions. Moderate (2 votes): may miss active-to-active WebID changes.
src/authSession/flagAuthorizationOnTransitions.ts Flags authorization metadata. Moderate (1 vote): may not force a fresh response due to rdflib metadata lookup behavior.
src/authSession/events.ts Adds the sessionChange event.
src/authSession/authSession.ts Wires transitions and compatibility state. Critical (1 vote): restoring synthesized info can retain a stale identity across transitions.
Review details

Suppressed comments (2)

src/authSession/flagAuthorizationOnTransitions.ts:15

  • After flagAuthorizationMetadata() this does not reliably lead to a fresh response. In rdflib 2.4.0, Fetcher.saveRequestMetadata() records link:requestedURI as a literal, but Fetcher.load() searches it with kb.sym(docuri); the lookup misses the flagged response, so it never sets force and an already-fetched document is returned from cache. Callers such as src/util/utilityLogic.ts:92-96 then still see editable() === undefined after a session transition. Please force/refresh the document or fix the metadata lookup, and cover that path rather than relying on manually inserted fresh metadata.
 * out-of-date. `fetcher.load()` clears the mark for a document and re-fetches
 * it with the current credentials, so editability answers definitively again
 * on the next load. Call sites that need the answer immediately use the async

test/rdflibEditableFlagContract.test.ts:42

  • The “fresh response” path is not exercised here: this test never calls checkEditable() or fetcher.load(); it directly inserts a second request/response into the store. That bypasses the cache invalidation and literal-vs-named-node lookup used by production, so the test can pass while a real post-transition load still returns the stale out-of-date metadata. Set up a real Fetcher with a stubbed network response and assert the load/checkEditable result.
    // The next load records a fresh, authenticated response.
    const fresh = { request: sym('urn:x-auth-test:req-2'), response: sym('urn:x-auth-test:res-2') }
    store.add(fresh.request, LINK('requestedURI'), lit(doc), meta)
    store.add(fresh.request, LINK('response'), fresh.response, meta)
    store.add(fresh.response, HTTPH('wac-allow'), lit('user="read write"'), meta)
    store.add(fresh.response, HTTPH('accept-patch'), lit('text/n3'), meta)
    expect(updater.editable(doc)).toBe('N3PATCH')
  • Files reviewed: 8/8 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/authSession/authSession.ts Outdated
Comment thread src/authSession/transitions.ts

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Three unresolved findings remain, including two critical stale-identity and authorization-cache issues.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 9/9 changed files
  • Comments generated: 3
  • Review effort level: Lite

Comment thread src/authSession/authSession.ts Outdated
Comment thread src/authSession/flagAuthorizationOnTransitions.ts Outdated
Comment thread src/authSession/transitions.ts Outdated
- sessionIsActive(): an explicit isActive:false wins over a cached WebID; every
  snapshot and the legacy authSession.info (legacySessionInfo()) share the rule.
- refreshDocumentAuthorization(): on rdflib 2.4.0 load() cannot re-answer a flagged
  document (requestedURI stored as a literal, looked up as a NamedNode), so call
  sites force-refresh; checkEditable() inherits the limitation.
- tests: authSessionInfo.test.ts; token-update identity change; real-Fetcher
  load-vs-refresh regression.
The ternary widened 'accept-patch' to string | undefined, which HeadersInit
rejects. Surfaces only under tsc -p tsconfig.test.json (the check CI runs);
annotate the object as Record<string, string>.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Unresolved session-fetch and authorization-cache invalidation issues block approval.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (3)

src/authSession/authSession.ts:129

  • The new isLoggedIn: false value is not sufficient for checkUser() to reject the cached WebID: SolidAuthnLogic.webIdFromSession() requires infoLoggedIn === false && rootLoggedIn === false && rootActive === false, but the actual session root has no isLoggedIn property, so rootLoggedIn is undefined and the function falls through to return this webId. After logout with a retained WebID, checkUser() can therefore still return the old identity; make the explicit inactive state win without requiring an absent root flag to be false.
export function legacySessionInfo (session: SessionLike): { webId?: string; isLoggedIn?: boolean } {
  return {
    webId: session.webId,
    isLoggedIn: sessionIsActive(session)

src/authSession/authSession.ts:129

  • Although this derived value now reports isLoggedIn: false, SolidAuthnLogic.currentUser() still ends its decision with || Boolean(this.fallbackWebId) and does not clear that fallback on logout/session transitions. Once checkUser() has cached identity A there, a logout can still make currentUser() return A despite the inactive session; invalidate or gate the fallback when the session explicitly reports inactive.
export function legacySessionInfo (session: SessionLike): { webId?: string; isLoggedIn?: boolean } {
  return {
    webId: session.webId,
    isLoggedIn: sessionIsActive(session)

src/authSession/flagAuthorizationOnTransitions.ts:81

  • The pinned rdflib 2.4.0 fetcher.refresh() is callback-based and returns void, so awaiting this call resumes before the refresh response records new authorization metadata. The helper can therefore read editable() while it is still undefined; its test misses this because the fake refresh returns a Promise. Promisify the refresh callback and only read editability after that callback completes.
    try {
      await refresh(doc)
  • Files reviewed: 10/10 changed files
  • Comments generated: 3
  • Review effort level: Lite

Comment thread src/authSession/authSession.ts
Comment thread src/authSession/flagAuthorizationOnTransitions.ts Outdated
Comment thread src/logic/solidLogic.ts
- transitions.ts: sessionExplicitlyInactive() as the shared rule —
  an explicit isActive/isLoggedIn false beats a cached WebID.
- solidLogicSingleton fetch bridge: choose the authenticated fetch only for a
  session that has not explicitly gone inactive.
- SolidAuthnLogic: webIdFromSession() lets any explicit false win (the session
  root has no isLoggedIn, so requiring every source to be false kept a cached
  WebID alive across a logout); currentUser() drops its remembered fallback and
  reports logged out when the session explicitly went inactive.
- flagAuthorizationOnTransitions.ts: rdflib refresh() is callback-based — wait
  for its completion callback before reading editable(); a failing flag or
  refresh is warned instead of swallowed.
- utilityLogic: the two link-creation decision points repair an unknown
  (flagged) answer through refreshDocumentAuthorization().
- tests: +8 (119 total).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Unresolved critical session-state and authorization-refresh issues, plus a moderate cache-refresh documentation issue, remain.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (2)

src/authn/SolidAuthnLogic.ts:294

  • Returning null for an explicitly inactive session now sends resolveCurrentUser() into the NSS cookie fallback at SolidAuthnLogic.ts:195-198. After a real logout, the uvdsl session clears its local state but does not clear the server cookie, so probeNssCookieBackedWebId() can recover the same WebID and checkUser() immediately re-establishes the logged-out identity. Skip that fallback for an explicit logout (while retaining it for a fresh/unknown session), and add a regression test for the cookie-backed case.
    if (infoLoggedIn === false || rootLoggedIn === false || rootActive === false) {

src/logic/solidLogic.ts:33

  • With the pinned rdflib 2.4.0, fetcher.load() does not clear the flagged request for an already-loaded document; editable() remains undefined until refreshDocumentAuthorization() calls refresh(). This comment therefore promises a repair that the wiring alone does not provide and can mislead future call sites. Describe the force-refresh requirement instead.
    // Whose credentials a request would carry changed: mark every recorded
    // response out-of-date so editability re-answers per document on its next
    // load instead of reporting the previous identity's access. See
    // flagAuthorizationOnTransitions.ts.
    flagAuthorizationOnSessionTransitions(store, session)
  • Files reviewed: 14/14 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment thread src/authn/SolidAuthnLogic.ts Outdated
Comment thread src/util/utilityLogic.ts Outdated
- webIdFromSession(): the explicit-false check now precedes the positive
  ones, so a mixed snapshot (info logged in, root inactive) is logged out —
  the same rule as sessionExplicitlyInactive(). The explicit-true branch was
  dropped: it returned what the final fallback already returns.
- refreshDocumentAuthorization(): each attempt is stamped with the transition
  generation (bumped on every identity event) and rechecked after the refresh
  records its response; when the identity changed in flight the response
  belongs to the previous identity, so the refresh is repeated (3 attempts)
  and otherwise the answer stays unknown instead of stale.
- tests: mixed-source logout case; overtaken refresh retries and the overtaken
  response is never read; always-overtaken refresh fails closed. +3 (122 total).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Unresolved critical stale-authorization and cached-data issues can permit incorrect identity or editability decisions.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (3)

src/authSession/flagAuthorizationOnTransitions.ts:79

  • This generation counter is module-global even though transition handlers are registered per store/session. If two createSolidLogic instances use different sessions, a transition in session B makes an in-flight refresh for session A look overtaken and can force retries or an undefined result despite A never changing. Scope the generation to the relevant store/session (or pass a per-session generation into the refresh helper).
// Every observed identity transition is counted, so an in-flight
// authorization refresh can tell whether the response it recorded still
// belongs to the identity that asked for it — see
// refreshDocumentAuthorization().
let authorizationGeneration = 0

src/logic/solidLogic.ts:32

  • This comment says editability is repaired by the document's next load(), but the pinned rdflib 2.4.0 path deliberately does not refetch a flagged, already-loaded document. The actual repair is the explicit refreshDocumentAuthorization() used by decision points, so this wording can mislead future callers into relying on load() and reintroducing the stale-authorization bug.
    // Whose credentials a request would carry changed: mark every recorded
    // response out-of-date so editability re-answers per document on its next
    // load instead of reporting the previous identity's access. See
    // flagAuthorizationOnTransitions.ts.

src/util/utilityLogic.ts:137

  • The same stale-data bypass exists here: after a flagged load(), the store.any(...) result on the preceding line can be a link from the previous identity, so this new refresh is skipped and that private link is returned. Force/validate a fresh document before reading cached triples, rather than only repairing editability when no result exists.
    const editable = store.updater.editable(doc) ??
      await refreshDocumentAuthorization(store, doc)
  • Files reviewed: 14/14 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment thread src/authSession/flagAuthorizationOnTransitions.ts Outdated
Comment thread src/util/utilityLogic.ts Outdated
- flagAuthorizationOnTransitions.ts: the transition generation and a new
  refreshRequired flag live in a per-store WeakMap — two createSolidLogic
  instances with different sessions no longer overtake each other's refreshes.
  A failed flag records refreshRequired (cleared by the next successful flag)
  instead of being treated as recovery.
- ensureDocumentAuthorization(): repairs a flagged or unrepairable document
  before a caller reads its triples; used by the utilityLogic decision points,
  which now repair BEFORE store.any, so a link that was loaded under the
  previous identity is never returned after an A→B switch. The editable()
  gate is definitive after the repair and fails closed on undefined.
- solidLogic.ts: the comment points at the force repair instead of implying
  load() re-answers.
- tests: +4 (126 total).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Unresolved authorization-cache safety and session-fallback issues require fixes before approval.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (4)

src/authSession/flagAuthorizationOnTransitions.ts:130

  • forceRefresh() resolves even when the refresh callback reports failure or the refresh call throws, and this line then returns whatever editable() has cached. When flagging failed, that value is explicitly from the previous identity, so ensureDocumentAuthorization() can treat a failed repair as a definitive permission and allow stale data/writes. Carry refresh success through this helper and return undefined on failure instead of reading the old authorization result.
      return store.updater?.editable?.(doc)

src/authSession/flagAuthorizationOnTransitions.ts:150

  • refreshDocumentAuthorization() can return undefined when every refresh attempt is overtaken, but this helper discards that result and still resolves. The utility callers then execute store.any(...) and may return the old cached link before the later editable() check, so the documented fail-closed behavior is not achieved. Propagate the unknown result or throw before callers consume cached triples, while preserving a definitive false (read-only) result.
  if (state.refreshRequired || store.updater?.editable?.(doc) === undefined) {
    await refreshDocumentAuthorization(store, doc)

src/authn/SolidAuthnLogic.ts:54

  • This guard also clears the NSS cookie-backed fallback that resolveCurrentUser() intentionally sets when the OIDC session has no active client state (probeNssCookieBackedWebId() at SolidAuthnLogic.ts:194-202). After a successful cookie probe, a later currentUser() still sees sessionExplicitlyInactive(), drops fallbackWebId, and returns null, so the fallback login cannot remain usable. Distinguish a remembered pre-logout fallback from a freshly probed cookie-backed identity, or clear the fallback only on an observed logout transition.
    if (sessionExplicitlyInactive(sessionAny)) {
      // A logout that leaves the WebID cached must not keep answering for the
      // previous user: drop the remembered fallback and report logged out.
      this.fallbackWebId = null
      return offlineTestID() // null unless testing

src/util/utilityLogic.ts:134

  • The same fail-open path exists here: ensureDocumentAuthorization() may return after an unsuccessful or overtaken refresh, yet store.any still consumes the cached graph. That can return a link loaded under the previous identity; do not read triples unless the authorization repair reports a current result.
    await ensureDocumentAuthorization(store, doc)
    const result = store.any(subject, predicate, null, doc)
  • Files reviewed: 14/14 changed files
  • Comments generated: 3
  • Review effort level: Lite

Comment thread src/authSession/flagAuthorizationOnTransitions.ts Outdated
Comment thread src/logic/solidLogic.ts
Comment thread src/util/utilityLogic.ts Outdated
- flagAuthorizationOnTransitions.ts: a missing flagAuthorizationMetadata is
  a failed invalidation, not a success (refreshRequired is recorded); the flag
  handler throws internally and warns.
- forceRefresh() reports whether a refresh completed and
  refreshDocumentAuthorization() returns undefined when it did not (no
  capability, failed callback, rejected promise, throw) — the recorded answer
  from the previous identity is never returned as current.
- ensureDocumentAuthorization() now returns whether the answer was
  established; a definitive false (read-only) counts as established.
- utilityLogic: both decision points fail closed (NotEditableError) before
  store.any() when the repair could not complete.
- SolidAuthnLogic: cookieBackedFallback distinguishes the NSS cookie-probed
  identity (survives an inactive OIDC session) from a session-derived fallback
  (dropped on explicit inactivation) — the earlier stand-down broke the
  cookie-backed login.
- tests: +4 (130 total).
- identityReplaced(prev, next): true when a session that HAD a WebID no
  longer reports the same one (A -> B) or no longer reports being active
  (A -> logged out/none). Start-up and same-identity token refreshes are not
  replacements.
- watchSessionTransitions emits the new identityReplaced event alongside
  logout/sessionChange; events.ts gains the name.
- reloadOnIdentityReplaced(events, reload?): consumer-side helper — the
  pragmatic way for a consumer holding data fetched under the previous
  identity to drop it (default action is a page reload; tests inject their
  own). Exported from the package index so a consumer wires it in one line.
- tests: identityReplaced truth table, emission order, reload helper. +6
  (136 total).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Unresolved authorization-generation and identity-transition issues could leave stale session data.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (3)

src/authSession/transitions.ts:60

  • note() runs on every visible refocus, not only when classifySessionTransition reports a change. If the page starts in the partial-logout state { isActive: false, webId: A }, this expression returns true for an unchanged snapshot solely because !next.isActive is true, so every refocus emits identityReplaced and can repeatedly trigger the consumer's reload. Gate the inactive transition on prev.isActive while retaining the WebID-difference case.
  return next.webId !== prev.webId || !next.isActive

src/authn/SolidAuthnLogic.ts:213

  • The NSS cookie fallback is a second identity source, but changing these fields emits no session event. Because the OIDC session remains inactive and has no WebID, the transition watcher cannot observe anonymous→cookie-user, cookie-user A→B, or cookie logout; the shared RDF store and authorization metadata therefore remain associated with the prior identity and reload consumers receive no identityReplaced signal. Treat changes to the cookie-backed fallback as an identity transition (and wire that event through invalidation/reload).
    if (webId) {
      this.fallbackWebId = webId
      this.cookieBackedFallback = cookieBacked

src/util/utilityLogic.ts:140

  • The same in-flight-load race exists here: a response begun before a session transition can be recorded after the invalidation and appear definitive to ensureDocumentAuthorization(). The subsequent store.any can therefore read the old-identity graph without a force refresh; include this load in the generation check/retry rather than relying only on the later helper.
    await store.fetcher.load(doc)
    if (!(await ensureDocumentAuthorization(store, doc))) {
      const msg = `followOrCreateLinkWithContentOnCreate: cannot establish the authorization of ${doc.value}`
      debug.warn(msg)
      throw new NotEditableError(msg)
  • Files reviewed: 15/15 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread src/util/utilityLogic.ts Outdated
- transitions.ts: identityReplaced now requires the transition OUT of an
  active session (prev.isActive && !next.isActive for the same WebID), so a
  steady partial-logout snapshot no longer reports a replacement on every
  refocus — which would have made a reload consumer loop.
- flagAuthorizationOnTransitions.ts: loadAuthorizedDocument(store, doc) — the
  helper owns the load, stamps the store generation around it (a response
  begun before a transition can be recorded after the flag, unflagged) and
  force-refreshes when the load was overtaken; returns whether the document
  can be consumed. utilityLogic uses it at both decision points.
- SolidAuthnLogic: the NSS cookie fallback is a second identity source the
  watcher cannot see. reportFallbackIdentityChange() emits sessionChange when
  it changes and identityReplaced when an established cookie identity was
  replaced or cleared (anonymous -> cookie gets sessionChange only).
- tests: +8 (144 total).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Cross-tab refocus synchronization and duplicate fallback notifications remain unresolved.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (1)

src/authSession/transitions.ts:181

  • On refocus this only calls note(), which snapshots the current tab's in-memory session; it never synchronizes from the shared session database. The local-dev path constructs SessionCore with IndexedDB (src/authSession/session.ts:177-189), and that instance does not receive another tab's login/logout, so after a cross-tab change previous and next remain identical and no transition is emitted. Trigger a restore/synchronization or add a cross-tab signal before comparing snapshots.
      if (doc.visibilityState === 'visible') note()
  • Files reviewed: 15/15 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread src/authn/SolidAuthnLogic.ts
- session.ts: sessionHasCrossTabPush() reports whether the chosen session
  receives another tab's change as a pushed event (WebSession/SharedWorker) —
  false for the SessionCore + IndexedDB session used in local dev and as the
  worker fallback.
- transitions.ts: watchSessionTransitions takes an optional resync action; the
  visibility listener re-reads the session BEFORE comparing snapshots, so a
  cross-tab login/logout is observed even when nothing pushes it. Workers push
  the change, so no resync is wired for them.
- authSession.ts: wires the resync (the session's restore()) only when
  sessionHasCrossTabPush() is false.
- SolidAuthnLogic: the fallback reporter is cookie-only — an OIDC identity
  change is already emitted by the watcher (with identityReplaced), so
  reporting it here duplicated both events and a reload consumer reloaded
  twice. The signature now takes the previous cookieBacked flag.
- tests: +3 (147 total).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Unresolved cross-tab resynchronization, logout detection, and duplicate transition signals remain.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (3)

src/authSession/authSession.ts:110

  • sessionHasCrossTabPush() is a static choice made during construction, but SolidAuthnLogic.resolveCurrentUser() already documents that a WebSession can construct successfully and later have a worker whose restore() hangs and times out. In that failure mode this branch still disables the visibility resync, so a cross-tab login/logout cannot update the local snapshot and no transition is emitted. The worker-health/timeout path needs to disable this optimization or otherwise retain a bounded refocus resync.
const resyncSession = sessionHasCrossTabPush()
  ? undefined
  : () => {

src/authSession/session.ts:184

  • crossTabPush is set to true solely because new WebSession() returned. A SharedWorker can be constructed successfully and then fail to load or connect asynchronously; this codebase already documents that such a worker can leave restore() pending. In that case authSession.ts passes no resync callback, so visibility changes never re-read the session and cross-tab login/logout transitions remain invisible. Detect worker readiness/failure and switch to a guarded resync path instead of treating constructor success as proof that push events are available.
// Whether the chosen session receives another tab's identity change as a
// pushed event (WebSession + SharedWorker) or must be re-read on refocus
// (SessionCore + IndexedDB). See watchSessionTransitions().
let crossTabPush = true

/** For consumers that must re-read the session when a tab regains focus. */
export function sessionHasCrossTabPush (): boolean {
  return crossTabPush

src/authn/SolidAuthnLogic.ts:310

  • This branch treats every non-null previous fallback as an established cookie identity, even when previousCookieBacked is false. For an OIDC user B followed by logout while a different cookie user A is available, the watcher already emits identityReplaced for B → inactive, then checkUser() reaches this branch and emits a second replacement for B → A; a reload consumer can therefore reload twice. The inverse cookie → OIDC path also duplicates the watcher’s sessionChange. Coordinate fallback reporting with the raw-session transition (and add integration coverage) so each transition signal is emitted once.
    if (previousFallback !== null) {
      // An established cookie-backed identity was replaced or cleared.
      events.emit('sessionChange')
      events.emit('identityReplaced')
  • Files reviewed: 16/16 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread src/authSession/authSession.ts Outdated
- session.ts / authSession.ts: the static sessionHasCrossTabPush() shortcut is
  gone. A worker can be constructed and then never answer, so "the worker
  exists" is not proof that a cross-tab change is pushed. The refocus resync is
  now always wired and bounded (restore() raced with a 2 s timeout), so a hung
  session can neither leave the tab blind nor stall the comparison.
- transitions.ts: a resync may resolve 'cleared' — the backing store no longer
  holds a session (a cross-tab logout). The logout + identityReplaced for the
  snapshot that was active are reported, and the session is treated as cleared
  so later refocuses do not repeat it. Transient failures still compare as they
  stand (a refresh error is not a logout).
- authSession.ts maps a 'no session to restore' rejection to 'cleared'; any
  other failure stays 'changed'.
- SolidAuthnLogic: the fallback reporter emits only what the watcher cannot —
  invalidation when the raw session is not active, and the replacement only
  when the identity being replaced was cookie-backed (an OIDC identity
  superseded by a cookie one was already reported by the watcher).
- tests: +3 (150 total).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Unresolved moderate issues affect restore resynchronization, fallback identity refresh, and duplicate transition events.

Review details

Suppressed comments (4)

src/authSession/authSession.ts:124

  • If restore() takes longer than 2 seconds, this race resolves as 'changed' and syncThenNote() compares the current snapshot, but the eventual restore outcome is discarded. A slow cross-tab logout can therefore reject with No session to restore after the timeout without emitting logout/identityReplaced, leaving this tab on the old identity until another visibility event; keep a continuation for the timed-out restore (with duplicate suppression) or otherwise do not discard its result.
  return Promise.race([
    restored,
    new Promise<'changed'>((resolve) => setTimeout(() => resolve('changed'), RESYNC_TIMEOUT_MS))

src/authSession/authSession.ts:127

  • The visibility resync only re-reads _session; it never re-runs the NSS cookie probe or updates SolidAuthnLogic.fallbackWebId, which is changed only by checkUser(). If this tab is using cookie-backed identity A and another tab logs out or switches identity while it is backgrounded, the raw OIDC session can remain inactive/unchanged, so refocus emits no replacement and currentUser() can continue returning cached A. Revalidate the cookie-backed source on refocus (and route the result through reportFallbackIdentityChange) before treating the snapshot as current.
watchSessionTransitions(_session as unknown as SessionLike, (event) => events.emit(event), undefined, resyncSession)

src/authSession/events.ts:17

  • The event documentation now lists logout and sessionChange but omits the newly supported identityReplaced event, even though it is emitted by this watcher and is the event consumed by the exported reload helper. Include it in this public compatibility-layer event list so consumers can discover the new subscription.
 * and by the transition watcher in authSession.ts (logout, sessionChange).

src/authSession/transitions.ts:60

  • After an active A→inactive A transition, watchSessionTransitions stores { isActive: false, webId: A } as previous after already emitting identityReplaced. A subsequent clear (A→undefined) or login as B still satisfies next.webId !== prev.webId here, so identityReplaced is emitted a second time. Require the previous snapshot to be active, or otherwise track that the replacement was already reported, before emitting another replacement.
  if (prev.webId === undefined) return false
  if (next.webId !== prev.webId) return true
  • Files reviewed: 15/15 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

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