From fc90d8d226a31a6c49facd284773672762cd0ef0 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Wed, 20 May 2026 19:15:32 +0200 Subject: [PATCH 01/22] 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 b5f22fc0..6ed4b548 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 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 cc582ca70fdc6f49953c02d2a2bf9074a11d5775 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Wed, 20 May 2026 19:47:02 +0200 Subject: [PATCH 02/22] 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 b5c842068f2786be6613f815d34f59951d376c9b Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Thu, 21 May 2026 13:17:32 +0200 Subject: [PATCH 03/22] 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 d4078aa6f9597ee485f5ecb835e1908dfb2607d9 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Thu, 21 May 2026 20:04:18 +0200 Subject: [PATCH 04/22] 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 41b9e23d7aec4b2c239744104dc71213b2446e85 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Fri, 22 May 2026 11:00:23 +0200 Subject: [PATCH 05/22] 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 c9ab4d93cc139276e6d18854a5c466a2f32306b0 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Fri, 22 May 2026 17:06:21 +0000 Subject: [PATCH 06/22] 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 593552502ebab51d14f51c16f8c1bb82079ed8ed Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Fri, 22 May 2026 19:49:34 +0200 Subject: [PATCH 07/22] 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 24c3ea5a281a39c53f34cdded6a8827cb03efb8b Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Sat, 23 May 2026 19:59:18 +0200 Subject: [PATCH 08/22] 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 ac99dd5d276c6bb96d16fc0dd6cac63aba850a20 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Mon, 25 May 2026 16:45:46 +0200 Subject: [PATCH 09/22] 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 0d895f00fb6e342116f0c401f356f5825d040a4f Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Fri, 29 May 2026 11:59:02 +0200 Subject: [PATCH 10/22] 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 232c30c86eb30f348fd510ac0dce6241ba195825 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Fri, 29 May 2026 11:59:09 +0200 Subject: [PATCH 11/22] 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 ff4a2d3851010e12b60d2949174562b5d8e0687d Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Fri, 29 May 2026 12:21:31 +0200 Subject: [PATCH 12/22] update rdflib --- 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 80db9eea..f1c5ede8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,7 +37,7 @@ "node": ">=18" }, "peerDependencies": { - "rdflib": "^2.3.7" + "rdflib": "^2.3.9" } }, "node_modules/@asamuzakjp/css-color": { @@ -8965,9 +8965,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.3.9", + "resolved": "https://registry.npmjs.org/rdflib/-/rdflib-2.3.9.tgz", + "integrity": "sha512-6HnEQ22QzgqPW2/R8y5IaeQoXnho6U+ovU1q/ZF556zEnSK4buwhw8/CDdRDwIHZQh5+PAncQxUhluO3JmguJQ==", "license": "MIT", "peer": true, "dependencies": { diff --git a/package.json b/package.json index dca2db12..e82e768a 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,6 @@ "solid-namespace": "^0.5.4" }, "peerDependencies": { - "rdflib": "^2.3.7" + "rdflib": "^2.3.9" } } From a54b216df0dc68ac6eec46dfb47eb08e7ef3294c Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Sat, 30 May 2026 11:38:44 +0200 Subject: [PATCH 13/22] 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 0bd46595c2dfec4dd5de09b57f46b538c64bd299 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Sat, 30 May 2026 18:53:06 +0200 Subject: [PATCH 14/22] 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 ae2d1206bad8afd26d47e03af38a07fe267f5760 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 11:18:46 +0000 Subject: [PATCH 15/22] Bump webpack-cli from 7.0.2 to 7.0.3 Bumps [webpack-cli](https://github.com/webpack/webpack-cli) from 7.0.2 to 7.0.3. - [Release notes](https://github.com/webpack/webpack-cli/releases) - [Changelog](https://github.com/webpack/webpack-cli/blob/main/CHANGELOG.md) - [Commits](https://github.com/webpack/webpack-cli/compare/webpack-cli@7.0.2...webpack-cli@7.0.3) --- updated-dependencies: - dependency-name: webpack-cli dependency-version: 7.0.3 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- package-lock.json | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index 41a86c2a..66e07507 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1963,7 +1963,9 @@ } }, "node_modules/@discoveryjs/json-ext": { - "version": "1.0.0", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-1.1.0.tgz", + "integrity": "sha512-Xc3VhU02wqZ1HvHRJUwL09HkZSTvidqY5Ya0NXBSYOxAp+Ln9dcJr9fySI+CkONzP3PekQo9WdzCv0PGER/mOA==", "dev": true, "license": "MIT", "engines": { @@ -5629,14 +5631,6 @@ ], "license": "BSD-3-Clause" }, - "node_modules/fastest-levenshtein": { - "version": "1.0.16", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.9.1" - } - }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -10886,17 +10880,16 @@ } }, "node_modules/webpack-cli": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-7.0.2.tgz", - "integrity": "sha512-dB0R4T+C/8YuvM+fabdvil6QE44/ChDXikV5lOOkrUeCkW5hTJv2pGLE3keh+D5hjYw8icBaJkZzpFoaHV4T+g==", + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-7.0.3.tgz", + "integrity": "sha512-2E2C6A1e2El7791zQgTH7LPIuwLjRliow9OHS/qlJc9pwhZlCoL/uiwqd/1WSlXT83wJfmfDbkcqHXuXoPJZ3g==", "dev": true, "license": "MIT", "dependencies": { - "@discoveryjs/json-ext": "^1.0.0", + "@discoveryjs/json-ext": "^1.1.0", "commander": "^14.0.3", "cross-spawn": "^7.0.6", "envinfo": "^7.14.0", - "fastest-levenshtein": "^1.0.12", "import-local": "^3.0.2", "interpret": "^3.1.1", "rechoir": "^0.8.0", From 85ed0beb9975b86b11e3e6a530b98f267008d51d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 11:21:42 +0000 Subject: [PATCH 16/22] Bump terser-webpack-plugin from 5.6.0 to 5.6.1 Bumps [terser-webpack-plugin](https://github.com/webpack/minimizer-webpack-plugin) from 5.6.0 to 5.6.1. - [Release notes](https://github.com/webpack/minimizer-webpack-plugin/releases) - [Changelog](https://github.com/webpack/minimizer-webpack-plugin/blob/main/CHANGELOG.md) - [Commits](https://github.com/webpack/minimizer-webpack-plugin/compare/v5.6.0...v5.6.1) --- updated-dependencies: - dependency-name: terser-webpack-plugin dependency-version: 5.6.1 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 66e07507..5080f402 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10072,9 +10072,9 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.0.tgz", - "integrity": "sha512-Eum+5ajkaOhf5KbM26osvv21kLD7BaGqQ1UA4Ami4arYwylmGUQTgHFpHDdmJod1q4QXa66p0to/FBKID+J1vA==", + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.1.tgz", + "integrity": "sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==", "dev": true, "license": "MIT", "dependencies": { From accd478e1261be0af1ebad810507b291f73e04c0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 11:23:47 +0000 Subject: [PATCH 17/22] Bump @babel/core from 7.29.0 to 7.29.7 Bumps [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) from 7.29.0 to 7.29.7. - [Release notes](https://github.com/babel/babel/releases) - [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md) - [Commits](https://github.com/babel/babel/commits/v7.29.7/packages/babel-core) --- updated-dependencies: - dependency-name: "@babel/core" dependency-version: 7.29.7 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- package-lock.json | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5080f402..fb169d6b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -87,19 +87,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -392,12 +394,14 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" From 05626abbfa1f2222a0ab2168d58eb6250e01f91b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 11:25:52 +0000 Subject: [PATCH 18/22] Bump @typescript-eslint/parser from 8.60.0 to 8.60.1 Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 8.60.0 to 8.60.1. - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.60.1/packages/parser) --- updated-dependencies: - dependency-name: "@typescript-eslint/parser" dependency-version: 8.60.1 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- package-lock.json | 74 +++++++++++++++++++++++------------------------ 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/package-lock.json b/package-lock.json index fb169d6b..27b734d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3055,16 +3055,16 @@ "license": "MIT" }, "node_modules/@typescript-eslint/parser": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.0.tgz", - "integrity": "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.1.tgz", + "integrity": "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.60.0", - "@typescript-eslint/types": "8.60.0", - "@typescript-eslint/typescript-estree": "8.60.0", - "@typescript-eslint/visitor-keys": "8.60.0", + "@typescript-eslint/scope-manager": "8.60.1", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1", "debug": "^4.4.3" }, "engines": { @@ -3080,14 +3080,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.0.tgz", - "integrity": "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.1.tgz", + "integrity": "sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.60.0", - "@typescript-eslint/types": "^8.60.0", + "@typescript-eslint/tsconfig-utils": "^8.60.1", + "@typescript-eslint/types": "^8.60.1", "debug": "^4.4.3" }, "engines": { @@ -3102,14 +3102,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz", - "integrity": "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.1.tgz", + "integrity": "sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.60.0", - "@typescript-eslint/visitor-keys": "8.60.0" + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3120,9 +3120,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz", - "integrity": "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.1.tgz", + "integrity": "sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==", "dev": true, "license": "MIT", "engines": { @@ -3137,9 +3137,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.0.tgz", - "integrity": "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.1.tgz", + "integrity": "sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==", "dev": true, "license": "MIT", "engines": { @@ -3151,16 +3151,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz", - "integrity": "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.1.tgz", + "integrity": "sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.60.0", - "@typescript-eslint/tsconfig-utils": "8.60.0", - "@typescript-eslint/types": "8.60.0", - "@typescript-eslint/visitor-keys": "8.60.0", + "@typescript-eslint/project-service": "8.60.1", + "@typescript-eslint/tsconfig-utils": "8.60.1", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -3192,13 +3192,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz", - "integrity": "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.1.tgz", + "integrity": "sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/types": "8.60.1", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -10240,9 +10240,9 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { From 1a13227fd0c0d6240c5aa98f689bb0e44eb9fd02 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Fri, 5 Jun 2026 16:57:05 +0200 Subject: [PATCH 19/22] 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 069ebe0c52b5bdbe74c7d68b8df78ff3baef9d3a Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Fri, 5 Jun 2026 17:55:00 +0200 Subject: [PATCH 20/22] 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 74a5e6f0749ab964e584df505a98eec45cf57bf4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:03:14 +0000 Subject: [PATCH 21/22] Bump @typescript-eslint/parser from 8.60.1 to 8.61.0 Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 8.60.1 to 8.61.0. - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.61.0/packages/parser) --- updated-dependencies: - dependency-name: "@typescript-eslint/parser" dependency-version: 8.61.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- package-lock.json | 74 +++++++++++++++++++++++------------------------ 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/package-lock.json b/package-lock.json index 27b734d3..e99822b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3055,16 +3055,16 @@ "license": "MIT" }, "node_modules/@typescript-eslint/parser": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.1.tgz", - "integrity": "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.0.tgz", + "integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.60.1", - "@typescript-eslint/types": "8.60.1", - "@typescript-eslint/typescript-estree": "8.60.1", - "@typescript-eslint/visitor-keys": "8.60.1", + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", "debug": "^4.4.3" }, "engines": { @@ -3080,14 +3080,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.1.tgz", - "integrity": "sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.0.tgz", + "integrity": "sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.60.1", - "@typescript-eslint/types": "^8.60.1", + "@typescript-eslint/tsconfig-utils": "^8.61.0", + "@typescript-eslint/types": "^8.61.0", "debug": "^4.4.3" }, "engines": { @@ -3102,14 +3102,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.1.tgz", - "integrity": "sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.0.tgz", + "integrity": "sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.60.1", - "@typescript-eslint/visitor-keys": "8.60.1" + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3120,9 +3120,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.1.tgz", - "integrity": "sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.0.tgz", + "integrity": "sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==", "dev": true, "license": "MIT", "engines": { @@ -3137,9 +3137,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.1.tgz", - "integrity": "sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.0.tgz", + "integrity": "sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==", "dev": true, "license": "MIT", "engines": { @@ -3151,16 +3151,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.1.tgz", - "integrity": "sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.0.tgz", + "integrity": "sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.60.1", - "@typescript-eslint/tsconfig-utils": "8.60.1", - "@typescript-eslint/types": "8.60.1", - "@typescript-eslint/visitor-keys": "8.60.1", + "@typescript-eslint/project-service": "8.61.0", + "@typescript-eslint/tsconfig-utils": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -3179,9 +3179,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.3.tgz", + "integrity": "sha512-wnilbGyMxzbY7dNOl7jpKbLSjcfeweJWU5j4+u5qW+6/wuGD9KzIGOyZnQVSBM9E7DtWaaH3CyHkppYrKYoxwg==", "dev": true, "license": "ISC", "bin": { @@ -3192,13 +3192,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.1.tgz", - "integrity": "sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.0.tgz", + "integrity": "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/types": "8.61.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { From a3873333cd4bea2665998224a52d40d08f4fd52a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:06:32 +0000 Subject: [PATCH 22/22] Bump ts-loader from 9.5.7 to 9.6.0 Bumps [ts-loader](https://github.com/TypeStrong/ts-loader) from 9.5.7 to 9.6.0. - [Release notes](https://github.com/TypeStrong/ts-loader/releases) - [Changelog](https://github.com/TypeStrong/ts-loader/blob/main/CHANGELOG.md) - [Commits](https://github.com/TypeStrong/ts-loader/compare/v9.5.7...v9.6.0) --- updated-dependencies: - dependency-name: ts-loader dependency-version: 9.6.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- package-lock.json | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index e99822b3..c9c4ff24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10347,9 +10347,9 @@ } }, "node_modules/ts-loader": { - "version": "9.5.7", - "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.7.tgz", - "integrity": "sha512-/ZNrKgA3K3PtpMYOC71EeMWIloGw3IYEa5/t1cyz2r5/PyUwTXGzYJvcD3kfUvmhlfpz1rhV8B2O6IVTQ0avsg==", + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.6.0.tgz", + "integrity": "sha512-dsJO0S+T7grTDWTc4a0nTygXGjKncVUpx8Y+af8EvI/D5WgTJby5UEk5eoMCB9EcLQmnvitqh99MqtjtHgAwFQ==", "dev": true, "license": "MIT", "dependencies": { @@ -10363,8 +10363,14 @@ "node": ">=12.0.0" }, "peerDependencies": { + "loader-utils": "*", "typescript": "*", - "webpack": "^5.0.0" + "webpack": "^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "loader-utils": { + "optional": true + } } }, "node_modules/ts-loader/node_modules/semver": {