diff --git a/src/authSession/authSession.ts b/src/authSession/authSession.ts index 901b0ec..3b27ab6 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 { sessionIsActive, watchSessionTransitions, type SessionLike } from './transitions' type SessionCompatibilityShape = { webId?: string @@ -93,22 +94,67 @@ 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. +// 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 maps a backing store that no longer holds a session (a cross-tab +// logout) to 'cleared'; watchSessionTransitions() bounds the wait. +const resyncSession = (): unknown => { + const restore = (_session as any)?.restore + if (typeof restore !== 'function') return undefined + return Promise.resolve() + .then(() => restore.call(_session)) + .then(() => 'changed', (error: unknown) => { + // A transient refresh/network failure is compared as it stands; a store + // that has no session to restore means this tab's identity is gone. + const message = error instanceof Error ? error.message : String(error) + return /no session to restore/i.test(message) ? 'cleared' : 'changed' + }) } +watchSessionTransitions(_session as unknown as SessionLike, (event) => events.emit(event), undefined, resyncSession) 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 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. +// +// `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 } { + 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. + } +}) \ No newline at end of file diff --git a/src/authSession/events.ts b/src/authSession/events.ts index 8e7704a..afae945 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 = 'identityReplaced' | 'login' | 'logout' | 'sessionChange' | 'sessionRestore' type LegacyEventHandler = (...args: unknown[]) => void /** @@ -14,7 +14,8 @@ 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, + * identityReplaced — the event the reload helper subscribes to). */ 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..ad93434 --- /dev/null +++ b/src/authSession/flagAuthorizationOnTransitions.ts @@ -0,0 +1,239 @@ +/** + * 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, 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. + */ + +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. +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 => { + const state = storeState(store) + state.generation += 1 + try { + 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 + } catch (error) { + // 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}`) + } + } + const events = session?.events + if (!events || typeof events.on !== 'function') return + for (const transition of SESSION_TRANSITIONS) { + events.on(transition, flag) + } +} + +export type RefreshableStore = { + fetcher?: { + refresh?: (doc: unknown, callback?: (...args: unknown[]) => void) => unknown + load?: (doc: unknown) => unknown + } + updater?: { editable?: (uri: unknown) => string | boolean | undefined } +} + +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 + +/** + * 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 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, + doc: unknown +): Promise { + const state = storeState(store) + for (let attempt = 0; attempt < REFRESH_ATTEMPTS; attempt++) { + const generation = state.generation + 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) { + 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). + * + * 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 { + const state = storeState(store) + if (!state.refreshRequired && store.updater?.editable?.(doc) !== undefined) { + return true + } + 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: + * 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). + * + * 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 { + const refresh = store.fetcher?.refresh + 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) + } + } + try { + 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(false) + } + }) +} diff --git a/src/authSession/transitions.ts b/src/authSession/transitions.ts new file mode 100644 index 0000000..ab14075 --- /dev/null +++ b/src/authSession/transitions.ts @@ -0,0 +1,239 @@ +/** + * Session identity transitions. + * + * The uvdsl session announces a state change in this tab through its + * `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; + * - 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. + * + * 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. + */ + +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 +} + +/** + * 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 + // 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 +} + +/** + * 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 + addEventListener?: (type: string, listener: () => void) => void + 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)) + +/** + * 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 +} + +/** 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 +}) + +// 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 + * visibility listener (when a document exists) so a transition made in + * 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, + emit: (event: 'logout' | 'sessionChange' | 'identityReplaced') => void, + doc: DocumentLike | undefined = typeof document === 'undefined' ? undefined : document, + 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 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 = 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 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 => { + 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) + } + watchTokenUpdates(session, note) + if (doc && typeof doc.addEventListener === 'function') { + doc.addEventListener('visibilitychange', () => { + if (doc.visibilityState === 'visible') void syncThenNote() + }) + } +} diff --git a/src/authn/SolidAuthnLogic.ts b/src/authn/SolidAuthnLogic.ts index 79ceb08..edbfbe7 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' @@ -32,9 +33,42 @@ export class SolidAuthnLogic implements AuthnLogic { private checkUserInFlight: 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 + 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 @@ -46,6 +80,17 @@ 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 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 + } const infoWebId = sessionAny?.info?.webId const sessionWebId = sessionAny?.webId const webId = infoWebId || sessionWebId || this.fallbackWebId @@ -184,18 +229,26 @@ export class SolidAuthnLogic implements AuthnLogic { return me } + const previousFallback = this.fallbackWebId + const previousCookieBacked = this.cookieBackedFallback 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 } + this.reportFallbackIdentityChange(previousFallback, previousCookieBacked) + if (webId) { me = this.saveUser(webId) } @@ -264,6 +317,40 @@ 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. + * + * 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, 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 + + // 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') + } + } + /** * @returns {Promise} Resolves with WebID URI or null */ @@ -278,12 +365,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 - } - if (infoLoggedIn === false && rootLoggedIn === false && rootActive === false) { + // 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/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/src/logic/solidLogic.ts b/src/logic/solidLogic.ts index 5150d92..f6954d9 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,13 @@ 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 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) const authn: AuthnLogic = new SolidAuthnLogic(session) 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..2bb42a2 100644 --- a/src/util/utilityLogic.ts +++ b/src/util/utilityLogic.ts @@ -1,4 +1,5 @@ import { NamedNode, st, sym } from 'rdflib' +import { loadAuthorizedDocument } from '../authSession/flagAuthorizationOnTransitions' import { CrossOriginForbiddenError, FetchError, @@ -89,7 +90,15 @@ 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, 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) + } const result = store.any(subject, predicate, null, doc) if (result) return result as NamedNode @@ -123,7 +132,11 @@ export function createUtilityLogic(store, aclLogic, containerLogic) { doc: NamedNode, data: string ): Promise { - await store.fetcher.load(doc) + if (!(await loadAuthorizedDocument(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/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 new file mode 100644 index 0000000..55ce965 --- /dev/null +++ b/test/flagAuthorizationOnTransitions.test.ts @@ -0,0 +1,365 @@ +import { describe, expect, it, vi } from 'vitest' +import { SessionEvents } from '../src/authSession/events' +import { SESSION_TRANSITIONS, ensureDocumentAuthorization, flagAuthorizationOnSessionTransitions, loadAuthorizedDocument, refreshDocumentAuthorization } from '../src/authSession/flagAuthorizationOnTransitions' +import { silenceDebugMessages } from './helpers/debugger' + +silenceDebugMessages() + +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() + }) +}) + +describe('refreshDocumentAuthorization', () => { + it('force-refreshes the document before answering editability', async () => { + const order: string[] = [] + const store = { + fetcher: { + // 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 => { + 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('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('stays unknown when the store cannot refresh (no capability)', async () => { + const store = { updater: { editable: (): boolean => 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 () => { + const session = { events: new SessionEvents() } + let calls = 0 + let editableReads = 0 + const store: any = { + 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: { + flagAuthorizationMetadata: (): void => {}, + editable: (): string => { + editableReads += 1 + return 'N3PATCH' + } + } + } + flagAuthorizationOnSessionTransitions(store, session) + + 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 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 storeA: any = { + fetcher: { + refresh: (_doc: unknown, done?: () => void): void => { + calls += 1 + // 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(ensureDocumentAuthorization(store, 'https://a.example/')).resolves.toBe(true) + 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 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) + }) + + 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 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) + }) +}) + +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/logic.test.ts b/test/logic.test.ts index 14bad48..eec6e00 100644 --- a/test/logic.test.ts +++ b/test/logic.test.ts @@ -35,7 +35,28 @@ describe('solidLogicSingleton fetch bridge', () => { let originalFetch: any let originalAuthFetch: any - let originalInfo: 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 + // descriptor back afterwards. + const setInfo = (value: any): void => { + Object.defineProperty(authSession, 'info', { + configurable: true, + enumerable: true, + get: () => value + }) + } + + // 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() @@ -43,21 +64,25 @@ describe('solidLogicSingleton fetch bridge', () => { const sessionAny = authSession as any originalFetch = sessionAny.fetch originalAuthFetch = sessionAny.authFetch - originalInfo = sessionAny.info + originalInfoDescriptor = Object.getOwnPropertyDescriptor(authSession, 'info') + originalActiveDescriptor = Object.getOwnPropertyDescriptor(authSession, 'isActive') - 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) + 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 - sessionAny.info = { webId: 'https://alice.example/profile#me', isLoggedIn: true } + setInfo({ webId: 'https://alice.example/profile#me', isLoggedIn: true }) + setSessionActive(true) sessionAny.fetch = vi.fn().mockResolvedValue(new Response('session')) fetchMock.mockResponseOnce('window') @@ -70,7 +95,8 @@ 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 }) + setSessionActive(true) sessionAny.fetch = undefined sessionAny.authFetch = vi.fn().mockResolvedValue(new Response('auth')) @@ -79,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 new file mode 100644 index 0000000..8f4ff98 --- /dev/null +++ b/test/rdflibEditableFlagContract.test.ts @@ -0,0 +1,79 @@ +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}`) + +// 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') + }) + + 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: 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 }) + } + 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() + + // 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) + }) +}) diff --git a/test/solidAuthLogic.test.ts b/test/solidAuthLogic.test.ts index aed3c05..95ead9b 100644 --- a/test/solidAuthLogic.test.ts +++ b/test/solidAuthLogic.test.ts @@ -38,6 +38,194 @@ 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') + }) + + 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', () => { + 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') + }) + 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('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', 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')) + // 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(['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)', () => { + 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[] = [] + 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, false) + + // 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', true) + + 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 new file mode 100644 index 0000000..6af5269 --- /dev/null +++ b/test/transitions.test.ts @@ -0,0 +1,365 @@ +import { describe, expect, it, vi } from 'vitest' +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', () => { + 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() + }) +}) + +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) + }) + + 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) + }) + + 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', () => { + 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 { + isActive = false + webId: string | undefined + + async setTokenDetails (details: { webId?: string }): Promise { + this.webId = details.webId + } +} + +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() + 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', 'identityReplaced']) + }) + + 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', 'identityReplaced']) + }) + + 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', '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('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[] = [] + 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('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('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('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[] = [] + 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', 'identityReplaced']) + }) +})