From fb4ae6351c95a8a01b4e1caad5033c7594445c49 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Wed, 20 May 2026 19:15:32 +0200 Subject: [PATCH 01/23] replace Inrupt by Uvdsl OIDC client --- jest.config.mjs | 3 + package-lock.json | 84 +++---------------------- package.json | 2 +- src/authSession/authSession.ts | 51 ++++++++++++++- src/authn/SolidAuthnLogic.ts | 72 ++++++++++++++++----- src/logic/solidLogic.ts | 4 +- src/logic/solidLogicSingleton.ts | 12 +++- src/types.ts | 4 +- test/mocks/solid-oidc-client-browser.ts | 53 ++++++++++++++++ test/solidAuthLogic.test.ts | 14 +++++ 10 files changed, 201 insertions(+), 98 deletions(-) create mode 100644 test/mocks/solid-oidc-client-browser.ts diff --git a/jest.config.mjs b/jest.config.mjs index 6574372c..2debc41e 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -11,6 +11,9 @@ export default { transform: { '^.+\\.[tj]sx?$': ['babel-jest', { configFile: './babel.config.mjs' }], }, + moduleNameMapper: { + '^@uvdsl/solid-oidc-client-browser$': '/test/mocks/solid-oidc-client-browser.ts', + }, setupFilesAfterEnv: ['./test/helpers/setup.ts'], testMatch: ['**/__tests__/**/*.ts?(x)', '**/?(*.)+(spec|test).ts?(x)'], roots: ['/src', '/test'], diff --git a/package-lock.json b/package-lock.json index 544de1f7..f34bc691 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "4.0.7", "license": "MIT", "dependencies": { - "@inrupt/solid-client-authn-browser": "^4.0.0", + "@uvdsl/solid-oidc-client-browser": "^0.2.2", "solid-namespace": "^0.5.4" }, "devDependencies": { @@ -2284,45 +2284,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@inrupt/oidc-client-ext": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@inrupt/oidc-client-ext/-/oidc-client-ext-4.0.0.tgz", - "integrity": "sha512-E32/yElFpADyWRFO6FdCyB1Ew1svsNX/fFdvHWP3qCBhSlfJVq2hMChWxs/RIRmTjHePyjT2UKEuItM09WXaWA==", - "license": "MIT", - "dependencies": { - "@inrupt/solid-client-authn-core": "^4.0.0", - "jose": "^5.1.3", - "oidc-client-ts": "^3.5.0", - "uuid": "^11.1.0" - } - }, - "node_modules/@inrupt/solid-client-authn-browser": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@inrupt/solid-client-authn-browser/-/solid-client-authn-browser-4.0.0.tgz", - "integrity": "sha512-b7DpLMjYVMPiRv3QWqOmCeYqKL1t2THYQawuYM1zNqtN1SJGG5XEkXIy3ZQxx12tzAjeLNjH3ZAOg/CK/ehg2w==", - "license": "MIT", - "dependencies": { - "@inrupt/oidc-client-ext": "^4.0.0", - "@inrupt/solid-client-authn-core": "^4.0.0", - "events": "^3.3.0", - "jose": "^5.1.3", - "uuid": "^11.1.0" - } - }, - "node_modules/@inrupt/solid-client-authn-core": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@inrupt/solid-client-authn-core/-/solid-client-authn-core-4.0.0.tgz", - "integrity": "sha512-q4iur4TxEkhk9XaGAvyRP/+MjU1oBv2xlBdGE+uoXmDHAnIqUN71zZjCWZfZlyQFRETgH3OfZ9tPrNSDIPA/wg==", - "license": "MIT", - "dependencies": { - "events": "^3.3.0", - "jose": "^5.1.3", - "uuid": "^11.1.0" - }, - "engines": { - "node": "^20.0.0 || ^22.0.0 || ^24.0.0" - } - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -3485,6 +3446,15 @@ "win32" ] }, + "node_modules/@uvdsl/solid-oidc-client-browser": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@uvdsl/solid-oidc-client-browser/-/solid-oidc-client-browser-0.2.2.tgz", + "integrity": "sha512-JhcfSPu+eVyPMl2Dz46jq9ZHZwfZSqzCrQiHkvFZyam9ZEGXmLF1QJs4O+MddiEJaF5rVeEPd20YWprp5drLKw==", + "license": "MIT", + "dependencies": { + "jose": "^5.9.6" + } + }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", "dev": true, @@ -7844,15 +7814,6 @@ "license": "ISC", "peer": true }, - "node_modules/jwt-decode": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", - "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/keyv": { "version": "4.5.4", "dev": true, @@ -8377,18 +8338,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/oidc-client-ts": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.5.0.tgz", - "integrity": "sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==", - "license": "Apache-2.0", - "dependencies": { - "jwt-decode": "^4.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/once": { "version": "1.4.0", "dev": true, @@ -10672,19 +10621,6 @@ "license": "MIT", "peer": true }, - "node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/esm/bin/uuid" - } - }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", diff --git a/package.json b/package.json index 885be6ff..c311a3aa 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "webpack-cli": "^7.0.2" }, "dependencies": { - "@inrupt/solid-client-authn-browser": "^4.0.0", + "@uvdsl/solid-oidc-client-browser": "^0.2.2", "solid-namespace": "^0.5.4" }, "peerDependencies": { diff --git a/src/authSession/authSession.ts b/src/authSession/authSession.ts index a125a97a..1e33f263 100644 --- a/src/authSession/authSession.ts +++ b/src/authSession/authSession.ts @@ -1,7 +1,54 @@ import { Session, -} from '@inrupt/solid-client-authn-browser' +} from '@uvdsl/solid-oidc-client-browser' -export const authSession = new Session() +type LegacyEventName = 'login' | 'logout' | 'sessionRestore' +type LegacyEventHandler = (...args: unknown[]) => void + +/** + * Minimal EventEmitter-style shim so that existing consumers using + * `authSession.events.on('login' | 'logout' | 'sessionRestore', handler)` + * continue working without modification. + * + * Events are emitted by SolidAuthnLogic.checkUser() (login/sessionRestore) + * and by the sessionStateChange listener below (logout). + */ +export class SessionEvents { + private readonly listeners: Map> = new Map() + + on (event: LegacyEventName, handler: LegacyEventHandler): void { + if (!this.listeners.has(event)) this.listeners.set(event, new Set()) + this.listeners.get(event)!.add(handler) + } + + off (event: LegacyEventName, handler: LegacyEventHandler): void { + this.listeners.get(event)?.delete(handler) + } + + emit (event: LegacyEventName, ...args: unknown[]): void { + this.listeners.get(event)?.forEach(h => h(...args)) + } +} + +export type SessionWithLegacyEvents = Session & { events: SessionEvents } + +const _session = new Session() +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 = false +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 + }) +} + +export const authSession: SessionWithLegacyEvents = Object.assign(_session, { events }) \ No newline at end of file diff --git a/src/authn/SolidAuthnLogic.ts b/src/authn/SolidAuthnLogic.ts index 6d49a8ee..51c2398f 100644 --- a/src/authn/SolidAuthnLogic.ts +++ b/src/authn/SolidAuthnLogic.ts @@ -1,26 +1,29 @@ import { namedNode, NamedNode, sym } from 'rdflib' import { appContext, offlineTestID } from './authUtil' import * as debug from '../util/debug' -import { EVENTS, Session } from '@inrupt/solid-client-authn-browser' +import { SessionWithLegacyEvents } from '../authSession/authSession' import { AuthenticationContext, AuthnLogic } from '../types' export class SolidAuthnLogic implements AuthnLogic { - private session: Session + private session: SessionWithLegacyEvents - constructor(solidAuthSession: Session) { + constructor(solidAuthSession: SessionWithLegacyEvents) { this.session = solidAuthSession } // we created authSession getter because we want to access it as authn.authSession externally - get authSession():Session { return this.session } + get authSession(): SessionWithLegacyEvents { return this.session } currentUser(): NamedNode | null { const app = appContext() if (app.viewingNoAuthPage) { return sym(app.webId) } - if (this && this.session && this.session.info && this.session.info.webId && this.session.info.isLoggedIn) { - return sym(this.session.info.webId) + const sessionAny = this.session as any + const webId = sessionAny?.info?.webId || sessionAny?.webId + const isLoggedIn = sessionAny?.info?.isLoggedIn ?? sessionAny?.isActive ?? Boolean(webId) + if (this && this.session && webId && isLoggedIn) { + return sym(webId) } return offlineTestID() // null unless testing } @@ -40,21 +43,51 @@ export class SolidAuthnLogic implements AuthnLogic { if (preLoginRedirectHash) { window.localStorage.setItem('preLoginRedirectHash', preLoginRedirectHash) } - this.session.events.on(EVENTS.SESSION_RESTORED, (url) => { - debug.log(`Session restored to ${url}`) - if (document.location.toString() !== url) history.replaceState(null, '', url) - }) + const sessionAny = this.session as any + if (typeof sessionAny?.events?.on === 'function') { + // Backward-compatible hook for auth clients exposing an EventEmitter-style API. + sessionAny.events.on('sessionRestore', (url: string) => { + debug.log(`Session restored to ${url}`) + if (document.location.toString() !== url) history.replaceState(null, '', url) + }) + } /** * Handle a successful authentication redirect */ const redirectUrl = new URL(window.location.href) redirectUrl.hash = '' - await this.session - .handleIncomingRedirect({ + if (typeof sessionAny?.handleIncomingRedirect === 'function') { + await sessionAny.handleIncomingRedirect({ restorePreviousSession: true, url: redirectUrl.href }) + } else { + if (typeof sessionAny?.restore === 'function') { + const wasActive = sessionAny?.isActive ?? Boolean(sessionAny?.webId) + try { + await sessionAny.restore() + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (!/no session to restore/i.test(message)) { + throw error + } + debug.log('No previous session to restore') + } + const isNowActive = sessionAny?.isActive ?? Boolean(sessionAny?.webId) + if (!wasActive && isNowActive) { + sessionAny.events?.emit('sessionRestore', window.location.href) + } + } + if (typeof sessionAny?.handleRedirectFromLogin === 'function') { + const wasActive = sessionAny?.isActive ?? Boolean(sessionAny?.webId) + await sessionAny.handleRedirectFromLogin() + const isNowActive = sessionAny?.isActive ?? Boolean(sessionAny?.webId) + if (!wasActive && isNowActive) { + sessionAny.events?.emit('login') + } + } + } // Check to see if a hash was stored in local storage const postLoginRedirectHash = window.localStorage.getItem('preLoginRedirectHash') @@ -81,7 +114,7 @@ export class SolidAuthnLogic implements AuthnLogic { return Promise.resolve(setUserCallback ? setUserCallback(me) : me) } - const webId = this.webIdFromSession(this.session.info) + const webId = this.webIdFromSession(sessionAny?.info || sessionAny) if (webId) { me = this.saveUser(webId) } @@ -119,8 +152,17 @@ export class SolidAuthnLogic implements AuthnLogic { /** * @returns {Promise} Resolves with WebID URI or null */ - webIdFromSession (session?: { webId?: string, isLoggedIn: boolean }): string | null { - const webId = session?.webId && session.isLoggedIn ? session.webId : null + webIdFromSession (session?: { webId?: string, isLoggedIn?: boolean, isActive?: boolean }): string | null { + const webId = session?.webId + if (!webId) { + return null + } + if (typeof session?.isLoggedIn === 'boolean') { + return session.isLoggedIn ? webId : null + } + if (typeof session?.isActive === 'boolean') { + return session.isActive ? webId : null + } return webId } diff --git a/src/logic/solidLogic.ts b/src/logic/solidLogic.ts index 9c62391a..18fb4cae 100644 --- a/src/logic/solidLogic.ts +++ b/src/logic/solidLogic.ts @@ -1,8 +1,8 @@ -import { Session } from '@inrupt/solid-client-authn-browser' import * as rdf from 'rdflib' import { LiveStore, NamedNode, Statement } from 'rdflib' import { createAclLogic } from '../acl/aclLogic' import { SolidAuthnLogic } from '../authn/SolidAuthnLogic' +import { SessionWithLegacyEvents } from '../authSession/authSession' import { createChatLogic } from '../chat/chatLogic' import { createInboxLogic } from '../inbox/inboxLogic' import { createProfileLogic } from '../profile/profileLogic' @@ -17,7 +17,7 @@ import * as debug from '../util/debug' ** into a `ConnectedStore` or a `LiveStore`. A Fetcher object is ** available at store.fetcher, and `fetch` function at `store.fetcher._fetch`, */ -export function createSolidLogic(specialFetch: { fetch: (url: any, requestInit: any) => any }, session: Session): SolidLogic { +export function createSolidLogic(specialFetch: { fetch: (url: any, requestInit: any) => any }, session: SessionWithLegacyEvents): SolidLogic { debug.log('SolidLogic: Unique instance created. There should only be one of these.') const store: LiveStore = rdf.graph() as LiveStore diff --git a/src/logic/solidLogicSingleton.ts b/src/logic/solidLogicSingleton.ts index fed3e235..8320b6ff 100644 --- a/src/logic/solidLogicSingleton.ts +++ b/src/logic/solidLogicSingleton.ts @@ -5,9 +5,17 @@ import { SolidLogic } from '../types' const _fetch = async (url, requestInit) => { const omitCreds = requestInit && requestInit.credentials && requestInit.credentials == 'omit' - if (authSession.info.webId && !omitCreds) { // see https://github.com/solidos/solidos/issues/114 + const sessionAny = authSession as any + const sessionWebId = sessionAny?.info?.webId || sessionAny?.webId + if (sessionWebId && !omitCreds) { // see https://github.com/solidos/solidos/issues/114 // In fact fetch should respect credentials omit itself - return authSession.fetch(url, requestInit) + const authenticatedFetch = (typeof sessionAny.fetch === 'function') + ? sessionAny.fetch.bind(sessionAny) + : (typeof sessionAny.authFetch === 'function' ? sessionAny.authFetch.bind(sessionAny) : null) + if (authenticatedFetch) { + return authenticatedFetch(url, requestInit) + } + return window.fetch(url, requestInit) } else { return window.fetch(url, requestInit) } diff --git a/src/types.ts b/src/types.ts index 62a65850..82fc7624 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -import { Session } from '@inrupt/solid-client-authn-browser' +import { SessionWithLegacyEvents } from './authSession/authSession' import { LiveStore, NamedNode, Statement } from 'rdflib' export type AppDetails = { @@ -21,7 +21,7 @@ export type AuthenticationContext = { } export interface AuthnLogic { - authSession: Session //this needs to be deprecated in the future. Is only here to allow imports like panes.UI.authn.authSession prior to moving authn from ui to logic + authSession: SessionWithLegacyEvents //this needs to be deprecated in the future. Is only here to allow imports like panes.UI.authn.authSession prior to moving authn from ui to logic currentUser: () => NamedNode | null checkUser: (setUserCallback?: (me: NamedNode | null) => T) => Promise saveUser: (webId: NamedNode | string | null, diff --git a/test/mocks/solid-oidc-client-browser.ts b/test/mocks/solid-oidc-client-browser.ts new file mode 100644 index 00000000..76e41bb5 --- /dev/null +++ b/test/mocks/solid-oidc-client-browser.ts @@ -0,0 +1,53 @@ +type Listener = (...args: any[]) => void + +class EventEmitterLike { + private listeners: Record = {} + + on(event: string, listener: Listener): void { + const list = this.listeners[event] || [] + list.push(listener) + this.listeners[event] = list + } + + emit(event: string, ...args: any[]): void { + const list = this.listeners[event] || [] + list.forEach(listener => listener(...args)) + } +} + +export class Session { + info: { webId?: string, isLoggedIn: boolean } = { isLoggedIn: false } + webId?: string + isActive = false + events = new EventEmitterLike() + + async handleIncomingRedirect(): Promise { + return + } + + async handleRedirectFromLogin(): Promise { + return + } + + async restore(): Promise { + return + } + + async login(): Promise { + return + } + + async logout(): Promise { + this.info = { isLoggedIn: false } + this.webId = undefined + this.isActive = false + } + + fetch(input: RequestInfo | URL, init?: RequestInit): Promise { + return globalThis.fetch(input, init) + } + + authFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + return globalThis.fetch(input, init) + } +} diff --git a/test/solidAuthLogic.test.ts b/test/solidAuthLogic.test.ts index 4d1d6336..b298ab01 100644 --- a/test/solidAuthLogic.test.ts +++ b/test/solidAuthLogic.test.ts @@ -10,6 +10,20 @@ import { AuthenticationContext } from '../src/types' silenceDebugMessages() let solidAuthnLogic +jest.mock('../src/authSession/authSession', () => { + const EventEmitter = require('events'); + const authSession = { + events: new EventEmitter(), + addEventListener: function (event, listener) { + this.events.on(event, listener); + }, + removeEventListener: function (event, listener) { + this.events.off(event, listener); + }, + }; + return { authSession }; +}); + describe('SolidAuthnLogic', () => { beforeEach(() => { From ce9cf44ed18e8186475034b640163970dbb5d455 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Wed, 20 May 2026 19:47:02 +0200 Subject: [PATCH 02/23] lint-fix --- package.json | 1 + test/solidAuthLogic.test.ts | 12 ++++++------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index c311a3aa..dca2db12 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "build-dist": "webpack --progress", "postbuild-js": "rm -f dist/versionInfo.d.ts dist/versionInfo.d.ts.map", "lint": "eslint", + "lint-fix": "eslint --fix", "typecheck": "tsc --noEmit", "typecheck-test": "tsc --noEmit -p tsconfig.test.json", "test": "jest --no-coverage", diff --git a/test/solidAuthLogic.test.ts b/test/solidAuthLogic.test.ts index b298ab01..b0132a2b 100644 --- a/test/solidAuthLogic.test.ts +++ b/test/solidAuthLogic.test.ts @@ -11,18 +11,18 @@ silenceDebugMessages() let solidAuthnLogic jest.mock('../src/authSession/authSession', () => { - const EventEmitter = require('events'); + const EventEmitter = require('events') const authSession = { events: new EventEmitter(), addEventListener: function (event, listener) { - this.events.on(event, listener); + this.events.on(event, listener) }, removeEventListener: function (event, listener) { - this.events.off(event, listener); + this.events.off(event, listener) }, - }; - return { authSession }; -}); + } + return { authSession } +}) describe('SolidAuthnLogic', () => { From 0de57a1c43269db08285cb7461f798987b6be40b Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Thu, 21 May 2026 13:17:32 +0200 Subject: [PATCH 03/23] feat(auth): address Copilot review findings for uvdsl migration - Convert `SessionWithLegacyEvents` imports to type-only imports to avoid runtime side effects when importing auth-related modules - Prevent eager initialization of `authSession` via type-only usage in: - types.ts - SolidAuthnLogic.ts - solidLogic.ts - Add focused tests for fetch bridge behavior in `solidLogicSingleton`: - use `window.fetch` when `credentials: omit` - fall back to `authFetch` when `session.fetch` is unavailable - Keep migration compatibility behavior intact while improving import safety and regression coverage --- src/authn/SolidAuthnLogic.ts | 4 +-- src/logic/solidLogic.ts | 4 +-- src/types.ts | 2 +- test/logic.test.ts | 57 ++++++++++++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 5 deletions(-) diff --git a/src/authn/SolidAuthnLogic.ts b/src/authn/SolidAuthnLogic.ts index 51c2398f..1556ba36 100644 --- a/src/authn/SolidAuthnLogic.ts +++ b/src/authn/SolidAuthnLogic.ts @@ -1,8 +1,8 @@ import { namedNode, NamedNode, sym } from 'rdflib' import { appContext, offlineTestID } from './authUtil' import * as debug from '../util/debug' -import { SessionWithLegacyEvents } from '../authSession/authSession' -import { AuthenticationContext, AuthnLogic } from '../types' +import type { SessionWithLegacyEvents } from '../authSession/authSession' +import type { AuthenticationContext, AuthnLogic } from '../types' export class SolidAuthnLogic implements AuthnLogic { private session: SessionWithLegacyEvents diff --git a/src/logic/solidLogic.ts b/src/logic/solidLogic.ts index 18fb4cae..11621d15 100644 --- a/src/logic/solidLogic.ts +++ b/src/logic/solidLogic.ts @@ -2,14 +2,14 @@ import * as rdf from 'rdflib' import { LiveStore, NamedNode, Statement } from 'rdflib' import { createAclLogic } from '../acl/aclLogic' import { SolidAuthnLogic } from '../authn/SolidAuthnLogic' -import { SessionWithLegacyEvents } from '../authSession/authSession' +import type { SessionWithLegacyEvents } from '../authSession/authSession' import { createChatLogic } from '../chat/chatLogic' import { createInboxLogic } from '../inbox/inboxLogic' import { createProfileLogic } from '../profile/profileLogic' import { createTypeIndexLogic } from '../typeIndex/typeIndexLogic' import { createContainerLogic } from '../util/containerLogic' import { createUtilityLogic } from '../util/utilityLogic' -import { AuthnLogic, SolidLogic } from '../types' +import type { AuthnLogic, SolidLogic } from '../types' import * as debug from '../util/debug' /* ** It is important to distinquish `fetch`, a function provided by the browser diff --git a/src/types.ts b/src/types.ts index 82fc7624..8faa83a5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -import { SessionWithLegacyEvents } from './authSession/authSession' +import type { SessionWithLegacyEvents } from './authSession/authSession' import { LiveStore, NamedNode, Statement } from 'rdflib' export type AppDetails = { diff --git a/test/logic.test.ts b/test/logic.test.ts index 5cdb5485..e406f92e 100644 --- a/test/logic.test.ts +++ b/test/logic.test.ts @@ -1,4 +1,6 @@ import { solidLogicSingleton } from '../src/logic/solidLogicSingleton' +import { authSession } from '../src/authSession/authSession' +import fetchMock from 'jest-fetch-mock' import { silenceDebugMessages } from './helpers/debugger' silenceDebugMessages() @@ -27,3 +29,58 @@ describe('authn', () => { }) }) +describe('solidLogicSingleton fetch bridge', () => { + const singletonFetch = (solidLogicSingleton.store.fetcher as any)._fetch as (url: string, init?: RequestInit) => Promise + + let originalFetch: any + let originalAuthFetch: any + let originalWebId: any + let originalInfo: any + + beforeEach(() => { + fetchMock.resetMocks() + + const sessionAny = authSession as any + originalFetch = sessionAny.fetch + originalAuthFetch = sessionAny.authFetch + originalWebId = sessionAny.webId + originalInfo = sessionAny.info + + sessionAny.webId = undefined + sessionAny.info = { isLoggedIn: false } + }) + + afterEach(() => { + const sessionAny = authSession as any + sessionAny.fetch = originalFetch + sessionAny.authFetch = originalAuthFetch + sessionAny.webId = originalWebId + sessionAny.info = originalInfo + }) + + it('uses window.fetch when credentials are omit even if a session exists', async () => { + const sessionAny = authSession as any + sessionAny.webId = 'https://alice.example/profile#me' + sessionAny.fetch = jest.fn().mockResolvedValue(new Response('session')) + + fetchMock.mockResponseOnce('window') + + await singletonFetch('https://example.com/resource', { credentials: 'omit' }) + + expect(sessionAny.fetch).not.toHaveBeenCalled() + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('falls back to authFetch when session.fetch is unavailable', async () => { + const sessionAny = authSession as any + sessionAny.webId = 'https://alice.example/profile#me' + sessionAny.fetch = undefined + sessionAny.authFetch = jest.fn().mockResolvedValue(new Response('auth')) + + await singletonFetch('https://example.com/resource') + + expect(sessionAny.authFetch).toHaveBeenCalledTimes(1) + expect(fetchMock).not.toHaveBeenCalled() + }) +}) + From 3c65203a0f5ae4c4f0325d0e5fa4562d59efe306 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Thu, 21 May 2026 20:04:18 +0200 Subject: [PATCH 04/23] add IndexedDbSessionDatabase fallback --- jest.config.mjs | 1 + src/authSession/authSession.ts | 138 +++++++++++++++++++++++- test/mocks/solid-oidc-client-browser.ts | 32 ++++++ 3 files changed, 168 insertions(+), 3 deletions(-) diff --git a/jest.config.mjs b/jest.config.mjs index 2debc41e..2c05f9dc 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -13,6 +13,7 @@ export default { }, moduleNameMapper: { '^@uvdsl/solid-oidc-client-browser$': '/test/mocks/solid-oidc-client-browser.ts', + '^@uvdsl/solid-oidc-client-browser/core$': '/test/mocks/solid-oidc-client-browser.ts', }, setupFilesAfterEnv: ['./test/helpers/setup.ts'], testMatch: ['**/__tests__/**/*.ts?(x)', '**/?(*.)+(spec|test).ts?(x)'], diff --git a/src/authSession/authSession.ts b/src/authSession/authSession.ts index 1e33f263..681ed1b9 100644 --- a/src/authSession/authSession.ts +++ b/src/authSession/authSession.ts @@ -1,6 +1,10 @@ import { - Session, + Session as WebSession, } from '@uvdsl/solid-oidc-client-browser' +import { + SessionCore, +} from '@uvdsl/solid-oidc-client-browser/core' +import type { Session as OidcSession, SessionDatabase } from '@uvdsl/solid-oidc-client-browser/core' type LegacyEventName = 'login' | 'logout' | 'sessionRestore' type LegacyEventHandler = (...args: unknown[]) => void @@ -30,9 +34,137 @@ export class SessionEvents { } } -export type SessionWithLegacyEvents = Session & { events: SessionEvents } +type SessionCompatibilityShape = { + webId?: string + isActive?: boolean + info?: { + webId?: string + isLoggedIn?: boolean + } + fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise + authFetch?: (input: string | URL | Request, init?: RequestInit, dpopPayload?: any) => Promise +} + +export type SessionWithLegacyEvents = OidcSession & SessionCompatibilityShape & { events: SessionEvents } + +class MemorySessionDatabase implements SessionDatabase { + private readonly map = new Map() + + async init (): Promise { + return this + } + + async setItem (id: string, value: any): Promise { + this.map.set(id, value) + } + + async getItem (id: string): Promise { + return this.map.has(id) ? this.map.get(id) : null + } + + async deleteItem (id: string): Promise { + this.map.delete(id) + } + + async clear (): Promise { + this.map.clear() + } + + close (): void { + // No-op for in-memory database + } +} + +class IndexedDbSessionDatabase implements SessionDatabase { + private db: IDBDatabase | null = null + private readonly dbName = 'soidc' + private readonly storeName = 'session' + private readonly dbVersion = 1 + + async init (): Promise { + if (this.db) return this + + await new Promise((resolve, reject) => { + const request = indexedDB.open(this.dbName, this.dbVersion) + + request.onerror = () => reject(request.error) + request.onsuccess = () => { + this.db = request.result + resolve() + } + request.onupgradeneeded = () => { + const db = request.result + if (!db.objectStoreNames.contains(this.storeName)) { + db.createObjectStore(this.storeName) + } + } + }) + + return this + } + + async setItem (id: string, value: any): Promise { + await this.init() + await this.withStore('readwrite', store => store.put(value, id)) + } + + async getItem (id: string): Promise { + await this.init() + return this.withStore('readonly', store => store.get(id)) + } + + async deleteItem (id: string): Promise { + await this.init() + await this.withStore('readwrite', store => store.delete(id)) + } + + async clear (): Promise { + await this.init() + await this.withStore('readwrite', store => store.clear()) + } + + close (): void { + if (this.db) { + this.db.close() + this.db = null + } + } + + private withStore(mode: IDBTransactionMode, op: (store: IDBObjectStore) => IDBRequest): Promise { + return new Promise((resolve, reject) => { + if (!this.db) { + reject(new Error('Session database not initialized')) + return + } + + const tx = this.db.transaction(this.storeName, mode) + const store = tx.objectStore(this.storeName) + const request = op(store) + + request.onerror = () => reject(request.error) + request.onsuccess = () => resolve(request.result ?? null) + }) + } +} + +function createSession (): OidcSession { + try { + return new WebSession() + } catch (error) { + // In some deployments, worker URL resolution can become file:// and fail cross-origin. + // Fall back to SessionCore so auth still works without background refresh worker. + // Use IndexedDB to keep refresh-token persistence across page reloads. + console.warn('solid-logic: falling back to non-worker auth session:', error) + try { + return new SessionCore(undefined, { database: new IndexedDbSessionDatabase() }) + } catch (dbError) { + console.warn('solid-logic: IndexedDB unavailable, using in-memory session database:', dbError) + return new SessionCore(undefined, { database: new MemorySessionDatabase() }) + } + } +} -const _session = new Session() +const _session = createSession() const events = new SessionEvents() // Emit the legacy 'logout' event when the session transitions from active to inactive. diff --git a/test/mocks/solid-oidc-client-browser.ts b/test/mocks/solid-oidc-client-browser.ts index 76e41bb5..76668a0f 100644 --- a/test/mocks/solid-oidc-client-browser.ts +++ b/test/mocks/solid-oidc-client-browser.ts @@ -51,3 +51,35 @@ export class Session { return globalThis.fetch(input, init) } } + +export class SessionCore extends Session { + constructor(_clientDetails?: unknown, _sessionOptions?: unknown) { + super() + } +} + +export class SessionIDB { + async init(): Promise { + return this + } + + async setItem(_id: string, _value: any): Promise { + return + } + + async getItem(_id: string): Promise { + return null + } + + async deleteItem(_id: string): Promise { + return + } + + async clear(): Promise { + return + } + + close(): void { + return + } +} From 2ead52f7ec3a86f17b53b0e0093848e462a27e22 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Fri, 22 May 2026 11:00:23 +0200 Subject: [PATCH 05/23] feat(auth): export RefreshWorker asset and support same-origin worker URL override --- README.md | 28 ++++++++++++++++++++++++++++ package.json | 7 ++++++- src/authSession/authSession.ts | 24 +++++++++++++++++++++++- test/packageExports.test.ts | 14 ++++++++++++++ 4 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 test/packageExports.test.ts diff --git a/README.md b/README.md index 6d0cdca3..351b1941 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,34 @@ import { } from 'solid-logic'; ``` +## Worker Asset and Runtime Configuration + +solid-logic publishes the OIDC refresh worker as a package export so host apps can serve it from the same origin. + +### Worker export + +- Package export: `solid-logic/RefreshWorker` +- Built file: `dist/RefreshWorker.js` + +Host applications should copy or serve this file so the browser can load it with a same-origin URL. + +### Worker URL override + +Before solid-logic initializes, applications can set: + +```js +window.__SOLID_LOGIC_WORKER_URL__ = 'https://app.example.com/RefreshWorker.js' +``` + +If this override is not set, solid-logic resolves the worker URL to `./RefreshWorker.js` against `window.location.href`. + +### Session fallback behavior + +When worker session initialization fails, solid-logic falls back in this order: + +1. `SessionCore` with IndexedDB-backed session database +2. `SessionCore` with in-memory session database + # How to develop Check the scripts in the `package.json` for build, watch, lint and test. diff --git a/package.json b/package.json index dca2db12..a8afd8b7 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,10 @@ "import": "./dist/solid-logic.esm.js", "require": "./dist/solid-logic.js", "types": "./dist/index.d.ts" + }, + "./RefreshWorker": { + "import": "./dist/RefreshWorker.js", + "default": "./dist/RefreshWorker.js" } }, "sideEffects": false, @@ -21,10 +25,11 @@ ], "scripts": { "clean": "rm -rf dist src/versionInfo.ts", - "build": "npm run clean && npm run typecheck && npm run build-version && npm run build-js && npm run build-dist && npm run postbuild-js", + "build": "npm run clean && npm run typecheck && npm run build-version && npm run build-js && npm run build-dist && npm run copy-worker && npm run postbuild-js", "build-version": "./timestamp.sh > src/versionInfo.ts && eslint 'src/versionInfo.ts' --fix", "build-js": "tsc", "build-dist": "webpack --progress", + "copy-worker": "node -e \"const fs=require('fs');const path=require('path');const src=path.resolve('node_modules/@uvdsl/solid-oidc-client-browser/dist/esm/web/RefreshWorker.js');const dst=path.resolve('dist/RefreshWorker.js');fs.copyFileSync(src,dst);\"", "postbuild-js": "rm -f dist/versionInfo.d.ts dist/versionInfo.d.ts.map", "lint": "eslint", "lint-fix": "eslint --fix", diff --git a/src/authSession/authSession.ts b/src/authSession/authSession.ts index 681ed1b9..a4cdff06 100644 --- a/src/authSession/authSession.ts +++ b/src/authSession/authSession.ts @@ -147,9 +147,31 @@ class IndexedDbSessionDatabase implements SessionDatabase { } } +function resolveWorkerUrl (): string | URL | undefined { + if (typeof window === 'undefined') return undefined + + const explicitWorkerUrl = (window as any).__SOLID_LOGIC_WORKER_URL__ + if (typeof explicitWorkerUrl === 'string' && explicitWorkerUrl.trim().length > 0) { + return explicitWorkerUrl + } + if (explicitWorkerUrl instanceof URL) { + return explicitWorkerUrl + } + + try { + // Default to same-origin sibling asset next to the current page URL. + return new URL('./RefreshWorker.js', window.location.href).toString() + } catch { + return undefined + } +} + function createSession (): OidcSession { try { - return new WebSession() + const workerUrl = resolveWorkerUrl() + return workerUrl + ? new WebSession(undefined, { workerUrl }) + : new WebSession() } catch (error) { // In some deployments, worker URL resolution can become file:// and fail cross-origin. // Fall back to SessionCore so auth still works without background refresh worker. diff --git a/test/packageExports.test.ts b/test/packageExports.test.ts new file mode 100644 index 00000000..fea73f03 --- /dev/null +++ b/test/packageExports.test.ts @@ -0,0 +1,14 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +describe('package exports', () => { + it('exports RefreshWorker.js from dist', () => { + const packageJsonPath = resolve(__dirname, '..', 'package.json') + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) + + expect(packageJson.exports['./RefreshWorker']).toEqual({ + import: './dist/RefreshWorker.js', + default: './dist/RefreshWorker.js', + }) + }) +}) \ No newline at end of file From 562d2b4231ce6d539d64a21176b41942a8f4cac0 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Fri, 22 May 2026 17:06:21 +0000 Subject: [PATCH 06/23] update RefreshWorker URL --- src/authSession/authSession.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/authSession/authSession.ts b/src/authSession/authSession.ts index a4cdff06..9953ac82 100644 --- a/src/authSession/authSession.ts +++ b/src/authSession/authSession.ts @@ -160,7 +160,7 @@ function resolveWorkerUrl (): string | URL | undefined { try { // Default to same-origin sibling asset next to the current page URL. - return new URL('./RefreshWorker.js', window.location.href).toString() + new URL('/RefreshWorker.js', window.location.origin).toString() } catch { return undefined } @@ -205,4 +205,4 @@ if (typeof (_session as unknown as EventTarget).addEventListener === 'function') export const authSession: SessionWithLegacyEvents = Object.assign(_session, { events }) - \ No newline at end of file + From 3d9d428c6150c1cd1248ba57c79af0579c6deae6 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Fri, 22 May 2026 19:49:34 +0200 Subject: [PATCH 07/23] revert(auth): remove RefreshWorker integration and URL override --- README.md | 28 ---------------------------- package.json | 7 +------ src/authSession/authSession.ts | 26 ++------------------------ test/packageExports.test.ts | 14 -------------- 4 files changed, 3 insertions(+), 72 deletions(-) delete mode 100644 test/packageExports.test.ts diff --git a/README.md b/README.md index 351b1941..6d0cdca3 100644 --- a/README.md +++ b/README.md @@ -113,34 +113,6 @@ import { } from 'solid-logic'; ``` -## Worker Asset and Runtime Configuration - -solid-logic publishes the OIDC refresh worker as a package export so host apps can serve it from the same origin. - -### Worker export - -- Package export: `solid-logic/RefreshWorker` -- Built file: `dist/RefreshWorker.js` - -Host applications should copy or serve this file so the browser can load it with a same-origin URL. - -### Worker URL override - -Before solid-logic initializes, applications can set: - -```js -window.__SOLID_LOGIC_WORKER_URL__ = 'https://app.example.com/RefreshWorker.js' -``` - -If this override is not set, solid-logic resolves the worker URL to `./RefreshWorker.js` against `window.location.href`. - -### Session fallback behavior - -When worker session initialization fails, solid-logic falls back in this order: - -1. `SessionCore` with IndexedDB-backed session database -2. `SessionCore` with in-memory session database - # How to develop Check the scripts in the `package.json` for build, watch, lint and test. diff --git a/package.json b/package.json index a8afd8b7..dca2db12 100644 --- a/package.json +++ b/package.json @@ -11,10 +11,6 @@ "import": "./dist/solid-logic.esm.js", "require": "./dist/solid-logic.js", "types": "./dist/index.d.ts" - }, - "./RefreshWorker": { - "import": "./dist/RefreshWorker.js", - "default": "./dist/RefreshWorker.js" } }, "sideEffects": false, @@ -25,11 +21,10 @@ ], "scripts": { "clean": "rm -rf dist src/versionInfo.ts", - "build": "npm run clean && npm run typecheck && npm run build-version && npm run build-js && npm run build-dist && npm run copy-worker && npm run postbuild-js", + "build": "npm run clean && npm run typecheck && npm run build-version && npm run build-js && npm run build-dist && npm run postbuild-js", "build-version": "./timestamp.sh > src/versionInfo.ts && eslint 'src/versionInfo.ts' --fix", "build-js": "tsc", "build-dist": "webpack --progress", - "copy-worker": "node -e \"const fs=require('fs');const path=require('path');const src=path.resolve('node_modules/@uvdsl/solid-oidc-client-browser/dist/esm/web/RefreshWorker.js');const dst=path.resolve('dist/RefreshWorker.js');fs.copyFileSync(src,dst);\"", "postbuild-js": "rm -f dist/versionInfo.d.ts dist/versionInfo.d.ts.map", "lint": "eslint", "lint-fix": "eslint --fix", diff --git a/src/authSession/authSession.ts b/src/authSession/authSession.ts index 9953ac82..681ed1b9 100644 --- a/src/authSession/authSession.ts +++ b/src/authSession/authSession.ts @@ -147,31 +147,9 @@ class IndexedDbSessionDatabase implements SessionDatabase { } } -function resolveWorkerUrl (): string | URL | undefined { - if (typeof window === 'undefined') return undefined - - const explicitWorkerUrl = (window as any).__SOLID_LOGIC_WORKER_URL__ - if (typeof explicitWorkerUrl === 'string' && explicitWorkerUrl.trim().length > 0) { - return explicitWorkerUrl - } - if (explicitWorkerUrl instanceof URL) { - return explicitWorkerUrl - } - - try { - // Default to same-origin sibling asset next to the current page URL. - new URL('/RefreshWorker.js', window.location.origin).toString() - } catch { - return undefined - } -} - function createSession (): OidcSession { try { - const workerUrl = resolveWorkerUrl() - return workerUrl - ? new WebSession(undefined, { workerUrl }) - : new WebSession() + return new WebSession() } catch (error) { // In some deployments, worker URL resolution can become file:// and fail cross-origin. // Fall back to SessionCore so auth still works without background refresh worker. @@ -205,4 +183,4 @@ if (typeof (_session as unknown as EventTarget).addEventListener === 'function') export const authSession: SessionWithLegacyEvents = Object.assign(_session, { events }) - + \ No newline at end of file diff --git a/test/packageExports.test.ts b/test/packageExports.test.ts deleted file mode 100644 index fea73f03..00000000 --- a/test/packageExports.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { readFileSync } from 'node:fs' -import { resolve } from 'node:path' - -describe('package exports', () => { - it('exports RefreshWorker.js from dist', () => { - const packageJsonPath = resolve(__dirname, '..', 'package.json') - const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) - - expect(packageJson.exports['./RefreshWorker']).toEqual({ - import: './dist/RefreshWorker.js', - default: './dist/RefreshWorker.js', - }) - }) -}) \ No newline at end of file From 07881e3eba26077940390fc09f1b14e5ebcfd107 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Sat, 23 May 2026 19:59:18 +0200 Subject: [PATCH 08/23] fix(auth): avoid SharedWorker startup on localhost dev http Skip WebSession worker initialization for localhost/127.0.0.1 over http and use SessionCore with IndexedDB directly. This prevents browser SecurityError noise from file:// worker resolution in local dev while keeping normal session behavior in other environments. --- src/authSession/authSession.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/authSession/authSession.ts b/src/authSession/authSession.ts index 681ed1b9..fd8b9e30 100644 --- a/src/authSession/authSession.ts +++ b/src/authSession/authSession.ts @@ -148,6 +148,14 @@ class IndexedDbSessionDatabase implements SessionDatabase { } function createSession (): OidcSession { + const shouldSkipWorkerInLocalDev = typeof window !== 'undefined' && + window.location.protocol === 'http:' && + /^(localhost|127\.0\.0\.1)$/.test(window.location.hostname) + + if (shouldSkipWorkerInLocalDev) { + return new SessionCore(undefined, { database: new IndexedDbSessionDatabase() }) + } + try { return new WebSession() } catch (error) { From 39b67e41fd1a9f60d392933d7c537e243febfdfa Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Mon, 25 May 2026 16:45:46 +0200 Subject: [PATCH 09/23] fix(auth): accept object-style login args for UVDSL session --- src/authSession/authSession.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/authSession/authSession.ts b/src/authSession/authSession.ts index fd8b9e30..4f2304a9 100644 --- a/src/authSession/authSession.ts +++ b/src/authSession/authSession.ts @@ -175,6 +175,25 @@ function createSession (): OidcSession { const _session = createSession() const events = new SessionEvents() +const sessionAny = _session as any +const originalLogin = typeof sessionAny.login === 'function' + ? sessionAny.login.bind(_session) + : undefined + +if (originalLogin) { + // Keep compatibility with older call sites that pass an options object. + sessionAny.login = async (idpOrOptions: any, redirectUri?: string) => { + if (idpOrOptions && typeof idpOrOptions === 'object' && !Array.isArray(idpOrOptions)) { + const oidcIssuer = idpOrOptions.oidcIssuer ?? idpOrOptions.idp ?? idpOrOptions.issuer + const redirectUrl = idpOrOptions.redirectUrl ?? idpOrOptions.redirect_uri ?? idpOrOptions.redirectUri + if (typeof oidcIssuer === 'string' && typeof redirectUrl === 'string') { + return originalLogin(oidcIssuer, redirectUrl) + } + } + return originalLogin(idpOrOptions, redirectUri) + } +} + // 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. From 81f4c4fee780315f87d70d83017f3594baaead4e Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Fri, 29 May 2026 11:59:02 +0200 Subject: [PATCH 10/23] fix(auth): handle NSS local issuer and cookie-backed logout --- src/authSession/authSession.ts | 80 +++++++++++++++++++++---- src/authn/SolidAuthnLogic.ts | 105 +++++++++++++++++++++++++++++---- src/authn/serverLogout.ts | 40 +++++++++++++ src/index.ts | 1 + 4 files changed, 203 insertions(+), 23 deletions(-) create mode 100644 src/authn/serverLogout.ts diff --git a/src/authSession/authSession.ts b/src/authSession/authSession.ts index 4f2304a9..a44065f6 100644 --- a/src/authSession/authSession.ts +++ b/src/authSession/authSession.ts @@ -1,9 +1,7 @@ import { Session as WebSession, } from '@uvdsl/solid-oidc-client-browser' -import { - SessionCore, -} from '@uvdsl/solid-oidc-client-browser/core' +import * as OidcCore from '@uvdsl/solid-oidc-client-browser/core' import type { Session as OidcSession, SessionDatabase } from '@uvdsl/solid-oidc-client-browser/core' type LegacyEventName = 'login' | 'logout' | 'sessionRestore' @@ -140,20 +138,47 @@ class IndexedDbSessionDatabase implements SessionDatabase { const tx = this.db.transaction(this.storeName, mode) const store = tx.objectStore(this.storeName) const request = op(store) + let result: any = null + + tx.onerror = () => reject(tx.error ?? request.error) + tx.onabort = () => reject(tx.error ?? request.error ?? new Error('IndexedDB transaction aborted')) + tx.oncomplete = () => resolve(result) request.onerror = () => reject(request.error) - request.onsuccess = () => resolve(request.result ?? null) + request.onsuccess = () => { + result = request.result ?? null + } }) } } +function getSessionCoreCtor (): (new (...args: any[]) => OidcSession) | null { + const coreAny = OidcCore as any + const candidate = coreAny.SessionCore ?? coreAny.default?.SessionCore ?? coreAny.default + + if (typeof candidate !== 'function') { + return null + } + + return candidate as new (...args: any[]) => OidcSession +} + +const SessionCoreCtor = getSessionCoreCtor() + function createSession (): OidcSession { - const shouldSkipWorkerInLocalDev = typeof window !== 'undefined' && - window.location.protocol === 'http:' && - /^(localhost|127\.0\.0\.1)$/.test(window.location.hostname) + const shouldSkipWorkerInLocalDev = typeof window !== 'undefined' && (() => { + const host = window.location.hostname + // In local NSS setups (including subdomain mode like alice.localhost), + // worker-based session storage can be brittle and lose state on reload. + // Prefer SessionCore + IndexedDB for deterministic persistence. + return host === 'localhost' || host === '127.0.0.1' || host.endsWith('.localhost') + })() if (shouldSkipWorkerInLocalDev) { - return new SessionCore(undefined, { database: new IndexedDbSessionDatabase() }) + if (SessionCoreCtor) { + return new SessionCoreCtor(undefined, { database: new IndexedDbSessionDatabase() }) + } + return new WebSession() } try { @@ -164,10 +189,16 @@ function createSession (): OidcSession { // Use IndexedDB to keep refresh-token persistence across page reloads. console.warn('solid-logic: falling back to non-worker auth session:', error) try { - return new SessionCore(undefined, { database: new IndexedDbSessionDatabase() }) + if (SessionCoreCtor) { + return new SessionCoreCtor(undefined, { database: new IndexedDbSessionDatabase() }) + } + return new WebSession() } catch (dbError) { console.warn('solid-logic: IndexedDB unavailable, using in-memory session database:', dbError) - return new SessionCore(undefined, { database: new MemorySessionDatabase() }) + if (SessionCoreCtor) { + return new SessionCoreCtor(undefined, { database: new MemorySessionDatabase() }) + } + return new WebSession() } } } @@ -180,6 +211,30 @@ const originalLogin = typeof sessionAny.login === 'function' ? sessionAny.login.bind(_session) : undefined +function normalizeIssuerForLocalhostSubdomain (issuer: string, redirectUrl?: string): string { + try { + const issuerUrl = new URL(issuer) + const issuerHost = issuerUrl.hostname + // NSS local mode advertises localhost as issuer even when apps run on pod subdomains. + if (!issuerHost.endsWith('.localhost') || issuerHost === 'localhost') { + return issuer + } + + if (redirectUrl) { + const redirectHost = new URL(redirectUrl).hostname + // NSS local deployments use a root IdP (localhost) with pod subdomain apps. + if (!(redirectHost === 'localhost' || redirectHost.endsWith('.localhost'))) { + return issuer + } + } + + issuerUrl.hostname = 'localhost' + return issuerUrl.toString().replace(/\/$/, '') + } catch (_err) { + return issuer + } +} + if (originalLogin) { // Keep compatibility with older call sites that pass an options object. sessionAny.login = async (idpOrOptions: any, redirectUri?: string) => { @@ -187,9 +242,12 @@ if (originalLogin) { const oidcIssuer = idpOrOptions.oidcIssuer ?? idpOrOptions.idp ?? idpOrOptions.issuer const redirectUrl = idpOrOptions.redirectUrl ?? idpOrOptions.redirect_uri ?? idpOrOptions.redirectUri if (typeof oidcIssuer === 'string' && typeof redirectUrl === 'string') { - return originalLogin(oidcIssuer, redirectUrl) + return originalLogin(normalizeIssuerForLocalhostSubdomain(oidcIssuer, redirectUrl), redirectUrl) } } + if (typeof idpOrOptions === 'string') { + return originalLogin(normalizeIssuerForLocalhostSubdomain(idpOrOptions, redirectUri), redirectUri) + } return originalLogin(idpOrOptions, redirectUri) } } diff --git a/src/authn/SolidAuthnLogic.ts b/src/authn/SolidAuthnLogic.ts index 1556ba36..6aa198c0 100644 --- a/src/authn/SolidAuthnLogic.ts +++ b/src/authn/SolidAuthnLogic.ts @@ -6,6 +6,9 @@ import type { AuthenticationContext, AuthnLogic } from '../types' export class SolidAuthnLogic implements AuthnLogic { private session: SessionWithLegacyEvents + private checkUserInFlight: Promise | null = null + private sessionRestoreHookAttached = false + private fallbackWebId: string | null = null constructor(solidAuthSession: SessionWithLegacyEvents) { this.session = solidAuthSession @@ -20,8 +23,14 @@ export class SolidAuthnLogic implements AuthnLogic { return sym(app.webId) } const sessionAny = this.session as any - const webId = sessionAny?.info?.webId || sessionAny?.webId - const isLoggedIn = sessionAny?.info?.isLoggedIn ?? sessionAny?.isActive ?? Boolean(webId) + 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) } @@ -44,14 +53,35 @@ export class SolidAuthnLogic implements AuthnLogic { window.localStorage.setItem('preLoginRedirectHash', preLoginRedirectHash) } const sessionAny = this.session as any - if (typeof sessionAny?.events?.on === 'function') { + if (!this.sessionRestoreHookAttached && typeof sessionAny?.events?.on === 'function') { // Backward-compatible hook for auth clients exposing an EventEmitter-style API. sessionAny.events.on('sessionRestore', (url: string) => { debug.log(`Session restored to ${url}`) if (document.location.toString() !== url) history.replaceState(null, '', url) }) + this.sessionRestoreHookAttached = true } + if (!this.checkUserInFlight) { + this.checkUserInFlight = this.resolveCurrentUser() + } + + const inFlight = this.checkUserInFlight + let me: NamedNode | null + try { + me = await inFlight + } finally { + if (this.checkUserInFlight === inFlight) { + this.checkUserInFlight = null + } + } + + return Promise.resolve(setUserCallback ? setUserCallback(me) : me) + } + + private async resolveCurrentUser (): Promise { + const sessionAny = this.session as any + /** * Handle a successful authentication redirect */ @@ -111,10 +141,21 @@ export class SolidAuthnLogic implements AuthnLogic { // Check to see if already logged in / have the WebID let me = offlineTestID() if (me) { - return Promise.resolve(setUserCallback ? setUserCallback(me) : me) + return me + } + + 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 } - const webId = this.webIdFromSession(sessionAny?.info || sessionAny) if (webId) { me = this.saveUser(webId) } @@ -123,7 +164,41 @@ export class SolidAuthnLogic implements AuthnLogic { debug.log(`(Logged in as ${me} by authentication)`) } - return Promise.resolve(setUserCallback ? setUserCallback(me) : me) + return me + } + + private async probeNssCookieBackedWebId (): Promise { + if (typeof window === 'undefined') { + return null + } + + const { hostname, port, protocol } = window.location + const localhostSuffix = '.localhost' + // NSS local pods use subdomains like alice.localhost. + if (!hostname.endsWith(localhostSuffix)) { + return null + } + + const podName = hostname.slice(0, -localhostSuffix.length) + if (!podName || podName === 'localhost' || podName.includes('.')) { + return null + } + + try { + // NSS returns 403 on this account page when the cookie session is valid. + const probeResponse = await fetch('/account/password/change', { + credentials: 'include', + redirect: 'manual', + cache: 'no-store' + }) + if (probeResponse.status !== 403) { + return null + } + const origin = `${protocol}//${hostname}${port ? `:${port}` : ''}` + return `${origin}/profile/card#me` + } catch (_error) { + return null + } } /** @@ -152,16 +227,22 @@ export class SolidAuthnLogic implements AuthnLogic { /** * @returns {Promise} Resolves with WebID URI or null */ - webIdFromSession (session?: { webId?: string, isLoggedIn?: boolean, isActive?: boolean }): string | null { - const webId = session?.webId + webIdFromSession ( + sessionInfo?: { webId?: string, isLoggedIn?: boolean }, + sessionRoot?: { webId?: string, isLoggedIn?: boolean, isActive?: boolean } + ): string | null { + const webId = sessionInfo?.webId || sessionRoot?.webId if (!webId) { return null } - if (typeof session?.isLoggedIn === 'boolean') { - return session.isLoggedIn ? webId : null + const infoLoggedIn = sessionInfo?.isLoggedIn + const rootLoggedIn = sessionRoot?.isLoggedIn + const rootActive = sessionRoot?.isActive + if (infoLoggedIn === true || rootLoggedIn === true || rootActive === true) { + return webId } - if (typeof session?.isActive === 'boolean') { - return session.isActive ? webId : null + if (infoLoggedIn === false && rootLoggedIn === false && rootActive === false) { + return null } return webId } diff --git a/src/authn/serverLogout.ts b/src/authn/serverLogout.ts new file mode 100644 index 00000000..e513f012 --- /dev/null +++ b/src/authn/serverLogout.ts @@ -0,0 +1,40 @@ +export type ServerLogoutOptions = { + issuer?: string + postLogoutRedirectPath?: string +} + +export async function performServerSideLogout (options: ServerLogoutOptions = {}): Promise { + const issuer = options.issuer || '' + const postLogoutRedirectPath = options.postLogoutRedirectPath || '/' + + // Provider-specific logout endpoint discovery (OIDC end_session_endpoint). + try { + if (issuer) { + const wellKnownUri = new URL(issuer) + wellKnownUri.pathname = '/.well-known/openid-configuration' + const wellKnownResult = await fetch(wellKnownUri.toString(), { credentials: 'include' }) + + if (wellKnownResult.status === 200) { + const openidConfiguration = await wellKnownResult.json() + if (openidConfiguration && openidConfiguration.end_session_endpoint) { + await fetch(openidConfiguration.end_session_endpoint, { credentials: 'include' }) + } + } + } + } catch (_err) { + // Continue with local logout even if provider logout is unavailable. + } + + // NSS well-known logout endpoint clears cookie-backed server sessions. + try { + const logoutResponse = await fetch('/.well-known/solid/logout', { credentials: 'include' }) + if (logoutResponse.ok || logoutResponse.redirected) { + window.location.assign(postLogoutRedirectPath) + return true + } + } catch (_err) { + // Not all deployments expose this endpoint. + } + + return false +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 9095ef19..5cbb29ca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ const store = solidLogicSingleton.store export { ACL_LINK } from './acl/aclLogic' export { offlineTestID, appContext } from './authn/authUtil' +export { performServerSideLogout } from './authn/serverLogout' export { getSuggestedIssuers } from './issuer/issuerLogic' export { createTypeIndexLogic } from './typeIndex/typeIndexLogic' export type { AppDetails, SolidNamespace, AuthenticationContext, SolidLogic, ChatLogic } from './types' From 0bf2d53eba3a800723fe68be6369f90678618f40 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Fri, 29 May 2026 11:59:09 +0200 Subject: [PATCH 11/23] fix(issuer): preserve current host suggestion for subdomains --- src/issuer/issuerLogic.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/issuer/issuerLogic.ts b/src/issuer/issuerLogic.ts index eea468b0..02ecd3ea 100644 --- a/src/issuer/issuerLogic.ts +++ b/src/issuer/issuerLogic.ts @@ -27,14 +27,9 @@ export function getSuggestedIssuers (): { name: string, uri: string }[] { // Suggest the current host if not already included const { host, origin } = new URL(location.href) const hosts = issuers.map(({ uri }) => new URL(uri).host) - if (!hosts.includes(host) && !hosts.some(existing => isSubdomainOf(host, existing))) { + if (!hosts.includes(host)) { issuers.unshift({ name: host, uri: origin }) } return issuers - } - -function isSubdomainOf (subdomain: string, domain: string): boolean { - const dot = subdomain.length - domain.length - 1 - return dot > 0 && subdomain[dot] === '.' && subdomain.endsWith(domain) -} \ No newline at end of file + } \ No newline at end of file From 34c63bb53f6fa7be33361518872b197eabbe6d53 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Sat, 30 May 2026 11:38:44 +0200 Subject: [PATCH 12/23] normalize subdomain issuer from /.well-known/openid-configuration --- src/authSession/authSession.ts | 38 ++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/src/authSession/authSession.ts b/src/authSession/authSession.ts index a44065f6..8db50065 100644 --- a/src/authSession/authSession.ts +++ b/src/authSession/authSession.ts @@ -211,28 +211,34 @@ const originalLogin = typeof sessionAny.login === 'function' ? sessionAny.login.bind(_session) : undefined -function normalizeIssuerForLocalhostSubdomain (issuer: string, redirectUrl?: string): string { +async function discoverIssuerFromWellKnown (issuer: string): Promise { try { const issuerUrl = new URL(issuer) - const issuerHost = issuerUrl.hostname - // NSS local mode advertises localhost as issuer even when apps run on pod subdomains. - if (!issuerHost.endsWith('.localhost') || issuerHost === 'localhost') { - return issuer + const wellKnownUrl = new URL('/.well-known/openid-configuration', issuerUrl.origin) + const wellKnownResponse = await fetch(wellKnownUrl.toString(), { credentials: 'include' }) + if (!wellKnownResponse.ok) { + return null } - if (redirectUrl) { - const redirectHost = new URL(redirectUrl).hostname - // NSS local deployments use a root IdP (localhost) with pod subdomain apps. - if (!(redirectHost === 'localhost' || redirectHost.endsWith('.localhost'))) { - return issuer - } + const wellKnownPayload = await wellKnownResponse.json() + if (typeof wellKnownPayload?.issuer !== 'string' || !wellKnownPayload.issuer) { + return null } - issuerUrl.hostname = 'localhost' - return issuerUrl.toString().replace(/\/$/, '') + return wellKnownPayload.issuer.replace(/\/$/, '') } catch (_err) { - return issuer + return null + } +} + +async function resolveIssuerForLogin (issuer: string): Promise { + // Prefer the issuer advertised by discovery; if app and issuer hosts still differ, + // redirecting to the canonical issuer host is cleaner than rewriting the issuer here. + const discoveredIssuer = await discoverIssuerFromWellKnown(issuer) + if (discoveredIssuer) { + return discoveredIssuer } + return issuer } if (originalLogin) { @@ -242,11 +248,11 @@ if (originalLogin) { const oidcIssuer = idpOrOptions.oidcIssuer ?? idpOrOptions.idp ?? idpOrOptions.issuer const redirectUrl = idpOrOptions.redirectUrl ?? idpOrOptions.redirect_uri ?? idpOrOptions.redirectUri if (typeof oidcIssuer === 'string' && typeof redirectUrl === 'string') { - return originalLogin(normalizeIssuerForLocalhostSubdomain(oidcIssuer, redirectUrl), redirectUrl) + return originalLogin(await resolveIssuerForLogin(oidcIssuer), redirectUrl) } } if (typeof idpOrOptions === 'string') { - return originalLogin(normalizeIssuerForLocalhostSubdomain(idpOrOptions, redirectUri), redirectUri) + return originalLogin(await resolveIssuerForLogin(idpOrOptions), redirectUri) } return originalLogin(idpOrOptions, redirectUri) } From 82f584c1d0d9a5cf13ea1b07526fbd00eaa17c95 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Sat, 30 May 2026 18:53:06 +0200 Subject: [PATCH 13/23] Preserve existing refresh_token when new value is null/empty --- src/authSession/authSession.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/authSession/authSession.ts b/src/authSession/authSession.ts index 8db50065..ff93a4f0 100644 --- a/src/authSession/authSession.ts +++ b/src/authSession/authSession.ts @@ -48,11 +48,20 @@ export type SessionWithLegacyEvents = OidcSession & SessionCompatibilityShape & class MemorySessionDatabase implements SessionDatabase { private readonly map = new Map() + private shouldPreserveExistingRefreshToken(id: string, value: any): boolean { + return id === 'refresh_token' && (value == null || value === '') && this.map.has(id) + } + async init (): Promise { return this } async setItem (id: string, value: any): Promise { + // Some Solid IdPs do not include refresh_token on refresh responses. + // Keep the previous token instead of overwriting it with null/undefined. + if (this.shouldPreserveExistingRefreshToken(id, value)) { + return + } this.map.set(id, value) } @@ -79,6 +88,14 @@ class IndexedDbSessionDatabase implements SessionDatabase { private readonly storeName = 'session' private readonly dbVersion = 1 + private async shouldPreserveExistingRefreshToken(id: string, value: any): Promise { + if (id !== 'refresh_token' || !(value == null || value === '')) { + return false + } + const existing = await this.getItem(id) + return existing != null && existing !== '' + } + async init (): Promise { if (this.db) return this @@ -103,6 +120,11 @@ class IndexedDbSessionDatabase implements SessionDatabase { async setItem (id: string, value: any): Promise { await this.init() + // Some Solid IdPs do not include refresh_token on refresh responses. + // Keep the previous token instead of overwriting it with null/undefined. + if (await this.shouldPreserveExistingRefreshToken(id, value)) { + return + } await this.withStore('readwrite', store => store.put(value, id)) } From cd388310604486b5047593175f8d647555e82908 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Fri, 5 Jun 2026 16:57:05 +0200 Subject: [PATCH 14/23] refactor(authSession): split into session, events, issuer modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - session.ts: OIDC session factory (WebSession/SessionCore, database backends) - events.ts: SessionEvents class (pure EventEmitter shim, zero side-effects) - issuer.ts: issuer discovery via /.well-known/openid-configuration - authSession.ts: login compatibility shim, legacy event wiring, authSession assembly; re-exports authSession and SessionWithLegacyEvents type All existing imports continue working unchanged — authSession.ts is a drop-in replacement with the same exports. --- src/authSession/authSession.ts | 280 +++++---------------------------- src/authSession/events.ts | 35 +++++ src/authSession/issuer.ts | 36 +++++ src/authSession/session.ts | 218 +++++++++++++++++++++++++ 4 files changed, 326 insertions(+), 243 deletions(-) create mode 100644 src/authSession/events.ts create mode 100644 src/authSession/issuer.ts create mode 100644 src/authSession/session.ts diff --git a/src/authSession/authSession.ts b/src/authSession/authSession.ts index ff93a4f0..56f5ca0c 100644 --- a/src/authSession/authSession.ts +++ b/src/authSession/authSession.ts @@ -1,36 +1,19 @@ -import { - Session as WebSession, -} from '@uvdsl/solid-oidc-client-browser' -import * as OidcCore from '@uvdsl/solid-oidc-client-browser/core' -import type { Session as OidcSession, SessionDatabase } from '@uvdsl/solid-oidc-client-browser/core' - -type LegacyEventName = 'login' | 'logout' | 'sessionRestore' -type LegacyEventHandler = (...args: unknown[]) => void - /** - * Minimal EventEmitter-style shim so that existing consumers using - * `authSession.events.on('login' | 'logout' | 'sessionRestore', handler)` - * continue working without modification. + * Auth session wiring. + * + * Takes the raw OIDC session from session.ts and layers on: + * - Login compatibility shim (normalises legacy call-site signatures + * and resolves the canonical issuer) + * - SessionEvents shim (legacy EventEmitter-style API) + * - Logout listener (emits 'logout' on session deactivation) * - * Events are emitted by SolidAuthnLogic.checkUser() (login/sessionRestore) - * and by the sessionStateChange listener below (logout). + * Exports the fully assembled authSession. */ -export class SessionEvents { - private readonly listeners: Map> = new Map() - on (event: LegacyEventName, handler: LegacyEventHandler): void { - if (!this.listeners.has(event)) this.listeners.set(event, new Set()) - this.listeners.get(event)!.add(handler) - } - - off (event: LegacyEventName, handler: LegacyEventHandler): void { - this.listeners.get(event)?.delete(handler) - } - - emit (event: LegacyEventName, ...args: unknown[]): void { - this.listeners.get(event)?.forEach(h => h(...args)) - } -} +import type { Session as OidcSession } from '@uvdsl/solid-oidc-client-browser/core' +import { _session } from './session' +import { resolveIssuerForLogin } from './issuer' +import { SessionEvents } from './events' type SessionCompatibilityShape = { webId?: string @@ -45,226 +28,32 @@ type SessionCompatibilityShape = { export type SessionWithLegacyEvents = OidcSession & SessionCompatibilityShape & { events: SessionEvents } -class MemorySessionDatabase implements SessionDatabase { - private readonly map = new Map() - - private shouldPreserveExistingRefreshToken(id: string, value: any): boolean { - return id === 'refresh_token' && (value == null || value === '') && this.map.has(id) - } - - async init (): Promise { - return this - } - - async setItem (id: string, value: any): Promise { - // Some Solid IdPs do not include refresh_token on refresh responses. - // Keep the previous token instead of overwriting it with null/undefined. - if (this.shouldPreserveExistingRefreshToken(id, value)) { - return - } - this.map.set(id, value) - } - - async getItem (id: string): Promise { - return this.map.has(id) ? this.map.get(id) : null - } - - async deleteItem (id: string): Promise { - this.map.delete(id) - } - - async clear (): Promise { - this.map.clear() - } - - close (): void { - // No-op for in-memory database - } -} - -class IndexedDbSessionDatabase implements SessionDatabase { - private db: IDBDatabase | null = null - private readonly dbName = 'soidc' - private readonly storeName = 'session' - private readonly dbVersion = 1 - - private async shouldPreserveExistingRefreshToken(id: string, value: any): Promise { - if (id !== 'refresh_token' || !(value == null || value === '')) { - return false - } - const existing = await this.getItem(id) - return existing != null && existing !== '' - } - - async init (): Promise { - if (this.db) return this - - await new Promise((resolve, reject) => { - const request = indexedDB.open(this.dbName, this.dbVersion) - - request.onerror = () => reject(request.error) - request.onsuccess = () => { - this.db = request.result - resolve() - } - request.onupgradeneeded = () => { - const db = request.result - if (!db.objectStoreNames.contains(this.storeName)) { - db.createObjectStore(this.storeName) - } - } - }) - - return this - } - - async setItem (id: string, value: any): Promise { - await this.init() - // Some Solid IdPs do not include refresh_token on refresh responses. - // Keep the previous token instead of overwriting it with null/undefined. - if (await this.shouldPreserveExistingRefreshToken(id, value)) { - return - } - await this.withStore('readwrite', store => store.put(value, id)) - } - - async getItem (id: string): Promise { - await this.init() - return this.withStore('readonly', store => store.get(id)) - } - - async deleteItem (id: string): Promise { - await this.init() - await this.withStore('readwrite', store => store.delete(id)) - } - - async clear (): Promise { - await this.init() - await this.withStore('readwrite', store => store.clear()) - } - - close (): void { - if (this.db) { - this.db.close() - this.db = null - } - } - - private withStore(mode: IDBTransactionMode, op: (store: IDBObjectStore) => IDBRequest): Promise { - return new Promise((resolve, reject) => { - if (!this.db) { - reject(new Error('Session database not initialized')) - return - } - - const tx = this.db.transaction(this.storeName, mode) - const store = tx.objectStore(this.storeName) - const request = op(store) - let result: any = null - - tx.onerror = () => reject(tx.error ?? request.error) - tx.onabort = () => reject(tx.error ?? request.error ?? new Error('IndexedDB transaction aborted')) - tx.oncomplete = () => resolve(result) - - request.onerror = () => reject(request.error) - request.onsuccess = () => { - result = request.result ?? null - } - }) - } -} - -function getSessionCoreCtor (): (new (...args: any[]) => OidcSession) | null { - const coreAny = OidcCore as any - const candidate = coreAny.SessionCore ?? coreAny.default?.SessionCore ?? coreAny.default - - if (typeof candidate !== 'function') { - return null - } - - return candidate as new (...args: any[]) => OidcSession -} - -const SessionCoreCtor = getSessionCoreCtor() - -function createSession (): OidcSession { - const shouldSkipWorkerInLocalDev = typeof window !== 'undefined' && (() => { - const host = window.location.hostname - // In local NSS setups (including subdomain mode like alice.localhost), - // worker-based session storage can be brittle and lose state on reload. - // Prefer SessionCore + IndexedDB for deterministic persistence. - return host === 'localhost' || host === '127.0.0.1' || host.endsWith('.localhost') - })() - - if (shouldSkipWorkerInLocalDev) { - if (SessionCoreCtor) { - return new SessionCoreCtor(undefined, { database: new IndexedDbSessionDatabase() }) - } - return new WebSession() - } - - try { - return new WebSession() - } catch (error) { - // In some deployments, worker URL resolution can become file:// and fail cross-origin. - // Fall back to SessionCore so auth still works without background refresh worker. - // Use IndexedDB to keep refresh-token persistence across page reloads. - console.warn('solid-logic: falling back to non-worker auth session:', error) - try { - if (SessionCoreCtor) { - return new SessionCoreCtor(undefined, { database: new IndexedDbSessionDatabase() }) - } - return new WebSession() - } catch (dbError) { - console.warn('solid-logic: IndexedDB unavailable, using in-memory session database:', dbError) - if (SessionCoreCtor) { - return new SessionCoreCtor(undefined, { database: new MemorySessionDatabase() }) - } - return new WebSession() - } - } -} - -const _session = createSession() -const events = new SessionEvents() +// --------------------------------------------------------------------------- +// Login compatibility shim +// --------------------------------------------------------------------------- +// Wraps _session.login() so that call sites with different calling +// conventions all work. The underlying session expects: +// login(issuer: string, redirectUrl: string) +// +// idpOrOptions can be: +// - a string (issuer URL) — passed through with less resolution +// - an options object with any of these field-name variants: +// issuer: oidcIssuer | idp | issuer +// redirect: redirectUrl | redirect_uri | redirectUri +// (all redirect field names map to the same value: the URL the IdP +// should send the browser back to after authentication) +// - anything else — passed through to the underlying session as-is +// +// In all cases the issuer is resolved through +// /.well-known/openid-configuration before redirect so the canonical +// issuer host is used. const sessionAny = _session as any const originalLogin = typeof sessionAny.login === 'function' ? sessionAny.login.bind(_session) : undefined -async function discoverIssuerFromWellKnown (issuer: string): Promise { - try { - const issuerUrl = new URL(issuer) - const wellKnownUrl = new URL('/.well-known/openid-configuration', issuerUrl.origin) - const wellKnownResponse = await fetch(wellKnownUrl.toString(), { credentials: 'include' }) - if (!wellKnownResponse.ok) { - return null - } - - const wellKnownPayload = await wellKnownResponse.json() - if (typeof wellKnownPayload?.issuer !== 'string' || !wellKnownPayload.issuer) { - return null - } - - return wellKnownPayload.issuer.replace(/\/$/, '') - } catch (_err) { - return null - } -} - -async function resolveIssuerForLogin (issuer: string): Promise { - // Prefer the issuer advertised by discovery; if app and issuer hosts still differ, - // redirecting to the canonical issuer host is cleaner than rewriting the issuer here. - const discoveredIssuer = await discoverIssuerFromWellKnown(issuer) - if (discoveredIssuer) { - return discoveredIssuer - } - return issuer -} - if (originalLogin) { - // Keep compatibility with older call sites that pass an options object. sessionAny.login = async (idpOrOptions: any, redirectUri?: string) => { if (idpOrOptions && typeof idpOrOptions === 'object' && !Array.isArray(idpOrOptions)) { const oidcIssuer = idpOrOptions.oidcIssuer ?? idpOrOptions.idp ?? idpOrOptions.issuer @@ -280,6 +69,12 @@ if (originalLogin) { } } +// --------------------------------------------------------------------------- +// Legacy event layer +// --------------------------------------------------------------------------- + +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. @@ -295,5 +90,4 @@ if (typeof (_session as unknown as EventTarget).addEventListener === 'function') } export const authSession: SessionWithLegacyEvents = Object.assign(_session, { events }) - \ No newline at end of file diff --git a/src/authSession/events.ts b/src/authSession/events.ts new file mode 100644 index 00000000..8e7704ab --- /dev/null +++ b/src/authSession/events.ts @@ -0,0 +1,35 @@ +/** + * Legacy event compatibility layer. + * + * Pure EventEmitter-style shim — no side effects, no uvdsl dependencies. + * Wired into the auth session by authSession.ts. + */ + +type LegacyEventName = 'login' | 'logout' | 'sessionRestore' +type LegacyEventHandler = (...args: unknown[]) => void + +/** + * Minimal EventEmitter-style shim so that existing consumers using + * `authSession.events.on('login' | 'logout' | 'sessionRestore', handler)` + * continue working without modification. + * + * Events are emitted by SolidAuthnLogic.checkUser() (login/sessionRestore) + * and by the sessionStateChange listener in authSession.ts (logout). + */ +export class SessionEvents { + private readonly listeners: Map> = new Map() + + on (event: LegacyEventName, handler: LegacyEventHandler): void { + if (!this.listeners.has(event)) this.listeners.set(event, new Set()) + this.listeners.get(event)!.add(handler) + } + + off (event: LegacyEventName, handler: LegacyEventHandler): void { + this.listeners.get(event)?.delete(handler) + } + + emit (event: LegacyEventName, ...args: unknown[]): void { + this.listeners.get(event)?.forEach(h => h(...args)) + } +} + diff --git a/src/authSession/issuer.ts b/src/authSession/issuer.ts new file mode 100644 index 00000000..1a3b69ed --- /dev/null +++ b/src/authSession/issuer.ts @@ -0,0 +1,36 @@ +/** + * Issuer discovery utilities. + * + * Resolves OIDC issuer endpoints from /.well-known/openid-configuration + * so that login can use the canonical issuer host. + */ + +async function discoverIssuerFromWellKnown (issuer: string): Promise { + try { + const issuerUrl = new URL(issuer) + const wellKnownUrl = new URL('/.well-known/openid-configuration', issuerUrl.origin) + const wellKnownResponse = await fetch(wellKnownUrl.toString(), { credentials: 'include' }) + if (!wellKnownResponse.ok) { + return null + } + + const wellKnownPayload = await wellKnownResponse.json() + if (typeof wellKnownPayload?.issuer !== 'string' || !wellKnownPayload.issuer) { + return null + } + + return wellKnownPayload.issuer.replace(/\/$/, '') + } catch (_err) { + return null + } +} + +export async function resolveIssuerForLogin (issuer: string): Promise { + // Prefer the issuer advertised by discovery; if app and issuer hosts still differ, + // redirecting to the canonical issuer host is cleaner than rewriting the issuer here. + const discoveredIssuer = await discoverIssuerFromWellKnown(issuer) + if (discoveredIssuer) { + return discoveredIssuer + } + return issuer +} diff --git a/src/authSession/session.ts b/src/authSession/session.ts new file mode 100644 index 00000000..2399f85c --- /dev/null +++ b/src/authSession/session.ts @@ -0,0 +1,218 @@ +/** + * OIDC session factory. + * + * Everything needed to create and configure the underlying auth session: + * - Session database backends (in-memory and IndexedDB) + * - Session instantiation (WebSession or SessionCore, with local-dev + * fallbacks for environments where the service worker can't load) + * - Login compatibility shim that normalises legacy call-site signatures + * and resolves the canonical issuer through /.well-known discovery + * + * The raw session instance (_session) is consumed by authSession.ts where + * the legacy event layer and logout listener are attached. + * + * DO NOT import _session directly — always go through authSession.ts so + * the event wiring is guaranteed to run. + */ + +import { + Session as WebSession, +} from '@uvdsl/solid-oidc-client-browser' +import * as OidcCore from '@uvdsl/solid-oidc-client-browser/core' +import type { Session as OidcSession, SessionDatabase } from '@uvdsl/solid-oidc-client-browser/core' + +// --------------------------------------------------------------------------- +// Session databases +// --------------------------------------------------------------------------- + +export class MemorySessionDatabase implements SessionDatabase { + private readonly map = new Map() + + private shouldPreserveExistingRefreshToken(id: string, value: any): boolean { + return id === 'refresh_token' && (value == null || value === '') && this.map.has(id) + } + + async init (): Promise { + return this + } + + async setItem (id: string, value: any): Promise { + // Some Solid IdPs do not include refresh_token on refresh responses. + // Keep the previous token instead of overwriting it with null/undefined. + if (this.shouldPreserveExistingRefreshToken(id, value)) { + return + } + this.map.set(id, value) + } + + async getItem (id: string): Promise { + return this.map.has(id) ? this.map.get(id) : null + } + + async deleteItem (id: string): Promise { + this.map.delete(id) + } + + async clear (): Promise { + this.map.clear() + } + + close (): void { + // No-op for in-memory database + } +} + +export class IndexedDbSessionDatabase implements SessionDatabase { + private db: IDBDatabase | null = null + private readonly dbName = 'soidc' + private readonly storeName = 'session' + private readonly dbVersion = 1 + + private async shouldPreserveExistingRefreshToken(id: string, value: any): Promise { + if (id !== 'refresh_token' || !(value == null || value === '')) { + return false + } + const existing = await this.getItem(id) + return existing != null && existing !== '' + } + + async init (): Promise { + if (this.db) return this + + await new Promise((resolve, reject) => { + const request = indexedDB.open(this.dbName, this.dbVersion) + + request.onerror = () => reject(request.error) + request.onsuccess = () => { + this.db = request.result + resolve() + } + request.onupgradeneeded = () => { + const db = request.result + if (!db.objectStoreNames.contains(this.storeName)) { + db.createObjectStore(this.storeName) + } + } + }) + + return this + } + + async setItem (id: string, value: any): Promise { + await this.init() + // Some Solid IdPs do not include refresh_token on refresh responses. + // Keep the previous token instead of overwriting it with null/undefined. + if (await this.shouldPreserveExistingRefreshToken(id, value)) { + return + } + await this.withStore('readwrite', store => store.put(value, id)) + } + + async getItem (id: string): Promise { + await this.init() + return this.withStore('readonly', store => store.get(id)) + } + + async deleteItem (id: string): Promise { + await this.init() + await this.withStore('readwrite', store => store.delete(id)) + } + + async clear (): Promise { + await this.init() + await this.withStore('readwrite', store => store.clear()) + } + + close (): void { + if (this.db) { + this.db.close() + this.db = null + } + } + + private withStore(mode: IDBTransactionMode, op: (store: IDBObjectStore) => IDBRequest): Promise { + return new Promise((resolve, reject) => { + if (!this.db) { + reject(new Error('Session database not initialized')) + return + } + + const tx = this.db.transaction(this.storeName, mode) + const store = tx.objectStore(this.storeName) + const request = op(store) + let result: any = null + + tx.onerror = () => reject(tx.error ?? request.error) + tx.onabort = () => reject(tx.error ?? request.error ?? new Error('IndexedDB transaction aborted')) + tx.oncomplete = () => resolve(result) + + request.onerror = () => reject(request.error) + request.onsuccess = () => { + result = request.result ?? null + } + }) + } +} + +// --------------------------------------------------------------------------- +// Session instantiation +// --------------------------------------------------------------------------- + +function getSessionCoreCtor (): (new (...args: any[]) => OidcSession) | null { + const coreAny = OidcCore as any + const candidate = coreAny.SessionCore ?? coreAny.default?.SessionCore ?? coreAny.default + + if (typeof candidate !== 'function') { + return null + } + + return candidate as new (...args: any[]) => OidcSession +} + +const SessionCoreCtor = getSessionCoreCtor() + +function createSession (): OidcSession { + const shouldSkipWorkerInLocalDev = typeof window !== 'undefined' && (() => { + const host = window.location.hostname + // In local NSS setups (including subdomain mode like alice.localhost), + // worker-based session storage can be brittle and lose state on reload. + // Prefer SessionCore + IndexedDB for deterministic persistence. + return host === 'localhost' || host === '127.0.0.1' || host.endsWith('.localhost') + })() + + if (shouldSkipWorkerInLocalDev) { + if (SessionCoreCtor) { + return new SessionCoreCtor(undefined, { database: new IndexedDbSessionDatabase() }) + } + return new WebSession() + } + + try { + return new WebSession() + } catch (error) { + // In some deployments, worker URL resolution can become file:// and fail cross-origin. + // Fall back to SessionCore so auth still works without background refresh worker. + // Use IndexedDB to keep refresh-token persistence across page reloads. + console.warn('solid-logic: falling back to non-worker auth session:', error) + try { + if (SessionCoreCtor) { + return new SessionCoreCtor(undefined, { database: new IndexedDbSessionDatabase() }) + } + return new WebSession() + } catch (dbError) { + console.warn('solid-logic: IndexedDB unavailable, using in-memory session database:', dbError) + if (SessionCoreCtor) { + return new SessionCoreCtor(undefined, { database: new MemorySessionDatabase() }) + } + return new WebSession() + } + } +} + +// --------------------------------------------------------------------------- +// Singleton session +// --------------------------------------------------------------------------- + +const _session = createSession() + +export { _session } From edd790c456750f94b8589bb02394016c5373c035 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Fri, 5 Jun 2026 17:55:00 +0200 Subject: [PATCH 15/23] apply Copilot reviews --- src/authSession/authSession.ts | 2 +- src/authSession/session.ts | 3 +++ src/authn/serverLogout.ts | 3 +++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/authSession/authSession.ts b/src/authSession/authSession.ts index 56f5ca0c..554c4b16 100644 --- a/src/authSession/authSession.ts +++ b/src/authSession/authSession.ts @@ -78,7 +78,7 @@ 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 = false +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) diff --git a/src/authSession/session.ts b/src/authSession/session.ts index 2399f85c..a7325fad 100644 --- a/src/authSession/session.ts +++ b/src/authSession/session.ts @@ -78,6 +78,9 @@ export class IndexedDbSessionDatabase implements SessionDatabase { async init (): Promise { if (this.db) return this + if (typeof indexedDB === 'undefined') { + throw new Error('IndexedDB is not available in this environment') + } await new Promise((resolve, reject) => { const request = indexedDB.open(this.dbName, this.dbVersion) diff --git a/src/authn/serverLogout.ts b/src/authn/serverLogout.ts index e513f012..68530082 100644 --- a/src/authn/serverLogout.ts +++ b/src/authn/serverLogout.ts @@ -4,6 +4,9 @@ export type ServerLogoutOptions = { } export async function performServerSideLogout (options: ServerLogoutOptions = {}): Promise { + if (typeof window === 'undefined') { + return false + } const issuer = options.issuer || '' const postLogoutRedirectPath = options.postLogoutRedirectPath || '/' From 449a48c4477d74641dd422654576d216d3c21433 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Fri, 12 Jun 2026 14:15:56 +0200 Subject: [PATCH 16/23] typed authSession --- src/authSession/authSession.ts | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/src/authSession/authSession.ts b/src/authSession/authSession.ts index 554c4b16..901b0ec8 100644 --- a/src/authSession/authSession.ts +++ b/src/authSession/authSession.ts @@ -26,7 +26,24 @@ type SessionCompatibilityShape = { authFetch?: (input: string | URL | Request, init?: RequestInit, dpopPayload?: any) => Promise } -export type SessionWithLegacyEvents = OidcSession & SessionCompatibilityShape & { events: SessionEvents } +type LoginOptionsLegacy = { + oidcIssuer?: string + idp?: string + issuer?: string + redirectUrl?: string + redirect_uri?: string + redirectUri?: string +} + +type LoginCompat = { + (issuer: string, redirectUrl: string): Promise + (options: LoginOptionsLegacy): Promise +} + +export type SessionWithLegacyEvents = Omit & SessionCompatibilityShape & { + login: LoginCompat + events: SessionEvents +} // --------------------------------------------------------------------------- // Login compatibility shim @@ -54,7 +71,7 @@ const originalLogin = typeof sessionAny.login === 'function' : undefined if (originalLogin) { - sessionAny.login = async (idpOrOptions: any, redirectUri?: string) => { + const compatLogin = async (idpOrOptions: any, redirectUri?: string): Promise => { if (idpOrOptions && typeof idpOrOptions === 'object' && !Array.isArray(idpOrOptions)) { const oidcIssuer = idpOrOptions.oidcIssuer ?? idpOrOptions.idp ?? idpOrOptions.issuer const redirectUrl = idpOrOptions.redirectUrl ?? idpOrOptions.redirect_uri ?? idpOrOptions.redirectUri @@ -67,6 +84,7 @@ if (originalLogin) { } return originalLogin(idpOrOptions, redirectUri) } + sessionAny.login = compatLogin as LoginCompat } // --------------------------------------------------------------------------- @@ -89,5 +107,8 @@ if (typeof (_session as unknown as EventTarget).addEventListener === 'function') }) } -export const authSession: SessionWithLegacyEvents = Object.assign(_session, { events }) +export const authSession: SessionWithLegacyEvents = Object.assign( + _session as Omit & { login: LoginCompat }, + { events } +) \ No newline at end of file From 6ca5b5ce85a9b91098810630cb228dc04bdd00b0 Mon Sep 17 00:00:00 2001 From: timea-solid <4144203+timea-solid@users.noreply.github.com> Date: Thu, 18 Jun 2026 12:54:05 +0200 Subject: [PATCH 17/23] externalize auth lib, fix vonfigs --- babel.config.mjs | 3 +++ jest.config.mjs | 5 +++-- package-lock.json | 15 +++++++++++++++ package.json | 5 +++-- webpack.config.mjs | 24 +++++++++++++++++++++++- 5 files changed, 47 insertions(+), 5 deletions(-) diff --git a/babel.config.mjs b/babel.config.mjs index bf1a0c14..4a800d85 100644 --- a/babel.config.mjs +++ b/babel.config.mjs @@ -6,5 +6,8 @@ export default { } }], '@babel/preset-typescript' + ], + plugins: [ + 'babel-plugin-transform-import-meta' ] } diff --git a/jest.config.mjs b/jest.config.mjs index 2c05f9dc..7cd18f3a 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -11,9 +11,10 @@ export default { transform: { '^.+\\.[tj]sx?$': ['babel-jest', { configFile: './babel.config.mjs' }], }, + transformIgnorePatterns: ['/node_modules/(?!(@uvdsl/solid-oidc-client-browser|mime-types|mime-db|uuid|@noble/curves|@noble/hashes)/)'], moduleNameMapper: { - '^@uvdsl/solid-oidc-client-browser$': '/test/mocks/solid-oidc-client-browser.ts', - '^@uvdsl/solid-oidc-client-browser/core$': '/test/mocks/solid-oidc-client-browser.ts', + '^@uvdsl/solid-oidc-client-browser(?:/core)?$': '/test/mocks/solid-oidc-client-browser.ts', + '^@uvdsl/solid-oidc-client-browser/(.*)$': '/test/mocks/solid-oidc-client-browser.ts', }, setupFilesAfterEnv: ['./test/helpers/setup.ts'], testMatch: ['**/__tests__/**/*.ts?(x)', '**/?(*.)+(spec|test).ts?(x)'], diff --git a/package-lock.json b/package-lock.json index f34bc691..26196816 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@typescript-eslint/parser": "^8.59.3", "babel-jest": "^30.4.1", "babel-loader": "^10.1.1", + "babel-plugin-transform-import-meta": "^2.3.3", "eslint": "^9.39.4", "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.32.0", @@ -4054,6 +4055,20 @@ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, + "node_modules/babel-plugin-transform-import-meta": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-import-meta/-/babel-plugin-transform-import-meta-2.3.3.tgz", + "integrity": "sha512-bbh30qz1m6ZU1ybJoNOhA2zaDvmeXMnGNBMVMDOJ1Fni4+wMBoy/j7MTRVmqAUCIcy54/rEnr9VEBsfcgbpm3Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/template": "^7.25.9", + "tslib": "^2.8.1" + }, + "peerDependencies": { + "@babel/core": "^7.10.0" + } + }, "node_modules/babel-preset-current-node-syntax": { "version": "1.2.0", "dev": true, diff --git a/package.json b/package.json index dca2db12..6fd81eec 100644 --- a/package.json +++ b/package.json @@ -23,9 +23,9 @@ "clean": "rm -rf dist src/versionInfo.ts", "build": "npm run clean && npm run typecheck && npm run build-version && npm run build-js && npm run build-dist && npm run postbuild-js", "build-version": "./timestamp.sh > src/versionInfo.ts && eslint 'src/versionInfo.ts' --fix", - "build-js": "tsc", + "build-js": "tsc --emitDeclarationOnly", "build-dist": "webpack --progress", - "postbuild-js": "rm -f dist/versionInfo.d.ts dist/versionInfo.d.ts.map", + "postbuild-js": "rm -f dist/versionInfo.d.ts dist/versionInfo.d.ts.map dist/index.js dist/index.js.map", "lint": "eslint", "lint-fix": "eslint --fix", "typecheck": "tsc --noEmit", @@ -55,6 +55,7 @@ "@babel/core": "^7.29.0", "@babel/preset-env": "^7.29.5", "@babel/preset-typescript": "^7.28.5", + "babel-plugin-transform-import-meta": "^2.3.3", "@types/jest": "^30.0.0", "@typescript-eslint/parser": "^8.59.3", "babel-jest": "^30.4.1", diff --git a/webpack.config.mjs b/webpack.config.mjs index dc61bd72..713565fd 100644 --- a/webpack.config.mjs +++ b/webpack.config.mjs @@ -10,11 +10,25 @@ const externalsBase = { '@trust/webcrypto': 'crypto', '@xmldom/xmldom': 'window', 'whatwg-url': 'URL', - 'rdflib': '$rdf' + 'rdflib': '$rdf', + '@uvdsl/solid-oidc-client-browser': { + commonjs: '@uvdsl/solid-oidc-client-browser', + commonjs2: '@uvdsl/solid-oidc-client-browser', + amd: '@uvdsl/solid-oidc-client-browser', + root: 'SolidOidcClientBrowser' + }, + '@uvdsl/solid-oidc-client-browser/core': { + commonjs: '@uvdsl/solid-oidc-client-browser/core', + commonjs2: '@uvdsl/solid-oidc-client-browser/core', + amd: '@uvdsl/solid-oidc-client-browser/core', + root: 'SolidOidcClientBrowserCore' + } } const externalsESM = { ...externalsBase, + '@uvdsl/solid-oidc-client-browser': '@uvdsl/solid-oidc-client-browser', + '@uvdsl/solid-oidc-client-browser/core': '@uvdsl/solid-oidc-client-browser/core', 'rdflib': 'rdflib' } @@ -35,6 +49,10 @@ const commonConfig = { extensions: ['.ts', '.js'] }, devtool: 'source-map', + optimization: { + splitChunks: false, + runtimeChunk: false + } }; export default [ @@ -44,6 +62,7 @@ export default [ output: { path: path.resolve(process.cwd(), 'dist'), filename: 'solid-logic.js', + publicPath: '', library: { name: 'SolidLogic', type: 'umd', @@ -63,6 +82,7 @@ export default [ output: { path: path.resolve(process.cwd(), 'dist'), filename: 'solid-logic.min.js', + publicPath: '', library: { name: 'SolidLogic', type: 'umd', @@ -83,6 +103,7 @@ export default [ output: { path: path.resolve(process.cwd(), 'dist'), filename: 'solid-logic.esm.js', + publicPath: '', library: { type: 'module' }, @@ -103,6 +124,7 @@ export default [ output: { path: path.resolve(process.cwd(), 'dist'), filename: 'solid-logic.esm.min.js', + publicPath: '', library: { type: 'module' }, From f4c0034a78cd1b0e8e59303b91e0107b5863266d Mon Sep 17 00:00:00 2001 From: timea-solid <4144203+timea-solid@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:07:29 +0200 Subject: [PATCH 18/23] update authn lib to latest (binds fetch to session) --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 26196816..23044034 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "4.0.7", "license": "MIT", "dependencies": { - "@uvdsl/solid-oidc-client-browser": "^0.2.2", + "@uvdsl/solid-oidc-client-browser": "^0.2.3", "solid-namespace": "^0.5.4" }, "devDependencies": { @@ -3448,9 +3448,9 @@ ] }, "node_modules/@uvdsl/solid-oidc-client-browser": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@uvdsl/solid-oidc-client-browser/-/solid-oidc-client-browser-0.2.2.tgz", - "integrity": "sha512-JhcfSPu+eVyPMl2Dz46jq9ZHZwfZSqzCrQiHkvFZyam9ZEGXmLF1QJs4O+MddiEJaF5rVeEPd20YWprp5drLKw==", + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@uvdsl/solid-oidc-client-browser/-/solid-oidc-client-browser-0.2.3.tgz", + "integrity": "sha512-WzVlxv46EUSoqm7ovsWJRZq8KEI/CdpA9O1fXoiP8bihs2cNxPnet3YcqvIYWYMsTrf0zsR031l5s/BzQ9MEgA==", "license": "MIT", "dependencies": { "jose": "^5.9.6" diff --git a/package.json b/package.json index 6fd81eec..8ab290d5 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,7 @@ "webpack-cli": "^7.0.2" }, "dependencies": { - "@uvdsl/solid-oidc-client-browser": "^0.2.2", + "@uvdsl/solid-oidc-client-browser": "^0.2.3", "solid-namespace": "^0.5.4" }, "peerDependencies": { From d9d52151de625a0f6cb5d4efdabf1481d7f8be3a Mon Sep 17 00:00:00 2001 From: timea-solid <4144203+timea-solid@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:38:32 +0200 Subject: [PATCH 19/23] bundle uvdsl authn lib in CDN only --- README.md | 4 +++- webpack.config.mjs | 14 +------------- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 6d0cdca3..be042ea0 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,9 @@ console.log('Current user:', authn.currentUser()); ## 🌐 Use directly in a browser -Both UMD and ESM bundles externalize rdflib to keep bundle sizes small and avoid version conflicts. +The UMD bundles externalize `rdflib` only. + +The ESM bundles externalize both `rdflib` and the auth library packages `@uvdsl/solid-oidc-client-browser` / `@uvdsl/solid-oidc-client-browser/core`. ## Available Files diff --git a/webpack.config.mjs b/webpack.config.mjs index 713565fd..39d61e89 100644 --- a/webpack.config.mjs +++ b/webpack.config.mjs @@ -10,19 +10,7 @@ const externalsBase = { '@trust/webcrypto': 'crypto', '@xmldom/xmldom': 'window', 'whatwg-url': 'URL', - 'rdflib': '$rdf', - '@uvdsl/solid-oidc-client-browser': { - commonjs: '@uvdsl/solid-oidc-client-browser', - commonjs2: '@uvdsl/solid-oidc-client-browser', - amd: '@uvdsl/solid-oidc-client-browser', - root: 'SolidOidcClientBrowser' - }, - '@uvdsl/solid-oidc-client-browser/core': { - commonjs: '@uvdsl/solid-oidc-client-browser/core', - commonjs2: '@uvdsl/solid-oidc-client-browser/core', - amd: '@uvdsl/solid-oidc-client-browser/core', - root: 'SolidOidcClientBrowserCore' - } + 'rdflib': '$rdf' } const externalsESM = { From f02950f8faebbef9007faa3cea9b9f44d0f17059 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Sun, 16 Aug 2026 14:48:28 +0200 Subject: [PATCH 20/23] chore: bump rdflib to ^2.4.0 and refresh lockfile --- package-lock.json | 8 ++++---- package.json | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 23044034..93e61a93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,7 +38,7 @@ "node": ">=18" }, "peerDependencies": { - "rdflib": "^2.3.7" + "rdflib": "^2.4.0" } }, "node_modules/@asamuzakjp/css-color": { @@ -8923,9 +8923,9 @@ } }, "node_modules/rdflib": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/rdflib/-/rdflib-2.3.7.tgz", - "integrity": "sha512-rpDq7AD8GrMO8aKu0FNoIfht2NNnIuP2JLGZvzBW+vfyRRU2HY0qHR9VHPB6udyIaPVAhUW/+QCcrEvbcglC1g==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/rdflib/-/rdflib-2.4.0.tgz", + "integrity": "sha512-DPBFlnkA7lWgskbgyPsRxHE5S/9Ni5KHNgwzrq8CucG+TBxEHTGRSeMKjWhZlZhBhmQFu0YQGjOYyrzmkX/gwg==", "license": "MIT", "peer": true, "dependencies": { diff --git a/package.json b/package.json index 8ab290d5..18fce801 100644 --- a/package.json +++ b/package.json @@ -55,11 +55,11 @@ "@babel/core": "^7.29.0", "@babel/preset-env": "^7.29.5", "@babel/preset-typescript": "^7.28.5", - "babel-plugin-transform-import-meta": "^2.3.3", "@types/jest": "^30.0.0", "@typescript-eslint/parser": "^8.59.3", "babel-jest": "^30.4.1", "babel-loader": "^10.1.1", + "babel-plugin-transform-import-meta": "^2.3.3", "eslint": "^9.39.4", "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.32.0", @@ -78,6 +78,6 @@ "solid-namespace": "^0.5.4" }, "peerDependencies": { - "rdflib": "^2.3.7" + "rdflib": "^2.4.0" } } From d385bc4270c0db57334a7b8e394dcf66dbe4bd38 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Sun, 16 Aug 2026 16:15:42 +0200 Subject: [PATCH 21/23] fix(auth): expose legacy authSession.info shape for uvdsl session --- src/authSession/authSession.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/authSession/authSession.ts b/src/authSession/authSession.ts index 901b0ec8..1befadc7 100644 --- a/src/authSession/authSession.ts +++ b/src/authSession/authSession.ts @@ -111,4 +111,24 @@ 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 getter so legacy login/logout UI renders the correct state +// (logout button instead of a mislabelled login button). +Object.defineProperty(authSession, 'info', { + enumerable: true, + configurable: true, + get (): { webId?: string; isLoggedIn?: boolean } { + const sessionAny = _session as any + const isActive = sessionAny.isActive === true || Boolean(sessionAny.webId) + return { + webId: sessionAny.webId, + isLoggedIn: isActive + } + } +}) \ No newline at end of file From ccd08bea31d5d3eb970cb50f8df1ee22464cb33d Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Wed, 19 Aug 2026 14:55:09 +0200 Subject: [PATCH 22/23] test(auth): stop assigning read-only authSession.info in fetch bridge tests info is now a derived getter (from webId/isActive), so tests reset the session via webId instead of assigning to info. --- test/logic.test.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/test/logic.test.ts b/test/logic.test.ts index e406f92e..dc095f29 100644 --- a/test/logic.test.ts +++ b/test/logic.test.ts @@ -35,7 +35,6 @@ describe('solidLogicSingleton fetch bridge', () => { let originalFetch: any let originalAuthFetch: any let originalWebId: any - let originalInfo: any beforeEach(() => { fetchMock.resetMocks() @@ -44,10 +43,10 @@ describe('solidLogicSingleton fetch bridge', () => { originalFetch = sessionAny.fetch originalAuthFetch = sessionAny.authFetch originalWebId = sessionAny.webId - originalInfo = sessionAny.info + // `info` is a derived read-only property (from webId/isActive), so resetting + // webId is sufficient to put the session in the logged-out state. sessionAny.webId = undefined - sessionAny.info = { isLoggedIn: false } }) afterEach(() => { @@ -55,7 +54,6 @@ describe('solidLogicSingleton fetch bridge', () => { sessionAny.fetch = originalFetch sessionAny.authFetch = originalAuthFetch sessionAny.webId = originalWebId - sessionAny.info = originalInfo }) it('uses window.fetch when credentials are omit even if a session exists', async () => { From f49de59040f4d21c5683682a515b5625039c5582 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Wed, 19 Aug 2026 15:02:26 +0200 Subject: [PATCH 23/23] 5.0.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 93e61a93..29c75409 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "solid-logic", - "version": "4.0.7", + "version": "5.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "solid-logic", - "version": "4.0.7", + "version": "5.0.0", "license": "MIT", "dependencies": { "@uvdsl/solid-oidc-client-browser": "^0.2.3", diff --git a/package.json b/package.json index 18fce801..b2ab39a0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "solid-logic", - "version": "4.0.7", + "version": "5.0.0", "description": "Core business logic of SolidOS", "type": "module", "main": "dist/solid-logic.js",