diff --git a/package-lock.json b/package-lock.json index 88f2070..2d25212 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "solid-logic", - "version": "6.0.0-0", + "version": "6.0.0-2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "solid-logic", - "version": "6.0.0-0", + "version": "6.0.0-2", "license": "MIT", "dependencies": { "@uvdsl/solid-oidc-client-browser": "^0.2.3", @@ -21,7 +21,7 @@ "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.32.0", "jsdom": "^21.1.0", - "rdflib": "^2.4.0", + "rdflib": "^2.4.1", "solidos-toolkit": "dev", "ts-loader": "^9.6.1", "tslib": "^2.8.1", @@ -8087,9 +8087,9 @@ } }, "node_modules/rdflib": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/rdflib/-/rdflib-2.4.0.tgz", - "integrity": "sha512-DPBFlnkA7lWgskbgyPsRxHE5S/9Ni5KHNgwzrq8CucG+TBxEHTGRSeMKjWhZlZhBhmQFu0YQGjOYyrzmkX/gwg==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/rdflib/-/rdflib-2.4.1.tgz", + "integrity": "sha512-VU4bvNcbgxKTLAzj9sWcjpWjT9Yhbpkc456tGhwcn/hXU2UXf2aTCJ+nKPVnWNZYP09zGrd0sJUL6wwTvt6IZg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 2f638b7..84232c5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "solid-logic", - "version": "6.0.0-1", + "version": "6.0.0-2", "description": "Core business logic of SolidOS", "main": "dist/index.cjs.js", "module": "dist/index.esm.js", @@ -55,7 +55,7 @@ "eslint-plugin-import": "^2.32.0", "vitest": "^4.1.9", "jsdom": "^21.1.0", - "rdflib": "^2.4.0", + "rdflib": "^2.4.1", "ts-loader": "^9.6.1", "tslib": "^2.8.1", "typescript": "^5.9.3", diff --git a/src/authSession/authSession.ts b/src/authSession/authSession.ts index 901b0ec..e6dbb14 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 { legacySessionInfo, restoreSession, subscribeIdentity, type SessionLike } from './identityState' type SessionCompatibilityShape = { webId?: string @@ -93,22 +94,55 @@ 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 by the identity state, which watches +// the session itself: 'logout' when the session goes inactive, 'sessionChange' +// when the identity changes some other way — including a login/logout made in +// another tab, which uvdsl does not broadcast as a state change and which is +// noticed when this tab is refocused — and 'identityReplaced' when an +// established identity is gone. +// +// The resync maps a backing store that no longer holds a session (a cross-tab +// logout) to 'cleared'; the state bounds the wait and drops an answer that +// belongs to an identity the session has left. +const resyncSession = (): unknown => { + const restoring = restoreSession(_session as unknown as SessionLike) + if (!restoring) return undefined + return restoring.then(() => 'changed', (error: unknown) => { + // A transient refresh/network failure is compared as it stands; a store + // that has no session to restore means this tab's identity is gone. + const message = error instanceof Error ? error.message : String(error) + return /no session to restore/i.test(message) ? 'cleared' : 'changed' }) } +subscribeIdentity(_session as unknown as SessionLike, { + onEvent: (event) => events.emit(event), + resync: 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: consumers snapshot and restore +// `info`, and a retained 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 } { + 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..e2a6681 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 identity state in identityState.ts ('logout', 'sessionChange', + * 'identityReplaced' — the event the reload helper subscribes to). */ export class SessionEvents { private readonly listeners: Map> = new Map() @@ -32,4 +33,3 @@ export class SessionEvents { this.listeners.get(event)?.forEach(h => h(...args)) } } - diff --git a/src/authSession/flagAuthorizationOnTransitions.ts b/src/authSession/flagAuthorizationOnTransitions.ts new file mode 100644 index 0000000..1cbfa41 --- /dev/null +++ b/src/authSession/flagAuthorizationOnTransitions.ts @@ -0,0 +1,210 @@ +/** + * 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. It is wired HERE, once, subscribed to the session's + * identity state — so it covers every transition whichever path noticed it (a + * session event, a token update, a refocus resync, a cookie probe), rather + * than whichever events a call site remembered to listen for. + * + * The repair is then a plain `load()`: rdflib refetches a document whose + * recorded answers are ALL flagged (linkeddata/rdflib.js#871, in 2.4.1), which + * is exactly the state a transition leaves behind — and `checkEditable()` + * heals the same way. Nothing forces a fetch by hand any more. + * + * REQUIRES rdflib >= 2.4.1: on 2.4.0 `load()` answered such a document from + * the cache, so the decision points below could not re-establish an answer. + */ + +import * as debug from '../util/debug' +import { subscribeIdentity, type SessionLike } from './identityState' + +export type TransitionStore = { + updater?: { flagAuthorizationMetadata?: () => void } +} + +export type AuthorizationStore = { + fetcher?: { load?: (doc: unknown) => unknown } + updater?: { + editable?: (uri: unknown) => string | boolean | undefined + flagAuthorizationMetadata?: () => void + } +} + +/** + * Marks every recorded response out-of-date. + * + * @returns whether the store could be invalidated. A store without the API, or + * one that throws, cannot be trusted afterwards — its answers stay definitive + * for the previous identity until a later transition flags successfully. + */ +function invalidate (store: AuthorizationStore): boolean { + try { + const flag = store.updater?.flagAuthorizationMetadata + if (typeof flag !== 'function') { + throw new Error('flagAuthorizationMetadata is unavailable') + } + flag.call(store.updater) + return true + } catch (error) { + debug.warn(`Could not flag authorization metadata: ${String(error)}`) + return false + } +} + +/** + * Flags the store's authorization metadata on every identity transition. + * + * @returns the unsubscribe function — the caller owns the lifetime (it ends + * with the session's subscription). + */ +export function flagAuthorizationOnSessionTransitions ( + store: TransitionStore, + session: SessionLike +): () => void { + const onTransition = (): void => { + const state = storeState(store) + // Whose credentials a request would carry changed: every answer recorded + // under the previous identity is suspect from here on. + state.generation += 1 + state.invalidationFailed = !invalidate(store) + } + const subscription = subscribeIdentity(session, { onTransition }) + return () => subscription.unsubscribe() +} + +type StoreAuthorizationState = { + /** Identity transitions observed for this store. */ + generation: number + /** + * The store could not invalidate its metadata on the last transition, so a + * definitive answer is not evidence that it is current. + */ + invalidationFailed: boolean +} + +// Scoped per store: two `createSolidLogic` instances with different sessions +// must not overtake each other's repairs, and a failed invalidation in one +// store says nothing about another. +const storeStates = new WeakMap() +const sharedState: StoreAuthorizationState = { generation: 0, invalidationFailed: false } + +function storeState (store: unknown): StoreAuthorizationState { + if (store === null || typeof store !== 'object') return sharedState + let state = storeStates.get(store) + if (!state) { + state = { generation: 0, invalidationFailed: false } + storeStates.set(store, state) + } + return state +} + +/** How many times a repair is repeated when the identity keeps changing. */ +const REPAIR_ATTEMPTS = 3 + +/** + * Re-establishes `doc`'s answer under the current identity: mark every + * recorded response out-of-date — including one recorded AFTER the transition, + * which the transition's own flag could not have marked — and load, which + * refetches a fully flagged document. + * + * Each attempt is stamped with the store's transition generation and repeated + * when the identity changed under it: 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. If the + * identity keeps changing, the answer stays "unknown" — never stale. + * + * Returns `undefined` whenever the answer cannot be established: no load + * capability, an invalidation that failed (the recorded answers are still + * definitive for the previous identity), a failed load, or an identity that + * changed throughout every attempt. + */ +async function repairDocument ( + store: AuthorizationStore, + doc: unknown +): Promise { + const state = storeState(store) + const load = store.fetcher?.load + if (typeof load !== 'function') return undefined + for (let attempt = 0; attempt < REPAIR_ATTEMPTS; attempt++) { + const generation = state.generation + if (!invalidate(store)) return undefined + state.invalidationFailed = false + try { + await load.call(store.fetcher, doc) + } catch (error) { + debug.warn(`Could not reload ${String(doc)}: ${String(error)}`) + 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 invalidation + * 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: the caller must not consume cached triples + * from that document and must not offer a write on it. + */ +export async function ensureDocumentAuthorization ( + store: AuthorizationStore, + doc: unknown +): Promise { + const state = storeState(store) + if (!state.invalidationFailed && store.updater?.editable?.(doc) !== undefined) { + return true + } + return (await repairDocument(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 repaired 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: AuthorizationStore, + doc: unknown +): Promise { + const state = storeState(store) + const generation = state.generation + const load = store.fetcher?.load + if (typeof load !== 'function') { + // A plain load() would fail on a store with no fetcher: this call loads + // `doc`, so it must not answer "consumed" after skipping the load. + throw new Error('fetcher.load is unavailable') + } + await load.call(store.fetcher, doc) + if (generation !== state.generation) { + return (await repairDocument(store, doc)) !== undefined + } + return ensureDocumentAuthorization(store, doc) +} diff --git a/src/authSession/identityState.ts b/src/authSession/identityState.ts new file mode 100644 index 0000000..bd14864 --- /dev/null +++ b/src/authSession/identityState.ts @@ -0,0 +1,620 @@ +/** + * One identity state per session. + * + * The session's identity is assembled from two sources — the OIDC session + * itself and (on NSS localhost setups) a cookie-backed fallback — and is + * observed by consumers that must invalidate what the previous identity + * fetched. Doing that with independent flags and predicates per call site let + * each new async path (a slow restore, a probe answering late, a refocus) + * reintroduce the same class of bug, so the identity lives here, once: + * + * TEMPORARY, pending the uvdsl change: two mechanisms here exist only because + * `@uvdsl/solid-oidc-client-browser` announces `sessionStateChange` when + * `isActive` changes and nothing else — it does not report a WebID change that + * keeps the session active, and it does not push a login/logout made in + * another tab. Until it does (issue drafted), the cookie fallback is the only + * way to see an NSS cookie identity change at all (see + * `reportCookieIdentity`) and `watchTokenUpdates()` is the only way to see an + * A -> B switch. Both are isolated here so they become a small deletion, not + * a hunt, when uvdsl reports identity changes itself. + * + * - `sessionIsActive()` is the single activity rule (`isActive` is + * authoritative, an explicit `false` wins over a cached WebID); + * - every observation (session event, token update, refocus resync, cookie + * probe result) is applied to ONE record, so a change is detected and + * reported once, whichever path noticed it; + * - the record carries a `version` (bumped when a transition is applied) and + * the newest refresh attempt, so an answer that belongs to an identity the + * session has since left — a restore started under Alice answering after + * Bob logged in — is dropped instead of being applied; + * - the transition is derived from the previous/next snapshots + * (`classifyTransition`, `identityReplaced`), not from flags: there is + * nothing to keep in sync, and an unchanged identity costs no event. + * + * Reported events (same vocabulary as before): + * 'logout' — the session went from active to inactive; + * 'sessionChange' — any other change of active state or WebID; + * 'identityReplaced' — an identity that WAS actively established is gone or + * replaced, so data fetched under it must be dropped. + * Start-up (none -> A) and a token refresh for the same identity are not + * replacements. + * + * A session whose backing store no longer holds it (a cross-tab logout) is + * marked `cleared` here: the local session object keeps reporting the previous + * identity, so the mark is what makes the derived reads report logged out + * until the session reports an identity again. + * + * DO NOT keep identity state anywhere else: `SolidAuthnLogic` reads it, + * `authSession` publishes it (`info`, events) and `solidLogicSingleton` + * decides from it whose credentials a request would carry. + */ + +export type SessionLike = { + isActive?: boolean + webId?: string + info?: { isLoggedIn?: boolean, webId?: string } + addEventListener?: (type: string, listener: () => void) => void + setTokenDetails?: (...args: unknown[]) => unknown + restore?: () => Promise +} + +export type IdentityEvent = 'logout' | 'sessionChange' | 'identityReplaced' + +/** The session's own view: what the raw session reports, cleared-aware. */ +export type IdentitySnapshot = { isActive: boolean, webId?: string } + +export type DocumentLike = { + visibilityState?: string + addEventListener?: (type: string, listener: () => void) => void + removeEventListener?: (type: string, listener: () => void) => void +} + +/** + * Which legacy event a session 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 classifyTransition ( + prev: IdentitySnapshot, + next: IdentitySnapshot +): '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. + * + * Only the transition OUT of an ACTIVELY established identity counts: 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. + */ +export function identityReplaced (prev: IdentitySnapshot, next: IdentitySnapshot): boolean { + if (prev.webId === undefined) return false + if (!prev.isActive) return false + return next.webId !== prev.webId || !next.isActive +} + +/** + * 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. + */ +export const sessionIsActive = (session: SessionLike): boolean => + session.isActive === true || (session.isActive === undefined && Boolean(session.webId)) + +/** + * How long a refocus resync may delay the snapshot comparison. + */ +const RESYNC_TIMEOUT_MS = 2000 + +type Subscriber = { + onEvent?: (event: IdentityEvent) => void + /** The whole transition, once — for consumers that only invalidate. */ + onTransition?: (events: IdentityEvent[]) => void + onRefocus?: () => void | Promise + resync?: () => unknown +} + +type Attempt = { + id: number + /** The record version the attempt started from. */ + version: number + /** The raw identity the attempt started from. */ + raw: { isActive: boolean, webId?: string } + promise: Promise +} + +type Record = { + session: SessionLike + /** The last applied view of the session, cleared-aware. */ + raw: IdentitySnapshot + /** The backing store no longer holds the session (a cross-tab logout). */ + cleared: boolean + /** The cookie-probed identity, when the session does not own one. */ + cookieWebId: string | null + /** Bumped whenever a session transition is applied. */ + version: number + /** The newest resync attempt, or undefined when none is running. */ + attempt?: Attempt + attemptId: number + subscribers: Set + /** Whether the session state listener is attached (once per session). */ + listenerAttached: boolean + /** Detaches the document listener when the last subscriber leaves. */ + detachDocument?: () => void + /** Wrapped once per session, see watchTokenUpdates(). */ + watchingTokenUpdates: boolean +} + +const records = new WeakMap() + +// One restore at a time per session: `restore()` can mutate the session before +// it resolves, so two overlapping restores could write an older identity back +// over a newer one. Every caller (the refocus resync and `checkUser()`) goes +// through this lock, and one that arrives while a restore is in flight joins +// it instead of starting another. +const restoresInFlight = new WeakMap>() + +const recordOf = (session: SessionLike): Record | undefined => { + if (typeof session !== 'object' || session === null) return undefined + let record = records.get(session as object) + if (!record) { + record = { + session, + raw: { isActive: false }, + cleared: false, + cookieWebId: null, + version: 0, + attemptId: 0, + subscribers: new Set(), + listenerAttached: false, + watchingTokenUpdates: false + } + record.raw = snapshotOf(record) + records.set(session as object, record) + } + return record +} + +/** + * Whether a session was reported cleared: its backing store lost the session + * while this tab still had one, so its identity no longer holds here. + */ +export function sessionWasCleared (session: unknown): boolean { + if (typeof session !== 'object' || session === null) return false + return records.get(session as object)?.cleared === true +} + +/** + * 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: SessionLike): boolean { + if (sessionWasCleared(session)) return true + return session?.isActive === false || session?.info?.isLoggedIn === false +} + +/** + * Whether the OIDC session currently owns the identity. A session that was + * reported cleared does not, and neither does one that explicitly reports + * itself logged out (a legacy shape that still carries a WebID would otherwise + * pass `sessionIsActive()` alone). + */ +export function sessionOwnsIdentity (session: SessionLike): boolean { + return !sessionExplicitlyInactive(session) && sessionIsActive(session) +} + +/** The WebID the raw session publishes, or undefined when it owns none. */ +export function sessionIdentityWebId (session: SessionLike): string | undefined { + return sessionOwnsIdentity(session) ? session.webId : undefined +} + +const snapshotOf = (record: Record): IdentitySnapshot => { + // A cleared session answers as logged out until it reports an identity + // again: the local session object may still carry the previous WebID. + if (record.cleared) return { isActive: false } + return { isActive: sessionIsActive(record.session), webId: record.session.webId } +} + +/** + * The identity a caller should act as: the session's own when it owns one, the + * cookie-probed one when the session is inactive (or cleared) and a probe + * established it, and undefined when neither does. This is the last word for + * `currentUser()`, `info` consumers and the fetch bridge. + */ +export function effectiveIdentity (session: SessionLike): { webId?: string, source: 'none' | 'session' | 'cookie' } { + const record = recordOf(session) + if (!record) return { source: 'none' } + if (sessionOwnsIdentity(session)) { + return session.webId === undefined ? { source: 'none' } : { webId: session.webId, source: 'session' } + } + if (record.cookieWebId !== null) return { webId: record.cookieWebId, source: 'cookie' } + // A session that only reports a logout keeps no identity — the retained + // WebID must not be used (see sessionExplicitlyInactive). + return { source: 'none' } +} + +/** + * The legacy `info` shape is session-derived and stays derived: callers + * snapshot and restore it, so a retained value must never answer for the + * session. `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 } { + if (sessionWasCleared(session)) return { webId: undefined, isLoggedIn: false } + return { webId: session.webId, isLoggedIn: sessionIsActive(session) } +} + +/** + * Runs `session.restore()`, sharing an attempt that is already in flight. + * + * @returns the shared promise, or undefined when the session has no restore. + */ +export function restoreSession (session: SessionLike | undefined): Promise | undefined { + const restore = session?.restore + if (typeof restore !== 'function' || typeof session !== 'object' || session === null) { + return undefined + } + const key = session as object + const inFlight = restoresInFlight.get(key) + if (inFlight) return inFlight + const started = Promise.resolve() + .then(() => restore.call(session)) + .finally(() => { restoresInFlight.delete(key) }) + restoresInFlight.set(key, started) + return started +} + +/** + * Reports one applied transition. `onEvent` receives each event separately + * (the legacy vocabulary); `onTransition` receives the whole transition once, + * so a consumer that only invalidates does not do its work twice when a + * transition carries two events (a logout that also replaces the identity). + * + * Every subscriber's `onTransition` runs BEFORE any subscriber's `onEvent`: + * a consumer that hears the event can rely on the transition having been + * applied everywhere else (the store is already invalidated when the legacy + * listeners run), instead of depending on the order subscriptions happened to + * be created in. + */ +const deliver = (record: Record, events: IdentityEvent[]): void => { + record.subscribers.forEach(subscriber => subscriber.onTransition?.(events)) + record.subscribers.forEach(subscriber => { + events.forEach(event => subscriber.onEvent?.(event)) + }) +} + +/** + * Re-reads the session and reports what changed since the last observation. + * The cleared mark is dropped first when the session reports an identity + * again, otherwise it would mask the new identity forever. + */ +function note (record: Record): void { + if (record.cleared && sessionIsActive(record.session)) { + record.cleared = false + } + // The session owns the identity again: a cookie identity remembered from + // before it activated is stale — the probe only runs while the session is + // inactive, and after a session logout the remembered cookie value must not + // answer as if it had just been probed. Silent on purpose: the session + // transition is what is reported, not this book-keeping. + if (sessionOwnsIdentity(record.session)) record.cookieWebId = null + const next = snapshotOf(record) + const event = classifyTransition(record.raw, next) + const replaced = identityReplaced(record.raw, next) + const moved = event !== null || replaced + record.raw = next + if (!moved) return + // The session moved on: an attempt that started before this transition is + // answering about a state that no longer holds. + record.version += 1 + const events: IdentityEvent[] = [] + if (event) events.push(event) + if (replaced) events.push('identityReplaced') + deliver(record, events) +} + +/** + * 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, + * once — a later refocus that still finds no session must not repeat it. + */ +function reportCleared (record: Record): void { + if (record.cleared) return + const wasActive = record.raw.isActive + const wasEstablished = record.raw.webId !== undefined + // Only a session that was actually in use has to be invalidated: an + // anonymous tab is cleared already, and marking it would make snapshotOf() + // report the cleared state — masking a later login until some other + // observation happens to run. + if (!wasActive || !wasEstablished) { + record.raw = snapshotOf(record) + if (wasActive) { + record.version += 1 + deliver(record, ['logout']) + } + return + } + // The local session object still reports the old identity: the mark is what + // makes the derived reads stop answering for it, and the cleared snapshot is + // the baseline — a later activation is a new login, and a still-cleared + // session cannot report the logout twice. + record.cleared = true + record.raw = snapshotOf(record) + record.version += 1 + deliver(record, ['logout', 'identityReplaced']) +} + +/** + * Starts (or joins) the newest resync attempt. A caller that joins an attempt + * in flight must judge its answer against the identity the ATTEMPT started + * from, not the one it sees now — hence the baseline is stored with the + * attempt. + */ +function startAttempt (record: Record, action: () => unknown): Attempt { + if (record.attempt) return record.attempt + const attempt: Attempt = { + id: ++record.attemptId, + version: record.version, + raw: { isActive: sessionIsActive(record.session), webId: record.session.webId }, + promise: Promise.resolve() + .then(action) + .finally(() => { if (record.attempt === attempt) record.attempt = undefined }) + } + record.attempt = attempt + return attempt +} + +/** + * Re-reads the session through `resync` (when given) and reports the + * transition, bounded by RESYNC_TIMEOUT_MS so a hung restore cannot stall a + * refocus — but keeping the outcome: 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 event. + */ +async function resyncThenNote (record: Record): Promise { + const resync = resyncActionOf(record) + if (typeof resync !== 'function') { + note(record) + return + } + const runResync = resync + const started = startAttempt(record, () => runResync()) + // This attempt's outcome only applies while it is still the newest one and no + // transition was applied since the attempt (not this caller) started. + const stale = (): boolean => started.id !== record.attemptId || record.version !== started.version + // A `'cleared'` answer only applies while the session still reports the + // identity the attempt started from: `restore()` rejects with "no session" + // for the identity it was started for, so if the session reports a different + // identity now, the answer is about the previous one and must not log the new + // one out. + const sameRawIdentity = (): boolean => + sessionIsActive(record.session) === started.raw.isActive && record.session.webId === started.raw.webId + let outcome: unknown + let done = false + const attempt = started.promise.then( + (value) => { outcome = value; done = true }, + () => { done = true } // compared as it stands + ) + const apply = (): void => { + if (stale()) return + if (outcome === 'cleared') { + if (sameRawIdentity()) reportCleared(record) + return + } + // Any other result may have updated the session (a cross-tab login): + // comparing again is what turns that into `sessionChange` / + // `identityReplaced` instead of leaving this tab on the old identity. + note(record) + } + let timer: ReturnType | undefined + const timeout = new Promise((resolve) => { timer = setTimeout(resolve, RESYNC_TIMEOUT_MS) }) + try { + await Promise.race([attempt, timeout]) + } finally { + if (timer !== undefined) clearTimeout(timer) + } + if (done) { + apply() + return + } + note(record) + void attempt.then(apply) +} + +/** + * The resync action lives with the subscriber that knows how to run it + * (`authSession` maps a restore rejection to `'cleared'`); the module keeps + * the newest one so a refocus started from any subscriber re-reads the + * session. + */ +function resyncActionOf (record: Record): (() => unknown) | undefined { + // Subscribers iterate in insertion order, so the last match is the newest. + let newest: (() => unknown) | undefined + for (const subscriber of record.subscribers) { + if (subscriber.resync) newest = subscriber.resync + } + return newest +} + +// uvdsl's session announces only changes of `isActive`; a WebID can change +// while both states stay active and would go unseen. Every token update goes +// through `setTokenDetails`, so compare the identity around it. Wrapped once +// per session. +// +// TEMPORARY, pending the uvdsl change: delete this wrapper when the library +// dispatches `sessionStateChange` for an active -> active WebID change too +// (see the module header). The state model does not depend on it: any +// observation path reports the same transition. +function watchTokenUpdates (record: Record): void { + const session = record.session + const original = session.setTokenDetails + if (typeof original !== 'function' || record.watchingTokenUpdates) return + record.watchingTokenUpdates = true + session.setTokenDetails = (...args: unknown[]): unknown => { + const before = snapshotOf(record) + const changed = (): boolean => { + const after = snapshotOf(record) + return after.webId !== before.webId || after.isActive !== before.isActive + } + // Whatever the outcome — a token update can apply the new identity and + // then fail (a failed persistence, for one) — the identity around the call + // is what matters, so the change is reported and the failure passes on. + let result: unknown + try { + result = original.apply(session, args) + } catch (error) { + if (changed()) note(record) + throw error + } + if (result && typeof (result as Promise).then === 'function') { + return (result as Promise).then( + (value: unknown) => { + if (changed()) note(record) + return value + }, + (error: unknown) => { + if (changed()) note(record) + throw error + } + ) + } + if (changed()) note(record) + return result + } +} + +export type IdentitySubscription = { + /** + * Applies a cookie-probe result. Ignored once the subscription is released, + * so a probe that answers after its owner was replaced cannot resurrect an + * identity — and while the session owns the identity it must not be replaced + * by a cookie one at all. + */ + reportCookieIdentity: (webId: string | null) => void + unsubscribe: () => void +} + +/** + * Observes the session for identity transitions, and for refocuses (one + * document listener per session, removed when the last subscription is + * released, so a replaced logic instance adds no second listener). + * + * `onEvent` receives the derived events; `onRefocus` runs on a refocus (the + * cookie revalidation, in SolidAuthnLogic); `resync` re-reads the session + * before the comparison (authSession owns it, because it knows that a restore + * rejection means "no session"). + */ +export function subscribeIdentity ( + session: SessionLike, + options: Subscriber = {} +): IdentitySubscription { + const record = recordOf(session) + if (!record) { + return { reportCookieIdentity: () => undefined, unsubscribe: () => undefined } + } + const subscriber: Subscriber = { ...options } + record.subscribers.add(subscriber) + if (!record.listenerAttached) { + record.listenerAttached = true + if (typeof session.addEventListener === 'function') { + session.addEventListener('sessionStateChange', () => note(record)) + } + watchTokenUpdates(record) + } + const doc: DocumentLike | undefined = typeof document === 'undefined' ? undefined : document + if (record.subscribers.size === 1 && doc && typeof doc.addEventListener === 'function') { + const handler = (): void => { + if (doc.visibilityState !== 'visible') return + // Wake up the session first; the cookie identity is invisible to it and + // is revalidated in parallel by the subscriber that owns the probe. + void resyncThenNote(record) + record.subscribers.forEach(sub => { void sub.onRefocus?.() }) + } + doc.addEventListener('visibilitychange', handler) + record.detachDocument = () => doc.removeEventListener?.('visibilitychange', handler) + } + let released = false + return { + reportCookieIdentity: (webId: string | null): void => { + if (released) return + applyCookieIdentity(record, webId) + }, + unsubscribe: (): void => { + if (released) return + released = true + record.subscribers.delete(subscriber) + if (record.subscribers.size === 0) { + record.detachDocument?.() + record.detachDocument = undefined + } + } + } +} + +/** + * Applies a cookie-probe result to the record. Only cookie-backed changes are + * reported: an OIDC identity change is already emitted by `note()`, and + * reporting it again would duplicate the events — a reload consumer would + * reload twice. While the session owns the identity a cookie one must not + * replace it, and `sessionChange` is emitted only when it does not (an active + * session already reported its own transition). + */ +function applyCookieIdentity (record: Record, webId: string | null): void { + if (record.cookieWebId === webId) return + // While the session owns the identity a cookie one must not replace it — a + // probe only runs while the session is inactive for exactly that reason. + if (sessionOwnsIdentity(record.session)) return + const previousCookieBacked = record.cookieWebId !== null + record.cookieWebId = webId + // The replacement is owed whenever the identity being REPLACED was + // cookie-backed — the session watcher could not see it. Adopting one is not + // a replacement: there was nothing of a previous user to drop. + const events: IdentityEvent[] = ['sessionChange'] + if (previousCookieBacked) events.push('identityReplaced') + deliver(record, events) +} + +/** + * 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) +} + +/** + * Re-reads the session and reports the transition, without a resync action + * (a plain observation, used by tests and by callers that already know the + * session was refreshed). + */ +export function observeSession (session: SessionLike): void { + const record = recordOf(session) + if (record) note(record) +} diff --git a/src/authn/SolidAuthnLogic.ts b/src/authn/SolidAuthnLogic.ts index 79ceb08..621d833 100644 --- a/src/authn/SolidAuthnLogic.ts +++ b/src/authn/SolidAuthnLogic.ts @@ -1,6 +1,15 @@ import { namedNode, NamedNode, sym } from 'rdflib' import { appContext, offlineTestID } from './authUtil' import * as debug from '../util/debug' +import { + effectiveIdentity, + restoreSession, + sessionOwnsIdentity, + sessionWasCleared, + subscribeIdentity, + type IdentitySubscription, + type SessionLike +} from '../authSession/identityState' import type { SessionWithLegacyEvents } from '../authSession/authSession' import type { AuthenticationContext, AuthnLogic } from '../types' @@ -31,33 +40,63 @@ export class SolidAuthnLogic implements AuthnLogic { private session: SessionWithLegacyEvents private checkUserInFlight: Promise | null = null private sessionRestoreHookAttached = false - private fallbackWebId: string | null = null + /** + * This instance's subscription to the session's identity state. Its lifetime + * IS this instance's: a cookie probe that answers after `dispose()` is + * ignored, and the refocus listener is removed with the last instance using + * the session (see identityState.ts). + */ + private identity: IdentitySubscription - constructor(solidAuthSession: SessionWithLegacyEvents) { + constructor (solidAuthSession: SessionWithLegacyEvents) { this.session = solidAuthSession + // The cookie-backed identity is invisible to the session (it stays inactive + // and WebID-less), so it is re-probed 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). + // + // TEMPORARY, pending the uvdsl change: this subscription exists because the + // library does not report a cookie identity change (nor a WebID change that + // keeps the session active). See the identityState header. + this.identity = subscribeIdentity(solidAuthSession as unknown as SessionLike, { + onRefocus: () => this.refreshCookieBackedFallback() + }) + } + + /** + * Detaches this instance from the session's identity state: the refocus + * listener is removed with the last instance using the session, and a probe + * that is still in flight can no longer apply its result. + */ + dispose (): void { + this.identity.unsubscribe() + } + + /** + * Re-probes the NSS cookie-backed identity. Skipped while the session owns + * the identity — the probe only exists for the case where it does not — and + * the state drops the result if the session takes ownership while the probe + * is in flight. + */ + private async refreshCookieBackedFallback (): Promise { + if (sessionOwnsIdentity(this.session as unknown as SessionLike)) return + this.identity.reportCookieIdentity(await this.probeNssCookieBackedWebId()) } // we created authSession getter because we want to access it as authn.authSession externally - get authSession(): SessionWithLegacyEvents { return this.session } + get authSession (): SessionWithLegacyEvents { return this.session } - currentUser(): NamedNode | null { + currentUser (): NamedNode | null { const app = appContext() if (app.viewingNoAuthPage) { return sym(app.webId) } - const sessionAny = this.session as any - const infoWebId = sessionAny?.info?.webId - const sessionWebId = sessionAny?.webId - const webId = infoWebId || sessionWebId || this.fallbackWebId - const infoLoggedIn = sessionAny?.info?.isLoggedIn - const sessionActive = sessionAny?.isActive - const isLoggedIn = infoLoggedIn === true || sessionActive === true || - ((infoLoggedIn == null && sessionActive == null) ? Boolean(webId) : false) || - Boolean(this.fallbackWebId) - if (this && this.session && webId && isLoggedIn) { - return sym(webId) - } - return offlineTestID() // null unless testing + // The state answers with the session's identity when it owns one, the + // probed cookie identity when the session is inactive (or cleared), and + // nothing when neither does — a logout that retains the cached WebID must + // not keep answering for the previous user. + const { webId } = effectiveIdentity(this.session as unknown as SessionLike) + return webId ? sym(webId) : offlineTestID() // null unless testing } /** @@ -126,9 +165,13 @@ export class SolidAuthnLogic implements AuthnLogic { // UI would spin forever. Race it against a timeout and treat a stall // as "no previous session" so the page can render the login button. const wasActive = sessionAny?.isActive ?? Boolean(sessionAny?.webId) - if (typeof sessionAny?.restore === 'function') { + // The shared restore lock also covers this call: a refocus resync can be + // in flight at the same time, and two overlapping restores could write an + // older identity back over a newer one. + const restoring = restoreSession(sessionAny) + if (restoring) { try { - await withRestoreTimeout(sessionAny.restore()) + await withRestoreTimeout(restoring) } catch (error) { const message = error instanceof Error ? error.message : String(error) // A failed restore on an inactive session just means "no usable @@ -186,14 +229,13 @@ export class SolidAuthnLogic implements AuthnLogic { let webId = this.webIdFromSession(sessionAny?.info, sessionAny) if (!webId) { - // NSS-specific fallback: recover WebID from NSS cookie session when client restore is empty. - webId = await this.probeNssCookieBackedWebId() - } - - if (webId) { - this.fallbackWebId = webId - } else { - this.fallbackWebId = null + // NSS-specific fallback: recover the WebID from the NSS cookie session + // when the client restore is empty. The result goes through the identity + // state, which drops it if the session took ownership while the probe was + // in flight (or if this instance was disposed meanwhile) — and reports the + // change like any other transition. + this.identity.reportCookieIdentity(await this.probeNssCookieBackedWebId()) + webId = effectiveIdentity(sessionAny).webId ?? null } if (webId) { @@ -278,13 +320,18 @@ 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 the identity + // state uses (see identityState.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. A + // session whose backing store lost it is inactive as well, however + // positive its own fields still look. + if (sessionWasCleared(sessionRoot) || + infoLoggedIn === false || rootLoggedIn === false || rootActive === false) { return null } + // Active, or a legacy session that reports no state at all. return webId } - } diff --git a/src/index.ts b/src/index.ts index 5cbb29c..c38a985 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/identityState' 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..fd45df5 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,8 +26,22 @@ 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() (see flagAuthorizationOnTransitions.ts). + const unsubscribeAuthorization = flagAuthorizationOnSessionTransitions(store, session) const authn: AuthnLogic = new SolidAuthnLogic(session) + + // The subscription is released with the auth logic: both belong to this + // instance's lifetime, so replacing a SolidLogic instance does not leave + // the old store subscribed to the session. + const disposeAuthn = authn.dispose?.bind(authn) + authn.dispose = (): void => { + unsubscribeAuthorization() + disposeAuthn?.() + } const acl = createAclLogic(store) const containerLogic = createContainerLogic(store) diff --git a/src/logic/solidLogicSingleton.ts b/src/logic/solidLogicSingleton.ts index 8320b6f..41620c4 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/identityState' 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/types.ts b/src/types.ts index 58ebd61..8101761 100644 --- a/src/types.ts +++ b/src/types.ts @@ -26,6 +26,12 @@ export interface AuthnLogic { checkUser: (setUserCallback?: (me: NamedNode | null) => T) => Promise saveUser: (webId: NamedNode | string | null, context?: AuthenticationContext) => NamedNode | null + /** + * Releases what the implementation registered elsewhere (document and + * session listeners). Optional so other implementations stay valid, but a + * caller that replaces a logic instance should dispose the old one. + */ + dispose?: () => void } export interface SolidNamespace { diff --git a/src/util/utilityLogic.ts b/src/util/utilityLogic.ts index f5b7e87..91e60ce 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) + // A response begun before an identity transition can be recorded after it, + // and on rdflib 2.4.0 a plain load does not refetch a flagged document: 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,15 @@ export function createUtilityLogic(store, aclLogic, containerLogic) { doc: NamedNode, data: string ): Promise { - await store.fetcher.load(doc) + // A response begun before an identity transition can be recorded after it, + // and on rdflib 2.4.0 a plain load does not refetch a flagged document: 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 = `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 new file mode 100644 index 0000000..c81626e --- /dev/null +++ b/test/flagAuthorizationOnTransitions.test.ts @@ -0,0 +1,262 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + ensureDocumentAuthorization, + flagAuthorizationOnSessionTransitions, + loadAuthorizedDocument +} from '../src/authSession/flagAuthorizationOnTransitions' +import { silenceDebugMessages } from './helpers/debugger' + +silenceDebugMessages() + +type Listener = () => void + +function fakeSession (init: { isActive?: boolean, webId?: string } = {}): any { + const listeners = new Map>() + const session: any = { + isActive: init.isActive, + webId: init.webId, + addEventListener (type: string, listener: Listener) { + if (!listeners.has(type)) listeners.set(type, new Set()) + listeners.get(type)!.add(listener) + }, + fire (type: string): void { listeners.get(type)?.forEach(listener => listener()) } + } + return session +} + +/** + * A store that models the rdflib contract this module relies on: + * `flagAuthorizationMetadata()` makes every recorded answer unusable + * (`editable()` → undefined), and a `load()` of a fully flagged document + * records a fresh answer (linkeddata/rdflib.js#871). + */ +function fakeStore (options: { + flagFails?: boolean + answer?: string | boolean | undefined + freshAnswer?: string | boolean | undefined + load?: (doc: unknown) => unknown +} = {}): any { + let flagged = false + let answer = options.answer ?? 'SPARQL' + const freshAnswer = options.freshAnswer ?? 'SPARQL' + return { + updater: { + flagAuthorizationMetadata: vi.fn(() => { + if (options.flagFails) throw new Error('no metadata support') + flagged = true + }), + editable: vi.fn(() => flagged ? undefined : answer) + }, + fetcher: { + load: vi.fn(async (doc: unknown) => { + await options.load?.(doc) + if (flagged) { + flagged = false + answer = freshAnswer + } + }) + } + } +} + +const doc = 'https://example.org/doc' + +let unsubscribes: Array<() => void> = [] + +const connect = (store: any, session: any): void => { + unsubscribes.push(flagAuthorizationOnSessionTransitions(store, session)) +} + +afterEach(() => { + unsubscribes.forEach(unsubscribe => unsubscribe()) + unsubscribes = [] +}) + +describe('flagAuthorizationOnSessionTransitions', () => { + it('flags the store when the identity changes, and only then', () => { + const session = fakeSession({ isActive: false }) + const store = fakeStore() + connect(store, session) + + expect(store.updater.flagAuthorizationMetadata).not.toHaveBeenCalled() + + // A refocus or event with no change costs nothing. + session.fire('sessionStateChange') + expect(store.updater.flagAuthorizationMetadata).not.toHaveBeenCalled() + + session.isActive = true + session.webId = 'https://alice.example/me' + session.fire('sessionStateChange') + expect(store.updater.flagAuthorizationMetadata).toHaveBeenCalledTimes(1) + }) + + it('covers logout and an identity change while active', () => { + const session = fakeSession({ isActive: true, webId: 'https://alice.example/me' }) + const store = fakeStore() + connect(store, session) + + session.webId = 'https://bob.example/me' + session.fire('sessionStateChange') + session.isActive = false + session.fire('sessionStateChange') + + expect(store.updater.flagAuthorizationMetadata).toHaveBeenCalledTimes(2) + // Every recorded answer is unknown from here on. + expect(store.updater.editable(doc)).toBeUndefined() + }) + + it('stops flagging once the subscription is released', () => { + const session = fakeSession({ isActive: false }) + const store = fakeStore() + connect(store, session) + unsubscribes.pop()!() // release the one this test created + + session.isActive = true + session.webId = 'https://alice.example/me' + session.fire('sessionStateChange') + + expect(store.updater.flagAuthorizationMetadata).not.toHaveBeenCalled() + }) +}) + +describe('ensureDocumentAuthorization', () => { + it('trusts a definitive answer that was not invalidated, without loading', async () => { + const store = fakeStore({ answer: false }) + await expect(ensureDocumentAuthorization(store, doc)).resolves.toBe(true) + expect(store.fetcher.load).not.toHaveBeenCalled() + }) + + it('repairs a flagged answer through a load', async () => { + const session = fakeSession({ isActive: false }) + const store = fakeStore() + connect(store, session) + + session.isActive = true + session.webId = 'https://alice.example/me' + session.fire('sessionStateChange') + expect(store.updater.editable(doc)).toBeUndefined() + + await expect(ensureDocumentAuthorization(store, doc)).resolves.toBe(true) + expect(store.fetcher.load).toHaveBeenCalledTimes(1) + // The fresh answer is what the caller reads afterwards. + expect(store.updater.editable(doc)).toBe('SPARQL') + }) + + it('repairs a store whose invalidation FAILED instead of trusting it', async () => { + const session = fakeSession({ isActive: false }) + const store = fakeStore({ flagFails: true }) + connect(store, session) + + session.isActive = true + session.webId = 'https://alice.example/me' + session.fire('sessionStateChange') + expect(store.updater.editable(doc)).toBe('SPARQL') // still the old identity's answer + + // Cannot invalidate and cannot repair — the caller must not trust it. + await expect(ensureDocumentAuthorization(store, doc)).resolves.toBe(false) + }) + + it('stays unknown when the load fails', async () => { + const session = fakeSession({ isActive: false }) + const store = fakeStore({ load: () => { throw new Error('network down') } }) + connect(store, session) + + session.isActive = true + session.webId = 'https://alice.example/me' + session.fire('sessionStateChange') + + await expect(ensureDocumentAuthorization(store, doc)).resolves.toBe(false) + }) + + it('gives up as unknown when the identity keeps changing under it', async () => { + const session = fakeSession({ isActive: false }) + const store = fakeStore({ + load: () => { + // The identity moves on while the response is on its way. + session.webId = session.webId === 'https://alice.example/me' + ? 'https://bob.example/me' + : 'https://alice.example/me' + session.fire('sessionStateChange') + } + }) + connect(store, session) + + // A first login flags the recorded answer; every repair is overtaken. + session.isActive = true + session.webId = 'https://alice.example/me' + session.fire('sessionStateChange') + + // Never a stale answer: the module answers unknown, whatever the last + // overtaken response left behind. + await expect(ensureDocumentAuthorization(store, doc)).resolves.toBe(false) + expect(store.fetcher.load).toHaveBeenCalledTimes(3) + }) + + it('has no answer when the store cannot load at all', async () => { + const session = fakeSession({ isActive: false }) + // No fetcher, and flagging fails: the recorded answer stays definitive for + // the previous identity and there is nothing to reload it with. + const store: any = { + updater: { + editable: vi.fn(() => 'SPARQL'), + flagAuthorizationMetadata: vi.fn(() => { throw new Error('cannot invalidate') }) + } + } + connect(store, session) + + session.isActive = true + session.webId = 'https://alice.example/me' + session.fire('sessionStateChange') + + // The caller must not trust it, and must not act on a stale answer either. + await expect(ensureDocumentAuthorization(store, doc)).resolves.toBe(false) + }) +}) + +describe('loadAuthorizedDocument', () => { + it('consumes a load that was not overtaken by a transition', async () => { + const store = fakeStore() + await expect(loadAuthorizedDocument(store, doc)).resolves.toBe(true) + expect(store.fetcher.load).toHaveBeenCalledTimes(1) + }) + + it('refuses to report success when the store cannot load at all', async () => { + // No fetcher to load with: the call fails like a plain load() would, + // instead of answering "consumed" for a document it never loaded. + const store: any = { updater: { editable: vi.fn(() => 'SPARQL') } } + await expect(loadAuthorizedDocument(store, doc)).rejects.toThrow('fetcher.load is unavailable') + }) + + it('repairs a load that a transition overtook', async () => { + const session = fakeSession({ isActive: false }) + const store = fakeStore({ + load: () => { + // A login lands while the response is in flight: the flag only marks + // responses that already existed, so this one would look definitive. + session.isActive = true + session.webId = 'https://alice.example/me' + session.fire('sessionStateChange') + } + }) + connect(store, session) + + await expect(loadAuthorizedDocument(store, doc)).resolves.toBe(true) + expect(store.fetcher.load).toHaveBeenCalledTimes(2) + expect(store.updater.editable(doc)).toBe('SPARQL') + }) + + it('reports false when the repair cannot be established', async () => { + const session = fakeSession({ isActive: false }) + const store = fakeStore({ + flagFails: true, + load: () => { + session.isActive = true + session.webId = 'https://alice.example/me' + session.fire('sessionStateChange') + } + }) + connect(store, session) + + await expect(loadAuthorizedDocument(store, doc)).resolves.toBe(false) + }) +}) diff --git a/test/identityState.test.ts b/test/identityState.test.ts new file mode 100644 index 0000000..10a05b1 --- /dev/null +++ b/test/identityState.test.ts @@ -0,0 +1,375 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + classifyTransition, + effectiveIdentity, + identityReplaced, + legacySessionInfo, + sessionExplicitlyInactive, + sessionIsActive, + sessionOwnsIdentity, + subscribeIdentity, + type IdentityEvent +} from '../src/authSession/identityState' + +type Listener = () => void + +/** + * A fake uvdsl-style session: `isActive`/`webId` properties, a + * `sessionStateChange` listener the test can fire, and an optional restore. + */ +function fakeSession (init: { + isActive?: boolean + webId?: string + info?: { isLoggedIn?: boolean, webId?: string } + restore?: () => Promise +} = {}): any { + const listeners = new Map>() + const session: any = { + isActive: init.isActive, + webId: init.webId, + info: init.info ?? {}, + addEventListener (type: string, listener: Listener) { + if (!listeners.has(type)) listeners.set(type, new Set()) + listeners.get(type)!.add(listener) + }, + fire (type: string) { listeners.get(type)?.forEach(listener => listener()) } + } + if (init.restore) session.restore = init.restore + return session +} + +const flush = async (): Promise => { + for (let i = 0; i < 12; i++) await Promise.resolve() +} + +const collect = (): { events: IdentityEvent[], emit: (event: IdentityEvent) => void } => { + const events: IdentityEvent[] = [] + return { events, emit: (event: IdentityEvent) => { events.push(event) } } +} + +const subscriptions: Array<{ unsubscribe: () => void }> = [] + +/** + * Subscribes and remembers the handle: a first subscription attaches a + * `visibilitychange` listener to the document, and the tests release theirs so + * a later test's refocus cannot run resyncs or probes from a finished one. + */ +const subscribe = ( + session: any, + options: Parameters[1] = {} +): ReturnType => { + const subscription = subscribeIdentity(session, options) + subscriptions.push(subscription) + return subscription +} + +// Release whatever each test subscribed, whichever assertions it made. +afterEach(() => { + subscriptions.forEach(subscription => subscription.unsubscribe()) + subscriptions.length = 0 +}) + +describe('identityState — predicates', () => { + it('treats isActive as authoritative and the WebID as the legacy fallback', () => { + expect(sessionIsActive({ isActive: true })).toBe(true) + expect(sessionIsActive({ isActive: false, webId: 'https://a.example/me' })).toBe(false) + expect(sessionIsActive({ webId: 'https://a.example/me' })).toBe(true) + expect(sessionIsActive({})).toBe(false) + expect(sessionOwnsIdentity({ isActive: false, webId: 'https://a.example/me' })).toBe(false) + expect(sessionOwnsIdentity({ webId: 'https://a.example/me' })).toBe(true) + expect(sessionExplicitlyInactive({ info: { isLoggedIn: false }, webId: 'https://a.example/me' })).toBe(true) + expect(legacySessionInfo({ isActive: false, webId: 'https://a.example/me' })) + .toEqual({ webId: 'https://a.example/me', isLoggedIn: false }) + expect(legacySessionInfo({ webId: 'https://a.example/me' })) + .toEqual({ webId: 'https://a.example/me', isLoggedIn: true }) + }) + + it('classifies transitions, and only replaces an actively established identity', () => { + expect(classifyTransition({ isActive: false }, { isActive: true, webId: 'A' })).toBe('sessionChange') + expect(classifyTransition({ isActive: true, webId: 'A' }, { isActive: false, webId: 'A' })).toBe('logout') + expect(classifyTransition({ isActive: true, webId: 'A' }, { isActive: true, webId: 'B' })).toBe('sessionChange') + expect(classifyTransition({ isActive: true, webId: 'A' }, { isActive: true, webId: 'A' })).toBe(null) + expect(classifyTransition({ isActive: true, webId: 'A' }, { isActive: false })).toBe('logout') + + expect(identityReplaced({ isActive: true, webId: 'A' }, { isActive: true, webId: 'B' })).toBe(true) + expect(identityReplaced({ isActive: true, webId: 'A' }, { isActive: false, webId: 'A' })).toBe(true) + expect(identityReplaced({ isActive: true, webId: 'A' }, { isActive: false })).toBe(true) + // Start-up and same-identity refreshes are not replacements. + expect(identityReplaced({ isActive: false }, { isActive: true, webId: 'A' })).toBe(false) + expect(identityReplaced({ isActive: true, webId: 'A' }, { isActive: true, webId: 'A' })).toBe(false) + // A session that already went inactive does not replace twice. + expect(identityReplaced({ isActive: false, webId: 'A' }, { isActive: false })).toBe(false) + expect(identityReplaced({ isActive: false, webId: 'A' }, { isActive: false, webId: 'B' })).toBe(false) + }) +}) + +describe('identityState — session transitions', () => { + beforeEach(() => { + Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true }) + }) + + it('reports a login once, and nothing when the identity does not move', () => { + const session = fakeSession({ isActive: false }) + const { events, emit } = collect() + subscribe(session, { onEvent: emit }) + + session.isActive = true + session.webId = 'https://alice.example/me' + session.fire('sessionStateChange') + expect(events).toEqual(['sessionChange']) + + // A refocus with the same identity costs no event. + document.dispatchEvent(new Event('visibilitychange')) + expect(events).toEqual(['sessionChange']) + session.fire('sessionStateChange') + expect(events).toEqual(['sessionChange']) + }) + + it('reports a logout, and one replacement for the identity that was active', () => { + const session = fakeSession({ isActive: true, webId: 'https://alice.example/me' }) + const { events, emit } = collect() + subscribe(session, { onEvent: emit }) + + session.isActive = false + session.fire('sessionStateChange') + expect(events).toEqual(['logout', 'identityReplaced']) + + // Clearing the retained WebID afterwards is no second replacement (the + // session already went inactive) — the WebID change itself still counts as + // a session change, which consumers only use to invalidate. + session.webId = undefined + session.fire('sessionStateChange') + expect(events).toEqual(['logout', 'identityReplaced', 'sessionChange']) + }) + + it('reports an identity change while the session stays active', () => { + const session = fakeSession({ isActive: true, webId: 'https://alice.example/me' }) + const { events, emit } = collect() + subscribe(session, { onEvent: emit }) + + session.webId = 'https://bob.example/me' + session.fire('sessionStateChange') + expect(events).toEqual(['sessionChange', 'identityReplaced']) + }) + + it('catches an A to B change that uvdsl never announces, around setTokenDetails', async () => { + const session = fakeSession({ isActive: true, webId: 'https://alice.example/me' }) + // The library's single entry point for token updates, installed before the + // subscription so the watcher wraps it. + const original = vi.fn(async () => { session.webId = 'https://bob.example/me' }) + session.setTokenDetails = original + const { events, emit } = collect() + subscribe(session, { onEvent: emit }) + + subscribe(session, {}) // second subscription must not wrap again + await session.setTokenDetails('token') + await flush() + expect(original).toHaveBeenCalledTimes(1) + expect(events).toEqual(['sessionChange', 'identityReplaced']) + }) + + it('reports an identity a rejected token update still applied', async () => { + const session = fakeSession({ isActive: true, webId: 'https://alice.example/me' }) + // The update applies the new identity and then fails (persistence). + session.setTokenDetails = vi.fn(async () => { + session.webId = 'https://bob.example/me' + throw new Error('persist failed') + }) + const { events, emit } = collect() + subscribe(session, { onEvent: emit }) + + await expect(session.setTokenDetails('token')).rejects.toThrow('persist failed') + await flush() + expect(events).toEqual(['sessionChange', 'identityReplaced']) + }) +}) + +describe('identityState — refocus resync', () => { + beforeEach(() => { + Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('reports a cross-tab logout once, and does not repeat it on a later refocus', async () => { + const session = fakeSession({ isActive: true, webId: 'https://alice.example/me' }) + const { events, emit } = collect() + let restores = 0 + subscribe(session, { + onEvent: emit, + resync: async () => { + restores += 1 + return 'cleared' + } + }) + + document.dispatchEvent(new Event('visibilitychange')) + await flush() + expect(restores).toBe(1) + expect(events).toEqual(['logout', 'identityReplaced']) + + document.dispatchEvent(new Event('visibilitychange')) + await flush() + expect(restores).toBe(2) + expect(events).toEqual(['logout', 'identityReplaced']) + }) + + it('joins a resync in flight instead of starting a second one', async () => { + const session = fakeSession({ isActive: true, webId: 'https://alice.example/me' }) + let resolveRestore: (value: unknown) => void = () => undefined + let restores = 0 + subscribe(session, { + resync: () => { + restores += 1 + return new Promise(resolve => { resolveRestore = resolve }) + } + }) + + document.dispatchEvent(new Event('visibilitychange')) + document.dispatchEvent(new Event('visibilitychange')) + await flush() + expect(restores).toBe(1) + resolveRestore('changed') + await flush() + }) + + it('takes the newest resync action when several subscribers provide one', async () => { + const session = fakeSession({ isActive: true, webId: 'https://alice.example/me' }) + const calls: string[] = [] + subscribe(session, { + resync: async () => { + calls.push('older') + } + }) + subscribe(session, { + resync: async () => { + calls.push('newer') + } + }) + + document.dispatchEvent(new Event('visibilitychange')) + await flush() + expect(calls).toEqual(['newer']) + }) + + it('drops an answer that belongs to an identity the session has left', async () => { + const session = fakeSession({ isActive: true, webId: 'https://alice.example/me' }) + const { events, emit } = collect() + let resolveRestore: (value: unknown) => void = () => undefined + subscribe(session, { + onEvent: emit, + resync: () => new Promise(resolve => { resolveRestore = resolve }) + }) + + document.dispatchEvent(new Event('visibilitychange')) + await flush() + + // Bob logs in while Alice's restore is still in flight: the attempt's + // answer is about Alice and must not log Bob out. + session.webId = 'https://bob.example/me' + session.fire('sessionStateChange') + expect(events).toEqual(['sessionChange', 'identityReplaced']) + + resolveRestore('cleared') + await flush() + expect(events).toEqual(['sessionChange', 'identityReplaced']) + expect(effectiveIdentity(session)).toEqual({ webId: 'https://bob.example/me', source: 'session' }) + }) + + it('ignores a cleared answer when the raw identity moved without an event', async () => { + const session = fakeSession({ isActive: true, webId: 'https://alice.example/me' }) + const { events, emit } = collect() + let resolveRestore: (value: unknown) => void = () => undefined + subscribe(session, { + onEvent: emit, + resync: () => new Promise(resolve => { resolveRestore = resolve }) + }) + + document.dispatchEvent(new Event('visibilitychange')) + await flush() + // No event was dispatched: the watcher never saw this, which is exactly + // the case the raw-identity guard exists for. + session.webId = 'https://bob.example/me' + resolveRestore('cleared') + await flush() + expect(events).toEqual([]) + expect(sessionExplicitlyInactive(session)).toBe(false) + }) +}) + +describe('identityState — cookie identity', () => { + beforeEach(() => { + Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true }) + }) + + it('adopts a cookie identity silently as a replacement source, and reports its loss', () => { + const session = fakeSession({ isActive: false }) + const { events, emit } = collect() + const subscription = subscribe(session, { onEvent: emit }) + + subscription.reportCookieIdentity('https://cookie.example/profile/card#me') + expect(events).toEqual(['sessionChange']) + expect(effectiveIdentity(session)).toEqual({ webId: 'https://cookie.example/profile/card#me', source: 'cookie' }) + + // Replacing a cookie identity is a replacement: the data fetched for the + // previous cookie user has to go. + subscription.reportCookieIdentity('https://other.example/profile/card#me') + expect(events).toEqual(['sessionChange', 'sessionChange', 'identityReplaced']) + + subscription.reportCookieIdentity(null) + expect(events).toEqual(['sessionChange', 'sessionChange', 'identityReplaced', 'sessionChange', 'identityReplaced']) + expect(effectiveIdentity(session)).toEqual({ source: 'none' }) + }) + + it('does not replace the identity while the session owns it', () => { + const session = fakeSession({ isActive: true, webId: 'https://alice.example/me' }) + const { events, emit } = collect() + const subscription = subscribe(session, { onEvent: emit }) + + subscription.reportCookieIdentity('https://cookie.example/profile/card#me') + expect(events).toEqual([]) + expect(effectiveIdentity(session)).toEqual({ webId: 'https://alice.example/me', source: 'session' }) + }) + + it('forgets a remembered cookie identity once the session owns one again', () => { + const session = fakeSession({ isActive: false }) + const subscription = subscribe(session, {}) + subscription.reportCookieIdentity('https://cookie.example/profile/card#me') + expect(effectiveIdentity(session).source).toBe('cookie') + + // The session logs in: it owns the identity now, and the remembered cookie + // value must not answer after a later logout. + session.isActive = true + session.webId = 'https://alice.example/me' + session.fire('sessionStateChange') + session.isActive = false + session.fire('sessionStateChange') + expect(effectiveIdentity(session)).toEqual({ source: 'none' }) + }) + + it('ignores a probe that answers after its subscription was released', () => { + const session = fakeSession({ isActive: false }) + const { events, emit } = collect() + const subscription = subscribe(session, { onEvent: emit }) + + subscription.unsubscribe() + subscription.reportCookieIdentity('https://cookie.example/profile/card#me') + expect(events).toEqual([]) + expect(effectiveIdentity(session)).toEqual({ source: 'none' }) + }) + + it('forwards a refocus to the subscribers and removes the listener with the last one', async () => { + const session = fakeSession({ isActive: true, webId: 'https://alice.example/me' }) + const onRefocus = vi.fn() + const first = subscribe(session, { onRefocus }) + + document.dispatchEvent(new Event('visibilitychange')) + expect(onRefocus).toHaveBeenCalledTimes(1) + + first.unsubscribe() + document.dispatchEvent(new Event('visibilitychange')) + expect(onRefocus).toHaveBeenCalledTimes(1) + }) +}) diff --git a/test/logic.test.ts b/test/logic.test.ts index 14bad48..bab2e33 100644 --- a/test/logic.test.ts +++ b/test/logic.test.ts @@ -35,7 +35,17 @@ describe('solidLogicSingleton fetch bridge', () => { let originalFetch: any let originalAuthFetch: any - let originalInfo: any + + // Whose credentials a request would carry is decided by the identity state, + // which reads the session itself — so the test fakes the session identity, + // not the derived `info` shape (whose assignment is ignored on purpose). + // `webId`/`isActive` are prototype getters on the real session, so an own + // property shadows them for the duration of a test. + const setSessionIdentity = (webId?: string): void => { + const sessionAny = authSession as any + Object.defineProperty(sessionAny, 'isActive', { value: webId !== undefined, configurable: true }) + Object.defineProperty(sessionAny, 'webId', { value: webId, configurable: true }) + } beforeEach(() => { fetchMock.resetMocks() @@ -43,21 +53,21 @@ describe('solidLogicSingleton fetch bridge', () => { const sessionAny = authSession as any originalFetch = sessionAny.fetch originalAuthFetch = sessionAny.authFetch - originalInfo = sessionAny.info - sessionAny.info = { isLoggedIn: false } + setSessionIdentity() }) afterEach(() => { const sessionAny = authSession as any sessionAny.fetch = originalFetch sessionAny.authFetch = originalAuthFetch - sessionAny.info = originalInfo + delete sessionAny.webId + delete sessionAny.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 } + setSessionIdentity('https://alice.example/profile#me') sessionAny.fetch = vi.fn().mockResolvedValue(new Response('session')) fetchMock.mockResponseOnce('window') @@ -70,7 +80,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 } + setSessionIdentity('https://alice.example/profile#me') sessionAny.fetch = undefined sessionAny.authFetch = vi.fn().mockResolvedValue(new Response('auth')) @@ -80,4 +90,3 @@ describe('solidLogicSingleton fetch bridge', () => { expect(fetchMock).not.toHaveBeenCalled() }) }) - diff --git a/test/rdflibEditableFlagContract.test.ts b/test/rdflibEditableFlagContract.test.ts new file mode 100644 index 0000000..b1b52ef --- /dev/null +++ b/test/rdflibEditableFlagContract.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' +import { fetcher, graph, lit, sym, UpdateManager } from 'rdflib' +import { ensureDocumentAuthorization } 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 handling relies on, proven against the +// real UpdateManager/Fetcher 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 a repair; +// 3. a fresh response under the new identity answers definitively again — +// through `load()` on rdflib >= 2.4.1, and through +// `refreshDocumentAuthorization()` on any version. +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()/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('heals a flagged, already-loaded document through load() (rdflib >= 2.4.1)', async () => { + const store: any = graph() + const doc = 'https://example.org/heal' + 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() + + // 2.4.1: load() finds the recorded request (the URI is stored as a literal) + // and refetches a document whose recorded answers are all flagged, so the + // stale read-only answer is replaced by the fresh one. + await store.fetcher.load(doc) + expect(calls).toBe(2) + expect(store.updater.editable(doc)).toBe('N3PATCH') + }) + + it('lets a decision point repair a flagged document through a load', 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) + store.updater.flagAuthorizationMetadata() + expect(store.updater.editable(doc)).toBeUndefined() + + // ensureDocumentAuthorization() re-flags and loads; rdflib refetches a + // fully flagged document, so the decision point answers from the fresh + // response instead of the previous identity's. + await expect(ensureDocumentAuthorization(store, doc)).resolves.toBe(true) + 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..511cebd 100644 --- a/test/solidAuthLogic.test.ts +++ b/test/solidAuthLogic.test.ts @@ -1,55 +1,338 @@ -import { beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { SolidAuthnLogic } from '../src/authn/SolidAuthnLogic' +import { authSession } from '../src/authSession/authSession' +import { subscribeIdentity, type IdentitySubscription } from '../src/authSession/identityState' import { silenceDebugMessages } from './helpers/debugger' -import { AuthenticationContext } from '../src/types' -import { EventEmitter } from 'node:events' silenceDebugMessages() -let solidAuthnLogic: SolidAuthnLogic -const authSession = { - events: new EventEmitter(), - addEventListener (event: string | symbol, listener: (...args: any[]) => void) { - this.events.on(event, listener) - }, - removeEventListener (event: string | symbol, listener: (...args: any[]) => void) { - this.events.off(event, listener) - }, + +type Listener = (...args: any[]) => void + +/** + * A fake uvdsl-style session: properties the identity state reads, a + * `sessionStateChange` listener, and the legacy `events` emitter the logic + * (and the state's subscription) publish through. + */ +function fakeSession (init: { + isActive?: boolean + webId?: string + info?: { isLoggedIn?: boolean, webId?: string } + restore?: () => Promise + handleRedirectFromLogin?: () => Promise +} = {}): any { + const listeners = new Map>() + const emitted: string[] = [] + const session: any = { + isActive: init.isActive, + webId: init.webId, + info: init.info, + restore: init.restore, + handleRedirectFromLogin: init.handleRedirectFromLogin, + emitted, + addEventListener (type: string, listener: Listener) { + if (!listeners.has(type)) listeners.set(type, new Set()) + listeners.get(type)!.add(listener) + }, + fire (type: string): void { + listeners.get(type)?.forEach(listener => listener()) + }, + events: { + on (_type: string, _listener: Listener): void { /* registered by checkUser */ }, + emit (type: string): void { emitted.push(type) } + } + } + return session +} + +const flush = async (): Promise => { + for (let i = 0; i < 12; i++) await Promise.resolve() +} + +// jsdom keeps its own location; the NSS probe reads hostname/port/protocol from +// it, so tests that exercise the probe replace it for their duration. +const originalLocation = Object.getOwnPropertyDescriptor(window, 'location') + +const setLocation = (hostname: string): void => { + Object.defineProperty(window, 'location', { + configurable: true, + value: { + hostname, + port: '', + protocol: 'http:', + href: `http://${hostname}/`, + toString: () => `http://${hostname}/` + } + }) +} + +// Every instance a test creates is disposed afterwards: the identity state +// keeps ONE document listener per session, and jsdom's document is shared by +// the whole file — a surviving subscription would probe during later tests. +let instances: SolidAuthnLogic[] = [] +let forwarders: IdentitySubscription[] = [] + +const createAuthn = (session: any): SolidAuthnLogic => { + // The app forwards the state's events through authSession's subscription; + // a fake session needs the same forwarder for the emitted list to mean + // anything. + forwarders.push(subscribeIdentity(session, { onEvent: (event) => session.emitted.push(event) })) + const authn = new SolidAuthnLogic(session) + instances.push(authn) + return authn +} + +afterEach(() => { + instances.forEach(authn => authn.dispose()) + instances = [] + forwarders.forEach(subscription => subscription.unsubscribe()) + forwarders = [] +}) + +const okProbe = (): void => { + vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 403 }))) } -describe('SolidAuthnLogic', () => { - +describe('SolidAuthnLogic — currentUser', () => { + it('returns the WebID of an active session', () => { + const authn = createAuthn(fakeSession({ isActive: true, webId: 'https://alice.example/me' })) + expect(authn.currentUser()?.value).toBe('https://alice.example/me') + }) + + it('reports logged out when the session explicitly went inactive, even with a cached WebID', () => { + const authn = createAuthn(fakeSession({ isActive: false, webId: 'https://alice.example/me' })) + expect(authn.currentUser()).toBe(null) + }) + + it('reports logged out when the legacy info says so, even with a cached WebID', () => { + const authn = createAuthn(fakeSession({ + webId: 'https://alice.example/me', + info: { isLoggedIn: false, webId: 'https://alice.example/me' } + })) + expect(authn.currentUser()).toBe(null) + }) + + it('accepts a legacy session that reports no state at all but carries a WebID', () => { + const authn = createAuthn(fakeSession({ webId: 'https://alice.example/me' })) + expect(authn.currentUser()?.value).toBe('https://alice.example/me') + }) +}) + +describe('SolidAuthnLogic — checkUser', () => { + beforeEach(() => { + setLocation('localhost') + }) + + afterEach(() => { + vi.unstubAllGlobals() + if (originalLocation) Object.defineProperty(window, 'location', originalLocation) + }) + + it('exists and runs', async () => { + const authn = createAuthn(fakeSession()) + expect(authn.checkUser).toBeInstanceOf(Function) + expect(await authn.checkUser()).toEqual(null) + }) + + it('activates a session from a restore and announces sessionRestore once', async () => { + const session = fakeSession({ isActive: false }) + session.restore = async () => { + session.isActive = true + session.webId = 'https://alice.example/me' + } + const authn = createAuthn(session) + + expect((await authn.checkUser() as any)?.value).toBe('https://alice.example/me') + expect(session.emitted).toEqual(['sessionRestore']) + expect(authn.currentUser()?.value).toBe('https://alice.example/me') + }) + + it('announces login after a redirect, not sessionRestore', async () => { + const session = fakeSession({ isActive: false }) + session.handleRedirectFromLogin = async () => { + session.isActive = true + session.webId = 'https://alice.example/me' + } + const authn = createAuthn(session) + + await authn.checkUser() + expect(session.emitted).toEqual(['login']) + }) + + it('treats "No session to restore." as logged out instead of failing', async () => { + const session = fakeSession({ isActive: false, restore: async () => { throw new Error('No session to restore.') } }) + const authn = createAuthn(session) + + await expect(authn.checkUser()).resolves.toEqual(null) + expect(session.emitted).toEqual([]) + }) + + it('rethrows a restore failure when the session nevertheless became active', async () => { + const session = fakeSession({ isActive: false }) + session.restore = async () => { + session.isActive = true + session.webId = 'https://alice.example/me' + throw new Error('refresh failed') + } + const authn = createAuthn(session) + + await expect(authn.checkUser()).rejects.toThrow('refresh failed') + }) + + it('recovers the NSS cookie WebID when the client restore is empty, and reports it as a session change', async () => { + setLocation('alice.localhost') + okProbe() + const session = fakeSession({ isActive: false }) + const authn = createAuthn(session) + + expect((await authn.checkUser() as any)?.value).toBe('http://alice.localhost/profile/card#me') + expect(authn.currentUser()?.value).toBe('http://alice.localhost/profile/card#me') + // Adopting a cookie identity is a change, not a replacement: there was no + // previous user to drop. + expect(session.emitted).toEqual(['sessionChange']) + }) + + it('ignores a cookie probe that answers after the session took the identity', async () => { + setLocation('alice.localhost') + let resolveFetch: (value: Response) => void = () => undefined + vi.stubGlobal('fetch', vi.fn(() => new Promise(resolve => { resolveFetch = resolve }))) + const session = fakeSession({ isActive: false }) + const authn = createAuthn(session) + + const checking = authn.checkUser() + await flush() + // The session logs in while the probe is in flight. + session.isActive = true + session.webId = 'https://alice.example/me' + session.fire('sessionStateChange') + resolveFetch(new Response(null, { status: 403 })) + await checking + + expect(authn.currentUser()?.value).toBe('https://alice.example/me') + expect(session.emitted).toEqual(['sessionChange']) + }) + + it('does not probe when the session already has a WebID', async () => { + setLocation('alice.localhost') + const fetchMock = vi.fn(async () => new Response(null, { status: 403 })) + vi.stubGlobal('fetch', fetchMock) + const session = fakeSession({ isActive: true, webId: 'https://alice.example/me' }) + const authn = createAuthn(session) + + await authn.checkUser() + expect(fetchMock).not.toHaveBeenCalled() + }) +}) + +describe('SolidAuthnLogic — refocus and dispose', () => { beforeEach(() => { - solidAuthnLogic = new SolidAuthnLogic(authSession as any) + setLocation('alice.localhost') + Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + if (originalLocation) Object.defineProperty(window, 'location', originalLocation) + }) + + it('re-probes the cookie identity on refocus and reports it when it is gone', async () => { + const fetchMock = vi.fn(async () => new Response(null, { status: 403 })) + vi.stubGlobal('fetch', fetchMock) + const session = fakeSession({ isActive: false }) + const authn = createAuthn(session) + + document.dispatchEvent(new Event('visibilitychange')) + await flush() + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(authn.currentUser()?.value).toBe('http://alice.localhost/profile/card#me') + + // The cookie session is gone in another tab. + fetchMock.mockImplementation(async () => new Response(null, { status: 200 })) + document.dispatchEvent(new Event('visibilitychange')) + await flush() + expect(authn.currentUser()).toBe(null) + expect(session.emitted).toEqual(['sessionChange', 'sessionChange', 'identityReplaced']) + }) + + it('stops probing once the last instance using the session is disposed', async () => { + const fetchMock = vi.fn(async () => new Response(null, { status: 403 })) + vi.stubGlobal('fetch', fetchMock) + const session = fakeSession({ isActive: false }) + const authn = createAuthn(session) + + authn.dispose() + document.dispatchEvent(new Event('visibilitychange')) + await flush() + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('keeps the refocus probe for the remaining instance', async () => { + const fetchMock = vi.fn(async () => new Response(null, { status: 403 })) + vi.stubGlobal('fetch', fetchMock) + const session = fakeSession({ isActive: false }) + const first = createAuthn(session) + const second = createAuthn(session) + + first.dispose() + document.dispatchEvent(new Event('visibilitychange')) + await flush() + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(second.currentUser()?.value).toBe('http://alice.localhost/profile/card#me') }) - describe('checkUser', () => { - it('exists', () => { - expect(solidAuthnLogic.checkUser).toBeInstanceOf(Function) - }) - it('runs', async () => { - expect(await solidAuthnLogic.checkUser()).toEqual(null) - }) + it('drops a probe result that was in flight when the instance was disposed', async () => { + let resolveFetch: (value: Response) => void = () => undefined + vi.stubGlobal('fetch', vi.fn(() => new Promise(resolve => { resolveFetch = resolve }))) + const session = fakeSession({ isActive: false }) + const authn = createAuthn(session) + + document.dispatchEvent(new Event('visibilitychange')) + await flush() + authn.dispose() + resolveFetch(new Response(null, { status: 403 })) + await flush() + + expect(authn.currentUser()).toBe(null) + expect(session.emitted).toEqual([]) }) - describe('currentUser', () => { - it('exists', () => { - expect(solidAuthnLogic.currentUser).toBeInstanceOf(Function) - }) - it('runs', async () => { - expect(await solidAuthnLogic.currentUser()).toEqual(null) - }) + it('does not touch the identity while the session owns it', async () => { + const fetchMock = vi.fn(async () => new Response(null, { status: 403 })) + vi.stubGlobal('fetch', fetchMock) + const session = fakeSession({ isActive: true, webId: 'https://alice.example/me' }) + const authn = createAuthn(session) + + document.dispatchEvent(new Event('visibilitychange')) + await flush() + expect(fetchMock).not.toHaveBeenCalled() + expect(authn.currentUser()?.value).toBe('https://alice.example/me') }) +}) - describe('saveUser', () => { - it('exists', () => { - expect(solidAuthnLogic.saveUser).toBeInstanceOf(Function) - }) - it('runs', () => { - expect(solidAuthnLogic.saveUser( - '', - {} as AuthenticationContext - )).toEqual(null) - }) +describe('authSession.info', () => { + afterEach(() => { + const sessionAny = authSession as any + delete sessionAny.webId + delete sessionAny.isActive }) -}) \ No newline at end of file + it('is derived from the session, and assignment is ignored', () => { + const sessionAny = authSession as any + Object.defineProperty(sessionAny, 'webId', { value: 'https://alice.example/me', configurable: true }) + Object.defineProperty(sessionAny, 'isActive', { value: true, configurable: true }) + + expect(sessionAny.info).toEqual({ webId: 'https://alice.example/me', isLoggedIn: true }) + + // Legacy code assigns snapshots; reads stay derived so a retained value + // cannot answer for the session. + sessionAny.info = { webId: 'https://mallory.example/me', isLoggedIn: true } + expect(sessionAny.info).toEqual({ webId: 'https://alice.example/me', isLoggedIn: true }) + }) + + it('reports logged out when the session explicitly went inactive', () => { + const sessionAny = authSession as any + Object.defineProperty(sessionAny, 'webId', { value: 'https://alice.example/me', configurable: true }) + Object.defineProperty(sessionAny, 'isActive', { value: false, configurable: true }) + + expect(sessionAny.info).toEqual({ webId: 'https://alice.example/me', isLoggedIn: false }) + }) +}) diff --git a/test/solidLogic.test.ts b/test/solidLogic.test.ts new file mode 100644 index 0000000..4e245e2 --- /dev/null +++ b/test/solidLogic.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from 'vitest' +import { createSolidLogic } from '../src/logic/solidLogic' +import { silenceDebugMessages } from './helpers/debugger' + +silenceDebugMessages() + +type Listener = () => void + +/** A fake uvdsl-style session: the properties the identity state reads. */ +function fakeSession (): any { + const listeners = new Map>() + return { + isActive: false, + webId: undefined as string | undefined, + addEventListener (type: string, listener: Listener) { + if (!listeners.has(type)) listeners.set(type, new Set()) + listeners.get(type)!.add(listener) + }, + fire (type: string): void { listeners.get(type)?.forEach(listener => listener()) } + } +} + +describe('createSolidLogic', () => { + it('releases the store invalidation subscription with the auth logic', () => { + const session = fakeSession() + const logic = createSolidLogic({ fetch: vi.fn() }, session) + const flag = vi.spyOn(logic.store.updater!, 'flagAuthorizationMetadata') + + // A login flags every recorded response: the store is subscribed. + session.isActive = true + session.webId = 'https://alice.example/me' + session.fire('sessionStateChange') + expect(flag).toHaveBeenCalledTimes(1) + + // Disposing the auth logic releases the store's subscription as well. + logic.authn.dispose?.() + + session.webId = 'https://bob.example/me' + session.fire('sessionStateChange') + expect(flag).toHaveBeenCalledTimes(1) + }) +})