Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand Down
54 changes: 44 additions & 10 deletions src/authSession/authSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<OidcSession, 'login'> & { 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.
}
})

6 changes: 3 additions & 3 deletions src/authSession/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand All @@ -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<string, Set<LegacyEventHandler>> = new Map()
Expand All @@ -32,4 +33,3 @@ export class SessionEvents {
this.listeners.get(event)?.forEach(h => h(...args))
}
}

210 changes: 210 additions & 0 deletions src/authSession/flagAuthorizationOnTransitions.ts
Original file line number Diff line number Diff line change
@@ -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<object, StoreAuthorizationState>()
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<string | boolean | undefined> {
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<boolean> {
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<boolean> {
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)
}
Loading
Loading