From 9f5bced203363f875ad3553bf1f98de321711cbf Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Tue, 15 Sep 2026 18:48:52 +0200 Subject: [PATCH 01/19] authSession: keep legacy info assignable 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. --- src/authSession/authSession.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/authSession/authSession.ts b/src/authSession/authSession.ts index 901b0ec..fee6535 100644 --- a/src/authSession/authSession.ts +++ b/src/authSession/authSession.ts @@ -111,4 +111,31 @@ export const authSession: SessionWithLegacyEvents = Object.assign( _session as Omit & { login: LoginCompat }, { events } ) + +// Legacy `info` compatibility shape. +// The uvdsl session stores state on `webId_`/`isActive_` and exposes them via +// `webId`/`isActive` getters, but legacy consumers (e.g. solid-ui's +// `loginStatusBox` widget, `SolidAuthnLogic.currentUser()`'s fallback path) +// read `authSession.info.webId` / `authSession.info.isLoggedIn`. Expose those +// as a derived value — and keep the property assignable. Legacy code (and the +// tests) own an `info` object and assign it; the assignment wins until it is +// cleared back to `undefined`, when the derived value takes over again. +let infoOverride: { webId?: string; isLoggedIn?: boolean } | undefined + +Object.defineProperty(authSession, 'info', { + enumerable: true, + configurable: true, + get (): { webId?: string; isLoggedIn?: boolean } { + if (infoOverride !== undefined) return infoOverride + const sessionAny = _session as any + const isActive = sessionAny.isActive === true || Boolean(sessionAny.webId) + return { + webId: sessionAny.webId, + isLoggedIn: isActive + } + }, + set (value: { webId?: string; isLoggedIn?: boolean } | undefined): void { + infoOverride = value + } +}) \ No newline at end of file From f6cc0c8c7d0386a97d9bd86d35316277187dccc7 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Tue, 15 Sep 2026 18:53:58 +0200 Subject: [PATCH 02/19] authSession: invalidate authorization metadata on identity transitions 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. --- src/authSession/authSession.ts | 20 ++-- src/authSession/events.ts | 4 +- .../flagAuthorizationOnTransitions.ts | 53 ++++++++ src/authSession/transitions.ts | 78 ++++++++++++ src/logic/solidLogic.ts | 6 + test/flagAuthorizationOnTransitions.test.ts | 35 ++++++ test/rdflibEditableFlagContract.test.ts | 44 +++++++ test/transitions.test.ts | 113 ++++++++++++++++++ 8 files changed, 339 insertions(+), 14 deletions(-) create mode 100644 src/authSession/flagAuthorizationOnTransitions.ts create mode 100644 src/authSession/transitions.ts create mode 100644 test/flagAuthorizationOnTransitions.test.ts create mode 100644 test/rdflibEditableFlagContract.test.ts create mode 100644 test/transitions.test.ts diff --git a/src/authSession/authSession.ts b/src/authSession/authSession.ts index fee6535..c8addaf 100644 --- a/src/authSession/authSession.ts +++ b/src/authSession/authSession.ts @@ -14,6 +14,7 @@ import type { Session as OidcSession } from '@uvdsl/solid-oidc-client-browser/co import { _session } from './session' import { resolveIssuerForLogin } from './issuer' import { SessionEvents } from './events' +import { watchSessionTransitions, type SessionLike } from './transitions' type SessionCompatibilityShape = { webId?: string @@ -93,19 +94,14 @@ if (originalLogin) { const events = new SessionEvents() -// Emit the legacy 'logout' event when the session transitions from active to inactive. // 'login' and 'sessionRestore' are emitted in SolidAuthnLogic.checkUser() -// because only that call site knows which path activated the session. -let _wasActive = (_session as any).isActive ?? Boolean((_session as any).webId) -if (typeof (_session as unknown as EventTarget).addEventListener === 'function') { - ;(_session as unknown as EventTarget).addEventListener('sessionStateChange', () => { - const isNowActive = (_session as any).isActive ?? Boolean((_session as any).webId) - if (_wasActive && !isNowActive) { - events.emit('logout') - } - _wasActive = isNowActive - }) -} +// because only that call site knows which path activated the session. Every +// other identity transition is reported from here: 'logout' when the session +// goes inactive, and 'sessionChange' when the identity changes some other way +// — including a login/logout made in another tab, which the uvdsl +// SharedWorker does not broadcast as a state change and which is noticed when +// this tab is refocused. +watchSessionTransitions(_session as unknown as SessionLike, (event) => events.emit(event)) export const authSession: SessionWithLegacyEvents = Object.assign( _session as Omit & { login: LoginCompat }, diff --git a/src/authSession/events.ts b/src/authSession/events.ts index 8e7704a..c539de6 100644 --- a/src/authSession/events.ts +++ b/src/authSession/events.ts @@ -5,7 +5,7 @@ * Wired into the auth session by authSession.ts. */ -type LegacyEventName = 'login' | 'logout' | 'sessionRestore' +export type LegacyEventName = 'login' | 'logout' | 'sessionChange' | 'sessionRestore' type LegacyEventHandler = (...args: unknown[]) => void /** @@ -14,7 +14,7 @@ type LegacyEventHandler = (...args: unknown[]) => void * continue working without modification. * * Events are emitted by SolidAuthnLogic.checkUser() (login/sessionRestore) - * and by the sessionStateChange listener in authSession.ts (logout). + * and by the transition watcher in authSession.ts (logout, sessionChange). */ export class SessionEvents { private readonly listeners: Map> = new Map() diff --git a/src/authSession/flagAuthorizationOnTransitions.ts b/src/authSession/flagAuthorizationOnTransitions.ts new file mode 100644 index 0000000..7054012 --- /dev/null +++ b/src/authSession/flagAuthorizationOnTransitions.ts @@ -0,0 +1,53 @@ +/** + * Session transitions invalidate the store's cached HTTP authorization + * metadata. + * + * `UpdateManager.editable()` is a synchronous read of the responses recorded + * under `fetcher.appNode`. Those responses are not keyed by identity, so a + * document fetched anonymously (before a restore completed) or under a + * previous WebID keeps answering for the old identity: a writable document + * can look read-only after login, and a read-only one can look writable after + * logout. + * + * `UpdateManager.flagAuthorizationMetadata()` marks every recorded response + * 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 + * `UpdateManager.checkEditable()` instead. + * + * Wired here rather than in UI code so the invalidation happens where the + * identity change is known, store-wide. + */ + +// Every transition that can change whose credentials a request would carry. +// 'login'/'sessionRestore' are emitted by SolidAuthnLogic; 'logout' and +// 'sessionChange' by the transition watcher in authSession.ts. +export const SESSION_TRANSITIONS = ['login', 'sessionRestore', 'logout', 'sessionChange'] as const +export type SessionTransition = (typeof SESSION_TRANSITIONS)[number] + +export type TransitionStore = { + updater?: { flagAuthorizationMetadata?: () => void } +} + +export type TransitionSession = { + events?: { on?: (event: SessionTransition, handler: () => void) => void } +} + +export function flagAuthorizationOnSessionTransitions ( + store: TransitionStore, + session: TransitionSession +): void { + const flag = (): void => { + try { + store.updater?.flagAuthorizationMetadata?.() + } catch { + // A store that cannot be reached must not take the session handling + // with it — the next load still re-fetches. + } + } + const events = session?.events + if (!events || typeof events.on !== 'function') return + for (const transition of SESSION_TRANSITIONS) { + events.on(transition, flag) + } +} diff --git a/src/authSession/transitions.ts b/src/authSession/transitions.ts new file mode 100644 index 0000000..225ccc7 --- /dev/null +++ b/src/authSession/transitions.ts @@ -0,0 +1,78 @@ +/** + * Session identity transitions. + * + * The uvdsl session announces a state change in this tab through its + * `sessionStateChange` event. Two gaps are closed here: + * + * - an identity that changes while the tab stays open (A -> B) is not a + * 'logout' and would otherwise go unnoticed; + * - a login/logout made in ANOTHER TAB is not broadcast by the uvdsl + * SharedWorker (it only carries refresh results), and is therefore + * noticed when this tab is refocused. + * + * Consumers invalidate identity-derived state on these events — see + * flagAuthorizationOnTransitions.ts. + */ + +export type SessionSnapshot = { isActive: boolean; webId?: string } + +/** + * Which legacy event a transition should emit: + * 'logout' — the session went from active to inactive; + * 'sessionChange' — any other change of active state or WebID (a login here + * is also announced as 'login' by SolidAuthnLogic; the + * duplicate is harmless — consumers only invalidate); + * null — nothing changed, so a refocused tab with the same + * identity costs no event and no invalidation. + */ +export function classifySessionTransition ( + prev: SessionSnapshot, + next: SessionSnapshot +): 'logout' | 'sessionChange' | null { + if (prev.isActive !== next.isActive) return next.isActive ? 'sessionChange' : 'logout' + return next.webId !== prev.webId ? 'sessionChange' : null +} + +export type SessionLike = { + isActive?: boolean + webId?: string + addEventListener?: (type: string, listener: () => void) => void +} + +export type DocumentLike = { + visibilityState?: string + addEventListener?: (type: string, listener: () => void) => void +} + +const snapshotOf = (session: SessionLike): SessionSnapshot => ({ + isActive: session.isActive === true || Boolean(session.webId), + webId: session.webId +}) + +/** + * Watch a session for identity transitions and report them through `emit`. + * Attaches the in-tab state listener when the session supports it, and a + * visibility listener (when a document exists) so a transition made in + * another tab is caught on refocus. + */ +export function watchSessionTransitions ( + session: SessionLike, + emit: (event: 'logout' | 'sessionChange') => void, + doc: DocumentLike | undefined = typeof document === 'undefined' ? undefined : document +): void { + let previous = snapshotOf(session) + const note = (): void => { + const next = snapshotOf(session) + const event = classifySessionTransition(previous, next) + previous = next + if (event) emit(event) + } + if (typeof session.addEventListener === 'function') { + session.addEventListener('sessionStateChange', note) + } + if (doc && typeof doc.addEventListener === 'function') { + doc.addEventListener('visibilitychange', () => { + if (doc.visibilityState === 'visible') note() + }) + } +} diff --git a/src/logic/solidLogic.ts b/src/logic/solidLogic.ts index 5150d92..393bed5 100644 --- a/src/logic/solidLogic.ts +++ b/src/logic/solidLogic.ts @@ -3,6 +3,7 @@ import { LiveStore, NamedNode, Statement } from 'rdflib' import { createAclLogic } from '../acl/aclLogic' import { SolidAuthnLogic } from '../authn/SolidAuthnLogic' import type { SessionWithLegacyEvents } from '../authSession/authSession' +import { flagAuthorizationOnSessionTransitions } from '../authSession/flagAuthorizationOnTransitions' import { createChatLogic } from '../chat/chatLogic' import { createInboxLogic } from '../inbox/inboxLogic' import { createResourceLogic } from '../resource/resourceLogic' @@ -25,6 +26,11 @@ export function createSolidLogic(specialFetch: { fetch: (url: any, requestInit: rdf.fetcher(store, {fetch: specialFetch.fetch}) // Attach a web I/O module, store.fetcher store.updater = new rdf.UpdateManager(store) // Add real-time live updates store.updater store.features = [] // disable automatic node merging on store load + // 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) const authn: AuthnLogic = new SolidAuthnLogic(session) diff --git a/test/flagAuthorizationOnTransitions.test.ts b/test/flagAuthorizationOnTransitions.test.ts new file mode 100644 index 0000000..ab85c3f --- /dev/null +++ b/test/flagAuthorizationOnTransitions.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from 'vitest' +import { SessionEvents } from '../src/authSession/events' +import { SESSION_TRANSITIONS, flagAuthorizationOnSessionTransitions } from '../src/authSession/flagAuthorizationOnTransitions' + +describe('flagAuthorizationOnSessionTransitions', () => { + it('marks the store metadata stale on every identity transition', () => { + const flagAuthorizationMetadata = vi.fn() + const session = { events: new SessionEvents() } + flagAuthorizationOnSessionTransitions({ updater: { flagAuthorizationMetadata } }, session) + + for (const transition of SESSION_TRANSITIONS) { + session.events.emit(transition) + } + + expect(flagAuthorizationMetadata).toHaveBeenCalledTimes(SESSION_TRANSITIONS.length) + }) + + it('survives a session without the legacy event layer', () => { + const flagAuthorizationMetadata = vi.fn() + expect(() => { + flagAuthorizationOnSessionTransitions({ updater: { flagAuthorizationMetadata } }, {}) + }).not.toThrow() + expect(flagAuthorizationMetadata).not.toHaveBeenCalled() + }) + + it('survives a store that cannot flag, and a flag that throws', () => { + const session = { events: new SessionEvents() } + expect(() => { + flagAuthorizationOnSessionTransitions({}, session) + }).not.toThrow() + + flagAuthorizationOnSessionTransitions({ updater: { flagAuthorizationMetadata: () => { throw new Error('store gone') } } }, session) + expect(() => session.events.emit('login')).not.toThrow() + }) +}) diff --git a/test/rdflibEditableFlagContract.test.ts b/test/rdflibEditableFlagContract.test.ts new file mode 100644 index 0000000..9b16319 --- /dev/null +++ b/test/rdflibEditableFlagContract.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { graph, lit, sym, UpdateManager } from 'rdflib' + +const LINK = (name: string) => sym(`http://www.w3.org/2007/ont/link#${name}`) +const HTTPH = (name: string) => sym(`http://www.w3.org/2007/ont/httph#${name}`) + +// The contract the session-transition fix relies on, proven against the real +// UpdateManager rather than a mock: +// 1. a response fetched anonymously answers `false` (definitively read-only); +// 2. flagging the metadata turns that into `undefined` (unknown), which is +// what sends callers to load again; +// 3. a fresh response under the new identity answers definitively again. +describe('rdflib authorization metadata contract', () => { + it('goes from definitive to unknown when flagged, and answers again after a fresh response', () => { + const store: any = graph() + const meta = sym('urn:x-auth-test:app') + store.fetcher = { appNode: meta } + const doc = 'https://example.org/foo' + + const anonymous = { request: sym('urn:x-auth-test:req-1'), response: sym('urn:x-auth-test:res-1') } + // The fetcher stores the document URI as a string literal, not a node + // (linkeddata/rdflib.js#427); `editable()` matches it through the same + // string-to-literal coercion. + store.add(anonymous.request, LINK('requestedURI'), lit(doc), meta) + store.add(anonymous.request, LINK('response'), anonymous.response, meta) + store.add(anonymous.response, HTTPH('wac-allow'), lit('user="read"'), meta) + + const updater = new UpdateManager(store) + expect(updater.editable(doc)).toBe(false) + + // The identity changed: every recorded response is out-of-date now, so the + // answer is "unknown" — the state checkEditable()/fetcher.load() repair. + updater.flagAuthorizationMetadata() + expect(updater.editable(doc)).toBeUndefined() + + // 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') + }) +}) diff --git a/test/transitions.test.ts b/test/transitions.test.ts new file mode 100644 index 0000000..5b9f151 --- /dev/null +++ b/test/transitions.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest' +import { classifySessionTransition, watchSessionTransitions, type DocumentLike, type SessionLike } from '../src/authSession/transitions' + +describe('classifySessionTransition', () => { + it('reports a logout when the session goes inactive', () => { + expect(classifySessionTransition( + { isActive: true, webId: 'https://a.example/#me' }, + { isActive: false } + )).toBe('logout') + }) + + it('reports a session change when a restored session becomes active', () => { + expect(classifySessionTransition( + { isActive: false }, + { isActive: true, webId: 'https://a.example/#me' } + )).toBe('sessionChange') + }) + + it('reports a session change when the WebID changes while active', () => { + expect(classifySessionTransition( + { isActive: true, webId: 'https://a.example/#me' }, + { isActive: true, webId: 'https://b.example/#me' } + )).toBe('sessionChange') + }) + + it('reports nothing when nothing changed (refocus with the same identity)', () => { + expect(classifySessionTransition( + { isActive: true, webId: 'https://a.example/#me' }, + { isActive: true, webId: 'https://a.example/#me' } + )).toBeNull() + expect(classifySessionTransition({ isActive: false }, { isActive: false })).toBeNull() + }) +}) + +// A session stand-in: a real EventTarget the test can poke. +class FakeSession extends EventTarget { + isActive = false + webId: string | undefined +} + +describe('watchSessionTransitions', () => { + it('emits on the session state event', () => { + const session = new FakeSession() + const emitted: string[] = [] + watchSessionTransitions(session as unknown as SessionLike, (event) => emitted.push(event), undefined) + + session.isActive = true + session.webId = 'https://a.example/#me' + session.dispatchEvent(new Event('sessionStateChange')) + expect(emitted).toEqual(['sessionChange']) + + session.isActive = false + session.webId = undefined + session.dispatchEvent(new Event('sessionStateChange')) + expect(emitted).toEqual(['sessionChange', 'logout']) + }) + + it('notices a change made elsewhere when the tab is refocused', () => { + const session = new FakeSession() + const emitted: string[] = [] + const handlers: Record void> = {} + const doc: DocumentLike = { + visibilityState: 'visible', + addEventListener: (type: string, listener: () => void): void => { handlers[type] = listener } + } + watchSessionTransitions(session as unknown as SessionLike, (event) => emitted.push(event), doc) + + // Another tab logged in while this one sat in the background. + session.isActive = true + session.webId = 'https://b.example/#me' + handlers.visibilitychange() + expect(emitted).toEqual(['sessionChange']) + + // Refocusing again with the same identity costs nothing. + handlers.visibilitychange() + expect(emitted).toEqual(['sessionChange']) + }) + + it('checks nothing while the tab is hidden', () => { + const session = new FakeSession() + const emitted: string[] = [] + const handlers: Record void> = {} + const doc: DocumentLike = { + visibilityState: 'hidden', + addEventListener: (type: string, listener: () => void): void => { handlers[type] = listener } + } + watchSessionTransitions(session as unknown as SessionLike, (event) => emitted.push(event), doc) + + session.isActive = true + session.webId = 'https://b.example/#me' + handlers.visibilitychange() + expect(emitted).toEqual([]) + + doc.visibilityState = 'visible' + handlers.visibilitychange() + expect(emitted).toEqual(['sessionChange']) + }) + + it('works with a session that has no event listener support', () => { + const emitted: string[] = [] + const handlers: Record void> = {} + const doc: DocumentLike = { + visibilityState: 'visible', + addEventListener: (type: string, listener: () => void): void => { handlers[type] = listener } + } + const session: SessionLike = { isActive: true, webId: 'https://a.example/#me' } + watchSessionTransitions(session, (event) => emitted.push(event), doc) + + session.webId = 'https://b.example/#me' + handlers.visibilitychange() + expect(emitted).toEqual(['sessionChange']) + }) +}) From 77048e050074a5d411f96c2fa86db72e2dc074c7 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Tue, 15 Sep 2026 19:35:44 +0200 Subject: [PATCH 03/19] review: keep info derived, notice active-to-active identity changes --- src/authSession/authSession.ts | 16 +++++++------- src/authSession/transitions.ts | 39 ++++++++++++++++++++++++++++++++-- test/logic.test.ts | 23 ++++++++++++++------ test/transitions.test.ts | 21 +++++++++++++++++- 4 files changed, 82 insertions(+), 17 deletions(-) diff --git a/src/authSession/authSession.ts b/src/authSession/authSession.ts index c8addaf..74f3a90 100644 --- a/src/authSession/authSession.ts +++ b/src/authSession/authSession.ts @@ -113,16 +113,16 @@ export const authSession: SessionWithLegacyEvents = Object.assign( // `webId`/`isActive` getters, but legacy consumers (e.g. solid-ui's // `loginStatusBox` widget, `SolidAuthnLogic.currentUser()`'s fallback path) // read `authSession.info.webId` / `authSession.info.isLoggedIn`. Expose those -// as a derived value — and keep the property assignable. Legacy code (and the -// tests) own an `info` object and assign it; the assignment wins until it is -// cleared back to `undefined`, when the derived value takes over again. -let infoOverride: { webId?: string; isLoggedIn?: boolean } | undefined - +// as a derived value — and keep it derived: `SolidAuthnLogic.webIdFromSession()` +// and the fetch bridge prefer `info.webId` when present, so a retained +// snapshot (callers snapshot and restore `info`) must never answer for the +// session. A sticky value would report the previous identity after a +// login/logout. Assignment is accepted and ignored so ordinary property +// writes cannot throw; a test that needs to fake `info` redefines it. Object.defineProperty(authSession, 'info', { enumerable: true, configurable: true, get (): { webId?: string; isLoggedIn?: boolean } { - if (infoOverride !== undefined) return infoOverride const sessionAny = _session as any const isActive = sessionAny.isActive === true || Boolean(sessionAny.webId) return { @@ -130,8 +130,8 @@ Object.defineProperty(authSession, 'info', { isLoggedIn: isActive } }, - set (value: { webId?: string; isLoggedIn?: boolean } | undefined): void { - infoOverride = value + set (_value: { webId?: string; isLoggedIn?: boolean } | undefined): void { + // Accepted for legacy code that assigns snapshots; reads stay derived. } }) \ No newline at end of file diff --git a/src/authSession/transitions.ts b/src/authSession/transitions.ts index 225ccc7..638a038 100644 --- a/src/authSession/transitions.ts +++ b/src/authSession/transitions.ts @@ -2,13 +2,18 @@ * Session identity transitions. * * The uvdsl session announces a state change in this tab through its - * `sessionStateChange` event. Two gaps are closed here: + * `sessionStateChange` event. Three gaps are closed here: * * - an identity that changes while the tab stays open (A -> B) is not a * 'logout' and would otherwise go unnoticed; * - a login/logout made in ANOTHER TAB is not broadcast by the uvdsl * SharedWorker (it only carries refresh results), and is therefore - * noticed when this tab is refocused. + * noticed when this tab is refocused; + * - uvdsl dispatches `sessionStateChange` only when `isActive` changes, so a + * WebID that changes while both states stay active (a worker + * TOKEN_DETAILS for another identity, e.g. a login made in another + * window) is caught by comparing the identity around `setTokenDetails`, + * the single entry point for token updates. * * Consumers invalidate identity-derived state on these events — see * flagAuthorizationOnTransitions.ts. @@ -37,6 +42,7 @@ export type SessionLike = { isActive?: boolean webId?: string addEventListener?: (type: string, listener: () => void) => void + setTokenDetails?: (...args: unknown[]) => unknown } export type DocumentLike = { @@ -49,6 +55,34 @@ const snapshotOf = (session: SessionLike): SessionSnapshot => ({ webId: session.webId }) +// uvdsl's session announces only changes of `isActive`; a WebID can change +// while both states stay active and would go unseen (see the header). Every +// token update goes through `setTokenDetails`, so compare the identity around +// it. Wrapped once per session. +const wrapping = new WeakSet() + +function watchTokenUpdates (session: SessionLike, note: () => void): void { + const original = session.setTokenDetails + if (typeof original !== 'function' || wrapping.has(session)) return + wrapping.add(session) + session.setTokenDetails = (...args: unknown[]): unknown => { + const before = snapshotOf(session) + const changed = (): boolean => { + const after = snapshotOf(session) + return after.webId !== before.webId || after.isActive !== before.isActive + } + const result = original.apply(session, args) + if (result && typeof (result as Promise).then === 'function') { + return (result as Promise).then((value) => { + if (changed()) note() + return value + }) + } + if (changed()) note() + return result + } +} + /** * Watch a session for identity transitions and report them through `emit`. * Attaches the in-tab state listener when the session supports it, and a @@ -70,6 +104,7 @@ export function watchSessionTransitions ( if (typeof session.addEventListener === 'function') { session.addEventListener('sessionStateChange', note) } + watchTokenUpdates(session, note) if (doc && typeof doc.addEventListener === 'function') { doc.addEventListener('visibilitychange', () => { if (doc.visibilityState === 'visible') note() diff --git a/test/logic.test.ts b/test/logic.test.ts index 14bad48..a17a0d7 100644 --- a/test/logic.test.ts +++ b/test/logic.test.ts @@ -35,7 +35,18 @@ describe('solidLogicSingleton fetch bridge', () => { let originalFetch: any let originalAuthFetch: any - let originalInfo: any + let originalInfoDescriptor: PropertyDescriptor | undefined + + // `info` is derived and getter-only (see authSession.ts), so it cannot be + // assigned in a test — redefine the property, and put the module's own + // descriptor back afterwards. + const setInfo = (value: any): void => { + Object.defineProperty(authSession, 'info', { + configurable: true, + enumerable: true, + get: () => value + }) + } beforeEach(() => { fetchMock.resetMocks() @@ -43,21 +54,21 @@ describe('solidLogicSingleton fetch bridge', () => { const sessionAny = authSession as any originalFetch = sessionAny.fetch originalAuthFetch = sessionAny.authFetch - originalInfo = sessionAny.info + originalInfoDescriptor = Object.getOwnPropertyDescriptor(authSession, 'info') - sessionAny.info = { isLoggedIn: false } + setInfo({ isLoggedIn: false }) }) afterEach(() => { const sessionAny = authSession as any sessionAny.fetch = originalFetch sessionAny.authFetch = originalAuthFetch - sessionAny.info = originalInfo + if (originalInfoDescriptor) Object.defineProperty(authSession, 'info', originalInfoDescriptor) }) it('uses window.fetch when credentials are omit even if a session exists', async () => { const sessionAny = authSession as any - sessionAny.info = { webId: 'https://alice.example/profile#me', isLoggedIn: true } + setInfo({ webId: 'https://alice.example/profile#me', isLoggedIn: true }) sessionAny.fetch = vi.fn().mockResolvedValue(new Response('session')) fetchMock.mockResponseOnce('window') @@ -70,7 +81,7 @@ describe('solidLogicSingleton fetch bridge', () => { it('falls back to authFetch when session.fetch is unavailable', async () => { const sessionAny = authSession as any - sessionAny.info = { webId: 'https://alice.example/profile#me', isLoggedIn: true } + setInfo({ webId: 'https://alice.example/profile#me', isLoggedIn: true }) sessionAny.fetch = undefined sessionAny.authFetch = vi.fn().mockResolvedValue(new Response('auth')) diff --git a/test/transitions.test.ts b/test/transitions.test.ts index 5b9f151..be12ecc 100644 --- a/test/transitions.test.ts +++ b/test/transitions.test.ts @@ -32,10 +32,15 @@ describe('classifySessionTransition', () => { }) }) -// A session stand-in: a real EventTarget the test can poke. +// A session stand-in: a real EventTarget the test can poke. setTokenDetails +// mirrors the uvdsl method every token update goes through. class FakeSession extends EventTarget { isActive = false webId: string | undefined + + async setTokenDetails (details: { webId?: string }): Promise { + this.webId = details.webId + } } describe('watchSessionTransitions', () => { @@ -55,6 +60,20 @@ describe('watchSessionTransitions', () => { expect(emitted).toEqual(['sessionChange', 'logout']) }) + it('notices a WebID change while the session stays active (token update)', async () => { + const session = new FakeSession() + session.isActive = true + session.webId = 'https://a.example/#me' + const emitted: string[] = [] + watchSessionTransitions(session as unknown as SessionLike, (event) => emitted.push(event), undefined) + + // uvdsl dispatches sessionStateChange only when isActive changes; the + // token update itself is the evidence of an A -> B switch. + await session.setTokenDetails({ webId: 'https://b.example/#me' }) + + expect(emitted).toEqual(['sessionChange']) + }) + it('notices a change made elsewhere when the tab is refocused', () => { const session = new FakeSession() const emitted: string[] = [] From f77d0182ddea6b3b0df443b9fe1431b57b2dd733 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Wed, 16 Sep 2026 15:08:23 +0200 Subject: [PATCH 04/19] =?UTF-8?q?review:=20isActive=20is=20authoritative;?= =?UTF-8?q?=20load()=20does=20not=20repair=20=E2=80=94=20refresh=20does?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- src/authSession/authSession.ts | 20 +++++---- .../flagAuthorizationOnTransitions.ts | 42 +++++++++++++++++-- src/authSession/transitions.ts | 11 ++++- test/authSessionInfo.test.ts | 19 +++++++++ test/flagAuthorizationOnTransitions.test.ts | 27 +++++++++++- test/rdflibEditableFlagContract.test.ts | 36 +++++++++++++++- test/transitions.test.ts | 30 ++++++++++++- 7 files changed, 170 insertions(+), 15 deletions(-) create mode 100644 test/authSessionInfo.test.ts diff --git a/src/authSession/authSession.ts b/src/authSession/authSession.ts index 74f3a90..d5bb002 100644 --- a/src/authSession/authSession.ts +++ b/src/authSession/authSession.ts @@ -14,7 +14,7 @@ import type { Session as OidcSession } from '@uvdsl/solid-oidc-client-browser/co import { _session } from './session' import { resolveIssuerForLogin } from './issuer' import { SessionEvents } from './events' -import { watchSessionTransitions, type SessionLike } from './transitions' +import { sessionIsActive, watchSessionTransitions, type SessionLike } from './transitions' type SessionCompatibilityShape = { webId?: string @@ -119,16 +119,22 @@ export const authSession: SessionWithLegacyEvents = Object.assign( // session. A sticky value would report the previous identity after a // login/logout. Assignment is accepted and ignored so ordinary property // writes cannot throw; a test that needs to fake `info` redefines it. +// +// `isLoggedIn` follows `sessionIsActive`: an explicit `isActive: false` +// reports logged out even when a WebID is still cached, or the fetch bridge +// would keep routing anonymous requests through the authenticated fetch. +export function legacySessionInfo (session: SessionLike): { webId?: string; isLoggedIn?: boolean } { + return { + webId: session.webId, + isLoggedIn: sessionIsActive(session) + } +} + Object.defineProperty(authSession, 'info', { enumerable: true, configurable: true, get (): { webId?: string; isLoggedIn?: boolean } { - const sessionAny = _session as any - const isActive = sessionAny.isActive === true || Boolean(sessionAny.webId) - return { - webId: sessionAny.webId, - isLoggedIn: isActive - } + return legacySessionInfo(_session as unknown as SessionLike) }, set (_value: { webId?: string; isLoggedIn?: boolean } | undefined): void { // Accepted for legacy code that assigns snapshots; reads stay derived. diff --git a/src/authSession/flagAuthorizationOnTransitions.ts b/src/authSession/flagAuthorizationOnTransitions.ts index 7054012..efa29f0 100644 --- a/src/authSession/flagAuthorizationOnTransitions.ts +++ b/src/authSession/flagAuthorizationOnTransitions.ts @@ -10,10 +10,19 @@ * logout. * * `UpdateManager.flagAuthorizationMetadata()` marks every recorded response - * 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 - * `UpdateManager.checkEditable()` instead. + * out-of-date, so `editable()` answers "unknown" instead of the previous + * identity's access. + * + * A document is repaired by a FORCE refresh, not by a plain load: on rdflib + * 2.4.0 `fetcher.load()` looks recorded requests up with a NamedNode + * (`kb.sym(docuri)`) while the fetcher records them as a string literal + * (linkeddata/rdflib.js#427), finds nothing, keeps the out-of-date mark and + * returns the cached copy — so neither `load()` nor the `checkEditable()` + * that wraps it re-answers for an already-loaded document. `fetcher.refresh()` + * sets `force: true, clearPreviousData: true` and records a fresh response; + * `refreshDocumentAuthorization()` below wraps that for call sites that need + * the answer immediately. (Once rdflib's `load` matches the literal form, + * `checkEditable()` heals too.) * * Wired here rather than in UI code so the invalidation happens where the * identity change is known, store-wide. @@ -51,3 +60,28 @@ export function flagAuthorizationOnSessionTransitions ( events.on(transition, flag) } } + +export type RefreshableStore = { + fetcher?: { refresh?: (doc: unknown) => unknown } + updater?: { editable?: (uri: unknown) => string | boolean | undefined } +} + +/** + * Force-refresh one document and answer its editability under the current + * identity — the repair path for a flagged store (see above). It costs a + * round-trip; decision points that need an immediate, correct answer use it. + */ +export async function refreshDocumentAuthorization ( + store: RefreshableStore, + doc: unknown +): Promise { + const refresh = store.fetcher?.refresh + if (typeof refresh === 'function') { + try { + await refresh(doc) + } catch { + // A failed refresh leaves the answer unknown; the caller decides. + } + } + return store.updater?.editable?.(doc) +} diff --git a/src/authSession/transitions.ts b/src/authSession/transitions.ts index 638a038..2d272b5 100644 --- a/src/authSession/transitions.ts +++ b/src/authSession/transitions.ts @@ -45,13 +45,22 @@ export type SessionLike = { setTokenDetails?: (...args: unknown[]) => unknown } +/** + * Whether the session counts as active. `isActive` is authoritative — an + * explicit `false` wins even when a WebID is still cached (a logout that has + * not cleared it yet); the WebID only fills in an undefined state. Every + * identity snapshot and the legacy `info` shape use this one rule. + */ +export const sessionIsActive = (session: SessionLike): boolean => + session.isActive === true || (session.isActive === undefined && Boolean(session.webId)) + export type DocumentLike = { visibilityState?: string addEventListener?: (type: string, listener: () => void) => void } const snapshotOf = (session: SessionLike): SessionSnapshot => ({ - isActive: session.isActive === true || Boolean(session.webId), + isActive: sessionIsActive(session), webId: session.webId }) diff --git a/test/authSessionInfo.test.ts b/test/authSessionInfo.test.ts new file mode 100644 index 0000000..bfdc23d --- /dev/null +++ b/test/authSessionInfo.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { legacySessionInfo } from '../src/authSession/authSession' +import type { SessionLike } from '../src/authSession/transitions' + +describe('legacySessionInfo', () => { + it('reports an inactive session as logged out even when a WebID is still cached', () => { + expect(legacySessionInfo({ isActive: false, webId: 'https://a.example/#me' } as SessionLike)) + .toEqual({ webId: 'https://a.example/#me', isLoggedIn: false }) + }) + + it('falls back to the WebID only when isActive is undefined', () => { + expect(legacySessionInfo({ webId: 'https://a.example/#me' } as SessionLike).isLoggedIn).toBe(true) + expect(legacySessionInfo({} as SessionLike)).toEqual({ webId: undefined, isLoggedIn: false }) + }) + + it('reports an active session as logged in', () => { + expect(legacySessionInfo({ isActive: true, webId: 'https://a.example/#me' } as SessionLike).isLoggedIn).toBe(true) + }) +}) diff --git a/test/flagAuthorizationOnTransitions.test.ts b/test/flagAuthorizationOnTransitions.test.ts index ab85c3f..d855698 100644 --- a/test/flagAuthorizationOnTransitions.test.ts +++ b/test/flagAuthorizationOnTransitions.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { SessionEvents } from '../src/authSession/events' -import { SESSION_TRANSITIONS, flagAuthorizationOnSessionTransitions } from '../src/authSession/flagAuthorizationOnTransitions' +import { SESSION_TRANSITIONS, flagAuthorizationOnSessionTransitions, refreshDocumentAuthorization } from '../src/authSession/flagAuthorizationOnTransitions' describe('flagAuthorizationOnSessionTransitions', () => { it('marks the store metadata stale on every identity transition', () => { @@ -33,3 +33,28 @@ describe('flagAuthorizationOnSessionTransitions', () => { expect(() => session.events.emit('login')).not.toThrow() }) }) + +describe('refreshDocumentAuthorization', () => { + it('force-refreshes the document before answering editability', async () => { + const order: string[] = [] + const store = { + fetcher: { + refresh: async (doc: unknown): Promise => { order.push(`refresh:${String(doc)}`) } + }, + updater: { + editable: (doc: unknown): string | boolean | undefined => { + order.push(`editable:${String(doc)}`) + return 'N3PATCH' + } + } + } + + await expect(refreshDocumentAuthorization(store, 'https://a.example/')).resolves.toBe('N3PATCH') + expect(order).toEqual(['refresh:https://a.example/', 'editable:https://a.example/']) + }) + + it('answers editability even when the store cannot refresh', async () => { + const store = { updater: { editable: (): boolean => false } } + await expect(refreshDocumentAuthorization(store, 'https://a.example/')).resolves.toBe(false) + }) +}) diff --git a/test/rdflibEditableFlagContract.test.ts b/test/rdflibEditableFlagContract.test.ts index 9b16319..e11948e 100644 --- a/test/rdflibEditableFlagContract.test.ts +++ b/test/rdflibEditableFlagContract.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { graph, lit, sym, UpdateManager } from 'rdflib' +import { fetcher, graph, lit, sym, UpdateManager } from 'rdflib' const LINK = (name: string) => sym(`http://www.w3.org/2007/ont/link#${name}`) const HTTPH = (name: string) => sym(`http://www.w3.org/2007/ont/httph#${name}`) @@ -41,4 +41,38 @@ describe('rdflib authorization metadata contract', () => { store.add(fresh.response, HTTPH('accept-patch'), lit('text/n3'), meta) expect(updater.editable(doc)).toBe('N3PATCH') }) + + it('does not repair a flagged, already-loaded document via load(), but refresh() repairs it', async () => { + const store: any = graph() + const doc = 'https://example.org/repair' + let calls = 0 + const fakeFetch = async (): Promise => { + calls += 1 + const headers = calls === 1 + ? { 'content-type': 'text/turtle', 'wac-allow': 'user="read"' } + : { 'content-type': 'text/turtle', 'wac-allow': 'user="read write"', 'accept-patch': 'text/n3' } + return new Response('', { status: 200, headers }) + } + fetcher(store, { fetch: fakeFetch }) + store.updater = new UpdateManager(store) + + await store.fetcher.load(doc) + expect(calls).toBe(1) + expect(store.updater.editable(doc)).toBe(false) + + store.updater.flagAuthorizationMetadata() + expect(store.updater.editable(doc)).toBeUndefined() + + // rdflib 2.4.0: load() looks the recorded request up as a NamedNode while + // the fetcher stored a literal, finds nothing, keeps the mark and answers + // from the cache — no refetch, still unknown. + await store.fetcher.load(doc) + expect(calls).toBe(1) + expect(store.updater.editable(doc)).toBeUndefined() + + // refresh() forces the fetch and records a fresh response. + await new Promise((resolve) => { store.fetcher.refresh(sym(doc), () => resolve()) }) + expect(calls).toBe(2) + expect(store.updater.editable(doc)).toBe('N3PATCH') + }) }) diff --git a/test/transitions.test.ts b/test/transitions.test.ts index be12ecc..a3c1f66 100644 --- a/test/transitions.test.ts +++ b/test/transitions.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { classifySessionTransition, watchSessionTransitions, type DocumentLike, type SessionLike } from '../src/authSession/transitions' +import { classifySessionTransition, sessionIsActive, watchSessionTransitions, type DocumentLike, type SessionLike } from '../src/authSession/transitions' describe('classifySessionTransition', () => { it('reports a logout when the session goes inactive', () => { @@ -43,6 +43,21 @@ class FakeSession extends EventTarget { } } +describe('sessionIsActive', () => { + it('treats an explicit isActive:false as inactive even with a cached WebID', () => { + expect(sessionIsActive({ isActive: false, webId: 'https://a.example/#me' })).toBe(false) + }) + + it('falls back to the WebID only when isActive is undefined', () => { + expect(sessionIsActive({ webId: 'https://a.example/#me' })).toBe(true) + expect(sessionIsActive({})).toBe(false) + }) + + it('is active when isActive is true', () => { + expect(sessionIsActive({ isActive: true })).toBe(true) + }) +}) + describe('watchSessionTransitions', () => { it('emits on the session state event', () => { const session = new FakeSession() @@ -74,6 +89,19 @@ describe('watchSessionTransitions', () => { expect(emitted).toEqual(['sessionChange']) }) + it('emits logout when isActive flips false while a WebID is still cached', () => { + const session = new FakeSession() + session.isActive = true + session.webId = 'https://a.example/#me' + const emitted: string[] = [] + watchSessionTransitions(session as unknown as SessionLike, (event) => emitted.push(event), undefined) + + session.isActive = false // webId retained, as during a partial logout + session.dispatchEvent(new Event('sessionStateChange')) + + expect(emitted).toEqual(['logout']) + }) + it('notices a change made elsewhere when the tab is refocused', () => { const session = new FakeSession() const emitted: string[] = [] From cad071533ee14c13fca979df5b9319d5f6228e65 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Wed, 16 Sep 2026 15:19:25 +0200 Subject: [PATCH 05/19] test: fix HeadersInit typing in the load/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. --- test/rdflibEditableFlagContract.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/rdflibEditableFlagContract.test.ts b/test/rdflibEditableFlagContract.test.ts index e11948e..4d8f2b1 100644 --- a/test/rdflibEditableFlagContract.test.ts +++ b/test/rdflibEditableFlagContract.test.ts @@ -48,7 +48,7 @@ describe('rdflib authorization metadata contract', () => { let calls = 0 const fakeFetch = async (): Promise => { calls += 1 - const headers = calls === 1 + const headers: Record = calls === 1 ? { 'content-type': 'text/turtle', 'wac-allow': 'user="read"' } : { 'content-type': 'text/turtle', 'wac-allow': 'user="read write"', 'accept-patch': 'text/n3' } return new Response('', { status: 200, headers }) From eb94f57aa86a1b82b5aeabee6a3271d7323cc569 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Wed, 16 Sep 2026 15:46:33 +0200 Subject: [PATCH 06/19] review: explicit inactivation wins; wait for the refresh callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). --- .../flagAuthorizationOnTransitions.ts | 52 ++++++++++++++---- src/authSession/transitions.ts | 15 ++++++ src/authn/SolidAuthnLogic.ts | 12 ++++- src/logic/solidLogicSingleton.ts | 8 ++- src/util/utilityLogic.ts | 12 ++++- test/flagAuthorizationOnTransitions.test.ts | 54 ++++++++++++++++++- test/logic.test.ts | 29 ++++++++++ test/rdflibEditableFlagContract.test.ts | 7 +-- test/solidAuthLogic.test.ts | 45 ++++++++++++++++ 9 files changed, 216 insertions(+), 18 deletions(-) diff --git a/src/authSession/flagAuthorizationOnTransitions.ts b/src/authSession/flagAuthorizationOnTransitions.ts index efa29f0..49e5013 100644 --- a/src/authSession/flagAuthorizationOnTransitions.ts +++ b/src/authSession/flagAuthorizationOnTransitions.ts @@ -28,6 +28,8 @@ * identity change is known, store-wide. */ +import * as debug from '../util/debug' + // Every transition that can change whose credentials a request would carry. // 'login'/'sessionRestore' are emitted by SolidAuthnLogic; 'logout' and // 'sessionChange' by the transition watcher in authSession.ts. @@ -49,9 +51,12 @@ export function flagAuthorizationOnSessionTransitions ( const flag = (): void => { try { store.updater?.flagAuthorizationMetadata?.() - } catch { - // A store that cannot be reached must not take the session handling - // with it — the next load still re-fetches. + } catch (error) { + // A store that cannot flag must not take the session handling with it — + // but the failure is not swallowed either: the recorded answers stay + // definitive for the previous identity until a decision point forces a + // fresh response (refreshDocumentAuthorization below), so surface it. + debug.warn(`Could not flag authorization metadata after a session transition: ${error}`) } } const events = session?.events @@ -62,7 +67,7 @@ export function flagAuthorizationOnSessionTransitions ( } export type RefreshableStore = { - fetcher?: { refresh?: (doc: unknown) => unknown } + fetcher?: { refresh?: (doc: unknown, callback?: (...args: unknown[]) => void) => unknown } updater?: { editable?: (uri: unknown) => string | boolean | undefined } } @@ -75,13 +80,40 @@ export async function refreshDocumentAuthorization ( store: RefreshableStore, doc: unknown ): Promise { + await forceRefresh(store, doc) + return store.updater?.editable?.(doc) +} + +/** + * rdflib's `refresh(term, callback)` is callback-based and returns void — + * it delegates to `nowOrWhenFetched(term, { force: true, clearPreviousData: + * true }, callback)` and the callback is the completion signal. Awaiting the + * call itself would read `editable()` before the fresh response is recorded, + * so wait for the callback (a promise-returning wrapper is awaited too). A + * failed refresh resolves anyway, with a warning: the answer stays unknown + * and the caller decides. + */ +async function forceRefresh (store: RefreshableStore, doc: unknown): Promise { const refresh = store.fetcher?.refresh - if (typeof refresh === 'function') { + if (typeof refresh !== 'function') return + await new Promise((resolve) => { + let settled = false + const done = (ok?: unknown, message?: unknown): void => { + if (ok === false) { + debug.warn(`Could not refresh ${String(doc)}: ${String(message)}`) + } + if (settled) return + settled = true + resolve() + } try { - await refresh(doc) - } catch { - // A failed refresh leaves the answer unknown; the caller decides. + const result = refresh.call(store.fetcher, doc, done) + if (result && typeof (result as Promise).then === 'function') { + void (result as Promise).then(() => done(), (error) => done(false, error)) + } + } catch (error) { + debug.warn(`Could not refresh ${String(doc)}: ${String(error)}`) + done() } - } - return store.updater?.editable?.(doc) + }) } diff --git a/src/authSession/transitions.ts b/src/authSession/transitions.ts index 2d272b5..015418f 100644 --- a/src/authSession/transitions.ts +++ b/src/authSession/transitions.ts @@ -54,6 +54,21 @@ export type SessionLike = { export const sessionIsActive = (session: SessionLike): boolean => session.isActive === true || (session.isActive === undefined && Boolean(session.webId)) +/** + * Whether the session explicitly reports itself inactive. An explicit `false` + * — `isActive` on the session or `isLoggedIn` on the legacy `info` shape — + * wins over a retained WebID: a partial logout that has not cleared the + * cached WebID must not keep identifying the previous user. Consumers that + * would otherwise act on the WebID alone (authenticated fetch, currentUser) + * use this to stand down. + */ +export function sessionExplicitlyInactive (session: { + isActive?: boolean + info?: { isLoggedIn?: boolean } +}): boolean { + return session?.isActive === false || session?.info?.isLoggedIn === false +} + export type DocumentLike = { visibilityState?: string addEventListener?: (type: string, listener: () => void) => void diff --git a/src/authn/SolidAuthnLogic.ts b/src/authn/SolidAuthnLogic.ts index 79ceb08..e520808 100644 --- a/src/authn/SolidAuthnLogic.ts +++ b/src/authn/SolidAuthnLogic.ts @@ -1,6 +1,7 @@ import { namedNode, NamedNode, sym } from 'rdflib' import { appContext, offlineTestID } from './authUtil' import * as debug from '../util/debug' +import { sessionExplicitlyInactive } from '../authSession/transitions' import type { SessionWithLegacyEvents } from '../authSession/authSession' import type { AuthenticationContext, AuthnLogic } from '../types' @@ -46,6 +47,12 @@ export class SolidAuthnLogic implements AuthnLogic { return sym(app.webId) } const sessionAny = this.session as any + 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 + } const infoWebId = sessionAny?.info?.webId const sessionWebId = sessionAny?.webId const webId = infoWebId || sessionWebId || this.fallbackWebId @@ -281,7 +288,10 @@ export class SolidAuthnLogic implements AuthnLogic { if (infoLoggedIn === true || rootLoggedIn === true || rootActive === true) { return webId } - if (infoLoggedIn === false && rootLoggedIn === false && rootActive === false) { + // An explicit inactive/not-logged-in flag wins even when the other + // sources are absent: the session root has no `isLoggedIn` property, so + // requiring it to be false kept a cached WebID alive across a logout. + if (infoLoggedIn === false || rootLoggedIn === false || rootActive === false) { return null } return webId diff --git a/src/logic/solidLogicSingleton.ts b/src/logic/solidLogicSingleton.ts index 8320b6f..dfa2948 100644 --- a/src/logic/solidLogicSingleton.ts +++ b/src/logic/solidLogicSingleton.ts @@ -1,12 +1,18 @@ import * as debug from '../util/debug' import { authSession } from '../authSession/authSession' +import { sessionExplicitlyInactive } from '../authSession/transitions' import { createSolidLogic } from './solidLogic' import { SolidLogic } from '../types' const _fetch = async (url, requestInit) => { const omitCreds = requestInit && requestInit.credentials && requestInit.credentials == 'omit' const sessionAny = authSession as any - const sessionWebId = sessionAny?.info?.webId || sessionAny?.webId + // A session that explicitly reports itself inactive must not keep + // identifying the last user: with a retained WebID, choosing the + // authenticated fetch would send the previous identity's credentials. + const sessionWebId = sessionExplicitlyInactive(sessionAny) + ? undefined + : (sessionAny?.info?.webId || sessionAny?.webId) if (sessionWebId && !omitCreds) { // see https://github.com/solidos/solidos/issues/114 // In fact fetch should respect credentials omit itself const authenticatedFetch = (typeof sessionAny.fetch === 'function') diff --git a/src/util/utilityLogic.ts b/src/util/utilityLogic.ts index f5b7e87..3046edc 100644 --- a/src/util/utilityLogic.ts +++ b/src/util/utilityLogic.ts @@ -1,4 +1,5 @@ import { NamedNode, st, sym } from 'rdflib' +import { refreshDocumentAuthorization } from '../authSession/flagAuthorizationOnTransitions' import { CrossOriginForbiddenError, FetchError, @@ -93,7 +94,12 @@ export function createUtilityLogic(store, aclLogic, containerLogic) { const result = store.any(subject, predicate, null, doc) if (result) return result as NamedNode - if (!store.updater.editable(doc)) { + // A session transition since this document was recorded leaves the store + // answering "unknown" (undefined) for its editability: force a fresh + // response before deciding. See flagAuthorizationOnTransitions.ts. + const editable = store.updater.editable(doc) ?? + await refreshDocumentAuthorization(store, doc) + if (!editable) { const msg = `followOrCreateLink: cannot edit ${doc.value}` debug.warn(msg) throw new NotEditableError(msg) @@ -127,7 +133,9 @@ export function createUtilityLogic(store, aclLogic, containerLogic) { const result = store.any(subject, predicate, null, doc) if (result) return result as NamedNode - if (!store.updater.editable(doc)) { + const editable = store.updater.editable(doc) ?? + await refreshDocumentAuthorization(store, doc) + if (!editable) { const msg = `followOrCreateLinkWithContentOnCreate: cannot edit ${doc.value}` debug.warn(msg) throw new NotEditableError(msg) diff --git a/test/flagAuthorizationOnTransitions.test.ts b/test/flagAuthorizationOnTransitions.test.ts index d855698..aa7ef41 100644 --- a/test/flagAuthorizationOnTransitions.test.ts +++ b/test/flagAuthorizationOnTransitions.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import { SessionEvents } from '../src/authSession/events' import { SESSION_TRANSITIONS, flagAuthorizationOnSessionTransitions, refreshDocumentAuthorization } from '../src/authSession/flagAuthorizationOnTransitions' +import { silenceDebugMessages } from './helpers/debugger' + +silenceDebugMessages() describe('flagAuthorizationOnSessionTransitions', () => { it('marks the store metadata stale on every identity transition', () => { @@ -39,7 +42,11 @@ describe('refreshDocumentAuthorization', () => { const order: string[] = [] const store = { fetcher: { - refresh: async (doc: unknown): Promise => { order.push(`refresh:${String(doc)}`) } + // rdflib's real signature: callback completion, no useful return value. + refresh: (doc: unknown, done?: () => void): void => { + order.push(`refresh:${String(doc)}`) + done?.() + } }, updater: { editable: (doc: unknown): string | boolean | undefined => { @@ -53,6 +60,51 @@ describe('refreshDocumentAuthorization', () => { expect(order).toEqual(['refresh:https://a.example/', 'editable:https://a.example/']) }) + it('waits for the refresh callback before reading editability', async () => { + const order: string[] = [] + const store = { + fetcher: { + refresh: (_doc: unknown, done?: () => void): void => { + // The fresh response lands after the refresh call has returned. + setTimeout(() => { + order.push('refreshed') + done?.() + }, 0) + } + }, + updater: { + editable: (): string => { + order.push('editable') + return 'N3PATCH' + } + } + } + + await refreshDocumentAuthorization(store, 'https://a.example/') + expect(order).toEqual(['refreshed', 'editable']) + }) + + it('still awaits a promise-returning refresh wrapper', async () => { + const order: string[] = [] + const store = { + fetcher: { + refresh: async (): Promise => { + await Promise.resolve() + order.push('refreshed') + } + }, + updater: { + editable: (): boolean => { + order.push('editable') + return true + } + } + } + + await refreshDocumentAuthorization(store, 'https://a.example/') + expect(order).toEqual(['refreshed', 'editable']) + }) + it('answers editability even when the store cannot refresh', async () => { const store = { updater: { editable: (): boolean => false } } await expect(refreshDocumentAuthorization(store, 'https://a.example/')).resolves.toBe(false) diff --git a/test/logic.test.ts b/test/logic.test.ts index a17a0d7..eec6e00 100644 --- a/test/logic.test.ts +++ b/test/logic.test.ts @@ -36,6 +36,7 @@ describe('solidLogicSingleton fetch bridge', () => { let originalFetch: any let originalAuthFetch: any let originalInfoDescriptor: PropertyDescriptor | undefined + let originalActiveDescriptor: PropertyDescriptor | undefined // `info` is derived and getter-only (see authSession.ts), so it cannot be // assigned in a test — redefine the property, and put the module's own @@ -48,6 +49,15 @@ describe('solidLogicSingleton fetch bridge', () => { }) } + // The uvdsl session exposes `isActive` as a getter as well; tests that need + // an active session shadow it on the instance and restore it afterwards. + const setSessionActive = (value: boolean): void => { + Object.defineProperty(authSession, 'isActive', { + configurable: true, + get: () => value + }) + } + beforeEach(() => { fetchMock.resetMocks() @@ -55,6 +65,7 @@ describe('solidLogicSingleton fetch bridge', () => { originalFetch = sessionAny.fetch originalAuthFetch = sessionAny.authFetch originalInfoDescriptor = Object.getOwnPropertyDescriptor(authSession, 'info') + originalActiveDescriptor = Object.getOwnPropertyDescriptor(authSession, 'isActive') setInfo({ isLoggedIn: false }) }) @@ -64,11 +75,14 @@ describe('solidLogicSingleton fetch bridge', () => { sessionAny.fetch = originalFetch sessionAny.authFetch = originalAuthFetch if (originalInfoDescriptor) Object.defineProperty(authSession, 'info', originalInfoDescriptor) + if (originalActiveDescriptor) Object.defineProperty(authSession, 'isActive', originalActiveDescriptor) + else delete (authSession as any).isActive }) it('uses window.fetch when credentials are omit even if a session exists', async () => { const sessionAny = authSession as any setInfo({ webId: 'https://alice.example/profile#me', isLoggedIn: true }) + setSessionActive(true) sessionAny.fetch = vi.fn().mockResolvedValue(new Response('session')) fetchMock.mockResponseOnce('window') @@ -82,6 +96,7 @@ describe('solidLogicSingleton fetch bridge', () => { it('falls back to authFetch when session.fetch is unavailable', async () => { const sessionAny = authSession as any setInfo({ webId: 'https://alice.example/profile#me', isLoggedIn: true }) + setSessionActive(true) sessionAny.fetch = undefined sessionAny.authFetch = vi.fn().mockResolvedValue(new Response('auth')) @@ -90,5 +105,19 @@ describe('solidLogicSingleton fetch bridge', () => { expect(sessionAny.authFetch).toHaveBeenCalledTimes(1) expect(fetchMock).not.toHaveBeenCalled() }) + + it('uses window.fetch when the session reports inactive even though a WebID is cached', async () => { + const sessionAny = authSession as any + setInfo({ webId: 'https://alice.example/profile#me', isLoggedIn: false }) + setSessionActive(false) + sessionAny.fetch = vi.fn().mockResolvedValue(new Response('session')) + + fetchMock.mockResponseOnce('window') + + await singletonFetch('https://example.com/resource') + + expect(sessionAny.fetch).not.toHaveBeenCalled() + expect(fetchMock).toHaveBeenCalledTimes(1) + }) }) diff --git a/test/rdflibEditableFlagContract.test.ts b/test/rdflibEditableFlagContract.test.ts index 4d8f2b1..8f4ff98 100644 --- a/test/rdflibEditableFlagContract.test.ts +++ b/test/rdflibEditableFlagContract.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { fetcher, graph, lit, sym, UpdateManager } from 'rdflib' +import { refreshDocumentAuthorization } from '../src/authSession/flagAuthorizationOnTransitions' const LINK = (name: string) => sym(`http://www.w3.org/2007/ont/link#${name}`) const HTTPH = (name: string) => sym(`http://www.w3.org/2007/ont/httph#${name}`) @@ -70,9 +71,9 @@ describe('rdflib authorization metadata contract', () => { expect(calls).toBe(1) expect(store.updater.editable(doc)).toBeUndefined() - // refresh() forces the fetch and records a fresh response. - await new Promise((resolve) => { store.fetcher.refresh(sym(doc), () => resolve()) }) + // refreshDocumentAuthorization() forces the fetch, awaiting the fetcher's + // completion callback, and only then answers from the fresh response. + await expect(refreshDocumentAuthorization(store, doc)).resolves.toBe('N3PATCH') expect(calls).toBe(2) - expect(store.updater.editable(doc)).toBe('N3PATCH') }) }) diff --git a/test/solidAuthLogic.test.ts b/test/solidAuthLogic.test.ts index aed3c05..b53f745 100644 --- a/test/solidAuthLogic.test.ts +++ b/test/solidAuthLogic.test.ts @@ -38,6 +38,51 @@ describe('SolidAuthnLogic', () => { it('runs', async () => { expect(await solidAuthnLogic.currentUser()).toEqual(null) }) + it('reports logged out when the session explicitly went inactive, even with a cached WebID and a remembered fallback', () => { + const authn = new SolidAuthnLogic({ + isActive: false, + webId: 'https://alice.example/profile#me', + info: { webId: 'https://alice.example/profile#me', isLoggedIn: false } + } as any) + // checkUser() had cached the identity before the logout. + ;(authn as any).fallbackWebId = 'https://alice.example/profile#me' + + expect(authn.currentUser()).toBeNull() + // The fallback must not survive the logout and resurrect the identity. + expect((authn as any).fallbackWebId).toBeNull() + }) + it('returns the WebID while the session is active', () => { + const authn = new SolidAuthnLogic({ + isActive: true, + webId: 'https://alice.example/profile#me', + info: { webId: 'https://alice.example/profile#me', isLoggedIn: true } + } as any) + + expect(authn.currentUser()?.uri).toBe('https://alice.example/profile#me') + }) + }) + + describe('webIdFromSession', () => { + it('returns null when the info reports logged out, even though the session root has no isLoggedIn', () => { + // Regression: requiring every source to be explicitly false let the + // cached WebID survive a logout (the root has no `isLoggedIn` property). + expect(solidAuthnLogic.webIdFromSession( + { webId: 'https://alice.example/profile#me', isLoggedIn: false }, + { webId: 'https://alice.example/profile#me', isActive: false } + )).toBeNull() + }) + it('returns the WebID while the session is active', () => { + expect(solidAuthnLogic.webIdFromSession( + { webId: 'https://alice.example/profile#me', isLoggedIn: true }, + { webId: 'https://alice.example/profile#me' } + )).toBe('https://alice.example/profile#me') + }) + it('falls back to the WebID for legacy sessions that report no state at all', () => { + expect(solidAuthnLogic.webIdFromSession( + { webId: 'https://alice.example/profile#me' }, + { webId: 'https://alice.example/profile#me' } + )).toBe('https://alice.example/profile#me') + }) }) describe('saveUser', () => { From 2a81d64f679392c9edefb61dd3e0353f35454d4f Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Wed, 16 Sep 2026 18:19:01 +0200 Subject: [PATCH 07/19] review: false wins in webIdFromSession; guard the refresh race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). --- .../flagAuthorizationOnTransitions.ts | 29 ++++++++++- src/authn/SolidAuthnLogic.ts | 13 ++--- test/flagAuthorizationOnTransitions.test.ts | 51 +++++++++++++++++++ test/solidAuthLogic.test.ts | 6 +++ 4 files changed, 91 insertions(+), 8 deletions(-) diff --git a/src/authSession/flagAuthorizationOnTransitions.ts b/src/authSession/flagAuthorizationOnTransitions.ts index 49e5013..f0baba8 100644 --- a/src/authSession/flagAuthorizationOnTransitions.ts +++ b/src/authSession/flagAuthorizationOnTransitions.ts @@ -49,6 +49,7 @@ export function flagAuthorizationOnSessionTransitions ( session: TransitionSession ): void { const flag = (): void => { + authorizationGeneration += 1 try { store.updater?.flagAuthorizationMetadata?.() } catch (error) { @@ -71,17 +72,41 @@ export type RefreshableStore = { updater?: { editable?: (uri: unknown) => string | boolean | undefined } } +// 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 + +/** How many times a refresh is repeated when the identity keeps changing. */ +const REFRESH_ATTEMPTS = 3 + /** * Force-refresh one document and answer its editability under the current * identity — the repair path for a flagged store (see above). It costs a * round-trip; decision points that need an immediate, correct answer use it. + * + * The identity can change while the refresh is in flight; the response then + * belongs to the previous identity and must not answer for the current one, + * or a caller could write under the new identity on the old identity's + * authorization. Each attempt is stamped with the transition generation and + * repeated under the new identity when it was overtaken; if the identity + * keeps changing the answer stays "unknown" rather than stale. */ export async function refreshDocumentAuthorization ( store: RefreshableStore, doc: unknown ): Promise { - await forceRefresh(store, doc) - return store.updater?.editable?.(doc) + for (let attempt = 0; attempt < REFRESH_ATTEMPTS; attempt++) { + const generation = authorizationGeneration + await forceRefresh(store, doc) + // The read below is synchronous, so a generation that still matches means + // no transition slipped in between the response and the answer. + if (generation === authorizationGeneration) { + return store.updater?.editable?.(doc) + } + } + return undefined } /** diff --git a/src/authn/SolidAuthnLogic.ts b/src/authn/SolidAuthnLogic.ts index e520808..c3bf875 100644 --- a/src/authn/SolidAuthnLogic.ts +++ b/src/authn/SolidAuthnLogic.ts @@ -285,15 +285,16 @@ export class SolidAuthnLogic implements AuthnLogic { const infoLoggedIn = sessionInfo?.isLoggedIn const rootLoggedIn = sessionRoot?.isLoggedIn const rootActive = sessionRoot?.isActive - if (infoLoggedIn === true || rootLoggedIn === true || rootActive === true) { - return webId - } - // An explicit inactive/not-logged-in flag wins even when the other - // sources are absent: the session root has no `isLoggedIn` property, so - // requiring it to be false kept a cached WebID alive across a logout. + // An explicit inactive/not-logged-in flag wins over a cached WebID and + // over a positive flag in another source — the same rule as + // sessionExplicitlyInactive() in transitions.ts. The session root has no + // `isLoggedIn` property, so requiring every source to be false kept a + // cached WebID alive across a logout; a mixed snapshot must not resurrect + // one either. if (infoLoggedIn === false || rootLoggedIn === false || rootActive === false) { return null } + // Active, or a legacy session that reports no state at all. return webId } diff --git a/test/flagAuthorizationOnTransitions.test.ts b/test/flagAuthorizationOnTransitions.test.ts index aa7ef41..f022da7 100644 --- a/test/flagAuthorizationOnTransitions.test.ts +++ b/test/flagAuthorizationOnTransitions.test.ts @@ -109,4 +109,55 @@ describe('refreshDocumentAuthorization', () => { const store = { updater: { editable: (): boolean => false } } await expect(refreshDocumentAuthorization(store, 'https://a.example/')).resolves.toBe(false) }) + + it('refreshes again when the identity changed while the refresh was in flight', async () => { + const store: any = { updater: { flagAuthorizationMetadata: (): void => {} } } + const session = { events: new SessionEvents() } + flagAuthorizationOnSessionTransitions(store, session) + + let calls = 0 + let editableReads = 0 + const flaky = { + fetcher: { + refresh: (_doc: unknown, done?: () => void): void => { + calls += 1 + // The identity changes mid-flight on the first refresh only. + if (calls === 1) session.events.emit('sessionChange') + done?.() + } + }, + updater: { + editable: (): string => { + editableReads += 1 + return 'N3PATCH' + } + } + } + + await expect(refreshDocumentAuthorization(flaky, 'https://a.example/')).resolves.toBe('N3PATCH') + expect(calls).toBe(2) + // The overtaken response is never read as the answer. + expect(editableReads).toBe(1) + }) + + it('fails closed (unknown) when the identity keeps changing', async () => { + const store: any = { updater: { flagAuthorizationMetadata: (): void => {} } } + const session = { events: new SessionEvents() } + flagAuthorizationOnSessionTransitions(store, session) + + let calls = 0 + const alwaysOvertaken = { + fetcher: { + refresh: (_doc: unknown, done?: () => void): void => { + calls += 1 + session.events.emit('sessionChange') + done?.() + } + }, + updater: { editable: (): string => 'N3PATCH' } + } + + await expect(refreshDocumentAuthorization(alwaysOvertaken, 'https://a.example/')).resolves.toBeUndefined() + expect(calls).toBe(3) + }) }) diff --git a/test/solidAuthLogic.test.ts b/test/solidAuthLogic.test.ts index b53f745..50a3de1 100644 --- a/test/solidAuthLogic.test.ts +++ b/test/solidAuthLogic.test.ts @@ -83,6 +83,12 @@ describe('SolidAuthnLogic', () => { { webId: 'https://alice.example/profile#me' } )).toBe('https://alice.example/profile#me') }) + it('treats a mixed snapshot as logged out when any source reports inactive', () => { + expect(solidAuthnLogic.webIdFromSession( + { webId: 'https://alice.example/profile#me', isLoggedIn: true }, + { webId: 'https://alice.example/profile#me', isActive: false } + )).toBeNull() + }) }) describe('saveUser', () => { From 505342a017791510568dd2f907ab0c6e885b4eac Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Wed, 16 Sep 2026 18:33:12 +0200 Subject: [PATCH 08/19] review: per-store invalidation state; repair before cached reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). --- .../flagAuthorizationOnTransitions.ts | 72 +++++++++--- src/logic/solidLogic.ts | 6 +- src/util/utilityLogic.ts | 19 ++-- test/flagAuthorizationOnTransitions.test.ts | 103 ++++++++++++++++-- 4 files changed, 162 insertions(+), 38 deletions(-) diff --git a/src/authSession/flagAuthorizationOnTransitions.ts b/src/authSession/flagAuthorizationOnTransitions.ts index f0baba8..7f291b8 100644 --- a/src/authSession/flagAuthorizationOnTransitions.ts +++ b/src/authSession/flagAuthorizationOnTransitions.ts @@ -49,14 +49,20 @@ export function flagAuthorizationOnSessionTransitions ( session: TransitionSession ): void { const flag = (): void => { - authorizationGeneration += 1 + const state = storeState(store) + state.generation += 1 try { store.updater?.flagAuthorizationMetadata?.() + // Every recorded response is invalidated; decision points see that as + // "unknown" and repair from there. + state.refreshRequired = false } catch (error) { - // A store that cannot flag must not take the session handling with it — - // but the failure is not swallowed either: the recorded answers stay - // definitive for the previous identity until a decision point forces a - // fresh response (refreshDocumentAuthorization below), so surface it. + // The store could not invalidate its metadata, so its answers stay + // definitive for the previous identity. Do not take the session + // handling down with it, but do not treat the warning as recovery + // either: record that a fresh response is required and have the + // decision points honour it (ensureDocumentAuthorization below). + state.refreshRequired = true debug.warn(`Could not flag authorization metadata after a session transition: ${error}`) } } @@ -72,11 +78,28 @@ export type RefreshableStore = { updater?: { editable?: (uri: unknown) => string | boolean | undefined } } -// 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 +type StoreAuthorizationState = { + /** Identity transitions observed for this store. */ + generation: number + /** The store could not invalidate its metadata — do not trust its answers. */ + refreshRequired: boolean +} + +// Scoped per store: two `createSolidLogic` instances with different sessions +// must not overtake each other's refreshes, and a failed invalidation in one +// store says nothing about another. +const storeStates = new WeakMap() +const sharedState: StoreAuthorizationState = { generation: 0, refreshRequired: false } + +function storeState (store: unknown): StoreAuthorizationState { + if (store === null || typeof store !== 'object') return sharedState + let state = storeStates.get(store) + if (!state) { + state = { generation: 0, refreshRequired: false } + storeStates.set(store, state) + } + return state +} /** How many times a refresh is repeated when the identity keeps changing. */ const REFRESH_ATTEMPTS = 3 @@ -89,26 +112,45 @@ const REFRESH_ATTEMPTS = 3 * The identity can change while the refresh is in flight; the response then * belongs to the previous identity and must not answer for the current one, * or a caller could write under the new identity on the old identity's - * authorization. Each attempt is stamped with the transition generation and - * repeated under the new identity when it was overtaken; if the identity - * keeps changing the answer stays "unknown" rather than stale. + * authorization. Each attempt is stamped with the store's transition + * generation and repeated under the new identity when it was overtaken; if + * the identity keeps changing the answer stays "unknown" rather than stale. */ export async function refreshDocumentAuthorization ( store: RefreshableStore, doc: unknown ): Promise { + const state = storeState(store) for (let attempt = 0; attempt < REFRESH_ATTEMPTS; attempt++) { - const generation = authorizationGeneration + const generation = state.generation await forceRefresh(store, doc) // The read below is synchronous, so a generation that still matches means // no transition slipped in between the response and the answer. - if (generation === authorizationGeneration) { + if (generation === state.generation) { return store.updater?.editable?.(doc) } } return undefined } +/** + * Make the store able to answer for `doc` under the current identity before + * its cached triples are read or its editability gates a write. A flagged + * store answers `undefined` and is repaired here; a store whose flag FAILED + * still answers definitively for the previous identity, so it is repaired + * too (and keeps being repaired until a later transition flags successfully, + * since the failure says nothing about which other documents are stale). + */ +export async function ensureDocumentAuthorization ( + store: RefreshableStore, + doc: unknown +): Promise { + const state = storeState(store) + if (state.refreshRequired || store.updater?.editable?.(doc) === undefined) { + await refreshDocumentAuthorization(store, doc) + } +} + /** * rdflib's `refresh(term, callback)` is callback-based and returns void — * it delegates to `nowOrWhenFetched(term, { force: true, clearPreviousData: diff --git a/src/logic/solidLogic.ts b/src/logic/solidLogic.ts index 393bed5..f6954d9 100644 --- a/src/logic/solidLogic.ts +++ b/src/logic/solidLogic.ts @@ -27,8 +27,10 @@ export function createSolidLogic(specialFetch: { fetch: (url: any, requestInit: store.updater = new rdf.UpdateManager(store) // Add real-time live updates store.updater store.features = [] // disable automatic node merging on store load // 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 + // response out-of-date so editability answers "unknown" instead of the + // previous identity's access. Decision points repair with + // ensureDocumentAuthorization() — a plain load() does not refetch a + // flagged, already-loaded document on rdflib 2.4.0. See // flagAuthorizationOnTransitions.ts. flagAuthorizationOnSessionTransitions(store, session) diff --git a/src/util/utilityLogic.ts b/src/util/utilityLogic.ts index 3046edc..6764cd6 100644 --- a/src/util/utilityLogic.ts +++ b/src/util/utilityLogic.ts @@ -1,5 +1,5 @@ import { NamedNode, st, sym } from 'rdflib' -import { refreshDocumentAuthorization } from '../authSession/flagAuthorizationOnTransitions' +import { ensureDocumentAuthorization } from '../authSession/flagAuthorizationOnTransitions' import { CrossOriginForbiddenError, FetchError, @@ -91,15 +91,15 @@ export function createUtilityLogic(store, aclLogic, containerLogic) { doc: NamedNode ): Promise { await store.fetcher.load(doc) + // On rdflib 2.4.0 a plain load() does not refetch a flagged document, so + // the cached graph can still hold the previous identity's link and answer + // its editability: repair before consuming either (see + // flagAuthorizationOnTransitions.ts). + await ensureDocumentAuthorization(store, doc) const result = store.any(subject, predicate, null, doc) if (result) return result as NamedNode - // A session transition since this document was recorded leaves the store - // answering "unknown" (undefined) for its editability: force a fresh - // response before deciding. See flagAuthorizationOnTransitions.ts. - const editable = store.updater.editable(doc) ?? - await refreshDocumentAuthorization(store, doc) - if (!editable) { + if (!store.updater.editable(doc)) { const msg = `followOrCreateLink: cannot edit ${doc.value}` debug.warn(msg) throw new NotEditableError(msg) @@ -130,12 +130,11 @@ export function createUtilityLogic(store, aclLogic, containerLogic) { data: string ): Promise { await store.fetcher.load(doc) + await ensureDocumentAuthorization(store, doc) const result = store.any(subject, predicate, null, doc) if (result) return result as NamedNode - const editable = store.updater.editable(doc) ?? - await refreshDocumentAuthorization(store, doc) - if (!editable) { + if (!store.updater.editable(doc)) { const msg = `followOrCreateLinkWithContentOnCreate: cannot edit ${doc.value}` debug.warn(msg) throw new NotEditableError(msg) diff --git a/test/flagAuthorizationOnTransitions.test.ts b/test/flagAuthorizationOnTransitions.test.ts index f022da7..495ae6a 100644 --- a/test/flagAuthorizationOnTransitions.test.ts +++ b/test/flagAuthorizationOnTransitions.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { SessionEvents } from '../src/authSession/events' -import { SESSION_TRANSITIONS, flagAuthorizationOnSessionTransitions, refreshDocumentAuthorization } from '../src/authSession/flagAuthorizationOnTransitions' +import { SESSION_TRANSITIONS, ensureDocumentAuthorization, flagAuthorizationOnSessionTransitions, refreshDocumentAuthorization } from '../src/authSession/flagAuthorizationOnTransitions' import { silenceDebugMessages } from './helpers/debugger' silenceDebugMessages() @@ -111,13 +111,10 @@ describe('refreshDocumentAuthorization', () => { }) it('refreshes again when the identity changed while the refresh was in flight', async () => { - const store: any = { updater: { flagAuthorizationMetadata: (): void => {} } } const session = { events: new SessionEvents() } - flagAuthorizationOnSessionTransitions(store, session) - let calls = 0 let editableReads = 0 - const flaky = { + const store: any = { fetcher: { refresh: (_doc: unknown, done?: () => void): void => { calls += 1 @@ -127,37 +124,121 @@ describe('refreshDocumentAuthorization', () => { } }, updater: { + flagAuthorizationMetadata: (): void => {}, editable: (): string => { editableReads += 1 return 'N3PATCH' } } } + flagAuthorizationOnSessionTransitions(store, session) - await expect(refreshDocumentAuthorization(flaky, 'https://a.example/')).resolves.toBe('N3PATCH') + await expect(refreshDocumentAuthorization(store, 'https://a.example/')).resolves.toBe('N3PATCH') expect(calls).toBe(2) // The overtaken response is never read as the answer. expect(editableReads).toBe(1) }) it('fails closed (unknown) when the identity keeps changing', async () => { - const store: any = { updater: { flagAuthorizationMetadata: (): void => {} } } const session = { events: new SessionEvents() } + let calls = 0 + const store: any = { + fetcher: { + refresh: (_doc: unknown, done?: () => void): void => { + calls += 1 + session.events.emit('sessionChange') + done?.() + } + }, + updater: { + flagAuthorizationMetadata: (): void => {}, + editable: (): string => 'N3PATCH' + } + } flagAuthorizationOnSessionTransitions(store, session) + await expect(refreshDocumentAuthorization(store, 'https://a.example/')).resolves.toBeUndefined() + expect(calls).toBe(3) + }) + + it('scopes the generation to the store — another store\'s transition is no overtake', async () => { + const sessionA = { events: new SessionEvents() } + const sessionB = { events: new SessionEvents() } let calls = 0 - const alwaysOvertaken = { + const storeA: any = { fetcher: { refresh: (_doc: unknown, done?: () => void): void => { calls += 1 - session.events.emit('sessionChange') + // A transition in ANOTHER store/session must not overtake this refresh. + sessionB.events.emit('sessionChange') done?.() } }, + updater: { + flagAuthorizationMetadata: (): void => {}, + editable: (): string => 'N3PATCH' + } + } + const storeB: any = { updater: { flagAuthorizationMetadata: (): void => {} } } + flagAuthorizationOnSessionTransitions(storeA, sessionA) + flagAuthorizationOnSessionTransitions(storeB, sessionB) + + await expect(refreshDocumentAuthorization(storeA, 'https://a.example/')).resolves.toBe('N3PATCH') + expect(calls).toBe(1) + }) +}) + +describe('ensureDocumentAuthorization', () => { + it('does not refresh when the store can answer definitively', async () => { + let calls = 0 + const store: any = { + fetcher: { refresh: (): void => { calls += 1 } }, updater: { editable: (): string => 'N3PATCH' } } - await expect(refreshDocumentAuthorization(alwaysOvertaken, 'https://a.example/')).resolves.toBeUndefined() - expect(calls).toBe(3) + await ensureDocumentAuthorization(store, 'https://a.example/') + expect(calls).toBe(0) + }) + + it('refreshes a flagged document before its triples are consumed', async () => { + let calls = 0 + let flagged = true + const store: any = { + fetcher: { + refresh: (_doc: unknown, done?: () => void): void => { + calls += 1 + flagged = false + done?.() + } + }, + updater: { editable: (): string | undefined => (flagged ? undefined : 'N3PATCH') } + } + + await ensureDocumentAuthorization(store, 'https://a.example/') + expect(calls).toBe(1) + }) + + it('refreshes when a flag failure left the store answering for the previous identity', async () => { + const session = { events: new SessionEvents() } + let calls = 0 + const store: any = { + fetcher: { + refresh: (_doc: unknown, done?: () => void): void => { + calls += 1 + done?.() + } + }, + updater: { + flagAuthorizationMetadata: (): void => { throw new Error('store gone') }, + // Definitive, but from the previous identity: the failure must not be + // treated as recovery. + editable: (): string => 'N3PATCH' + } + } + flagAuthorizationOnSessionTransitions(store, session) + session.events.emit('sessionChange') + + await ensureDocumentAuthorization(store, 'https://a.example/') + expect(calls).toBe(1) }) }) From 8b231106b065eca9d7d3e43e554a88994c27b395 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Wed, 16 Sep 2026 18:49:55 +0200 Subject: [PATCH 09/19] review: propagate repair failure; keep cookie-backed fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). --- .../flagAuthorizationOnTransitions.ts | 54 ++++++++++----- src/authn/SolidAuthnLogic.ts | 15 ++++- src/util/utilityLogic.ts | 16 +++-- test/flagAuthorizationOnTransitions.test.ts | 65 +++++++++++++++++-- test/solidAuthLogic.test.ts | 13 ++++ 5 files changed, 138 insertions(+), 25 deletions(-) diff --git a/src/authSession/flagAuthorizationOnTransitions.ts b/src/authSession/flagAuthorizationOnTransitions.ts index 7f291b8..e0e71f4 100644 --- a/src/authSession/flagAuthorizationOnTransitions.ts +++ b/src/authSession/flagAuthorizationOnTransitions.ts @@ -52,7 +52,13 @@ export function flagAuthorizationOnSessionTransitions ( const state = storeState(store) state.generation += 1 try { - store.updater?.flagAuthorizationMetadata?.() + const invalidate = store.updater?.flagAuthorizationMetadata + if (typeof invalidate !== 'function') { + // A store without the API cannot be invalidated — that is a failure, + // not a success: the decision points must not trust its answers. + throw new Error('flagAuthorizationMetadata is unavailable') + } + invalidate.call(store.updater) // Every recorded response is invalidated; decision points see that as // "unknown" and repair from there. state.refreshRequired = false @@ -115,6 +121,12 @@ const REFRESH_ATTEMPTS = 3 * authorization. Each attempt is stamped with the store's transition * generation and repeated under the new identity when it was overtaken; if * the identity keeps changing the answer stays "unknown" rather than stale. + * + * Returns `undefined` whenever the answer cannot be established under the + * current identity: no refresh capability, a failed refresh, or an identity + * that changed throughout every attempt. A failed refresh must NOT fall back + * to the recorded answer — when the store could not be invalidated that + * answer belongs to the previous identity. */ export async function refreshDocumentAuthorization ( store: RefreshableStore, @@ -123,7 +135,8 @@ export async function refreshDocumentAuthorization ( const state = storeState(store) for (let attempt = 0; attempt < REFRESH_ATTEMPTS; attempt++) { const generation = state.generation - await forceRefresh(store, doc) + const refreshed = await forceRefresh(store, doc) + if (!refreshed) return undefined // The read below is synchronous, so a generation that still matches means // no transition slipped in between the response and the answer. if (generation === state.generation) { @@ -140,15 +153,21 @@ export async function refreshDocumentAuthorization ( * still answers definitively for the previous identity, so it is repaired * too (and keeps being repaired until a later transition flags successfully, * since the failure says nothing about which other documents are stale). + * + * Returns whether the answer was established. `false` means a repair was + * needed and could not complete (no refresh capability, a failed refresh, or + * an identity that changed throughout): the caller must not consume cached + * triples from that document and must not offer a write on it. */ export async function ensureDocumentAuthorization ( store: RefreshableStore, doc: unknown -): Promise { +): Promise { const state = storeState(store) - if (state.refreshRequired || store.updater?.editable?.(doc) === undefined) { - await refreshDocumentAuthorization(store, doc) + if (!state.refreshRequired && store.updater?.editable?.(doc) !== undefined) { + return true } + return (await refreshDocumentAuthorization(store, doc)) !== undefined } /** @@ -156,22 +175,27 @@ export async function ensureDocumentAuthorization ( * it delegates to `nowOrWhenFetched(term, { force: true, clearPreviousData: * true }, callback)` and the callback is the completion signal. Awaiting the * call itself would read `editable()` before the fresh response is recorded, - * so wait for the callback (a promise-returning wrapper is awaited too). A - * failed refresh resolves anyway, with a warning: the answer stays unknown - * and the caller decides. + * so wait for the callback (a promise-returning wrapper is awaited too). + * + * Resolves `true` only when a refresh actually completed; a missing refresh + * capability, a callback that reports failure, a rejected promise or a + * synchronous throw all resolve `false`, with a warning — the caller must not + * read the recorded answer in that case. */ -async function forceRefresh (store: RefreshableStore, doc: unknown): Promise { +async function forceRefresh (store: RefreshableStore, doc: unknown): Promise { const refresh = store.fetcher?.refresh - if (typeof refresh !== 'function') return - await new Promise((resolve) => { + if (typeof refresh !== 'function') return false + return await new Promise((resolve) => { let settled = false const done = (ok?: unknown, message?: unknown): void => { + if (settled) return + settled = true if (ok === false) { debug.warn(`Could not refresh ${String(doc)}: ${String(message)}`) + resolve(false) + } else { + resolve(true) } - if (settled) return - settled = true - resolve() } try { const result = refresh.call(store.fetcher, doc, done) @@ -180,7 +204,7 @@ async function forceRefresh (store: RefreshableStore, doc: unknown): Promise | null = null private sessionRestoreHookAttached = false private fallbackWebId: string | null = null + // Set when `fallbackWebId` came from the NSS cookie probe rather than from + // the OIDC session: an inactive OIDC session is exactly why that identity + // was probed, so it must survive `currentUser()`'s stand-down below. + private cookieBackedFallback = false constructor(solidAuthSession: SessionWithLegacyEvents) { this.session = solidAuthSession @@ -49,7 +53,12 @@ export class SolidAuthnLogic implements AuthnLogic { const sessionAny = this.session as any 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. + // previous user: drop the remembered session fallback and report logged + // out. A cookie-backed fallback is different — it was probed precisely + // because the OIDC session is inactive, so it stays usable. + if (this.cookieBackedFallback && this.fallbackWebId) { + return sym(this.fallbackWebId) + } this.fallbackWebId = null return offlineTestID() // null unless testing } @@ -192,15 +201,19 @@ export class SolidAuthnLogic implements AuthnLogic { } let webId = this.webIdFromSession(sessionAny?.info, sessionAny) + let cookieBacked = false if (!webId) { // NSS-specific fallback: recover WebID from NSS cookie session when client restore is empty. webId = await this.probeNssCookieBackedWebId() + cookieBacked = webId !== null } if (webId) { this.fallbackWebId = webId + this.cookieBackedFallback = cookieBacked } else { this.fallbackWebId = null + this.cookieBackedFallback = false } if (webId) { diff --git a/src/util/utilityLogic.ts b/src/util/utilityLogic.ts index 6764cd6..d0628fa 100644 --- a/src/util/utilityLogic.ts +++ b/src/util/utilityLogic.ts @@ -92,10 +92,14 @@ export function createUtilityLogic(store, aclLogic, containerLogic) { ): Promise { await store.fetcher.load(doc) // On rdflib 2.4.0 a plain load() does not refetch a flagged document, so - // the cached graph can still hold the previous identity's link and answer - // its editability: repair before consuming either (see + // the cached graph can still hold the previous identity's link: establish + // the current identity's answer before reading anything (see // flagAuthorizationOnTransitions.ts). - await ensureDocumentAuthorization(store, doc) + if (!(await ensureDocumentAuthorization(store, doc))) { + const msg = `followOrCreateLink: cannot establish the authorization of ${doc.value}` + debug.warn(msg) + throw new NotEditableError(msg) + } const result = store.any(subject, predicate, null, doc) if (result) return result as NamedNode @@ -130,7 +134,11 @@ export function createUtilityLogic(store, aclLogic, containerLogic) { data: string ): Promise { await store.fetcher.load(doc) - await ensureDocumentAuthorization(store, doc) + if (!(await ensureDocumentAuthorization(store, doc))) { + const msg = `followOrCreateLinkWithContentOnCreate: cannot establish the authorization of ${doc.value}` + debug.warn(msg) + throw new NotEditableError(msg) + } const result = store.any(subject, predicate, null, doc) if (result) return result as NamedNode diff --git a/test/flagAuthorizationOnTransitions.test.ts b/test/flagAuthorizationOnTransitions.test.ts index 495ae6a..88e7d03 100644 --- a/test/flagAuthorizationOnTransitions.test.ts +++ b/test/flagAuthorizationOnTransitions.test.ts @@ -105,9 +105,11 @@ describe('refreshDocumentAuthorization', () => { expect(order).toEqual(['refreshed', 'editable']) }) - it('answers editability even when the store cannot refresh', async () => { + it('stays unknown when the store cannot refresh (no capability)', async () => { const store = { updater: { editable: (): boolean => false } } - await expect(refreshDocumentAuthorization(store, 'https://a.example/')).resolves.toBe(false) + // Without a refresh capability the previous identity's answer must not be + // handed back as if it were current. + await expect(refreshDocumentAuthorization(store, 'https://a.example/')).resolves.toBeUndefined() }) it('refreshes again when the identity changed while the refresh was in flight', async () => { @@ -196,7 +198,7 @@ describe('ensureDocumentAuthorization', () => { updater: { editable: (): string => 'N3PATCH' } } - await ensureDocumentAuthorization(store, 'https://a.example/') + await expect(ensureDocumentAuthorization(store, 'https://a.example/')).resolves.toBe(true) expect(calls).toBe(0) }) @@ -214,7 +216,25 @@ describe('ensureDocumentAuthorization', () => { updater: { editable: (): string | undefined => (flagged ? undefined : 'N3PATCH') } } - await ensureDocumentAuthorization(store, 'https://a.example/') + await expect(ensureDocumentAuthorization(store, 'https://a.example/')).resolves.toBe(true) + expect(calls).toBe(1) + }) + + it('keeps a definitive read-only answer readable (false is not a failure)', async () => { + let calls = 0 + let flagged = true + const store: any = { + fetcher: { + refresh: (_doc: unknown, done?: () => void): void => { + calls += 1 + flagged = false + done?.() + } + }, + updater: { editable: (): boolean | undefined => (flagged ? undefined : false) } + } + + await expect(ensureDocumentAuthorization(store, 'https://a.example/')).resolves.toBe(true) expect(calls).toBe(1) }) @@ -238,7 +258,42 @@ describe('ensureDocumentAuthorization', () => { flagAuthorizationOnSessionTransitions(store, session) session.events.emit('sessionChange') - await ensureDocumentAuthorization(store, 'https://a.example/') + await expect(ensureDocumentAuthorization(store, 'https://a.example/')).resolves.toBe(true) expect(calls).toBe(1) }) + + it('treats a missing flag API as a failed invalidation', async () => { + const session = { events: new SessionEvents() } + let calls = 0 + const store: any = { + fetcher: { + refresh: (_doc: unknown, done?: () => void): void => { + calls += 1 + done?.() + } + }, + // No flagAuthorizationMetadata: the store cannot be invalidated. + updater: { editable: (): string => 'N3PATCH' } + } + flagAuthorizationOnSessionTransitions(store, session) + session.events.emit('sessionChange') + + await expect(ensureDocumentAuthorization(store, 'https://a.example/')).resolves.toBe(true) + expect(calls).toBe(1) + }) + + it('reports false when the needed repair cannot complete', async () => { + const session = { events: new SessionEvents() } + const store: any = { + updater: { + flagAuthorizationMetadata: (): void => { throw new Error('store gone') }, + editable: (): string => 'N3PATCH' + } + } + flagAuthorizationOnSessionTransitions(store, session) + session.events.emit('sessionChange') + + // No refresh capability: the caller must not consume cached triples. + await expect(ensureDocumentAuthorization(store, 'https://a.example/')).resolves.toBe(false) + }) }) diff --git a/test/solidAuthLogic.test.ts b/test/solidAuthLogic.test.ts index 50a3de1..e9dd87a 100644 --- a/test/solidAuthLogic.test.ts +++ b/test/solidAuthLogic.test.ts @@ -60,6 +60,19 @@ describe('SolidAuthnLogic', () => { expect(authn.currentUser()?.uri).toBe('https://alice.example/profile#me') }) + + it('keeps a cookie-backed fallback usable while the OIDC session is inactive', () => { + // The NSS cookie probe is precisely the case where the OIDC session has + // no active client state; that identity is not the "previous user". + const authn = new SolidAuthnLogic({ + isActive: false, + info: { isLoggedIn: false } + } as any) + ;(authn as any).fallbackWebId = 'https://alice.localhost/profile/card#me' + ;(authn as any).cookieBackedFallback = true + + expect(authn.currentUser()?.uri).toBe('https://alice.localhost/profile/card#me') + }) }) describe('webIdFromSession', () => { From 9808901508f1321180783d741bea361b021aebc1 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Wed, 16 Sep 2026 19:06:06 +0200 Subject: [PATCH 10/19] feat(authSession): signal when an established identity is replaced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). --- src/authSession/events.ts | 2 +- src/authSession/transitions.ts | 44 ++++++++++++++++++++- src/index.ts | 1 + test/transitions.test.ts | 70 +++++++++++++++++++++++++++++++--- 4 files changed, 110 insertions(+), 7 deletions(-) diff --git a/src/authSession/events.ts b/src/authSession/events.ts index c539de6..dc1a900 100644 --- a/src/authSession/events.ts +++ b/src/authSession/events.ts @@ -5,7 +5,7 @@ * Wired into the auth session by authSession.ts. */ -export type LegacyEventName = 'login' | 'logout' | 'sessionChange' | 'sessionRestore' +export type LegacyEventName = 'identityReplaced' | 'login' | 'logout' | 'sessionChange' | 'sessionRestore' type LegacyEventHandler = (...args: unknown[]) => void /** diff --git a/src/authSession/transitions.ts b/src/authSession/transitions.ts index 015418f..bc26e6f 100644 --- a/src/authSession/transitions.ts +++ b/src/authSession/transitions.ts @@ -15,6 +15,13 @@ * window) is caught by comparing the identity around `setTokenDetails`, * the single entry point for token updates. * + * It also reports `identityReplaced` when a session that HAD a WebID no longer + * reports the same one, or no longer reports being active: that is the signal + * for a consumer holding data fetched under the previous identity — it cannot + * be re-validated document by document, so it should discard its cache + * (reloading the page is the pragmatic form, see `reloadOnIdentityReplaced`). + * Start-up and same-identity token refreshes are deliberately not replacements. + * * Consumers invalidate identity-derived state on these events — see * flagAuthorizationOnTransitions.ts. */ @@ -38,6 +45,39 @@ export function classifySessionTransition ( return next.webId !== prev.webId ? 'sessionChange' : null } +/** + * Whether an established identity was replaced or cleared — a session that had + * a WebID no longer reports the same one (A -> B), or no longer reports being + * active (A -> logged out, A -> none). Data fetched under the previous + * identity cannot be re-validated document by document, so this is the signal + * to discard it. + * + * Start-up (no identity -> A) and a token refresh for the same identity are + * not replacements: there is nothing of a previous user to drop. + */ +export function identityReplaced (prev: SessionSnapshot, next: SessionSnapshot): boolean { + if (prev.webId === undefined) return false + return next.webId !== prev.webId || !next.isActive +} + +/** + * Reload the page when the identity that was active in this tab is replaced or + * cleared — the pragmatic way to drop everything fetched under the previous + * identity (store, panes, editability), instead of repairing every read path. + * + * Consumer-side on purpose: navigation is an application decision (solid-ui, + * mashlib), and tests inject their own action. + */ +export function reloadOnIdentityReplaced ( + events: { on?: (event: 'identityReplaced', handler: () => void) => void } | undefined, + reload: () => void = () => { + if (typeof window !== 'undefined') window.location.reload() + } +): void { + if (!events || typeof events.on !== 'function') return + events.on('identityReplaced', reload) +} + export type SessionLike = { isActive?: boolean webId?: string @@ -115,15 +155,17 @@ function watchTokenUpdates (session: SessionLike, note: () => void): void { */ export function watchSessionTransitions ( session: SessionLike, - emit: (event: 'logout' | 'sessionChange') => void, + emit: (event: 'logout' | 'sessionChange' | 'identityReplaced') => void, doc: DocumentLike | undefined = typeof document === 'undefined' ? undefined : document ): void { let previous = snapshotOf(session) const note = (): void => { const next = snapshotOf(session) const event = classifySessionTransition(previous, next) + const replaced = identityReplaced(previous, next) previous = next if (event) emit(event) + if (replaced) emit('identityReplaced') } if (typeof session.addEventListener === 'function') { session.addEventListener('sessionStateChange', note) diff --git a/src/index.ts b/src/index.ts index 5cbb29c..6c1418f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ const store = solidLogicSingleton.store export { ACL_LINK } from './acl/aclLogic' export { offlineTestID, appContext } from './authn/authUtil' export { performServerSideLogout } from './authn/serverLogout' +export { reloadOnIdentityReplaced } from './authSession/transitions' export { getSuggestedIssuers } from './issuer/issuerLogic' export { createTypeIndexLogic } from './typeIndex/typeIndexLogic' export type { AppDetails, SolidNamespace, AuthenticationContext, SolidLogic, ChatLogic } from './types' diff --git a/test/transitions.test.ts b/test/transitions.test.ts index a3c1f66..66a3111 100644 --- a/test/transitions.test.ts +++ b/test/transitions.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { classifySessionTransition, sessionIsActive, watchSessionTransitions, type DocumentLike, type SessionLike } from '../src/authSession/transitions' +import { classifySessionTransition, identityReplaced, reloadOnIdentityReplaced, sessionIsActive, watchSessionTransitions, type DocumentLike, type SessionLike } from '../src/authSession/transitions' describe('classifySessionTransition', () => { it('reports a logout when the session goes inactive', () => { @@ -32,6 +32,53 @@ describe('classifySessionTransition', () => { }) }) +describe('identityReplaced', () => { + it('reports a replacement when an established WebID changes (A -> B)', () => { + expect(identityReplaced( + { isActive: true, webId: 'https://a.example/#me' }, + { isActive: true, webId: 'https://b.example/#me' } + )).toBe(true) + }) + + it('reports a replacement when an established identity is cleared (A -> logged out)', () => { + expect(identityReplaced( + { isActive: true, webId: 'https://a.example/#me' }, + { isActive: false, webId: 'https://a.example/#me' } + )).toBe(true) + expect(identityReplaced( + { isActive: true, webId: 'https://a.example/#me' }, + { isActive: false } + )).toBe(true) + }) + + it('ignores start-up (no identity -> A) and a same-identity token refresh', () => { + expect(identityReplaced({ isActive: false }, { isActive: true, webId: 'https://a.example/#me' })).toBe(false) + expect(identityReplaced( + { isActive: true, webId: 'https://a.example/#me' }, + { isActive: true, webId: 'https://a.example/#me' } + )).toBe(false) + }) +}) + +describe('reloadOnIdentityReplaced', () => { + it('subscribes the reload action to identityReplaced', () => { + const handlers: Record void> = {} + const events = { + on: (event: string, handler: () => void): void => { handlers[event] = handler } + } + let reloads = 0 + reloadOnIdentityReplaced(events, () => { reloads += 1 }) + + expect(handlers.identityReplaced).toBeInstanceOf(Function) + handlers.identityReplaced() + expect(reloads).toBe(1) + }) + + it('does nothing without an event layer', () => { + expect(() => reloadOnIdentityReplaced(undefined)).not.toThrow() + }) +}) + // A session stand-in: a real EventTarget the test can poke. setTokenDetails // mirrors the uvdsl method every token update goes through. class FakeSession extends EventTarget { @@ -72,7 +119,7 @@ describe('watchSessionTransitions', () => { session.isActive = false session.webId = undefined session.dispatchEvent(new Event('sessionStateChange')) - expect(emitted).toEqual(['sessionChange', 'logout']) + expect(emitted).toEqual(['sessionChange', 'logout', 'identityReplaced']) }) it('notices a WebID change while the session stays active (token update)', async () => { @@ -86,7 +133,7 @@ describe('watchSessionTransitions', () => { // token update itself is the evidence of an A -> B switch. await session.setTokenDetails({ webId: 'https://b.example/#me' }) - expect(emitted).toEqual(['sessionChange']) + expect(emitted).toEqual(['sessionChange', 'identityReplaced']) }) it('emits logout when isActive flips false while a WebID is still cached', () => { @@ -99,7 +146,20 @@ describe('watchSessionTransitions', () => { session.isActive = false // webId retained, as during a partial logout session.dispatchEvent(new Event('sessionStateChange')) - expect(emitted).toEqual(['logout']) + expect(emitted).toEqual(['logout', 'identityReplaced']) + }) + + it('emits identityReplaced when an established identity is replaced', () => { + const session = new FakeSession() + session.isActive = true + session.webId = 'https://a.example/#me' + const emitted: string[] = [] + watchSessionTransitions(session as unknown as SessionLike, (event) => emitted.push(event), undefined) + + session.webId = 'https://b.example/#me' + session.dispatchEvent(new Event('sessionStateChange')) + + expect(emitted).toEqual(['sessionChange', 'identityReplaced']) }) it('notices a change made elsewhere when the tab is refocused', () => { @@ -155,6 +215,6 @@ describe('watchSessionTransitions', () => { session.webId = 'https://b.example/#me' handlers.visibilitychange() - expect(emitted).toEqual(['sessionChange']) + expect(emitted).toEqual(['sessionChange', 'identityReplaced']) }) }) From fbe062ef6fa85accb1ff9ff22243be1354550e3c Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Wed, 16 Sep 2026 19:39:09 +0200 Subject: [PATCH 11/19] review: check the load for overtakes; scope the reload signal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). --- .../flagAuthorizationOnTransitions.ts | 31 ++++++++- src/authSession/transitions.ts | 7 +- src/authn/SolidAuthnLogic.ts | 25 +++++++ src/util/utilityLogic.ts | 16 ++--- test/flagAuthorizationOnTransitions.test.ts | 68 ++++++++++++++++++- test/solidAuthLogic.test.ts | 44 ++++++++++++ test/transitions.test.ts | 26 +++++++ 7 files changed, 205 insertions(+), 12 deletions(-) diff --git a/src/authSession/flagAuthorizationOnTransitions.ts b/src/authSession/flagAuthorizationOnTransitions.ts index e0e71f4..ad93434 100644 --- a/src/authSession/flagAuthorizationOnTransitions.ts +++ b/src/authSession/flagAuthorizationOnTransitions.ts @@ -80,7 +80,10 @@ export function flagAuthorizationOnSessionTransitions ( } export type RefreshableStore = { - fetcher?: { refresh?: (doc: unknown, callback?: (...args: unknown[]) => void) => unknown } + fetcher?: { + refresh?: (doc: unknown, callback?: (...args: unknown[]) => void) => unknown + load?: (doc: unknown) => unknown + } updater?: { editable?: (uri: unknown) => string | boolean | undefined } } @@ -170,6 +173,32 @@ export async function ensureDocumentAuthorization ( return (await refreshDocumentAuthorization(store, doc)) !== undefined } +/** + * Load a document and make sure its cached triples can be read under the + * current identity. The load itself is generation-checked: a response begun + * under the previous identity can be recorded AFTER + * `flagAuthorizationMetadata()` ran (the flag only marks response nodes that + * already existed), which leaves a definitive-looking answer from the old + * identity behind — so an overtaken load is force-refreshed instead of being + * trusted. + * + * Returns whether the document can be consumed (see + * ensureDocumentAuthorization). Load errors propagate, as a plain `load()` + * would. + */ +export async function loadAuthorizedDocument ( + store: RefreshableStore, + doc: unknown +): Promise { + const state = storeState(store) + const generation = state.generation + await store.fetcher?.load?.(doc) + if (generation !== state.generation) { + return (await refreshDocumentAuthorization(store, doc)) !== undefined + } + return ensureDocumentAuthorization(store, doc) +} + /** * rdflib's `refresh(term, callback)` is callback-based and returns void — * it delegates to `nowOrWhenFetched(term, { force: true, clearPreviousData: diff --git a/src/authSession/transitions.ts b/src/authSession/transitions.ts index bc26e6f..6de9723 100644 --- a/src/authSession/transitions.ts +++ b/src/authSession/transitions.ts @@ -57,7 +57,12 @@ export function classifySessionTransition ( */ export function identityReplaced (prev: SessionSnapshot, next: SessionSnapshot): boolean { if (prev.webId === undefined) return false - return next.webId !== prev.webId || !next.isActive + if (next.webId !== prev.webId) return true + // The same WebID can be retained through a partial logout ({ isActive: false, + // webId: A }): only the transition OUT of an active session is a + // replacement, so a steady partial-logout snapshot does not report one — + // and repeat one — on every refocus. + return prev.isActive && !next.isActive } /** diff --git a/src/authn/SolidAuthnLogic.ts b/src/authn/SolidAuthnLogic.ts index 648bbd9..a20210a 100644 --- a/src/authn/SolidAuthnLogic.ts +++ b/src/authn/SolidAuthnLogic.ts @@ -200,6 +200,7 @@ export class SolidAuthnLogic implements AuthnLogic { return me } + const previousFallback = this.fallbackWebId let webId = this.webIdFromSession(sessionAny?.info, sessionAny) let cookieBacked = false if (!webId) { @@ -216,6 +217,8 @@ export class SolidAuthnLogic implements AuthnLogic { this.cookieBackedFallback = false } + this.reportFallbackIdentityChange(previousFallback) + if (webId) { me = this.saveUser(webId) } @@ -284,6 +287,28 @@ export class SolidAuthnLogic implements AuthnLogic { return null } + /** + * The NSS cookie fallback is a second identity source: the OIDC session + * stays inactive and WebID-less, so the transition watcher cannot observe + * anonymous -> cookie-user, cookie-user A -> B, or a cookie logout. Report + * those changes like a session transition so invalidation and reload + * consumers still react. + */ + private reportFallbackIdentityChange (previousFallback: string | null): void { + if (previousFallback === this.fallbackWebId) return + const events = (this.session as any)?.events + if (typeof events?.emit !== 'function') return + if (previousFallback !== null) { + // An established cookie-backed identity was replaced or cleared. + events.emit('sessionChange') + events.emit('identityReplaced') + } else if (this.fallbackWebId !== null) { + // Anonymous -> cookie-backed identity: the recorded answers must be + // invalidated, but there is no previous identity's data to discard. + events.emit('sessionChange') + } + } + /** * @returns {Promise} Resolves with WebID URI or null */ diff --git a/src/util/utilityLogic.ts b/src/util/utilityLogic.ts index d0628fa..2bb42a2 100644 --- a/src/util/utilityLogic.ts +++ b/src/util/utilityLogic.ts @@ -1,5 +1,5 @@ import { NamedNode, st, sym } from 'rdflib' -import { ensureDocumentAuthorization } from '../authSession/flagAuthorizationOnTransitions' +import { loadAuthorizedDocument } from '../authSession/flagAuthorizationOnTransitions' import { CrossOriginForbiddenError, FetchError, @@ -90,12 +90,11 @@ export function createUtilityLogic(store, aclLogic, containerLogic) { object: NamedNode, doc: NamedNode ): Promise { - await store.fetcher.load(doc) - // On rdflib 2.4.0 a plain load() does not refetch a flagged document, so - // the cached graph can still hold the previous identity's link: establish - // the current identity's answer before reading anything (see - // flagAuthorizationOnTransitions.ts). - if (!(await ensureDocumentAuthorization(store, doc))) { + // On rdflib 2.4.0 a plain load() does not refetch a flagged document, and a + // response begun before a transition can be recorded after it: the helper + // owns the load, checks it was not overtaken and repairs before anything is + // read (see flagAuthorizationOnTransitions.ts). + if (!(await loadAuthorizedDocument(store, doc))) { const msg = `followOrCreateLink: cannot establish the authorization of ${doc.value}` debug.warn(msg) throw new NotEditableError(msg) @@ -133,8 +132,7 @@ export function createUtilityLogic(store, aclLogic, containerLogic) { doc: NamedNode, data: string ): Promise { - await store.fetcher.load(doc) - if (!(await ensureDocumentAuthorization(store, doc))) { + if (!(await loadAuthorizedDocument(store, doc))) { const msg = `followOrCreateLinkWithContentOnCreate: cannot establish the authorization of ${doc.value}` debug.warn(msg) throw new NotEditableError(msg) diff --git a/test/flagAuthorizationOnTransitions.test.ts b/test/flagAuthorizationOnTransitions.test.ts index 88e7d03..55ce965 100644 --- a/test/flagAuthorizationOnTransitions.test.ts +++ b/test/flagAuthorizationOnTransitions.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { SessionEvents } from '../src/authSession/events' -import { SESSION_TRANSITIONS, ensureDocumentAuthorization, flagAuthorizationOnSessionTransitions, refreshDocumentAuthorization } from '../src/authSession/flagAuthorizationOnTransitions' +import { SESSION_TRANSITIONS, ensureDocumentAuthorization, flagAuthorizationOnSessionTransitions, loadAuthorizedDocument, refreshDocumentAuthorization } from '../src/authSession/flagAuthorizationOnTransitions' import { silenceDebugMessages } from './helpers/debugger' silenceDebugMessages() @@ -297,3 +297,69 @@ describe('ensureDocumentAuthorization', () => { await expect(ensureDocumentAuthorization(store, 'https://a.example/')).resolves.toBe(false) }) }) + +describe('loadAuthorizedDocument', () => { + it('loads and answers without a refresh when nothing overtook the load', async () => { + let loads = 0 + let refreshes = 0 + const store: any = { + fetcher: { + load: async (): Promise => { loads += 1 }, + refresh: (_doc: unknown, done?: () => void): void => { + refreshes += 1 + done?.() + } + }, + updater: { editable: (): string => 'N3PATCH' } + } + + await expect(loadAuthorizedDocument(store, 'https://a.example/')).resolves.toBe(true) + expect(loads).toBe(1) + expect(refreshes).toBe(0) + }) + + it('forces a refresh when a transition overtook the load', async () => { + const session = { events: new SessionEvents() } + let loads = 0 + let refreshes = 0 + const store: any = { + fetcher: { + // The response lands after the transition, so its metadata is never + // flagged — it still belongs to the previous identity. + load: async (): Promise => { + loads += 1 + session.events.emit('sessionChange') + }, + refresh: (_doc: unknown, done?: () => void): void => { + refreshes += 1 + done?.() + } + }, + updater: { + flagAuthorizationMetadata: (): void => {}, + editable: (): string => 'N3PATCH' + } + } + flagAuthorizationOnSessionTransitions(store, session) + + await expect(loadAuthorizedDocument(store, 'https://a.example/')).resolves.toBe(true) + expect(loads).toBe(1) + expect(refreshes).toBe(1) + }) + + it('reports false when an overtaken load cannot be repaired', async () => { + const session = { events: new SessionEvents() } + const store: any = { + fetcher: { + load: async (): Promise => { session.events.emit('sessionChange') } + }, + updater: { + flagAuthorizationMetadata: (): void => {}, + editable: (): string => 'N3PATCH' + } + } + flagAuthorizationOnSessionTransitions(store, session) + + await expect(loadAuthorizedDocument(store, 'https://a.example/')).resolves.toBe(false) + }) +}) diff --git a/test/solidAuthLogic.test.ts b/test/solidAuthLogic.test.ts index e9dd87a..69d6d6f 100644 --- a/test/solidAuthLogic.test.ts +++ b/test/solidAuthLogic.test.ts @@ -104,6 +104,50 @@ describe('SolidAuthnLogic', () => { }) }) + describe('cookie-backed fallback identity changes', () => { + it('reports a replacement when an established cookie identity is cleared', () => { + const events = new EventEmitter() + const emitted: string[] = [] + events.on('sessionChange', () => emitted.push('sessionChange')) + events.on('identityReplaced', () => emitted.push('identityReplaced')) + const authn = new SolidAuthnLogic({ events } as any) + ;(authn as any).fallbackWebId = null + ;(authn as any).cookieBackedFallback = false + + ;(authn as any).reportFallbackIdentityChange('https://alice.localhost/profile/card#me') + + expect(emitted).toEqual(['sessionChange', 'identityReplaced']) + }) + + it('reports an anonymous-to-cookie transition without a replacement', () => { + const events = new EventEmitter() + const emitted: string[] = [] + events.on('sessionChange', () => emitted.push('sessionChange')) + events.on('identityReplaced', () => emitted.push('identityReplaced')) + const authn = new SolidAuthnLogic({ events } as any) + ;(authn as any).fallbackWebId = 'https://alice.localhost/profile/card#me' + ;(authn as any).cookieBackedFallback = true + + ;(authn as any).reportFallbackIdentityChange(null) + + // Nothing of a previous identity was cached, so no replacement. + expect(emitted).toEqual(['sessionChange']) + }) + + it('stays silent when the fallback identity is unchanged', () => { + const events = new EventEmitter() + const emitted: string[] = [] + events.on('sessionChange', () => emitted.push('sessionChange')) + events.on('identityReplaced', () => emitted.push('identityReplaced')) + const authn = new SolidAuthnLogic({ events } as any) + ;(authn as any).fallbackWebId = 'https://alice.localhost/profile/card#me' + + ;(authn as any).reportFallbackIdentityChange('https://alice.localhost/profile/card#me') + + expect(emitted).toEqual([]) + }) + }) + describe('saveUser', () => { it('exists', () => { expect(solidAuthnLogic.saveUser).toBeInstanceOf(Function) diff --git a/test/transitions.test.ts b/test/transitions.test.ts index 66a3111..cfddd98 100644 --- a/test/transitions.test.ts +++ b/test/transitions.test.ts @@ -58,6 +58,15 @@ describe('identityReplaced', () => { { isActive: true, webId: 'https://a.example/#me' } )).toBe(false) }) + + it('ignores a steady partial-logout state (the same WebID retained while inactive)', () => { + // Otherwise every refocus would report a replacement again and a reload + // consumer would loop. + expect(identityReplaced( + { isActive: false, webId: 'https://a.example/#me' }, + { isActive: false, webId: 'https://a.example/#me' } + )).toBe(false) + }) }) describe('reloadOnIdentityReplaced', () => { @@ -162,6 +171,23 @@ describe('watchSessionTransitions', () => { expect(emitted).toEqual(['sessionChange', 'identityReplaced']) }) + it('does not repeat identityReplaced for a steady partial-logout state on refocus', () => { + const session = new FakeSession() + session.isActive = false + session.webId = 'https://a.example/#me' // retained, as during a partial logout + const emitted: string[] = [] + const handlers: Record void> = {} + const doc: DocumentLike = { + visibilityState: 'visible', + addEventListener: (type: string, listener: () => void): void => { handlers[type] = listener } + } + watchSessionTransitions(session as unknown as SessionLike, (event) => emitted.push(event), doc) + + handlers.visibilitychange() + handlers.visibilitychange() + expect(emitted).toEqual([]) + }) + it('notices a change made elsewhere when the tab is refocused', () => { const session = new FakeSession() const emitted: string[] = [] From 040f7e1efffeff41b54f70dec558fae1595115f9 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Wed, 16 Sep 2026 19:55:56 +0200 Subject: [PATCH 12/19] review: resync on refocus without a worker; dedupe the cookie reporter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). --- src/authSession/authSession.ts | 14 ++++++++++++-- src/authSession/session.ts | 13 +++++++++++++ src/authSession/transitions.ts | 18 ++++++++++++++++-- src/authn/SolidAuthnLogic.ts | 10 ++++++++-- test/solidAuthLogic.test.ts | 34 +++++++++++++++++++++++++++++++--- test/transitions.test.ts | 24 ++++++++++++++++++++++++ 6 files changed, 104 insertions(+), 9 deletions(-) diff --git a/src/authSession/authSession.ts b/src/authSession/authSession.ts index d5bb002..7d78854 100644 --- a/src/authSession/authSession.ts +++ b/src/authSession/authSession.ts @@ -11,7 +11,7 @@ */ import type { Session as OidcSession } from '@uvdsl/solid-oidc-client-browser/core' -import { _session } from './session' +import { _session, sessionHasCrossTabPush } from './session' import { resolveIssuerForLogin } from './issuer' import { SessionEvents } from './events' import { sessionIsActive, watchSessionTransitions, type SessionLike } from './transitions' @@ -101,7 +101,17 @@ const events = new SessionEvents() // — including a login/logout made in another tab, which the uvdsl // SharedWorker does not broadcast as a state change and which is noticed when // this tab is refocused. -watchSessionTransitions(_session as unknown as SessionLike, (event) => events.emit(event)) +// A worker-backed session pushes another tab's change to this one; the +// SessionCore + IndexedDB session used where the worker is skipped or +// unavailable does not, so re-read it on refocus before snapshots are +// compared — otherwise a cross-tab login/logout stays invisible here. +const resyncSession = sessionHasCrossTabPush() + ? undefined + : () => { + const restore = (_session as any)?.restore + return typeof restore === 'function' ? restore.call(_session) : undefined + } +watchSessionTransitions(_session as unknown as SessionLike, (event) => events.emit(event), undefined, resyncSession) export const authSession: SessionWithLegacyEvents = Object.assign( _session as Omit & { login: LoginCompat }, diff --git a/src/authSession/session.ts b/src/authSession/session.ts index a7325fa..16831db 100644 --- a/src/authSession/session.ts +++ b/src/authSession/session.ts @@ -174,6 +174,16 @@ function getSessionCoreCtor (): (new (...args: any[]) => OidcSession) | null { const SessionCoreCtor = getSessionCoreCtor() +// 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 +} + function createSession (): OidcSession { const shouldSkipWorkerInLocalDev = typeof window !== 'undefined' && (() => { const host = window.location.hostname @@ -185,6 +195,7 @@ function createSession (): OidcSession { if (shouldSkipWorkerInLocalDev) { if (SessionCoreCtor) { + crossTabPush = false return new SessionCoreCtor(undefined, { database: new IndexedDbSessionDatabase() }) } return new WebSession() @@ -199,12 +210,14 @@ function createSession (): OidcSession { console.warn('solid-logic: falling back to non-worker auth session:', error) try { if (SessionCoreCtor) { + crossTabPush = false return new SessionCoreCtor(undefined, { database: new IndexedDbSessionDatabase() }) } return new WebSession() } catch (dbError) { console.warn('solid-logic: IndexedDB unavailable, using in-memory session database:', dbError) if (SessionCoreCtor) { + crossTabPush = false return new SessionCoreCtor(undefined, { database: new MemorySessionDatabase() }) } return new WebSession() diff --git a/src/authSession/transitions.ts b/src/authSession/transitions.ts index 6de9723..512cd87 100644 --- a/src/authSession/transitions.ts +++ b/src/authSession/transitions.ts @@ -161,7 +161,8 @@ function watchTokenUpdates (session: SessionLike, note: () => void): void { export function watchSessionTransitions ( session: SessionLike, emit: (event: 'logout' | 'sessionChange' | 'identityReplaced') => void, - doc: DocumentLike | undefined = typeof document === 'undefined' ? undefined : document + doc: DocumentLike | undefined = typeof document === 'undefined' ? undefined : document, + resync?: () => unknown ): void { let previous = snapshotOf(session) const note = (): void => { @@ -172,13 +173,26 @@ export function watchSessionTransitions ( if (event) emit(event) if (replaced) emit('identityReplaced') } + // A session that cannot receive another tab's change as a pushed event has + // to be re-read before the snapshots are compared, or the change is simply + // invisible here. Workers push it, so no resync is passed for them. + const syncThenNote = async (): Promise => { + if (typeof resync === 'function') { + try { + await resync() + } catch { + // A session that cannot be re-read is compared as it stands. + } + } + note() + } if (typeof session.addEventListener === 'function') { session.addEventListener('sessionStateChange', note) } watchTokenUpdates(session, note) if (doc && typeof doc.addEventListener === 'function') { doc.addEventListener('visibilitychange', () => { - if (doc.visibilityState === 'visible') note() + if (doc.visibilityState === 'visible') void syncThenNote() }) } } diff --git a/src/authn/SolidAuthnLogic.ts b/src/authn/SolidAuthnLogic.ts index a20210a..c6f8776 100644 --- a/src/authn/SolidAuthnLogic.ts +++ b/src/authn/SolidAuthnLogic.ts @@ -201,6 +201,7 @@ export class SolidAuthnLogic implements AuthnLogic { } const previousFallback = this.fallbackWebId + const previousCookieBacked = this.cookieBackedFallback let webId = this.webIdFromSession(sessionAny?.info, sessionAny) let cookieBacked = false if (!webId) { @@ -217,7 +218,7 @@ export class SolidAuthnLogic implements AuthnLogic { this.cookieBackedFallback = false } - this.reportFallbackIdentityChange(previousFallback) + this.reportFallbackIdentityChange(previousFallback, previousCookieBacked) if (webId) { me = this.saveUser(webId) @@ -293,9 +294,14 @@ export class SolidAuthnLogic implements AuthnLogic { * anonymous -> cookie-user, cookie-user A -> B, or a cookie logout. Report * those changes like a session transition so invalidation and reload * consumers still react. + * + * Only cookie-backed changes are reported here: an OIDC identity change is + * already emitted by the watcher (with `identityReplaced`), and reporting it + * again would duplicate the events — a reload consumer would reload twice. */ - private reportFallbackIdentityChange (previousFallback: string | null): void { + private reportFallbackIdentityChange (previousFallback: string | null, previousCookieBacked: boolean): void { if (previousFallback === this.fallbackWebId) return + if (!previousCookieBacked && !this.cookieBackedFallback) return const events = (this.session as any)?.events if (typeof events?.emit !== 'function') return if (previousFallback !== null) { diff --git a/test/solidAuthLogic.test.ts b/test/solidAuthLogic.test.ts index 69d6d6f..24e4463 100644 --- a/test/solidAuthLogic.test.ts +++ b/test/solidAuthLogic.test.ts @@ -114,11 +114,39 @@ describe('SolidAuthnLogic', () => { ;(authn as any).fallbackWebId = null ;(authn as any).cookieBackedFallback = false - ;(authn as any).reportFallbackIdentityChange('https://alice.localhost/profile/card#me') + ;(authn as any).reportFallbackIdentityChange('https://alice.localhost/profile/card#me', true) expect(emitted).toEqual(['sessionChange', 'identityReplaced']) }) + it('reports when a cookie-backed identity is replaced by an OIDC one', () => { + const events = new EventEmitter() + const emitted: string[] = [] + events.on('sessionChange', () => emitted.push('sessionChange')) + events.on('identityReplaced', () => emitted.push('identityReplaced')) + const authn = new SolidAuthnLogic({ events } as any) + ;(authn as any).fallbackWebId = 'https://bob.example/profile#me' + ;(authn as any).cookieBackedFallback = false + + ;(authn as any).reportFallbackIdentityChange('https://alice.localhost/profile/card#me', true) + + expect(emitted).toEqual(['sessionChange', 'identityReplaced']) + }) + + it('does not duplicate OIDC-sourced changes (the watcher already reports them)', () => { + const events = new EventEmitter() + const emitted: string[] = [] + events.on('sessionChange', () => emitted.push('sessionChange')) + events.on('identityReplaced', () => emitted.push('identityReplaced')) + const authn = new SolidAuthnLogic({ events } as any) + ;(authn as any).fallbackWebId = 'https://bob.example/profile#me' + ;(authn as any).cookieBackedFallback = false + + ;(authn as any).reportFallbackIdentityChange('https://alice.example/profile#me', false) + + expect(emitted).toEqual([]) + }) + it('reports an anonymous-to-cookie transition without a replacement', () => { const events = new EventEmitter() const emitted: string[] = [] @@ -128,7 +156,7 @@ describe('SolidAuthnLogic', () => { ;(authn as any).fallbackWebId = 'https://alice.localhost/profile/card#me' ;(authn as any).cookieBackedFallback = true - ;(authn as any).reportFallbackIdentityChange(null) + ;(authn as any).reportFallbackIdentityChange(null, false) // Nothing of a previous identity was cached, so no replacement. expect(emitted).toEqual(['sessionChange']) @@ -142,7 +170,7 @@ describe('SolidAuthnLogic', () => { const authn = new SolidAuthnLogic({ events } as any) ;(authn as any).fallbackWebId = 'https://alice.localhost/profile/card#me' - ;(authn as any).reportFallbackIdentityChange('https://alice.localhost/profile/card#me') + ;(authn as any).reportFallbackIdentityChange('https://alice.localhost/profile/card#me', true) expect(emitted).toEqual([]) }) diff --git a/test/transitions.test.ts b/test/transitions.test.ts index cfddd98..5294ce9 100644 --- a/test/transitions.test.ts +++ b/test/transitions.test.ts @@ -209,6 +209,30 @@ describe('watchSessionTransitions', () => { expect(emitted).toEqual(['sessionChange']) }) + it('re-reads the session on refocus when it cannot receive pushed changes', async () => { + const session = new FakeSession() + session.isActive = true + session.webId = 'https://a.example/#me' + const emitted: string[] = [] + const handlers: Record void> = {} + const doc: DocumentLike = { + visibilityState: 'visible', + addEventListener: (type: string, listener: () => void): void => { handlers[type] = listener } + } + watchSessionTransitions( + session as unknown as SessionLike, + (event) => emitted.push(event), + doc, + // SessionCore cannot hear the other tab: re-reading pulls the change in. + () => { session.webId = 'https://b.example/#me' } + ) + + handlers.visibilitychange() + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(emitted).toEqual(['sessionChange', 'identityReplaced']) + }) + it('checks nothing while the tab is hidden', () => { const session = new FakeSession() const emitted: string[] = [] From 8871163ce16253e867de0c63823a688cfae481f2 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Wed, 16 Sep 2026 20:20:12 +0200 Subject: [PATCH 13/19] review: always resync on refocus; report a cleared session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). --- src/authSession/authSession.ts | 35 ++++++++++++++++-------- src/authSession/session.ts | 13 --------- src/authSession/transitions.ts | 25 ++++++++++++++--- src/authn/SolidAuthnLogic.ts | 19 ++++++++----- test/solidAuthLogic.test.ts | 23 ++++++++++++++-- test/transitions.test.ts | 49 ++++++++++++++++++++++++++++++++++ 6 files changed, 128 insertions(+), 36 deletions(-) diff --git a/src/authSession/authSession.ts b/src/authSession/authSession.ts index 7d78854..84e76de 100644 --- a/src/authSession/authSession.ts +++ b/src/authSession/authSession.ts @@ -11,7 +11,7 @@ */ import type { Session as OidcSession } from '@uvdsl/solid-oidc-client-browser/core' -import { _session, sessionHasCrossTabPush } from './session' +import { _session } from './session' import { resolveIssuerForLogin } from './issuer' import { SessionEvents } from './events' import { sessionIsActive, watchSessionTransitions, type SessionLike } from './transitions' @@ -101,16 +101,29 @@ const events = new SessionEvents() // — including a login/logout made in another tab, which the uvdsl // SharedWorker does not broadcast as a state change and which is noticed when // this tab is refocused. -// A worker-backed session pushes another tab's change to this one; the -// SessionCore + IndexedDB session used where the worker is skipped or -// unavailable does not, so re-read it on refocus before snapshots are -// compared — otherwise a cross-tab login/logout stays invisible here. -const resyncSession = sessionHasCrossTabPush() - ? undefined - : () => { - const restore = (_session as any)?.restore - return typeof restore === 'function' ? restore.call(_session) : undefined - } +// A worker-backed session pushes another tab's change here, but a worker can +// also be constructed and then never answer — so "the worker exists" is not +// proof that a cross-tab change will be pushed, and the resync is always +// wired. It is bounded: a hung session cannot delay the comparison for long, +// and a backing store that no longer holds a session (a cross-tab logout) +// reports 'cleared' rather than being compared as if nothing happened. +const RESYNC_TIMEOUT_MS = 2000 +const resyncSession = (): unknown => { + const restore = (_session as any)?.restore + if (typeof restore !== 'function') return undefined + const restored = Promise.resolve() + .then(() => restore.call(_session)) + .then(() => 'changed', (error: unknown) => { + // A transient refresh/network failure is compared as it stands; a store + // that has no session to restore means this tab's identity is gone. + const message = error instanceof Error ? error.message : String(error) + return /no session to restore/i.test(message) ? 'cleared' : 'changed' + }) + return Promise.race([ + restored, + new Promise<'changed'>((resolve) => setTimeout(() => resolve('changed'), RESYNC_TIMEOUT_MS)) + ]) +} watchSessionTransitions(_session as unknown as SessionLike, (event) => events.emit(event), undefined, resyncSession) export const authSession: SessionWithLegacyEvents = Object.assign( diff --git a/src/authSession/session.ts b/src/authSession/session.ts index 16831db..a7325fa 100644 --- a/src/authSession/session.ts +++ b/src/authSession/session.ts @@ -174,16 +174,6 @@ function getSessionCoreCtor (): (new (...args: any[]) => OidcSession) | null { const SessionCoreCtor = getSessionCoreCtor() -// 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 -} - function createSession (): OidcSession { const shouldSkipWorkerInLocalDev = typeof window !== 'undefined' && (() => { const host = window.location.hostname @@ -195,7 +185,6 @@ function createSession (): OidcSession { if (shouldSkipWorkerInLocalDev) { if (SessionCoreCtor) { - crossTabPush = false return new SessionCoreCtor(undefined, { database: new IndexedDbSessionDatabase() }) } return new WebSession() @@ -210,14 +199,12 @@ function createSession (): OidcSession { console.warn('solid-logic: falling back to non-worker auth session:', error) try { if (SessionCoreCtor) { - crossTabPush = false return new SessionCoreCtor(undefined, { database: new IndexedDbSessionDatabase() }) } return new WebSession() } catch (dbError) { console.warn('solid-logic: IndexedDB unavailable, using in-memory session database:', dbError) if (SessionCoreCtor) { - crossTabPush = false return new SessionCoreCtor(undefined, { database: new MemorySessionDatabase() }) } return new WebSession() diff --git a/src/authSession/transitions.ts b/src/authSession/transitions.ts index 512cd87..e6a708d 100644 --- a/src/authSession/transitions.ts +++ b/src/authSession/transitions.ts @@ -156,7 +156,8 @@ function watchTokenUpdates (session: SessionLike, note: () => void): void { * Watch a session for identity transitions and report them through `emit`. * Attaches the in-tab state listener when the session supports it, and a * visibility listener (when a document exists) so a transition made in - * another tab is caught on refocus. + * another tab is caught on refocus — re-reading the session through `resync` + * first, since the session may not push another tab's change. */ export function watchSessionTransitions ( session: SessionLike, @@ -173,18 +174,34 @@ export function watchSessionTransitions ( if (event) emit(event) if (replaced) emit('identityReplaced') } + // The backing store has no session while this tab still believes it is + // signed in: report the logout and the replacement, then treat the session + // as cleared so later comparisons do not repeat it. + const reportCleared = (): void => { + const wasActive = previous.isActive + const wasEstablished = previous.webId !== undefined + previous = { isActive: false, webId: undefined } + if (wasActive) emit('logout') + if (wasActive && wasEstablished) emit('identityReplaced') + } // A session that cannot receive another tab's change as a pushed event has // to be re-read before the snapshots are compared, or the change is simply - // invisible here. Workers push it, so no resync is passed for them. + // invisible here. The resync may resolve with 'cleared' when the backing + // store no longer holds a session at all (a cross-tab logout). const syncThenNote = async (): Promise => { + let outcome: unknown if (typeof resync === 'function') { try { - await resync() + outcome = await resync() } catch { // A session that cannot be re-read is compared as it stands. } } - note() + if (outcome === 'cleared') { + reportCleared() + } else { + note() + } } if (typeof session.addEventListener === 'function') { session.addEventListener('sessionStateChange', note) diff --git a/src/authn/SolidAuthnLogic.ts b/src/authn/SolidAuthnLogic.ts index c6f8776..992e4b3 100644 --- a/src/authn/SolidAuthnLogic.ts +++ b/src/authn/SolidAuthnLogic.ts @@ -301,17 +301,24 @@ export class SolidAuthnLogic implements AuthnLogic { */ private reportFallbackIdentityChange (previousFallback: string | null, previousCookieBacked: boolean): void { if (previousFallback === this.fallbackWebId) return + // Only the cookie probe is invisible to the transition watcher: an OIDC + // identity change is already emitted from there. if (!previousCookieBacked && !this.cookieBackedFallback) return const events = (this.session as any)?.events if (typeof events?.emit !== 'function') return - if (previousFallback !== null) { - // An established cookie-backed identity was replaced or cleared. + + // Invalidate when the raw session is not active: an active one means the + // watcher has already emitted `sessionChange` for its own transition. + const sessionActive = Boolean((this.session as any)?.isActive) + if (!sessionActive) { events.emit('sessionChange') + } + // The replacement is owed whenever the identity being REPLACED was + // cookie-backed — the watcher could not see it. An OIDC identity that a + // cookie one succeeds has already been reported by the watcher when it + // went inactive, so no second replacement is emitted. + if (previousCookieBacked && previousFallback !== null) { events.emit('identityReplaced') - } else if (this.fallbackWebId !== null) { - // Anonymous -> cookie-backed identity: the recorded answers must be - // invalidated, but there is no previous identity's data to discard. - events.emit('sessionChange') } } diff --git a/test/solidAuthLogic.test.ts b/test/solidAuthLogic.test.ts index 24e4463..4384572 100644 --- a/test/solidAuthLogic.test.ts +++ b/test/solidAuthLogic.test.ts @@ -124,13 +124,32 @@ describe('SolidAuthnLogic', () => { const emitted: string[] = [] events.on('sessionChange', () => emitted.push('sessionChange')) events.on('identityReplaced', () => emitted.push('identityReplaced')) - const authn = new SolidAuthnLogic({ events } as any) + // The OIDC session is active: the watcher has already emitted + // `sessionChange` for it going active, so only the replacement is owed + // (the watcher could not see the cookie identity it replaces). + const authn = new SolidAuthnLogic({ events, isActive: true } as any) ;(authn as any).fallbackWebId = 'https://bob.example/profile#me' ;(authn as any).cookieBackedFallback = false ;(authn as any).reportFallbackIdentityChange('https://alice.localhost/profile/card#me', true) - expect(emitted).toEqual(['sessionChange', 'identityReplaced']) + expect(emitted).toEqual(['identityReplaced']) + }) + + it('does not repeat the replacement the watcher already emitted for the OIDC identity', () => { + const events = new EventEmitter() + const emitted: string[] = [] + events.on('sessionChange', () => emitted.push('sessionChange')) + events.on('identityReplaced', () => emitted.push('identityReplaced')) + const authn = new SolidAuthnLogic({ events, isActive: false } as any) + ;(authn as any).fallbackWebId = 'https://alice.localhost/profile/card#me' + ;(authn as any).cookieBackedFallback = true + + // OIDC B logged out (the watcher reported B -> inactive, replacement + // included) and the cookie probe then found A. + ;(authn as any).reportFallbackIdentityChange('https://bob.example/profile#me', false) + + expect(emitted).toEqual(['sessionChange']) }) it('does not duplicate OIDC-sourced changes (the watcher already reports them)', () => { diff --git a/test/transitions.test.ts b/test/transitions.test.ts index 5294ce9..b2862ba 100644 --- a/test/transitions.test.ts +++ b/test/transitions.test.ts @@ -233,6 +233,55 @@ describe('watchSessionTransitions', () => { expect(emitted).toEqual(['sessionChange', 'identityReplaced']) }) + it('reports the logout when the resync finds the backing session cleared', async () => { + const session = new FakeSession() + session.isActive = true + session.webId = 'https://a.example/#me' + const emitted: string[] = [] + const handlers: Record void> = {} + const doc: DocumentLike = { + visibilityState: 'visible', + addEventListener: (type: string, listener: () => void): void => { handlers[type] = listener } + } + watchSessionTransitions( + session as unknown as SessionLike, + (event) => emitted.push(event), + doc, + // Another tab logged out: restore() rejected with "No session to + // restore." and left the local session state untouched. + () => 'cleared' + ) + + handlers.visibilitychange() + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(emitted).toEqual(['logout', 'identityReplaced']) + }) + + it('compares as it stands when the resync fails transiently', async () => { + const session = new FakeSession() + session.isActive = true + session.webId = 'https://a.example/#me' + const emitted: string[] = [] + const handlers: Record void> = {} + const doc: DocumentLike = { + visibilityState: 'visible', + addEventListener: (type: string, listener: () => void): void => { handlers[type] = listener } + } + watchSessionTransitions( + session as unknown as SessionLike, + (event) => emitted.push(event), + doc, + () => { throw new Error('HTTP 400 on refresh') } + ) + + handlers.visibilitychange() + await new Promise((resolve) => setTimeout(resolve, 0)) + + // A transient refresh failure is not a logout. + expect(emitted).toEqual([]) + }) + it('checks nothing while the tab is hidden', () => { const session = new FakeSession() const emitted: string[] = [] From 3430ff6f671e5bb963463c805f759f23e04bfc68 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Wed, 16 Sep 2026 20:41:25 +0200 Subject: [PATCH 14/19] review: apply a slow resync outcome and revalidate the cookie fallback transitions.ts: identityReplaced only fires from an actively established identity, so a partial logout whose WebID is cleared afterwards is not a second replacement; a refocus resync that outlives the 2 s resync bound is no longer dropped, its outcome is applied when it lands, with duplicate suppression for repeated cleared reports. authSession.ts: the wait is bounded by the watcher, so the local race is gone and the resync only maps the outcome. SolidAuthnLogic: the cookie-backed identity is invisible to the watcher, so it is re-probed on refocus and reported through reportFallbackIdentityChange; the re-probe is skipped while the OIDC session is active. events.ts: the compatibility-layer doc lists identityReplaced. Tests: +4 (154 total). --- src/authSession/authSession.ts | 12 ++---- src/authSession/events.ts | 3 +- src/authSession/transitions.ts | 68 +++++++++++++++++++++++----------- src/authn/SolidAuthnLogic.ts | 29 +++++++++++++++ test/solidAuthLogic.test.ts | 33 +++++++++++++++++ test/transitions.test.ts | 48 +++++++++++++++++++++++- 6 files changed, 160 insertions(+), 33 deletions(-) diff --git a/src/authSession/authSession.ts b/src/authSession/authSession.ts index 84e76de..3b27ab6 100644 --- a/src/authSession/authSession.ts +++ b/src/authSession/authSession.ts @@ -104,14 +104,12 @@ const events = new SessionEvents() // A worker-backed session pushes another tab's change here, but a worker can // also be constructed and then never answer — so "the worker exists" is not // proof that a cross-tab change will be pushed, and the resync is always -// wired. It is bounded: a hung session cannot delay the comparison for long, -// and a backing store that no longer holds a session (a cross-tab logout) -// reports 'cleared' rather than being compared as if nothing happened. -const RESYNC_TIMEOUT_MS = 2000 +// wired. It maps a backing store that no longer holds a session (a cross-tab +// logout) to 'cleared'; watchSessionTransitions() bounds the wait. const resyncSession = (): unknown => { const restore = (_session as any)?.restore if (typeof restore !== 'function') return undefined - const restored = Promise.resolve() + return Promise.resolve() .then(() => restore.call(_session)) .then(() => 'changed', (error: unknown) => { // A transient refresh/network failure is compared as it stands; a store @@ -119,10 +117,6 @@ const resyncSession = (): unknown => { const message = error instanceof Error ? error.message : String(error) return /no session to restore/i.test(message) ? 'cleared' : 'changed' }) - return Promise.race([ - restored, - new Promise<'changed'>((resolve) => setTimeout(() => resolve('changed'), RESYNC_TIMEOUT_MS)) - ]) } watchSessionTransitions(_session as unknown as SessionLike, (event) => events.emit(event), undefined, resyncSession) diff --git a/src/authSession/events.ts b/src/authSession/events.ts index dc1a900..afae945 100644 --- a/src/authSession/events.ts +++ b/src/authSession/events.ts @@ -14,7 +14,8 @@ type LegacyEventHandler = (...args: unknown[]) => void * continue working without modification. * * Events are emitted by SolidAuthnLogic.checkUser() (login/sessionRestore) - * and by the transition watcher in authSession.ts (logout, sessionChange). + * and by the transition watcher in authSession.ts (logout, sessionChange, + * identityReplaced — the event the reload helper subscribes to). */ export class SessionEvents { private readonly listeners: Map> = new Map() diff --git a/src/authSession/transitions.ts b/src/authSession/transitions.ts index e6a708d..ab14075 100644 --- a/src/authSession/transitions.ts +++ b/src/authSession/transitions.ts @@ -57,12 +57,12 @@ export function classifySessionTransition ( */ export function identityReplaced (prev: SessionSnapshot, next: SessionSnapshot): boolean { if (prev.webId === undefined) return false - if (next.webId !== prev.webId) return true - // The same WebID can be retained through a partial logout ({ isActive: false, - // webId: A }): only the transition OUT of an active session is a - // replacement, so a steady partial-logout snapshot does not report one — - // and repeat one — on every refocus. - return prev.isActive && !next.isActive + // Only the transition OUT of an ACTIVELY established identity is a + // replacement: once the session has gone inactive (webId possibly retained), + // the replacement was already reported — clearing the WebID afterwards or + // logging in as someone else is not a second replacement. + if (!prev.isActive) return false + return next.webId !== prev.webId || !next.isActive } /** @@ -119,6 +119,9 @@ export type DocumentLike = { addEventListener?: (type: string, listener: () => void) => void } +/** How long a refocus resync may delay the snapshot comparison. */ +const RESYNC_TIMEOUT_MS = 2000 + const snapshotOf = (session: SessionLike): SessionSnapshot => ({ isActive: sessionIsActive(session), webId: session.webId @@ -166,42 +169,63 @@ export function watchSessionTransitions ( resync?: () => unknown ): void { let previous = snapshotOf(session) + let clearedReported = false const note = (): void => { const next = snapshotOf(session) const event = classifySessionTransition(previous, next) const replaced = identityReplaced(previous, next) + const moved = event !== null || replaced previous = next + // The session moved on again: a later 'cleared' resync is a new fact. + if (moved) clearedReported = false if (event) emit(event) if (replaced) emit('identityReplaced') } // The backing store has no session while this tab still believes it is - // signed in: report the logout and the replacement, then treat the session - // as cleared so later comparisons do not repeat it. + // signed in: report the logout and the replacement for the identity that was + // active. Reported once per session state — a later refocus that still finds + // no session must not repeat it. const reportCleared = (): void => { + if (clearedReported) return + clearedReported = true const wasActive = previous.isActive const wasEstablished = previous.webId !== undefined - previous = { isActive: false, webId: undefined } + previous = snapshotOf(session) if (wasActive) emit('logout') if (wasActive && wasEstablished) emit('identityReplaced') } // A session that cannot receive another tab's change as a pushed event has // to be re-read before the snapshots are compared, or the change is simply - // invisible here. The resync may resolve with 'cleared' when the backing - // store no longer holds a session at all (a cross-tab logout). + // invisible here. The wait is bounded so a hung session cannot stall the + // comparison — but the outcome is kept: a restore that only finishes later + // can still report the session gone, and dropping it would leave this tab on + // the old identity until some other visibility event. const syncThenNote = async (): Promise => { - let outcome: unknown - if (typeof resync === 'function') { - try { - outcome = await resync() - } catch { - // A session that cannot be re-read is compared as it stands. - } - } - if (outcome === 'cleared') { - reportCleared() - } else { + if (typeof resync !== 'function') { note() + return } + let outcome: unknown + let done = false + const attempt = Promise.resolve() + .then(() => resync()) + .then( + (value) => { outcome = value; done = true }, + () => { done = true } // compared as it stands + ) + await Promise.race([ + attempt, + new Promise((resolve) => setTimeout(resolve, RESYNC_TIMEOUT_MS)) + ]) + if (done) { + if (outcome === 'cleared') reportCleared() + else note() + return + } + note() + void attempt.then(() => { + if (outcome === 'cleared') reportCleared() + }) } if (typeof session.addEventListener === 'function') { session.addEventListener('sessionStateChange', note) diff --git a/src/authn/SolidAuthnLogic.ts b/src/authn/SolidAuthnLogic.ts index 992e4b3..edbfbe7 100644 --- a/src/authn/SolidAuthnLogic.ts +++ b/src/authn/SolidAuthnLogic.ts @@ -40,6 +40,35 @@ export class SolidAuthnLogic implements AuthnLogic { constructor(solidAuthSession: SessionWithLegacyEvents) { this.session = solidAuthSession + this.watchCookieBackedFallbackRefocus() + } + + /** + * The cookie-backed identity is invisible to the transition watcher (the + * OIDC session stays inactive and WebID-less), so re-probe it when the tab + * regains focus: another tab may have logged out or switched identity while + * this one was backgrounded. Only meaningful where the probe applies + * (*.localhost NSS setups). + */ + private watchCookieBackedFallbackRefocus (): void { + if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') return + document.addEventListener('visibilitychange', () => { + if (document.visibilityState !== 'visible') return + void this.refreshCookieBackedFallback() + }) + } + + /** Re-probe the NSS cookie-backed identity and report a change, if any. */ + async refreshCookieBackedFallback (): Promise { + // While the OIDC session is active it owns the identity. + if (Boolean((this.session as any)?.isActive)) return + const previousFallback = this.fallbackWebId + const previousCookieBacked = this.cookieBackedFallback + const webId = await this.probeNssCookieBackedWebId() + if (webId === null && !previousCookieBacked) return + this.fallbackWebId = webId + this.cookieBackedFallback = webId !== null + this.reportFallbackIdentityChange(previousFallback, previousCookieBacked) } // we created authSession getter because we want to access it as authn.authSession externally diff --git a/test/solidAuthLogic.test.ts b/test/solidAuthLogic.test.ts index 4384572..95ead9b 100644 --- a/test/solidAuthLogic.test.ts +++ b/test/solidAuthLogic.test.ts @@ -193,6 +193,39 @@ describe('SolidAuthnLogic', () => { expect(emitted).toEqual([]) }) + + it('revalidates a cookie-backed identity on refocus and reports it when it is gone', async () => { + const events = new EventEmitter() + const emitted: string[] = [] + events.on('sessionChange', () => emitted.push('sessionChange')) + events.on('identityReplaced', () => emitted.push('identityReplaced')) + const authn = new SolidAuthnLogic({ events, isActive: false } as any) + ;(authn as any).fallbackWebId = 'https://alice.localhost/profile/card#me' + ;(authn as any).cookieBackedFallback = true + + // jsdom's hostname is not a *.localhost pod, so the probe finds nothing: + // another tab logged the cookie session out. + await authn.refreshCookieBackedFallback() + + expect(emitted).toEqual(['sessionChange', 'identityReplaced']) + expect((authn as any).fallbackWebId).toBeNull() + expect((authn as any).cookieBackedFallback).toBe(false) + }) + + it('does not touch the fallback while the OIDC session is active', async () => { + const events = new EventEmitter() + const emitted: string[] = [] + events.on('sessionChange', () => emitted.push('sessionChange')) + events.on('identityReplaced', () => emitted.push('identityReplaced')) + const authn = new SolidAuthnLogic({ events, isActive: true } as any) + ;(authn as any).fallbackWebId = 'https://bob.example/profile#me' + ;(authn as any).cookieBackedFallback = false + + await authn.refreshCookieBackedFallback() + + expect(emitted).toEqual([]) + expect((authn as any).fallbackWebId).toBe('https://bob.example/profile#me') + }) }) describe('saveUser', () => { diff --git a/test/transitions.test.ts b/test/transitions.test.ts index b2862ba..6af5269 100644 --- a/test/transitions.test.ts +++ b/test/transitions.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { classifySessionTransition, identityReplaced, reloadOnIdentityReplaced, sessionIsActive, watchSessionTransitions, type DocumentLike, type SessionLike } from '../src/authSession/transitions' describe('classifySessionTransition', () => { @@ -67,6 +67,19 @@ describe('identityReplaced', () => { { isActive: false, webId: 'https://a.example/#me' } )).toBe(false) }) + + it('does not repeat the replacement after a partial logout', () => { + // The replacement was reported when A went inactive: clearing the retained + // WebID, or a later login as someone else, is not a second replacement. + expect(identityReplaced( + { isActive: false, webId: 'https://a.example/#me' }, + { isActive: false } + )).toBe(false) + expect(identityReplaced( + { isActive: false, webId: 'https://a.example/#me' }, + { isActive: true, webId: 'https://b.example/#me' } + )).toBe(false) + }) }) describe('reloadOnIdentityReplaced', () => { @@ -282,6 +295,39 @@ describe('watchSessionTransitions', () => { expect(emitted).toEqual([]) }) + it('reports a cleared session that only answers after the resync timeout', async () => { + vi.useFakeTimers() + try { + const session = new FakeSession() + session.isActive = true + session.webId = 'https://a.example/#me' + const emitted: string[] = [] + const handlers: Record void> = {} + const doc: DocumentLike = { + visibilityState: 'visible', + addEventListener: (type: string, listener: () => void): void => { handlers[type] = listener } + } + watchSessionTransitions( + session as unknown as SessionLike, + (event) => emitted.push(event), + doc, + // A slow cross-tab logout: the answer arrives long after the timeout. + () => new Promise((resolve) => setTimeout(() => resolve('cleared'), 5000)) + ) + + handlers.visibilitychange() + await vi.advanceTimersByTimeAsync(2500) + // Timed out: the comparison ran as it stood, nothing reported yet. + expect(emitted).toEqual([]) + + await vi.advanceTimersByTimeAsync(3000) + // The outcome was kept, not discarded. + expect(emitted).toEqual(['logout', 'identityReplaced']) + } finally { + vi.useRealTimers() + } + }) + it('checks nothing while the tab is hidden', () => { const session = new FakeSession() const emitted: string[] = [] From 812552a3b8a802620d35beb9db2d8bd1f6c5a2b7 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Fri, 18 Sep 2026 12:44:20 +0200 Subject: [PATCH 15/19] review: keep a slow resync change, stop answering for a cleared session transitions.ts: the late resync continuation now compares again for every non-cleared outcome, so a cross-tab login that lands after the resync timeout is reported instead of being dropped. A session whose backing store lost the session is marked cleared: snapshotOf() baselines it as logged out, sessionExplicitlyInactive() and legacySessionInfo() answer logged out, and the mark is dropped as soon as the session reports an identity again. The library state and the shared per-origin session database are left untouched - another tab may just have written its session there. SolidAuthnLogic: the cookie-backed fallback uses the shared activity rule in both the refocus probe and the duplicate-event guard, so a legacy session (no isActive, with a WebID) is not probed and does not emit a second sessionChange. The refocus probe is stamped with a generation and re-checks the session after awaiting, so a superseded or stale probe result is dropped. Tests: +7 (161). --- src/authSession/authSession.ts | 9 +++- src/authSession/transitions.ts | 57 +++++++++++++++++++++-- src/authn/SolidAuthnLogic.ts | 27 +++++++++-- test/authSessionInfo.test.ts | 26 ++++++++++- test/solidAuthLogic.test.ts | 85 +++++++++++++++++++++++++++++++++- test/transitions.test.ts | 76 +++++++++++++++++++++++++++++- 6 files changed, 267 insertions(+), 13 deletions(-) diff --git a/src/authSession/authSession.ts b/src/authSession/authSession.ts index 3b27ab6..e197ad6 100644 --- a/src/authSession/authSession.ts +++ b/src/authSession/authSession.ts @@ -14,7 +14,7 @@ import type { Session as OidcSession } from '@uvdsl/solid-oidc-client-browser/co import { _session } from './session' import { resolveIssuerForLogin } from './issuer' import { SessionEvents } from './events' -import { sessionIsActive, watchSessionTransitions, type SessionLike } from './transitions' +import { sessionIsActive, sessionWasCleared, watchSessionTransitions, type SessionLike } from './transitions' type SessionCompatibilityShape = { webId?: string @@ -141,6 +141,13 @@ export const authSession: SessionWithLegacyEvents = Object.assign( // reports logged out even when a WebID is still cached, or the fetch bridge // would keep routing anonymous requests through the authenticated fetch. export function legacySessionInfo (session: SessionLike): { webId?: string; isLoggedIn?: boolean } { + // A session whose backing store was reported cleared (see + // sessionWasCleared) answers as logged out: the local session object can + // still carry the identity it had before, and that identity must not keep + // being published through the legacy shape. + if (sessionWasCleared(session)) { + return { webId: undefined, isLoggedIn: false } + } return { webId: session.webId, isLoggedIn: sessionIsActive(session) diff --git a/src/authSession/transitions.ts b/src/authSession/transitions.ts index ab14075..84705de 100644 --- a/src/authSession/transitions.ts +++ b/src/authSession/transitions.ts @@ -99,6 +99,28 @@ export type SessionLike = { export const sessionIsActive = (session: SessionLike): boolean => session.isActive === true || (session.isActive === undefined && Boolean(session.webId)) +/** + * Sessions whose backing store no longer holds a session (a cross-tab logout): + * the local session object keeps reporting the previous identity, so every + * consumer that reads it would keep answering for that user. Such a session is + * marked here, which makes the transition watcher AND the derived reads + * (`sessionExplicitlyInactive()`, `legacySessionInfo()`) report the cleared + * state until the session reports an identity again. + * + * The mark is deliberately kept outside the session object: the library owns + * its state, and clearing it there would also wipe the shared (per-origin) + * session database that another tab may just have written. + */ +const clearedSessions = new WeakSet() + +/** + * Whether a session was reported cleared: its backing store lost the session + * while this tab still had one, so its identity no longer holds here. + */ +export function sessionWasCleared (session: unknown): boolean { + return typeof session === 'object' && session !== null && clearedSessions.has(session) +} + /** * Whether the session explicitly reports itself inactive. An explicit `false` * — `isActive` on the session or `isLoggedIn` on the legacy `info` shape — @@ -111,6 +133,10 @@ export function sessionExplicitlyInactive (session: { isActive?: boolean info?: { isLoggedIn?: boolean } }): boolean { + // A session that was reported cleared (its backing store lost the session) + // answers as logged out even though the local session object still carries + // the previous identity — see sessionWasCleared(). + if (sessionWasCleared(session)) return true return session?.isActive === false || session?.info?.isLoggedIn === false } @@ -122,10 +148,16 @@ export type DocumentLike = { /** How long a refocus resync may delay the snapshot comparison. */ const RESYNC_TIMEOUT_MS = 2000 -const snapshotOf = (session: SessionLike): SessionSnapshot => ({ - isActive: sessionIsActive(session), - webId: session.webId -}) +const snapshotOf = (session: SessionLike): SessionSnapshot => { + // A session that was reported cleared answers as logged out until it + // reports an identity again (see sessionWasCleared): the local object may + // still carry the previous WebID. + const cleared = sessionWasCleared(session) + return { + isActive: !cleared && sessionIsActive(session), + webId: cleared ? undefined : session.webId + } +} // uvdsl's session announces only changes of `isActive`; a WebID can change // while both states stay active and would go unseen (see the header). Every @@ -171,6 +203,12 @@ export function watchSessionTransitions ( let previous = snapshotOf(session) let clearedReported = false const note = (): void => { + // The session reports an identity again: the cleared state is superseded, + // so drop the mark before comparing (otherwise it would mask the new + // identity and no login would ever be noticed again). + if (sessionWasCleared(session) && sessionIsActive(session)) { + clearedSessions.delete(session as object) + } const next = snapshotOf(session) const event = classifySessionTransition(previous, next) const replaced = identityReplaced(previous, next) @@ -190,6 +228,11 @@ export function watchSessionTransitions ( clearedReported = true const wasActive = previous.isActive const wasEstablished = previous.webId !== undefined + // The local session object still reports the old identity: mark it cleared + // so the derived reads stop answering for it, and baseline the comparison + // on that cleared state — a later activation is then a new login, and a + // still-cleared session cannot report the logout twice. + clearedSessions.add(session as object) previous = snapshotOf(session) if (wasActive) emit('logout') if (wasActive && wasEstablished) emit('identityReplaced') @@ -224,7 +267,13 @@ export function watchSessionTransitions ( } note() void attempt.then(() => { + // Whatever the slow resync answers is worth acting on: 'cleared' means + // the session is gone, and any other result may have updated the session + // (a cross-tab login) — comparing again is what turns that into + // `sessionChange`/`identityReplaced` instead of leaving this tab on the + // old identity until the next refocus. if (outcome === 'cleared') reportCleared() + else note() }) } if (typeof session.addEventListener === 'function') { diff --git a/src/authn/SolidAuthnLogic.ts b/src/authn/SolidAuthnLogic.ts index edbfbe7..311de89 100644 --- a/src/authn/SolidAuthnLogic.ts +++ b/src/authn/SolidAuthnLogic.ts @@ -1,7 +1,7 @@ import { namedNode, NamedNode, sym } from 'rdflib' import { appContext, offlineTestID } from './authUtil' import * as debug from '../util/debug' -import { sessionExplicitlyInactive } from '../authSession/transitions' +import { sessionExplicitlyInactive, sessionIsActive } from '../authSession/transitions' import type { SessionWithLegacyEvents } from '../authSession/authSession' import type { AuthenticationContext, AuthnLogic } from '../types' @@ -37,6 +37,9 @@ export class SolidAuthnLogic implements AuthnLogic { // the OIDC session: an inactive OIDC session is exactly why that identity // was probed, so it must survive `currentUser()`'s stand-down below. private cookieBackedFallback = false + // Serialises the refocus cookie probes: a result that arrives after a newer + // probe started (or after the session became active) is stale and dropped. + private cookieProbeGeneration = 0 constructor(solidAuthSession: SessionWithLegacyEvents) { this.session = solidAuthSession @@ -60,11 +63,23 @@ export class SolidAuthnLogic implements AuthnLogic { /** Re-probe the NSS cookie-backed identity and report a change, if any. */ async refreshCookieBackedFallback (): Promise { - // While the OIDC session is active it owns the identity. - if (Boolean((this.session as any)?.isActive)) return + // While the OIDC session is active it owns the identity. Use the shared + // activity rule: a legacy session that reports no `isActive` but has a + // WebID counts as active too, and probing would then replace that identity + // with a cookie one. + if (sessionIsActive(this.session as any)) return + // Only the newest probe may apply its result: an older probe that answers + // late must not overwrite the identity a newer one (or the session) has + // established in the meantime. + const generation = ++this.cookieProbeGeneration const previousFallback = this.fallbackWebId const previousCookieBacked = this.cookieBackedFallback const webId = await this.probeNssCookieBackedWebId() + // The OIDC session can become active, or a newer probe can start, while + // this probe is in flight — then the identity it found no longer owns the + // session and its result is stale. + if (generation !== this.cookieProbeGeneration) return + if (sessionIsActive(this.session as any)) return if (webId === null && !previousCookieBacked) return this.fallbackWebId = webId this.cookieBackedFallback = webId !== null @@ -337,8 +352,10 @@ export class SolidAuthnLogic implements AuthnLogic { if (typeof events?.emit !== 'function') return // Invalidate when the raw session is not active: an active one means the - // watcher has already emitted `sessionChange` for its own transition. - const sessionActive = Boolean((this.session as any)?.isActive) + // watcher has already emitted `sessionChange` for its own transition. The + // shared activity rule is used here as well, so a legacy session that + // reports no `isActive` while carrying a WebID is not reported twice. + const sessionActive = sessionIsActive(this.session as any) if (!sessionActive) { events.emit('sessionChange') } diff --git a/test/authSessionInfo.test.ts b/test/authSessionInfo.test.ts index bfdc23d..7dd34a4 100644 --- a/test/authSessionInfo.test.ts +++ b/test/authSessionInfo.test.ts @@ -1,8 +1,32 @@ import { describe, expect, it } from 'vitest' import { legacySessionInfo } from '../src/authSession/authSession' -import type { SessionLike } from '../src/authSession/transitions' +import { watchSessionTransitions, type DocumentLike, type SessionLike } from '../src/authSession/transitions' describe('legacySessionInfo', () => { + it('reports a session whose backing store was cleared as logged out', async () => { + // Another tab logged out: the local session object still reports Alice. + const session = { isActive: true, webId: 'https://a.example/#me' } + const emitted: string[] = [] + const handlers: Record void> = {} + const doc: DocumentLike = { + visibilityState: 'visible', + addEventListener: (type: string, listener: () => void): void => { handlers[type] = listener } + } + watchSessionTransitions( + session as unknown as SessionLike, + (event) => emitted.push(event), + doc, + () => 'cleared' + ) + + handlers.visibilitychange() + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(emitted).toEqual(['logout', 'identityReplaced']) + expect(legacySessionInfo(session as unknown as SessionLike)) + .toEqual({ webId: undefined, isLoggedIn: false }) + }) + it('reports an inactive session as logged out even when a WebID is still cached', () => { expect(legacySessionInfo({ isActive: false, webId: 'https://a.example/#me' } as SessionLike)) .toEqual({ webId: 'https://a.example/#me', isLoggedIn: false }) diff --git a/test/solidAuthLogic.test.ts b/test/solidAuthLogic.test.ts index 95ead9b..d2ec94b 100644 --- a/test/solidAuthLogic.test.ts +++ b/test/solidAuthLogic.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { SolidAuthnLogic } from '../src/authn/SolidAuthnLogic' import { silenceDebugMessages } from './helpers/debugger' import { AuthenticationContext } from '../src/types' @@ -226,6 +226,89 @@ describe('SolidAuthnLogic', () => { expect(emitted).toEqual([]) expect((authn as any).fallbackWebId).toBe('https://bob.example/profile#me') }) + + it('treats a legacy active session (no isActive, but a WebID) as active', async () => { + const events = new EventEmitter() + const emitted: string[] = [] + events.on('sessionChange', () => emitted.push('sessionChange')) + events.on('identityReplaced', () => emitted.push('identityReplaced')) + // The supported legacy shape: no isActive at all, identity from the WebID. + const authn = new SolidAuthnLogic({ events, webId: 'https://alice.example/profile#me' } as any) + const probe = vi.fn(async (): Promise => null) + ;(authn as any).probeNssCookieBackedWebId = probe + + await authn.refreshCookieBackedFallback() + + // Probing here would replace an identity the session already owns. + expect(probe).not.toHaveBeenCalled() + expect(emitted).toEqual([]) + expect((authn as any).fallbackWebId).toBeNull() + }) + + it('does not report a second sessionChange for a legacy session', () => { + const events = new EventEmitter() + const emitted: string[] = [] + events.on('sessionChange', () => emitted.push('sessionChange')) + events.on('identityReplaced', () => emitted.push('identityReplaced')) + // The watcher treats this shape as active — an active session means it + // already emitted `sessionChange` for the transition it observed, so + // only the cookie replacement is owed here. + const authn = new SolidAuthnLogic({ events, webId: 'https://bob.example/profile#me' } as any) + ;(authn as any).fallbackWebId = 'https://bob.example/profile#me' + ;(authn as any).cookieBackedFallback = false + + ;(authn as any).reportFallbackIdentityChange('https://alice.localhost/profile/card#me', true) + + expect(emitted).toEqual(['identityReplaced']) + }) + + it('drops a probe result that an earlier probe superseded', async () => { + const events = new EventEmitter() + const emitted: string[] = [] + events.on('sessionChange', () => emitted.push('sessionChange')) + events.on('identityReplaced', () => emitted.push('identityReplaced')) + const authn = new SolidAuthnLogic({ events } as any) + const resolvers: ((webId: string | null) => void)[] = [] + ;(authn as any).probeNssCookieBackedWebId = (): Promise => + new Promise((resolve) => { resolvers.push(resolve) }) + + const first = authn.refreshCookieBackedFallback() + await Promise.resolve() + const second = authn.refreshCookieBackedFallback() + await Promise.resolve() + + // The newer probe answers first, the older one only afterwards. + resolvers[1]('https://carol.localhost/profile/card#me') + await second + resolvers[0]('https://alice.localhost/profile/card#me') + await first + + expect((authn as any).fallbackWebId).toBe('https://carol.localhost/profile/card#me') + expect(emitted).toEqual(['sessionChange']) + }) + + it('drops a probe result when the session became active while probing', async () => { + const events = new EventEmitter() + const emitted: string[] = [] + events.on('sessionChange', () => emitted.push('sessionChange')) + events.on('identityReplaced', () => emitted.push('identityReplaced')) + const session = { events, isActive: false } as any + const authn = new SolidAuthnLogic(session) + let resolveProbe: (webId: string | null) => void = () => undefined + ;(authn as any).probeNssCookieBackedWebId = (): Promise => + new Promise((resolve) => { resolveProbe = resolve }) + + const probing = authn.refreshCookieBackedFallback() + await Promise.resolve() + // The OIDC session takes over while the cookie probe is in flight. + session.isActive = true + session.webId = 'https://bob.example/profile#me' + resolveProbe('https://alice.localhost/profile/card#me') + await probing + + expect((authn as any).fallbackWebId).toBeNull() + expect(emitted).toEqual([]) + }) }) describe('saveUser', () => { diff --git a/test/transitions.test.ts b/test/transitions.test.ts index 6af5269..06f2332 100644 --- a/test/transitions.test.ts +++ b/test/transitions.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { classifySessionTransition, identityReplaced, reloadOnIdentityReplaced, sessionIsActive, watchSessionTransitions, type DocumentLike, type SessionLike } from '../src/authSession/transitions' +import { classifySessionTransition, identityReplaced, reloadOnIdentityReplaced, sessionExplicitlyInactive, sessionIsActive, sessionWasCleared, watchSessionTransitions, type DocumentLike, type SessionLike } from '../src/authSession/transitions' describe('classifySessionTransition', () => { it('reports a logout when the session goes inactive', () => { @@ -328,6 +328,80 @@ describe('watchSessionTransitions', () => { } }) + it('stops answering for the cleared identity once the backing session is gone', async () => { + const session = new FakeSession() + session.isActive = true + session.webId = 'https://a.example/#me' + const emitted: string[] = [] + const handlers: Record void> = {} + const doc: DocumentLike = { + visibilityState: 'visible', + addEventListener: (type: string, listener: () => void): void => { handlers[type] = listener } + } + watchSessionTransitions( + session as unknown as SessionLike, + (event) => emitted.push(event), + doc, + // Another tab logged out. The local session object still reports Alice: + // restore() can reject without mutating it. + () => 'cleared' + ) + + handlers.visibilitychange() + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(emitted).toEqual(['logout', 'identityReplaced']) + // The local object still carries Alice, but the session is no longer usable: + // the consumers that read it directly must stand down. + expect(session.webId).toBe('https://a.example/#me') + expect(sessionWasCleared(session)).toBe(true) + expect(sessionExplicitlyInactive(session)).toBe(true) + + // A later login in this tab is a new identity, not masked by the clear. + session.webId = 'https://b.example/#me' + session.dispatchEvent(new Event('sessionStateChange')) + expect(emitted).toEqual(['logout', 'identityReplaced', 'sessionChange']) + expect(sessionWasCleared(session)).toBe(false) + expect(sessionExplicitlyInactive(session)).toBe(false) + }) + + it('compares again when a slow resync only reports a change after the timeout', async () => { + vi.useFakeTimers() + try { + const session = new FakeSession() + session.isActive = true + session.webId = 'https://a.example/#me' + const emitted: string[] = [] + const handlers: Record void> = {} + const doc: DocumentLike = { + visibilityState: 'visible', + addEventListener: (type: string, listener: () => void): void => { handlers[type] = listener } + } + watchSessionTransitions( + session as unknown as SessionLike, + (event) => emitted.push(event), + doc, + // A slow cross-tab login: the re-read only updates the session (A -> B) + // long after the comparison gave up waiting. + () => new Promise((resolve) => setTimeout(() => { + session.webId = 'https://b.example/#me' + resolve('changed') + }, 5000)) + ) + + handlers.visibilitychange() + await vi.advanceTimersByTimeAsync(2500) + // Timed out: compared as it stood, nothing reported yet. + expect(emitted).toEqual([]) + + await vi.advanceTimersByTimeAsync(3000) + // The late change was compared instead of being dropped. + expect(emitted).toEqual(['sessionChange', 'identityReplaced']) + } finally { + vi.useRealTimers() + } + }) + it('checks nothing while the tab is hidden', () => { const session = new FakeSession() const emitted: string[] = [] From f03d39e5e2278c651791091afc3f70a8f97be6df Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Fri, 18 Sep 2026 14:57:18 +0200 Subject: [PATCH 16/19] review: drop resync and cookie-probe results that a newer state superseded transitions.ts: each resync attempt is stamped with a sequence and with the revision of the state it started from. Its outcome is applied only while that attempt is still the newest and no transition was applied since - a slow restore begun while A was active can resolve after B logged in, and applying its cleared result would log out B, mark a live session cleared and take away its credentials. SolidAuthnLogic: checkUser now runs its NSS probe through the same guard as the refocus revalidation (shared generation counter plus a post-await activity check). A stale cookie result can no longer overwrite fallbackWebId/cookieBackedFallback after the OIDC session took ownership, or when the two probes answer out of order; a superseded probe keeps whatever the newer one established. Tests: +3 (164). --- src/authSession/transitions.ts | 25 ++++++++++++++-- src/authn/SolidAuthnLogic.ts | 52 +++++++++++++++++++++++---------- test/solidAuthLogic.test.ts | 53 ++++++++++++++++++++++++++++++++++ test/transitions.test.ts | 43 +++++++++++++++++++++++++++ 4 files changed, 156 insertions(+), 17 deletions(-) diff --git a/src/authSession/transitions.ts b/src/authSession/transitions.ts index 84705de..f11fa56 100644 --- a/src/authSession/transitions.ts +++ b/src/authSession/transitions.ts @@ -202,6 +202,15 @@ export function watchSessionTransitions ( ): void { let previous = snapshotOf(session) let clearedReported = false + // Bumped whenever a transition is applied. A resync that started before such + // a transition is answering about an older state and must not be applied: a + // slow restore begun while A was active can resolve after B logged in, and + // reporting that as 'cleared' would log out the identity that now owns the + // session. + let revision = 0 + // The newest resync attempt: an older attempt that answers after a newer + // refocus started is superseded. + let resyncAttempt = 0 const note = (): void => { // The session reports an identity again: the cleared state is superseded, // so drop the mark before comparing (otherwise it would mask the new @@ -214,8 +223,12 @@ export function watchSessionTransitions ( const replaced = identityReplaced(previous, next) const moved = event !== null || replaced previous = next - // The session moved on again: a later 'cleared' resync is a new fact. - if (moved) clearedReported = false + if (moved) { + // The session moved on again: a later 'cleared' resync is a new fact, and + // any resync that started before this transition is now stale. + clearedReported = false + revision += 1 + } if (event) emit(event) if (replaced) emit('identityReplaced') } @@ -234,6 +247,7 @@ export function watchSessionTransitions ( // still-cleared session cannot report the logout twice. clearedSessions.add(session as object) previous = snapshotOf(session) + revision += 1 if (wasActive) emit('logout') if (wasActive && wasEstablished) emit('identityReplaced') } @@ -248,6 +262,11 @@ export function watchSessionTransitions ( note() return } + const attemptId = ++resyncAttempt + const baselineRevision = revision + // This attempt's outcome only applies while it is still the newest one and + // no transition was applied since it started. + const stale = (): boolean => attemptId !== resyncAttempt || revision !== baselineRevision let outcome: unknown let done = false const attempt = Promise.resolve() @@ -261,12 +280,14 @@ export function watchSessionTransitions ( new Promise((resolve) => setTimeout(resolve, RESYNC_TIMEOUT_MS)) ]) if (done) { + if (stale()) return if (outcome === 'cleared') reportCleared() else note() return } note() void attempt.then(() => { + if (stale()) return // Whatever the slow resync answers is worth acting on: 'cleared' means // the session is gone, and any other result may have updated the session // (a cross-tab login) — comparing again is what turns that into diff --git a/src/authn/SolidAuthnLogic.ts b/src/authn/SolidAuthnLogic.ts index 311de89..905ac3c 100644 --- a/src/authn/SolidAuthnLogic.ts +++ b/src/authn/SolidAuthnLogic.ts @@ -68,24 +68,33 @@ export class SolidAuthnLogic implements AuthnLogic { // WebID counts as active too, and probing would then replace that identity // with a cookie one. if (sessionIsActive(this.session as any)) return - // Only the newest probe may apply its result: an older probe that answers - // late must not overwrite the identity a newer one (or the session) has - // established in the meantime. - const generation = ++this.cookieProbeGeneration + const result = await this.probeCookieIdentity() + if (result.status !== 'probed') return const previousFallback = this.fallbackWebId const previousCookieBacked = this.cookieBackedFallback - const webId = await this.probeNssCookieBackedWebId() - // The OIDC session can become active, or a newer probe can start, while - // this probe is in flight — then the identity it found no longer owns the - // session and its result is stale. - if (generation !== this.cookieProbeGeneration) return - if (sessionIsActive(this.session as any)) return - if (webId === null && !previousCookieBacked) return - this.fallbackWebId = webId - this.cookieBackedFallback = webId !== null + if (result.webId === null && !previousCookieBacked) return + this.fallbackWebId = result.webId + this.cookieBackedFallback = result.webId !== null this.reportFallbackIdentityChange(previousFallback, previousCookieBacked) } + /** + * Runs the NSS cookie probe under the guard both call sites share: the probe + * takes a generation stamp, and its result is only usable when no newer probe + * started meanwhile (a refocus revalidation and `checkUser()` can overlap, + * and a probe that answers out of order must not win) and the OIDC session + * did not take ownership of the identity while the probe was in flight. + */ + private async probeCookieIdentity (): Promise< + { status: 'probed', webId: string | null } | { status: 'superseded' } | { status: 'session-active' } + > { + const generation = ++this.cookieProbeGeneration + const webId = await this.probeNssCookieBackedWebId() + if (generation !== this.cookieProbeGeneration) return { status: 'superseded' } + if (sessionIsActive(this.session as any)) return { status: 'session-active' } + return { status: 'probed', webId } + } + // we created authSession getter because we want to access it as authn.authSession externally get authSession(): SessionWithLegacyEvents { return this.session } @@ -250,8 +259,21 @@ export class SolidAuthnLogic implements AuthnLogic { let cookieBacked = false if (!webId) { // NSS-specific fallback: recover WebID from NSS cookie session when client restore is empty. - webId = await this.probeNssCookieBackedWebId() - cookieBacked = webId !== null + // The probe takes the same guard as the refocus revalidation: a result a + // newer probe superseded, or one that answers after the OIDC session took + // ownership, must not become the fallback identity (a later logout would + // then answer with that stale cookie identity). + const result = await this.probeCookieIdentity() + if (result.status === 'probed') { + webId = result.webId + cookieBacked = result.webId !== null + } else if (result.status === 'session-active') { + webId = this.webIdFromSession(sessionAny?.info, sessionAny) + } else { + // A newer probe owns the fallback now: keep what it established. + webId = this.fallbackWebId + cookieBacked = this.cookieBackedFallback + } } if (webId) { diff --git a/test/solidAuthLogic.test.ts b/test/solidAuthLogic.test.ts index d2ec94b..94a2af1 100644 --- a/test/solidAuthLogic.test.ts +++ b/test/solidAuthLogic.test.ts @@ -287,6 +287,59 @@ describe('SolidAuthnLogic', () => { expect(emitted).toEqual(['sessionChange']) }) + it('does not let a checkUser probe replace an identity the session took over', async () => { + const events = new EventEmitter() + const emitted: string[] = [] + events.on('sessionChange', () => emitted.push('sessionChange')) + events.on('identityReplaced', () => emitted.push('identityReplaced')) + const session = { events, isActive: false } as any + const authn = new SolidAuthnLogic(session) + let resolveProbe: (webId: string | null) => void = () => undefined + ;(authn as any).probeNssCookieBackedWebId = (): Promise => + new Promise((resolve) => { resolveProbe = resolve }) + + const checking = authn.checkUser() + await Promise.resolve() + // The OIDC session takes ownership while the NSS probe is in flight. + session.isActive = true + session.webId = 'https://bob.example/profile#me' + resolveProbe('https://alice.localhost/profile/card#me') + await checking + + // The stale cookie identity must not be usable after a later logout. + expect(authn.currentUser()?.uri).toBe('https://bob.example/profile#me') + expect((authn as any).cookieBackedFallback).toBe(false) + session.isActive = false + session.webId = undefined + expect(authn.currentUser()).toBeNull() + expect(emitted).toEqual([]) + }) + + it('does not let an older refocus probe win over a newer checkUser probe', async () => { + const events = new EventEmitter() + const emitted: string[] = [] + events.on('sessionChange', () => emitted.push('sessionChange')) + events.on('identityReplaced', () => emitted.push('identityReplaced')) + const authn = new SolidAuthnLogic({ events } as any) + const resolvers: ((webId: string | null) => void)[] = [] + ;(authn as any).probeNssCookieBackedWebId = (): Promise => + new Promise((resolve) => { resolvers.push(resolve) }) + + const refocus = authn.refreshCookieBackedFallback() + await Promise.resolve() + const checking = authn.checkUser() + await Promise.resolve() + + // checkUser's probe answers first, the refocus one only afterwards. + resolvers[1]('https://carol.localhost/profile/card#me') + await checking + resolvers[0]('https://alice.localhost/profile/card#me') + await refocus + + expect((authn as any).fallbackWebId).toBe('https://carol.localhost/profile/card#me') + expect(authn.currentUser()?.uri).toBe('https://carol.localhost/profile/card#me') + }) + it('drops a probe result when the session became active while probing', async () => { const events = new EventEmitter() const emitted: string[] = [] diff --git a/test/transitions.test.ts b/test/transitions.test.ts index 06f2332..c82cd9a 100644 --- a/test/transitions.test.ts +++ b/test/transitions.test.ts @@ -402,6 +402,49 @@ describe('watchSessionTransitions', () => { } }) + it('does not apply a cleared outcome that a newer transition superseded', async () => { + vi.useFakeTimers() + try { + const session = new FakeSession() + session.isActive = true + session.webId = 'https://a.example/#me' + const emitted: string[] = [] + const handlers: Record void> = {} + const doc: DocumentLike = { + visibilityState: 'visible', + addEventListener: (type: string, listener: () => void): void => { handlers[type] = listener } + } + let resolveResync: (value: unknown) => void = () => undefined + watchSessionTransitions( + session as unknown as SessionLike, + (event) => emitted.push(event), + doc, + () => new Promise((resolve) => { resolveResync = resolve }) + ) + + handlers.visibilitychange() + await vi.advanceTimersByTimeAsync(2500) + expect(emitted).toEqual([]) + + // Bob logs in in this tab while the slow resync is still in flight. + session.webId = 'https://b.example/#me' + session.dispatchEvent(new Event('sessionStateChange')) + expect(emitted).toEqual(['sessionChange', 'identityReplaced']) + + // The resync now answers about the state from before Bob logged in. + resolveResync('cleared') + await vi.advanceTimersByTimeAsync(0) + + // Bob must not be reported as logged out, and his credentials must stay + // usable: the outcome belonged to the superseded state. + expect(emitted).toEqual(['sessionChange', 'identityReplaced']) + expect(sessionWasCleared(session)).toBe(false) + expect(sessionExplicitlyInactive(session)).toBe(false) + } finally { + vi.useRealTimers() + } + }) + it('checks nothing while the tab is hidden', () => { const session = new FakeSession() const emitted: string[] = [] From e77242f1b5e4671a5c8f51590e6e7e412cae2cb0 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Fri, 18 Sep 2026 15:30:04 +0200 Subject: [PATCH 17/19] review: serialise resyncs, revalidate a cleared fallback, dispose the refocus watcher transitions.ts: only one resync runs at a time (a refocus joins the attempt in flight) because restore() can mutate the session before it resolves; and a cleared outcome is applied only while the session still reports the identity the attempt started from, so an answer about the previous identity cannot log the current one out. The resync timeout timer is now cleared when the attempt wins the race. SolidAuthnLogic: sessionOwnsIdentity() (active and not reported cleared) is used by the refocus probe, the guarded probe and the duplicate-event guard - a cleared OIDC session no longer blocks revalidation of the cookie fallback, so currentUser() cannot keep serving a cookie identity that is gone. The refocus listener is kept and removed by a new dispose(), so a replaced instance stops probing on every refocus. Tests: +4 (168). --- src/authSession/transitions.ts | 70 +++++++++++++++++++++++----------- src/authn/SolidAuthnLogic.ts | 52 +++++++++++++++++++------ test/solidAuthLogic.test.ts | 42 ++++++++++++++++++++ test/transitions.test.ts | 70 ++++++++++++++++++++++++++++++++++ 4 files changed, 200 insertions(+), 34 deletions(-) diff --git a/src/authSession/transitions.ts b/src/authSession/transitions.ts index f11fa56..c7084e7 100644 --- a/src/authSession/transitions.ts +++ b/src/authSession/transitions.ts @@ -257,45 +257,69 @@ export function watchSessionTransitions ( // comparison — but the outcome is kept: a restore that only finishes later // can still report the session gone, and dropping it would leave this tab on // the old identity until some other visibility event. + // Only one resync runs at a time: `restore()` can mutate the session before + // it resolves, so two overlapping restores could write an older identity + // back over a newer one. A refocus that arrives while one is in flight joins + // that attempt instead of starting another. + let resyncInFlight: Promise | undefined + const startResync = (action: () => unknown): Promise => { + if (!resyncInFlight) { + resyncInFlight = Promise.resolve() + .then(action) + .finally(() => { resyncInFlight = undefined }) + } + return resyncInFlight + } const syncThenNote = async (): Promise => { if (typeof resync !== 'function') { note() return } + const runResync = resync const attemptId = ++resyncAttempt const baselineRevision = revision + // The identity this attempt started from: a `'cleared'` answer only applies + // while the session still reports that same identity. `restore()` rejects + // with "no session" for the identity it was started for, so if the session + // reports a different identity now, the answer is about the previous one + // and must not log the new one out. + const rawBaseline = { isActive: sessionIsActive(session), webId: session.webId } + const sameRawIdentity = (): boolean => + sessionIsActive(session) === rawBaseline.isActive && session.webId === rawBaseline.webId // This attempt's outcome only applies while it is still the newest one and // no transition was applied since it started. const stale = (): boolean => attemptId !== resyncAttempt || revision !== baselineRevision let outcome: unknown let done = false - const attempt = Promise.resolve() - .then(() => resync()) - .then( - (value) => { outcome = value; done = true }, - () => { done = true } // compared as it stands - ) - await Promise.race([ - attempt, - new Promise((resolve) => setTimeout(resolve, RESYNC_TIMEOUT_MS)) - ]) - if (done) { + const attempt = startResync(runResync).then( + (value) => { outcome = value; done = true }, + () => { done = true } // compared as it stands + ) + const apply = (): void => { if (stale()) return - if (outcome === 'cleared') reportCleared() - else note() + if (outcome === 'cleared') { + if (sameRawIdentity()) reportCleared() + return + } + // Any other result may have updated the session (a cross-tab login): + // comparing again is what turns that into `sessionChange`/ + // `identityReplaced` instead of leaving this tab on the old identity + // until the next refocus. + note() + } + let timer: ReturnType | undefined + const timeout = new Promise((resolve) => { timer = setTimeout(resolve, RESYNC_TIMEOUT_MS) }) + try { + await Promise.race([attempt, timeout]) + } finally { + if (timer !== undefined) clearTimeout(timer) + } + if (done) { + apply() return } note() - void attempt.then(() => { - if (stale()) return - // Whatever the slow resync answers is worth acting on: 'cleared' means - // the session is gone, and any other result may have updated the session - // (a cross-tab login) — comparing again is what turns that into - // `sessionChange`/`identityReplaced` instead of leaving this tab on the - // old identity until the next refocus. - if (outcome === 'cleared') reportCleared() - else note() - }) + void attempt.then(apply) } if (typeof session.addEventListener === 'function') { session.addEventListener('sessionStateChange', note) diff --git a/src/authn/SolidAuthnLogic.ts b/src/authn/SolidAuthnLogic.ts index 905ac3c..f4c2ff4 100644 --- a/src/authn/SolidAuthnLogic.ts +++ b/src/authn/SolidAuthnLogic.ts @@ -1,7 +1,7 @@ import { namedNode, NamedNode, sym } from 'rdflib' import { appContext, offlineTestID } from './authUtil' import * as debug from '../util/debug' -import { sessionExplicitlyInactive, sessionIsActive } from '../authSession/transitions' +import { sessionExplicitlyInactive, sessionIsActive, sessionWasCleared } from '../authSession/transitions' import type { SessionWithLegacyEvents } from '../authSession/authSession' import type { AuthenticationContext, AuthnLogic } from '../types' @@ -40,6 +40,9 @@ export class SolidAuthnLogic implements AuthnLogic { // Serialises the refocus cookie probes: a result that arrives after a newer // probe started (or after the session became active) is stale and dropped. private cookieProbeGeneration = 0 + // The document listener installed by the constructor, kept so `dispose()` + // can remove it. + private refocusListener?: () => void constructor(solidAuthSession: SessionWithLegacyEvents) { this.session = solidAuthSession @@ -55,19 +58,44 @@ export class SolidAuthnLogic implements AuthnLogic { */ private watchCookieBackedFallbackRefocus (): void { if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') return - document.addEventListener('visibilitychange', () => { + const listener = (): void => { if (document.visibilityState !== 'visible') return void this.refreshCookieBackedFallback() - }) + } + this.refocusListener = listener + document.addEventListener('visibilitychange', listener) + } + + /** + * Detaches the refocus watcher from the document. A logic instance that is + * replaced stays reachable — and keeps probing on every refocus — until its + * listener is removed, so an embedder that creates a new instance should + * dispose the old one. + */ + dispose (): void { + if (!this.refocusListener) return + if (typeof document !== 'undefined' && typeof document.removeEventListener === 'function') { + document.removeEventListener('visibilitychange', this.refocusListener) + } + this.refocusListener = undefined + } + + /** + * Whether the OIDC session currently owns the identity. A session that was + * reported cleared does not, even when the local object still reports it as + * active: the backing store lost it, so the cookie fallback has to be + * revalidated instead of being left as it is. + */ + private sessionOwnsIdentity (): boolean { + return !sessionWasCleared(this.session) && sessionIsActive(this.session as any) } /** Re-probe the NSS cookie-backed identity and report a change, if any. */ async refreshCookieBackedFallback (): Promise { - // While the OIDC session is active it owns the identity. Use the shared - // activity rule: a legacy session that reports no `isActive` but has a - // WebID counts as active too, and probing would then replace that identity - // with a cookie one. - if (sessionIsActive(this.session as any)) return + // While the OIDC session owns the identity it must not be replaced by a + // cookie one. Use the shared activity rule: a legacy session that reports + // no `isActive` but has a WebID counts as active too. + if (this.sessionOwnsIdentity()) return const result = await this.probeCookieIdentity() if (result.status !== 'probed') return const previousFallback = this.fallbackWebId @@ -91,7 +119,7 @@ export class SolidAuthnLogic implements AuthnLogic { const generation = ++this.cookieProbeGeneration const webId = await this.probeNssCookieBackedWebId() if (generation !== this.cookieProbeGeneration) return { status: 'superseded' } - if (sessionIsActive(this.session as any)) return { status: 'session-active' } + if (this.sessionOwnsIdentity()) return { status: 'session-active' } return { status: 'probed', webId } } @@ -376,8 +404,10 @@ export class SolidAuthnLogic implements AuthnLogic { // Invalidate when the raw session is not active: an active one means the // watcher has already emitted `sessionChange` for its own transition. The // shared activity rule is used here as well, so a legacy session that - // reports no `isActive` while carrying a WebID is not reported twice. - const sessionActive = sessionIsActive(this.session as any) + // reports no `isActive` while carrying a WebID is not reported twice, and + // a cleared session (which no longer owns the identity) does not suppress + // the change either. + const sessionActive = this.sessionOwnsIdentity() if (!sessionActive) { events.emit('sessionChange') } diff --git a/test/solidAuthLogic.test.ts b/test/solidAuthLogic.test.ts index 94a2af1..071d1cf 100644 --- a/test/solidAuthLogic.test.ts +++ b/test/solidAuthLogic.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { SolidAuthnLogic } from '../src/authn/SolidAuthnLogic' +import { watchSessionTransitions } from '../src/authSession/transitions' import { silenceDebugMessages } from './helpers/debugger' import { AuthenticationContext } from '../src/types' import { EventEmitter } from 'node:events' @@ -340,6 +341,47 @@ describe('SolidAuthnLogic', () => { expect(authn.currentUser()?.uri).toBe('https://carol.localhost/profile/card#me') }) + it('revalidates the cookie fallback when the OIDC session was reported cleared', async () => { + const events = new EventEmitter() + const session = { events, isActive: true, webId: 'https://bob.example/profile#me' } as any + const authn = new SolidAuthnLogic(session) + ;(authn as any).fallbackWebId = 'https://alice.localhost/profile/card#me' + ;(authn as any).cookieBackedFallback = true + const probe = vi.fn(async (): Promise => null) + ;(authn as any).probeNssCookieBackedWebId = probe + + // Another tab logged the OIDC session out: the backing store lost it, so + // the local object no longer reports an identity that owns the session. + const handlers: Record void> = {} + watchSessionTransitions(session, () => undefined, { + visibilityState: 'visible', + addEventListener: (type: string, listener: () => void): void => { handlers[type] = listener } + }, () => 'cleared') + handlers.visibilitychange() + await new Promise((resolve) => setTimeout(resolve, 0)) + + // The retained cookie identity is probed again — and is gone here. + await authn.refreshCookieBackedFallback() + + expect(probe).toHaveBeenCalledTimes(1) + expect((authn as any).fallbackWebId).toBeNull() + expect((authn as any).cookieBackedFallback).toBe(false) + }) + + it('stops watching the document once disposed', async () => { + const events = new EventEmitter() + const authn = new SolidAuthnLogic({ events } as any) + const probe = vi.fn(async (): Promise => null) + ;(authn as any).probeNssCookieBackedWebId = probe + + authn.dispose() + document.dispatchEvent(new Event('visibilitychange')) + await Promise.resolve() + + // A replaced instance must not keep probing on every refocus. + expect(probe).not.toHaveBeenCalled() + }) + it('drops a probe result when the session became active while probing', async () => { const events = new EventEmitter() const emitted: string[] = [] diff --git a/test/transitions.test.ts b/test/transitions.test.ts index c82cd9a..82c24f7 100644 --- a/test/transitions.test.ts +++ b/test/transitions.test.ts @@ -445,6 +445,76 @@ describe('watchSessionTransitions', () => { } }) + it('runs one resync at a time when the tab is refocused repeatedly', async () => { + const session = new FakeSession() + const emitted: string[] = [] + const handlers: Record void> = {} + const doc: DocumentLike = { + visibilityState: 'visible', + addEventListener: (type: string, listener: () => void): void => { handlers[type] = listener } + } + let resyncs = 0 + let resolveResync: (value: unknown) => void = () => undefined + watchSessionTransitions( + session as unknown as SessionLike, + (event) => emitted.push(event), + doc, + // `restore()` can mutate the session, so overlapping restores could + // overwrite a newer identity: the second refocus joins the first resync. + () => { + resyncs += 1 + return new Promise((resolve) => { resolveResync = resolve }) + } + ) + + handlers.visibilitychange() + await Promise.resolve() + handlers.visibilitychange() + await Promise.resolve() + expect(resyncs).toBe(1) + + // The single resync pulled in Bob's login: reported once, by the newest refocus. + session.isActive = true + session.webId = 'https://b.example/#me' + resolveResync('changed') + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(resyncs).toBe(1) + expect(emitted).toEqual(['sessionChange']) + }) + + it('does not apply a cleared outcome once the session reports another identity', async () => { + const session = new FakeSession() + session.isActive = true + session.webId = 'https://a.example/#me' + const emitted: string[] = [] + const handlers: Record void> = {} + const doc: DocumentLike = { + visibilityState: 'visible', + addEventListener: (type: string, listener: () => void): void => { handlers[type] = listener } + } + let resolveResync: (value: unknown) => void = () => undefined + watchSessionTransitions( + session as unknown as SessionLike, + (event) => emitted.push(event), + doc, + () => new Promise((resolve) => { resolveResync = resolve }) + ) + + handlers.visibilitychange() + await Promise.resolve() + // The identity changes while the restore is in flight — without a session + // event the watcher cannot see a transition at all. + session.webId = 'https://b.example/#me' + resolveResync('cleared') + await new Promise((resolve) => setTimeout(resolve, 0)) + + // The answer was about Alice; Bob must not be logged out by it. + expect(emitted).toEqual([]) + expect(sessionWasCleared(session)).toBe(false) + expect(sessionExplicitlyInactive(session)).toBe(false) + }) + it('checks nothing while the tab is hidden', () => { const session = new FakeSession() const emitted: string[] = [] From a5636254ed8fa7263927590f18c63ae644104c66 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Fri, 18 Sep 2026 16:44:33 +0200 Subject: [PATCH 18/19] review: share one restore lock, honour the cleared mark in webIdFromSession transitions.ts: restoreSession() is a per-session single-flight lock used by both the refocus resync and checkUser, so two overlapping mutating restores cannot write an older identity back over a newer one. reportCleared() only marks/baselines an identity that was actually in use - an anonymous tab stays anonymous, so a later login cannot be masked by the mark. SolidAuthnLogic: webIdFromSession() returns null for a session reported cleared (its cached identity is not usable any more), dispose() bumps the probe generation so an in-flight probe result is superseded, and a superseded checkUser probe adopts the current fallback as its baseline so the transition the newer probe already reported is not emitted twice. Tests: +6 (174). --- src/authSession/authSession.ts | 20 +++++----- src/authSession/transitions.ts | 47 ++++++++++++++++++++++- src/authn/SolidAuthnLogic.ts | 28 ++++++++++---- test/solidAuthLogic.test.ts | 70 ++++++++++++++++++++++++++++++++++ test/transitions.test.ts | 63 +++++++++++++++++++++++++++++- 5 files changed, 207 insertions(+), 21 deletions(-) diff --git a/src/authSession/authSession.ts b/src/authSession/authSession.ts index e197ad6..a296e9a 100644 --- a/src/authSession/authSession.ts +++ b/src/authSession/authSession.ts @@ -14,7 +14,7 @@ import type { Session as OidcSession } from '@uvdsl/solid-oidc-client-browser/co import { _session } from './session' import { resolveIssuerForLogin } from './issuer' import { SessionEvents } from './events' -import { sessionIsActive, sessionWasCleared, watchSessionTransitions, type SessionLike } from './transitions' +import { restoreSession, sessionIsActive, sessionWasCleared, watchSessionTransitions, type SessionLike, type SessionRestoreLike } from './transitions' type SessionCompatibilityShape = { webId?: string @@ -107,16 +107,14 @@ const events = new SessionEvents() // wired. It maps a backing store that no longer holds a session (a cross-tab // logout) to 'cleared'; watchSessionTransitions() bounds the wait. const resyncSession = (): unknown => { - const restore = (_session as any)?.restore - if (typeof restore !== 'function') return undefined - return Promise.resolve() - .then(() => restore.call(_session)) - .then(() => 'changed', (error: unknown) => { - // A transient refresh/network failure is compared as it stands; a store - // that has no session to restore means this tab's identity is gone. - const message = error instanceof Error ? error.message : String(error) - return /no session to restore/i.test(message) ? 'cleared' : 'changed' - }) + const restoring = restoreSession(_session as unknown as SessionRestoreLike) + if (!restoring) return undefined + return restoring.then(() => 'changed', (error: unknown) => { + // A transient refresh/network failure is compared as it stands; a store + // that has no session to restore means this tab's identity is gone. + const message = error instanceof Error ? error.message : String(error) + return /no session to restore/i.test(message) ? 'cleared' : 'changed' + }) } watchSessionTransitions(_session as unknown as SessionLike, (event) => events.emit(event), undefined, resyncSession) diff --git a/src/authSession/transitions.ts b/src/authSession/transitions.ts index c7084e7..485a49e 100644 --- a/src/authSession/transitions.ts +++ b/src/authSession/transitions.ts @@ -148,6 +148,37 @@ export type DocumentLike = { /** How long a refocus resync may delay the snapshot comparison. */ const RESYNC_TIMEOUT_MS = 2000 +export type SessionRestoreLike = { + restore?: () => Promise +} + +// One restore at a time per session: `restore()` can mutate the session before +// it resolves, so two overlapping restores could write an older identity back +// over a newer one. Every call site (the refocus resync and `checkUser()`) goes +// through this lock, and a caller that arrives while a restore is in flight +// joins it instead of starting another. +const restoresInFlight = new WeakMap>() + +/** + * Runs `session.restore()`, sharing an attempt that is already in flight. + * + * @returns the shared promise, or undefined when the session has no restore. + */ +export function restoreSession (session: SessionRestoreLike | undefined): Promise | undefined { + const restore = session?.restore + if (typeof restore !== 'function' || typeof session !== 'object' || session === null) { + return undefined + } + const key = session as object + const inFlight = restoresInFlight.get(key) + if (inFlight) return inFlight + const started = Promise.resolve() + .then(() => restore.call(session)) + .finally(() => { restoresInFlight.delete(key) }) + restoresInFlight.set(key, started) + return started +} + const snapshotOf = (session: SessionLike): SessionSnapshot => { // A session that was reported cleared answers as logged out until it // reports an identity again (see sessionWasCleared): the local object may @@ -241,6 +272,18 @@ export function watchSessionTransitions ( clearedReported = true const wasActive = previous.isActive const wasEstablished = previous.webId !== undefined + // Only a session that was actually in use has to be invalidated: an + // anonymous tab is cleared already, and marking it would make + // snapshotOf() report the cleared state — masking a later login until + // some other watcher event happens to run. + if (!wasActive || !wasEstablished) { + previous = snapshotOf(session) + if (wasActive) { + revision += 1 + emit('logout') + } + return + } // The local session object still reports the old identity: mark it cleared // so the derived reads stop answering for it, and baseline the comparison // on that cleared state — a later activation is then a new login, and a @@ -248,8 +291,8 @@ export function watchSessionTransitions ( clearedSessions.add(session as object) previous = snapshotOf(session) revision += 1 - if (wasActive) emit('logout') - if (wasActive && wasEstablished) emit('identityReplaced') + emit('logout') + emit('identityReplaced') } // A session that cannot receive another tab's change as a pushed event has // to be re-read before the snapshots are compared, or the change is simply diff --git a/src/authn/SolidAuthnLogic.ts b/src/authn/SolidAuthnLogic.ts index f4c2ff4..84c8159 100644 --- a/src/authn/SolidAuthnLogic.ts +++ b/src/authn/SolidAuthnLogic.ts @@ -1,7 +1,7 @@ import { namedNode, NamedNode, sym } from 'rdflib' import { appContext, offlineTestID } from './authUtil' import * as debug from '../util/debug' -import { sessionExplicitlyInactive, sessionIsActive, sessionWasCleared } from '../authSession/transitions' +import { restoreSession, sessionExplicitlyInactive, sessionIsActive, sessionWasCleared } from '../authSession/transitions' import type { SessionWithLegacyEvents } from '../authSession/authSession' import type { AuthenticationContext, AuthnLogic } from '../types' @@ -73,6 +73,9 @@ export class SolidAuthnLogic implements AuthnLogic { * dispose the old one. */ dispose (): void { + // A probe that is already awaiting its fetch must not apply its result + // either: bumping the generation makes it superseded. + this.cookieProbeGeneration += 1 if (!this.refocusListener) return if (typeof document !== 'undefined' && typeof document.removeEventListener === 'function') { document.removeEventListener('visibilitychange', this.refocusListener) @@ -223,9 +226,13 @@ export class SolidAuthnLogic implements AuthnLogic { // UI would spin forever. Race it against a timeout and treat a stall // as "no previous session" so the page can render the login button. const wasActive = sessionAny?.isActive ?? Boolean(sessionAny?.webId) - if (typeof sessionAny?.restore === 'function') { + // The shared restore lock also covers this call: a refocus resync can be + // in flight at the same time, and two overlapping restores could write an + // older identity back over a newer one. + const restoring = restoreSession(sessionAny) + if (restoring) { try { - await withRestoreTimeout(sessionAny.restore()) + await withRestoreTimeout(restoring) } catch (error) { const message = error instanceof Error ? error.message : String(error) // A failed restore on an inactive session just means "no usable @@ -283,6 +290,8 @@ export class SolidAuthnLogic implements AuthnLogic { const previousFallback = this.fallbackWebId const previousCookieBacked = this.cookieBackedFallback + let baselineFallback = previousFallback + let baselineCookieBacked = previousCookieBacked let webId = this.webIdFromSession(sessionAny?.info, sessionAny) let cookieBacked = false if (!webId) { @@ -298,9 +307,12 @@ export class SolidAuthnLogic implements AuthnLogic { } else if (result.status === 'session-active') { webId = this.webIdFromSession(sessionAny?.info, sessionAny) } else { - // A newer probe owns the fallback now: keep what it established. + // A newer probe already owns the fallback and reported its change: keep + // its result, and do not report the transition a second time. webId = this.fallbackWebId cookieBacked = this.cookieBackedFallback + baselineFallback = this.fallbackWebId + baselineCookieBacked = this.cookieBackedFallback } } @@ -312,7 +324,7 @@ export class SolidAuthnLogic implements AuthnLogic { this.cookieBackedFallback = false } - this.reportFallbackIdentityChange(previousFallback, previousCookieBacked) + this.reportFallbackIdentityChange(baselineFallback, baselineCookieBacked) if (webId) { me = this.saveUser(webId) @@ -439,8 +451,10 @@ export class SolidAuthnLogic implements AuthnLogic { // sessionExplicitlyInactive() in transitions.ts. The session root has no // `isLoggedIn` property, so requiring every source to be false kept a // cached WebID alive across a logout; a mixed snapshot must not resurrect - // one either. - if (infoLoggedIn === false || rootLoggedIn === false || rootActive === false) { + // one either. A session that was reported cleared (its backing store lost + // it) is inactive as well, however positive its own fields still look. + if (sessionWasCleared(sessionRoot) || + infoLoggedIn === false || rootLoggedIn === false || rootActive === false) { return null } // Active, or a legacy session that reports no state at all. diff --git a/test/solidAuthLogic.test.ts b/test/solidAuthLogic.test.ts index 071d1cf..004c0e2 100644 --- a/test/solidAuthLogic.test.ts +++ b/test/solidAuthLogic.test.ts @@ -97,6 +97,24 @@ describe('SolidAuthnLogic', () => { { webId: 'https://alice.example/profile#me' } )).toBe('https://alice.example/profile#me') }) + it('returns null for a session whose backing store was cleared', async () => { + // The session still reports Alice, but the backing store lost it: the + // cached identity must not be accepted again. + const session = { isActive: true, webId: 'https://alice.example/profile#me' } + const handlers: Record void> = {} + watchSessionTransitions(session, () => undefined, { + visibilityState: 'visible', + addEventListener: (type: string, listener: () => void): void => { handlers[type] = listener } + }, () => 'cleared') + handlers.visibilitychange() + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(solidAuthnLogic.webIdFromSession( + { webId: 'https://alice.example/profile#me', isLoggedIn: true }, + session + )).toBeNull() + }) + it('treats a mixed snapshot as logged out when any source reports inactive', () => { expect(solidAuthnLogic.webIdFromSession( { webId: 'https://alice.example/profile#me', isLoggedIn: true }, @@ -288,6 +306,58 @@ describe('SolidAuthnLogic', () => { expect(emitted).toEqual(['sessionChange']) }) + it('does not report a superseded checkUser probe a second time', async () => { + const events = new EventEmitter() + const emitted: string[] = [] + events.on('sessionChange', () => emitted.push('sessionChange')) + events.on('identityReplaced', () => emitted.push('identityReplaced')) + const authn = new SolidAuthnLogic({ events } as any) + ;(authn as any).fallbackWebId = 'https://alice.localhost/profile/card#me' + ;(authn as any).cookieBackedFallback = true + const resolvers: ((webId: string | null) => void)[] = [] + ;(authn as any).probeNssCookieBackedWebId = (): Promise => + new Promise((resolve) => { resolvers.push(resolve) }) + + const checking = authn.checkUser() + await Promise.resolve() + const refocus = authn.refreshCookieBackedFallback() + await Promise.resolve() + + // The newer probe establishes a different cookie identity and reports it. + resolvers[1]('https://carol.localhost/profile/card#me') + await refocus + expect(emitted).toEqual(['sessionChange', 'identityReplaced']) + + // The older checkUser probe now answers: it is superseded, and the change + // it would compare against has already been reported. + resolvers[0](null) + await checking + + expect(emitted).toEqual(['sessionChange', 'identityReplaced']) + expect((authn as any).fallbackWebId).toBe('https://carol.localhost/profile/card#me') + }) + + it('drops a probe result that was in flight when the instance was disposed', async () => { + const events = new EventEmitter() + const emitted: string[] = [] + events.on('sessionChange', () => emitted.push('sessionChange')) + events.on('identityReplaced', () => emitted.push('identityReplaced')) + const authn = new SolidAuthnLogic({ events } as any) + let resolveProbe: (webId: string | null) => void = () => undefined + ;(authn as any).probeNssCookieBackedWebId = (): Promise => + new Promise((resolve) => { resolveProbe = resolve }) + + const probing = authn.refreshCookieBackedFallback() + await Promise.resolve() + authn.dispose() + resolveProbe('https://alice.localhost/profile/card#me') + await probing + + // A replaced instance must not apply results any more. + expect((authn as any).fallbackWebId).toBeNull() + expect(emitted).toEqual([]) + }) + it('does not let a checkUser probe replace an identity the session took over', async () => { const events = new EventEmitter() const emitted: string[] = [] diff --git a/test/transitions.test.ts b/test/transitions.test.ts index 82c24f7..5b80117 100644 --- a/test/transitions.test.ts +++ b/test/transitions.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { classifySessionTransition, identityReplaced, reloadOnIdentityReplaced, sessionExplicitlyInactive, sessionIsActive, sessionWasCleared, watchSessionTransitions, type DocumentLike, type SessionLike } from '../src/authSession/transitions' +import { classifySessionTransition, identityReplaced, reloadOnIdentityReplaced, restoreSession, sessionExplicitlyInactive, sessionIsActive, sessionWasCleared, watchSessionTransitions, type DocumentLike, type SessionLike } from '../src/authSession/transitions' describe('classifySessionTransition', () => { it('reports a logout when the session goes inactive', () => { @@ -127,6 +127,41 @@ describe('sessionIsActive', () => { }) }) +describe('restoreSession', () => { + it('shares a restore that is already in flight', async () => { + let restores = 0 + let resolveRestore: (value: unknown) => void = () => undefined + const session = { + restore: (): Promise => { + restores += 1 + return new Promise((resolve) => { resolveRestore = resolve }) + } + } + + const first = restoreSession(session) + const second = restoreSession(session) + // The shared attempt calls restore() on the next microtask. + await Promise.resolve() + + // `restore()` can mutate the session, so both call sites share one attempt. + expect(restores).toBe(1) + expect(second).toBe(first) + + resolveRestore('restored') + await expect(first).resolves.toBe('restored') + + // Once it settled, the next caller starts a new restore. + void restoreSession(session) + await Promise.resolve() + expect(restores).toBe(2) + }) + + it('has nothing to share when the session cannot restore', () => { + expect(restoreSession(undefined)).toBeUndefined() + expect(restoreSession({})).toBeUndefined() + }) +}) + describe('watchSessionTransitions', () => { it('emits on the session state event', () => { const session = new FakeSession() @@ -515,6 +550,32 @@ describe('watchSessionTransitions', () => { expect(sessionExplicitlyInactive(session)).toBe(false) }) + it('does not mark a session that was never established as cleared', async () => { + const session = new FakeSession() + const emitted: string[] = [] + const handlers: Record void> = {} + const doc: DocumentLike = { + visibilityState: 'visible', + addEventListener: (type: string, listener: () => void): void => { handlers[type] = listener } + } + watchSessionTransitions(session as unknown as SessionLike, (event) => emitted.push(event), doc, () => 'cleared') + + handlers.visibilitychange() + await new Promise((resolve) => setTimeout(resolve, 0)) + + // Nothing was in use, so nothing is reported and nothing is invalidated: + // a mark here would mask the login that follows. + expect(emitted).toEqual([]) + expect(sessionWasCleared(session)).toBe(false) + + session.isActive = true + session.webId = 'https://a.example/#me' + session.dispatchEvent(new Event('sessionStateChange')) + + expect(emitted).toEqual(['sessionChange']) + expect(sessionExplicitlyInactive(session)).toBe(false) + }) + it('checks nothing while the tab is hidden', () => { const session = new FakeSession() const emitted: string[] = [] From 20d48f824fce58e28231f9bd6d4d8c8afae65c91 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Fri, 18 Sep 2026 18:38:12 +0200 Subject: [PATCH 19/19] review: judge a joined resync by its own baseline; one refocus watcher per session transitions.ts: a resync attempt now carries its own id, revision and raw identity. A refocus that joins an attempt in flight uses those, so an answer about the identity the restore started from can no longer be applied to an identity that superseded it. SolidAuthnLogic: sessionOwnsIdentity() also honours sessionExplicitlyInactive(), so a legacy snapshot (isActive undefined, WebID cached, info.isLoggedIn false) does not block cookie revalidation; the refocus listener is now one per session (WeakMap registry, forwards to the newest instance, removed with the last dispose); AuthnLogic gains an optional dispose() so a replaced instance can be released. Tests: +3 (177). --- src/authSession/transitions.ts | 55 +++++++++++++++-------- src/authn/SolidAuthnLogic.ts | 80 +++++++++++++++++++++++++--------- src/types.ts | 6 +++ test/solidAuthLogic.test.ts | 47 ++++++++++++++++++++ test/transitions.test.ts | 47 ++++++++++++++++++++ 5 files changed, 195 insertions(+), 40 deletions(-) diff --git a/src/authSession/transitions.ts b/src/authSession/transitions.ts index 485a49e..4e9c507 100644 --- a/src/authSession/transitions.ts +++ b/src/authSession/transitions.ts @@ -152,6 +152,18 @@ export type SessionRestoreLike = { restore?: () => Promise } +/** + * A resync that is (or was) running: its own identity, the revision of the + * state it started from, and its outcome. A caller that joins an attempt in + * flight must use these, not its own view of the session. + */ +type ResyncAttempt = { + id: number + revision: number + raw: { isActive: boolean, webId?: string } + promise: Promise +} + // One restore at a time per session: `restore()` can mutate the session before // it resolves, so two overlapping restores could write an older identity back // over a newer one. Every call site (the refocus resync and `checkUser()`) goes @@ -303,13 +315,20 @@ export function watchSessionTransitions ( // Only one resync runs at a time: `restore()` can mutate the session before // it resolves, so two overlapping restores could write an older identity // back over a newer one. A refocus that arrives while one is in flight joins - // that attempt instead of starting another. - let resyncInFlight: Promise | undefined - const startResync = (action: () => unknown): Promise => { + // that attempt instead of starting another — and it joins the *attempt's* + // baseline too: the answer belongs to the identity the restore started from, + // not to the identity the joining caller happens to see now. + let resyncInFlight: ResyncAttempt | undefined + const startResync = (action: () => unknown): ResyncAttempt => { if (!resyncInFlight) { - resyncInFlight = Promise.resolve() - .then(action) - .finally(() => { resyncInFlight = undefined }) + resyncInFlight = { + id: ++resyncAttempt, + revision, + raw: { isActive: sessionIsActive(session), webId: session.webId }, + promise: Promise.resolve() + .then(action) + .finally(() => { resyncInFlight = undefined }) + } } return resyncInFlight } @@ -319,22 +338,20 @@ export function watchSessionTransitions ( return } const runResync = resync - const attemptId = ++resyncAttempt - const baselineRevision = revision - // The identity this attempt started from: a `'cleared'` answer only applies - // while the session still reports that same identity. `restore()` rejects - // with "no session" for the identity it was started for, so if the session - // reports a different identity now, the answer is about the previous one - // and must not log the new one out. - const rawBaseline = { isActive: sessionIsActive(session), webId: session.webId } - const sameRawIdentity = (): boolean => - sessionIsActive(session) === rawBaseline.isActive && session.webId === rawBaseline.webId + const started = startResync(runResync) // This attempt's outcome only applies while it is still the newest one and - // no transition was applied since it started. - const stale = (): boolean => attemptId !== resyncAttempt || revision !== baselineRevision + // no transition was applied since the attempt (not this caller) started. + const stale = (): boolean => started.id !== resyncAttempt || revision !== started.revision + // A `'cleared'` answer only applies while the session still reports the + // identity the attempt started from. `restore()` rejects with "no session" + // for the identity it was started for, so if the session reports a + // different identity now, the answer is about the previous one and must not + // log the new one out. + const sameRawIdentity = (): boolean => + sessionIsActive(session) === started.raw.isActive && session.webId === started.raw.webId let outcome: unknown let done = false - const attempt = startResync(runResync).then( + const attempt = started.promise.then( (value) => { outcome = value; done = true }, () => { done = true } // compared as it stands ) diff --git a/src/authn/SolidAuthnLogic.ts b/src/authn/SolidAuthnLogic.ts index 84c8159..04f0e5c 100644 --- a/src/authn/SolidAuthnLogic.ts +++ b/src/authn/SolidAuthnLogic.ts @@ -10,6 +10,42 @@ import type { AuthenticationContext, AuthnLogic } from '../types' // forever. This caps the wait so the login UI can never spin indefinitely. const SESSION_RESTORE_TIMEOUT_MS = 5000 +/** + * One refocus watcher per session. Logic instances can be replaced (a second + * `createSolidLogic()` call, a re-created app shell), and an instance that is + * replaced would otherwise stay reachable through its own document listener and + * probe the cookie fallback on every refocus. The watcher forwards to the + * newest instance using that session and is removed with the last one. + */ +type RefocusWatch = { + handler: () => void + owner?: SolidAuthnLogic + owners: number +} + +const refocusWatches = new WeakMap() + +function watchRefocusFor (authn: SolidAuthnLogic, session: object): RefocusWatch | undefined { + if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') return undefined + const existing = refocusWatches.get(session) + if (existing) { + existing.owner = authn + existing.owners += 1 + return existing + } + const watch: RefocusWatch = { + owners: 1, + owner: authn, + handler: (): void => { + if (document.visibilityState !== 'visible') return + void watch.owner?.refreshCookieBackedFallback() + } + } + document.addEventListener('visibilitychange', watch.handler) + refocusWatches.set(session, watch) + return watch +} + /** * Await a session restore promise, but give up after * SESSION_RESTORE_TIMEOUT_MS and resolve with undefined so callers can @@ -40,9 +76,9 @@ export class SolidAuthnLogic implements AuthnLogic { // Serialises the refocus cookie probes: a result that arrives after a newer // probe started (or after the session became active) is stale and dropped. private cookieProbeGeneration = 0 - // The document listener installed by the constructor, kept so `dispose()` - // can remove it. - private refocusListener?: () => void + // The session's refocus watcher, shared with any other instance wrapping the + // same session (see watchRefocusFor). + private refocusWatch?: RefocusWatch constructor(solidAuthSession: SessionWithLegacyEvents) { this.session = solidAuthSession @@ -57,40 +93,42 @@ export class SolidAuthnLogic implements AuthnLogic { * (*.localhost NSS setups). */ private watchCookieBackedFallbackRefocus (): void { - if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') return - const listener = (): void => { - if (document.visibilityState !== 'visible') return - void this.refreshCookieBackedFallback() - } - this.refocusListener = listener - document.addEventListener('visibilitychange', listener) + this.refocusWatch = watchRefocusFor(this, this.session as unknown as object) } /** - * Detaches the refocus watcher from the document. A logic instance that is - * replaced stays reachable — and keeps probing on every refocus — until its - * listener is removed, so an embedder that creates a new instance should - * dispose the old one. + * Detaches this instance from the session's refocus watcher (removing the + * document listener with the last instance using it) and makes any probe that + * is already in flight superseded, so a replaced instance stops probing and + * cannot apply results any more. */ dispose (): void { // A probe that is already awaiting its fetch must not apply its result // either: bumping the generation makes it superseded. this.cookieProbeGeneration += 1 - if (!this.refocusListener) return + const watch = this.refocusWatch + this.refocusWatch = undefined + if (!watch) return + watch.owners -= 1 + if (watch.owner === this) watch.owner = undefined + if (watch.owners > 0) return if (typeof document !== 'undefined' && typeof document.removeEventListener === 'function') { - document.removeEventListener('visibilitychange', this.refocusListener) + document.removeEventListener('visibilitychange', watch.handler) } - this.refocusListener = undefined + refocusWatches.delete(this.session as unknown as object) } /** * Whether the OIDC session currently owns the identity. A session that was - * reported cleared does not, even when the local object still reports it as - * active: the backing store lost it, so the cookie fallback has to be - * revalidated instead of being left as it is. + * reported cleared does not, and neither does one that explicitly reports + * itself logged out (`isActive: false`, or `info.isLoggedIn: false` with a + * cached WebID — the legacy shape `sessionIsActive()` alone would accept). */ private sessionOwnsIdentity (): boolean { - return !sessionWasCleared(this.session) && sessionIsActive(this.session as any) + const session = this.session + return !sessionWasCleared(session) && + !sessionExplicitlyInactive(session) && + sessionIsActive(session as any) } /** Re-probe the NSS cookie-backed identity and report a change, if any. */ diff --git a/src/types.ts b/src/types.ts index 58ebd61..8101761 100644 --- a/src/types.ts +++ b/src/types.ts @@ -26,6 +26,12 @@ export interface AuthnLogic { checkUser: (setUserCallback?: (me: NamedNode | null) => T) => Promise saveUser: (webId: NamedNode | string | null, context?: AuthenticationContext) => NamedNode | null + /** + * Releases what the implementation registered elsewhere (document and + * session listeners). Optional so other implementations stay valid, but a + * caller that replaces a logic instance should dispose the old one. + */ + dispose?: () => void } export interface SolidNamespace { diff --git a/test/solidAuthLogic.test.ts b/test/solidAuthLogic.test.ts index 004c0e2..c4992df 100644 --- a/test/solidAuthLogic.test.ts +++ b/test/solidAuthLogic.test.ts @@ -411,6 +411,53 @@ describe('SolidAuthnLogic', () => { expect(authn.currentUser()?.uri).toBe('https://carol.localhost/profile/card#me') }) + it('revalidates the cookie fallback for a legacy snapshot that is explicitly logged out', async () => { + const events = new EventEmitter() + // `isActive` is undefined, so sessionIsActive() would accept the cached + // WebID — but the info says logged out, and that wins everywhere else. + const authn = new SolidAuthnLogic({ + events, + webId: 'https://bob.example/profile#me', + info: { isLoggedIn: false } + } as any) + const probe = vi.fn(async (): Promise => null) + ;(authn as any).probeNssCookieBackedWebId = probe + ;(authn as any).fallbackWebId = 'https://alice.localhost/profile/card#me' + ;(authn as any).cookieBackedFallback = true + + await authn.refreshCookieBackedFallback() + + expect(probe).toHaveBeenCalledTimes(1) + }) + + it('shares one refocus watcher per session and removes it with the last instance', async () => { + Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true }) + const session = { events: new EventEmitter() } as any + const first = new SolidAuthnLogic(session) + const second = new SolidAuthnLogic(session) + const firstProbe = vi.fn(async (): Promise => null) + const secondProbe = vi.fn(async (): Promise => null) + ;(first as any).probeNssCookieBackedWebId = firstProbe + ;(second as any).probeNssCookieBackedWebId = secondProbe + + document.dispatchEvent(new Event('visibilitychange')) + await Promise.resolve() + // One watcher per session, driving the newest instance: a replaced + // instance does not add a probe of its own. + expect(firstProbe).not.toHaveBeenCalled() + expect(secondProbe).toHaveBeenCalledTimes(1) + + second.dispose() + document.dispatchEvent(new Event('visibilitychange')) + await Promise.resolve() + expect(secondProbe).toHaveBeenCalledTimes(1) + + first.dispose() + document.dispatchEvent(new Event('visibilitychange')) + await Promise.resolve() + expect(firstProbe).not.toHaveBeenCalled() + }) + it('revalidates the cookie fallback when the OIDC session was reported cleared', async () => { const events = new EventEmitter() const session = { events, isActive: true, webId: 'https://bob.example/profile#me' } as any diff --git a/test/transitions.test.ts b/test/transitions.test.ts index 5b80117..5339f9e 100644 --- a/test/transitions.test.ts +++ b/test/transitions.test.ts @@ -576,6 +576,53 @@ describe('watchSessionTransitions', () => { expect(sessionExplicitlyInactive(session)).toBe(false) }) + it('does not apply a joined resync outcome to the identity that superseded it', async () => { + vi.useFakeTimers() + try { + const session = new FakeSession() + session.isActive = true + session.webId = 'https://a.example/#me' + const emitted: string[] = [] + const handlers: Record void> = {} + const doc: DocumentLike = { + visibilityState: 'visible', + addEventListener: (type: string, listener: () => void): void => { handlers[type] = listener } + } + let resolveResync: (value: unknown) => void = () => undefined + watchSessionTransitions( + session as unknown as SessionLike, + (event) => emitted.push(event), + doc, + () => new Promise((resolve) => { resolveResync = resolve }) + ) + + // The resync starts for Alice … + handlers.visibilitychange() + await vi.advanceTimersByTimeAsync(2500) + expect(emitted).toEqual([]) + + // … Bob logs in while it is still running … + session.webId = 'https://b.example/#me' + session.dispatchEvent(new Event('sessionStateChange')) + expect(emitted).toEqual(['sessionChange', 'identityReplaced']) + + // … and a second refocus joins the very same restore, so the answer must + // be judged against the identity the attempt started from, not against + // the identity the joining refocus sees. + handlers.visibilitychange() + await vi.advanceTimersByTimeAsync(2500) + + resolveResync('cleared') + await vi.advanceTimersByTimeAsync(0) + + expect(emitted).toEqual(['sessionChange', 'identityReplaced']) + expect(sessionWasCleared(session)).toBe(false) + expect(sessionExplicitlyInactive(session)).toBe(false) + } finally { + vi.useRealTimers() + } + }) + it('checks nothing while the tab is hidden', () => { const session = new FakeSession() const emitted: string[] = []