From 9f5bced203363f875ad3553bf1f98de321711cbf Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Tue, 15 Sep 2026 18:48:52 +0200 Subject: [PATCH 01/14] 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/14] 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/14] 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/14] =?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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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[] = []