Skip to content
Open
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
68 changes: 57 additions & 11 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 { sessionIsActive, watchSessionTransitions, type SessionLike } from './transitions'

type SessionCompatibilityShape = {
webId?: string
Expand Down Expand Up @@ -93,22 +94,67 @@ if (originalLogin) {

const events = new SessionEvents()

// Emit the legacy 'logout' event when the session transitions from active to inactive.
// 'login' and 'sessionRestore' are emitted in SolidAuthnLogic.checkUser()
// because only that call site knows which path activated the session.
let _wasActive = (_session as any).isActive ?? Boolean((_session as any).webId)
if (typeof (_session as unknown as EventTarget).addEventListener === 'function') {
;(_session as unknown as EventTarget).addEventListener('sessionStateChange', () => {
const isNowActive = (_session as any).isActive ?? Boolean((_session as any).webId)
if (_wasActive && !isNowActive) {
events.emit('logout')
}
_wasActive = isNowActive
})
// because only that call site knows which path activated the session. Every
// other identity transition is reported from here: 'logout' when the session
// goes inactive, and 'sessionChange' when the identity changes some other way
// — including a login/logout made in another tab, which the uvdsl
// SharedWorker does not broadcast as a state change and which is noticed when
// this tab is refocused.
// A worker-backed session pushes another tab's change here, but a worker can
// also be constructed and then never answer — so "the worker exists" is not
// proof that a cross-tab change will be pushed, and the resync is always
// wired. It maps a backing store that no longer holds a session (a cross-tab
// logout) to 'cleared'; watchSessionTransitions() bounds the wait.
const resyncSession = (): unknown => {
const restore = (_session as any)?.restore
if (typeof restore !== 'function') return undefined
return Promise.resolve()
.then(() => restore.call(_session))
.then(() => 'changed', (error: unknown) => {
// A transient refresh/network failure is compared as it stands; a store
// that has no session to restore means this tab's identity is gone.
const message = error instanceof Error ? error.message : String(error)
return /no session to restore/i.test(message) ? 'cleared' : 'changed'
})
}
watchSessionTransitions(_session as unknown as SessionLike, (event) => events.emit(event), undefined, resyncSession)

export const authSession: SessionWithLegacyEvents = Object.assign(
_session as Omit<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: `SolidAuthnLogic.webIdFromSession()`
// and the fetch bridge prefer `info.webId` when present, so a retained
// snapshot (callers snapshot and restore `info`) must never answer for the
// session. A sticky value would report the previous identity after a
// login/logout. Assignment is accepted and ignored so ordinary property
// writes cannot throw; a test that needs to fake `info` redefines it.
//
// `isLoggedIn` follows `sessionIsActive`: an explicit `isActive: false`
// reports logged out even when a WebID is still cached, or the fetch bridge
// would keep routing anonymous requests through the authenticated fetch.
export function legacySessionInfo (session: SessionLike): { webId?: string; isLoggedIn?: boolean } {
return {
webId: session.webId,
isLoggedIn: sessionIsActive(session)
Comment thread
bourgeoa marked this conversation as resolved.
}
}

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.
}
})

5 changes: 3 additions & 2 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 transition watcher in authSession.ts (logout, sessionChange,
* identityReplaced — the event the reload helper subscribes to).
*/
export class SessionEvents {
private readonly listeners: Map<string, Set<LegacyEventHandler>> = new Map()
Expand Down
239 changes: 239 additions & 0 deletions src/authSession/flagAuthorizationOnTransitions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
/**
* Session transitions invalidate the store's cached HTTP authorization
* metadata.
*
* `UpdateManager.editable()` is a synchronous read of the responses recorded
* under `fetcher.appNode`. Those responses are not keyed by identity, so a
* document fetched anonymously (before a restore completed) or under a
* previous WebID keeps answering for the old identity: a writable document
* can look read-only after login, and a read-only one can look writable after
* logout.
*
* `UpdateManager.flagAuthorizationMetadata()` marks every recorded response
* out-of-date, so `editable()` answers "unknown" instead of the previous
* identity's access.
*
* A document is repaired by a FORCE refresh, not by a plain load: on rdflib
* 2.4.0 `fetcher.load()` looks recorded requests up with a NamedNode
* (`kb.sym(docuri)`) while the fetcher records them as a string literal
* (linkeddata/rdflib.js#427), finds nothing, keeps the out-of-date mark and
* returns the cached copy — so neither `load()` nor the `checkEditable()`
* that wraps it re-answers for an already-loaded document. `fetcher.refresh()`
* sets `force: true, clearPreviousData: true` and records a fresh response;
* `refreshDocumentAuthorization()` below wraps that for call sites that need
* the answer immediately. (Once rdflib's `load` matches the literal form,
* `checkEditable()` heals too.)
*
* Wired here rather than in UI code so the invalidation happens where the
* identity change is known, store-wide.
*/

import * as debug from '../util/debug'

// Every transition that can change whose credentials a request would carry.
// 'login'/'sessionRestore' are emitted by SolidAuthnLogic; 'logout' and
// 'sessionChange' by the transition watcher in authSession.ts.
export const SESSION_TRANSITIONS = ['login', 'sessionRestore', 'logout', 'sessionChange'] as const
export type SessionTransition = (typeof SESSION_TRANSITIONS)[number]

export type TransitionStore = {
updater?: { flagAuthorizationMetadata?: () => void }
}

export type TransitionSession = {
events?: { on?: (event: SessionTransition, handler: () => void) => void }
}

export function flagAuthorizationOnSessionTransitions (
store: TransitionStore,
session: TransitionSession
): void {
const flag = (): void => {
const state = storeState(store)
state.generation += 1
try {
const invalidate = store.updater?.flagAuthorizationMetadata
if (typeof invalidate !== 'function') {
// A store without the API cannot be invalidated — that is a failure,
// not a success: the decision points must not trust its answers.
throw new Error('flagAuthorizationMetadata is unavailable')
}
invalidate.call(store.updater)
// Every recorded response is invalidated; decision points see that as
// "unknown" and repair from there.
state.refreshRequired = false
} catch (error) {
// The store could not invalidate its metadata, so its answers stay
// definitive for the previous identity. Do not take the session
// handling down with it, but do not treat the warning as recovery
// either: record that a fresh response is required and have the
// decision points honour it (ensureDocumentAuthorization below).
state.refreshRequired = true
debug.warn(`Could not flag authorization metadata after a session transition: ${error}`)
}
}
const events = session?.events
if (!events || typeof events.on !== 'function') return
for (const transition of SESSION_TRANSITIONS) {
events.on(transition, flag)
}
}

export type RefreshableStore = {
fetcher?: {
refresh?: (doc: unknown, callback?: (...args: unknown[]) => void) => unknown
load?: (doc: unknown) => unknown
}
updater?: { editable?: (uri: unknown) => string | boolean | undefined }
}

type StoreAuthorizationState = {
/** Identity transitions observed for this store. */
generation: number
/** The store could not invalidate its metadata — do not trust its answers. */
refreshRequired: boolean
}

// Scoped per store: two `createSolidLogic` instances with different sessions
// must not overtake each other's refreshes, and a failed invalidation in one
// store says nothing about another.
const storeStates = new WeakMap<object, StoreAuthorizationState>()
const sharedState: StoreAuthorizationState = { generation: 0, refreshRequired: false }

function storeState (store: unknown): StoreAuthorizationState {
if (store === null || typeof store !== 'object') return sharedState
let state = storeStates.get(store)
if (!state) {
state = { generation: 0, refreshRequired: false }
storeStates.set(store, state)
}
return state
}

/** How many times a refresh is repeated when the identity keeps changing. */
const REFRESH_ATTEMPTS = 3

/**
* Force-refresh one document and answer its editability under the current
* identity — the repair path for a flagged store (see above). It costs a
* round-trip; decision points that need an immediate, correct answer use it.
*
* The identity can change while the refresh is in flight; the response then
* belongs to the previous identity and must not answer for the current one,
* or a caller could write under the new identity on the old identity's
* authorization. Each attempt is stamped with the store's transition
* generation and repeated under the new identity when it was overtaken; if
* the identity keeps changing the answer stays "unknown" rather than stale.
*
* Returns `undefined` whenever the answer cannot be established under the
* current identity: no refresh capability, a failed refresh, or an identity
* that changed throughout every attempt. A failed refresh must NOT fall back
* to the recorded answer — when the store could not be invalidated that
* answer belongs to the previous identity.
*/
export async function refreshDocumentAuthorization (
store: RefreshableStore,
doc: unknown
): Promise<string | boolean | undefined> {
const state = storeState(store)
for (let attempt = 0; attempt < REFRESH_ATTEMPTS; attempt++) {
const generation = state.generation
const refreshed = await forceRefresh(store, doc)
if (!refreshed) return undefined
// The read below is synchronous, so a generation that still matches means
// no transition slipped in between the response and the answer.
if (generation === state.generation) {
return store.updater?.editable?.(doc)
}
}
return undefined
}

/**
* Make the store able to answer for `doc` under the current identity before
* its cached triples are read or its editability gates a write. A flagged
* store answers `undefined` and is repaired here; a store whose flag FAILED
* still answers definitively for the previous identity, so it is repaired
* too (and keeps being repaired until a later transition flags successfully,
* since the failure says nothing about which other documents are stale).
*
* Returns whether the answer was established. `false` means a repair was
* needed and could not complete (no refresh capability, a failed refresh, or
* an identity that changed throughout): the caller must not consume cached
* triples from that document and must not offer a write on it.
*/
export async function ensureDocumentAuthorization (
store: RefreshableStore,
doc: unknown
): Promise<boolean> {
const state = storeState(store)
if (!state.refreshRequired && store.updater?.editable?.(doc) !== undefined) {
return true
}
return (await refreshDocumentAuthorization(store, doc)) !== undefined
}

/**
* Load a document and make sure its cached triples can be read under the
* current identity. The load itself is generation-checked: a response begun
* under the previous identity can be recorded AFTER
* `flagAuthorizationMetadata()` ran (the flag only marks response nodes that
* already existed), which leaves a definitive-looking answer from the old
* identity behind — so an overtaken load is force-refreshed instead of being
* trusted.
*
* Returns whether the document can be consumed (see
* ensureDocumentAuthorization). Load errors propagate, as a plain `load()`
* would.
*/
export async function loadAuthorizedDocument (
store: RefreshableStore,
doc: unknown
): Promise<boolean> {
const state = storeState(store)
const generation = state.generation
await store.fetcher?.load?.(doc)
if (generation !== state.generation) {
return (await refreshDocumentAuthorization(store, doc)) !== undefined
}
return ensureDocumentAuthorization(store, doc)
}

/**
* rdflib's `refresh(term, callback)` is callback-based and returns void —
* it delegates to `nowOrWhenFetched(term, { force: true, clearPreviousData:
* true }, callback)` and the callback is the completion signal. Awaiting the
* call itself would read `editable()` before the fresh response is recorded,
* so wait for the callback (a promise-returning wrapper is awaited too).
*
* Resolves `true` only when a refresh actually completed; a missing refresh
* capability, a callback that reports failure, a rejected promise or a
* synchronous throw all resolve `false`, with a warning — the caller must not
* read the recorded answer in that case.
*/
async function forceRefresh (store: RefreshableStore, doc: unknown): Promise<boolean> {
const refresh = store.fetcher?.refresh
if (typeof refresh !== 'function') return false
return await new Promise<boolean>((resolve) => {
let settled = false
const done = (ok?: unknown, message?: unknown): void => {
if (settled) return
settled = true
if (ok === false) {
debug.warn(`Could not refresh ${String(doc)}: ${String(message)}`)
resolve(false)
} else {
resolve(true)
}
}
try {
const result = refresh.call(store.fetcher, doc, done)
if (result && typeof (result as Promise<unknown>).then === 'function') {
void (result as Promise<unknown>).then(() => done(), (error) => done(false, error))
}
} catch (error) {
debug.warn(`Could not refresh ${String(doc)}: ${String(error)}`)
done(false)
}
})
}
Loading
Loading