From fc90d8d226a31a6c49facd284773672762cd0ef0 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Wed, 20 May 2026 19:15:32 +0200 Subject: [PATCH 1/8] 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 6574372..2debc41 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 b5f22fc..6ed4b54 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": { @@ -2110,45 +2110,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", @@ -3329,6 +3290,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, @@ -7722,15 +7692,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, @@ -8278,18 +8239,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, @@ -10589,19 +10538,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 885be6f..c311a3a 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 a125a97..1e33f26 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 6d49a8e..51c2398 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 9c62391..18fb4ca 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 fed3e23..8320b6f 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 62a6585..82fc762 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 0000000..76e41bb --- /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 4d1d633..b298ab0 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 cc582ca70fdc6f49953c02d2a2bf9074a11d5775 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Wed, 20 May 2026 19:47:02 +0200 Subject: [PATCH 2/8] 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 c311a3a..dca2db1 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 b298ab0..b0132a2 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 b5c842068f2786be6613f815d34f59951d376c9b Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Thu, 21 May 2026 13:17:32 +0200 Subject: [PATCH 3/8] 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 51c2398..1556ba3 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 18fb4ca..11621d1 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 82fc762..8faa83a 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 5cdb548..e406f92 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 d4078aa6f9597ee485f5ecb835e1908dfb2607d9 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Thu, 21 May 2026 20:04:18 +0200 Subject: [PATCH 4/8] 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 2debc41..2c05f9d 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 1e33f26..681ed1b 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 76e41bb..76668a0 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 41b9e23d7aec4b2c239744104dc71213b2446e85 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Fri, 22 May 2026 11:00:23 +0200 Subject: [PATCH 5/8] 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 6d0cdca..351b194 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 dca2db1..a8afd8b 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 681ed1b..a4cdff0 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 0000000..fea73f0 --- /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 c9ab4d93cc139276e6d18854a5c466a2f32306b0 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Fri, 22 May 2026 17:06:21 +0000 Subject: [PATCH 6/8] 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 a4cdff0..9953ac8 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 593552502ebab51d14f51c16f8c1bb82079ed8ed Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Fri, 22 May 2026 19:49:34 +0200 Subject: [PATCH 7/8] 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 351b194..6d0cdca 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 a8afd8b..dca2db1 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 9953ac8..681ed1b 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 fea73f0..0000000 --- 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 24c3ea5a281a39c53f34cdded6a8827cb03efb8b Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Sat, 23 May 2026 19:59:18 +0200 Subject: [PATCH 8/8] 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 681ed1b..fd8b9e3 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) {