diff --git a/jest.config.mjs b/jest.config.mjs index 407d95975..9861e34bd 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -15,7 +15,9 @@ export default { ], setupFilesAfterEnv: ['./test/helpers/setup.ts'], moduleNameMapper: { - '^.+\\.css$': '/__mocks__/styleMock.js' + '^.+\\.css$': '/__mocks__/styleMock.js', + '^solid-logic$': '/../solid-logic/src', + '^@uvdsl/solid-oidc-client-browser$': '/test/mocks/solid-oidc-client-browser.ts' }, testMatch: ['**/?(*.)+(spec|test).[tj]s?(x)'], roots: ['/src', '/test', '/__mocks__'], diff --git a/src/login/login.ts b/src/login/login.ts index b93e07f11..07817f614 100644 --- a/src/login/login.ts +++ b/src/login/login.ts @@ -513,10 +513,7 @@ export function renderSignInPopup (dom: HTMLDocument) { // Login const locationUrl = new URL(window.location.href) locationUrl.hash = '' // remove hash part - await authSession.login({ - redirectUrl: locationUrl.href, - oidcIssuer: issuerUri - }) + await authSession.login(issuerUri, locationUrl.href) } catch (err) { alert(err.message) } @@ -669,9 +666,9 @@ export function loginStatusBox ( } box.refresh = function () { - const sessionInfo = authSession.info - if (sessionInfo && sessionInfo.webId && sessionInfo.isLoggedIn) { - me = solidLogicSingleton.store.sym(sessionInfo.webId) + const webId = authSession.webId + if (webId) { + me = solidLogicSingleton.store.sym(webId) } else { me = null } @@ -716,6 +713,12 @@ authSession.events.on('logout', async () => { await fetch(openidConfiguration.end_session_endpoint, { credentials: 'include' }) } } + + try { + await fetch('/.well-known/solid/logout', { credentials: 'include' }) + } catch (_err) { + // Not all deployments expose NSS-compatible well-known logout endpoint. + } } catch (_err) { // Do nothing } diff --git a/src/v2/components/footer/Footer.ts b/src/v2/components/footer/Footer.ts index e3277dc55..efec2c61c 100644 --- a/src/v2/components/footer/Footer.ts +++ b/src/v2/components/footer/Footer.ts @@ -107,9 +107,6 @@ export class Footer extends LitElement { if (typeof authSession.events.off === 'function') { authSession.events.off('login', this._updateFooter) authSession.events.off('logout', this._updateFooter) - } else if (typeof authSession.events.removeListener === 'function') { - authSession.events.removeListener('login', this._updateFooter) - authSession.events.removeListener('logout', this._updateFooter) } super.disconnectedCallback() } diff --git a/src/v2/components/header/Header.ts b/src/v2/components/header/Header.ts index 0f1774e64..f083f3638 100644 --- a/src/v2/components/header/Header.ts +++ b/src/v2/components/header/Header.ts @@ -1,6 +1,6 @@ import { LitElement, html, css } from 'lit' import { icons } from '../../../iconBase' -import { authSession } from 'solid-logic' +import { authSession, authn } from 'solid-logic' import '../loginButton/index' import '../signupButton/index' import { ifDefined } from 'lit/directives/if-defined.js' @@ -510,6 +510,9 @@ export class Header extends LitElement { declare helpMenuOpen: boolean declare hasSlottedAccountMenu: boolean declare hasSlottedHelpMenu: boolean + private readonly handleAuthSessionChange = () => { + this.refreshAuthStateFromSession() + } constructor () { super() @@ -540,14 +543,34 @@ export class Header extends LitElement { super.connectedCallback() document.addEventListener('click', this.handleDocumentClick) window.addEventListener('keydown', this.handleWindowKeydown) + if (typeof authSession.events?.on === 'function') { + authSession.events.on('login', this.handleAuthSessionChange) + authSession.events.on('logout', this.handleAuthSessionChange) + authSession.events.on('sessionRestore', this.handleAuthSessionChange) + } + this.refreshAuthStateFromSession() } disconnectedCallback () { document.removeEventListener('click', this.handleDocumentClick) window.removeEventListener('keydown', this.handleWindowKeydown) + if (typeof authSession.events?.off === 'function') { + authSession.events.off('login', this.handleAuthSessionChange) + authSession.events.off('logout', this.handleAuthSessionChange) + authSession.events.off('sessionRestore', this.handleAuthSessionChange) + } super.disconnectedCallback() } + private async refreshAuthStateFromSession () { + try { + await authn.checkUser() + } catch (_err) { + // Keep rendering even if session refresh cannot complete. + } + this.authState = authn.currentUser() ? 'logged-in' : 'logged-out' + } + private handleHelpMenuClick (item: HeaderMenuItem, event: MouseEvent) { event.preventDefault() this.helpMenuOpen = false @@ -665,8 +688,8 @@ export class Header extends LitElement { ` } - private handleLoginSuccess () { - this.authState = 'logged-in' + private async handleLoginSuccess () { + await this.refreshAuthStateFromSession() this.dispatchEvent(new CustomEvent('auth-action-select', { detail: { role: 'login' }, bubbles: true, @@ -676,12 +699,17 @@ export class Header extends LitElement { private async handleLogout () { this.accountMenuOpen = false + const issuer = window.localStorage.getItem('loginIssuer') || '' + try { await authSession.logout() } catch (_err) { // logout errors are non-fatal — proceed to clear state } - this.authState = 'logged-out' + + await this.performServerLogout(issuer) + + await this.refreshAuthStateFromSession() this.dispatchEvent(new CustomEvent('logout-select', { detail: { role: 'logout' }, bubbles: true, @@ -689,6 +717,32 @@ export class Header extends LitElement { })) } + private async performServerLogout (issuer: string) { + // Best-effort server logout for cookie-backed sessions on NSS-like servers. + 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 state even if remote IdP logout is unavailable. + } + + try { + await fetch('/.well-known/solid/logout', { credentials: 'include' }) + } catch (_err) { + // Not all deployments expose NSS-compatible well-known logout. + } + } + private renderAccountMenuItem (item: HeaderAccountMenuItem) { const content = html` ${this.renderLoggedInAvatar(item.avatar, 'account-menu-avatar')} diff --git a/src/v2/components/header/header.test.ts b/src/v2/components/header/header.test.ts index db621f23b..55c4ee282 100644 --- a/src/v2/components/header/header.test.ts +++ b/src/v2/components/header/header.test.ts @@ -1,9 +1,39 @@ import { Header } from './Header' import './index' +import { authn, authSession } from 'solid-logic' + +type Listener = () => void +const mockSessionListeners = new Map>() + +jest.mock('solid-logic', () => ({ + authn: { + checkUser: jest.fn(async () => null), + currentUser: jest.fn(() => null) + }, + authSession: { + logout: jest.fn(async () => undefined), + events: { + on: jest.fn((event: string, handler: Listener) => { + if (!mockSessionListeners.has(event)) mockSessionListeners.set(event, new Set()) + mockSessionListeners.get(event)?.add(handler) + }), + off: jest.fn((event: string, handler: Listener) => { + mockSessionListeners.get(event)?.delete(handler) + }), + emit: jest.fn((event: string) => { + mockSessionListeners.get(event)?.forEach(handler => handler()) + }) + } + } +})) describe('SolidUIHeaderElement', () => { beforeEach(() => { document.body.innerHTML = '' + jest.clearAllMocks() + mockSessionListeners.clear() + ;(authn.currentUser as jest.Mock).mockReturnValue(null) + ;(authn.checkUser as jest.Mock).mockResolvedValue(null) Object.defineProperty(window, 'open', { configurable: true, writable: true, @@ -77,6 +107,8 @@ describe('SolidUIHeaderElement', () => { expect(signUpLink.getAttribute('icon')).toBe('https://example.com/signup-icon-top.svg') loginButton.dispatchEvent(new CustomEvent('login-success', { bubbles: true, composed: true })) + await Promise.resolve() + await header.updateComplete expect(authActionSelected).toHaveBeenCalledWith({ role: 'login' @@ -105,6 +137,7 @@ describe('SolidUIHeaderElement', () => { it('uses a custom fallback avatar when no accountAvatar is configured', async () => { const header = new Header() + ;(authn.currentUser as jest.Mock).mockReturnValue({ uri: 'https://alice.example/profile/card#me' }) header.authState = 'logged-in' header.accountAvatar = '' @@ -123,6 +156,7 @@ describe('SolidUIHeaderElement', () => { it('renders an accounts dropdown with avatar when logged in', async () => { const header = new Header() const accountMenuSelected = jest.fn() + ;(authn.currentUser as jest.Mock).mockReturnValue({ uri: 'https://alice.example/profile/card#me' }) header.authState = 'logged-in' header.accountIcon = 'https://example.com/account-icon.svg' @@ -173,6 +207,7 @@ describe('SolidUIHeaderElement', () => { it('does not render the logout icon on mobile layout', async () => { const header = new Header() + ;(authn.currentUser as jest.Mock).mockReturnValue({ uri: 'https://alice.example/profile/card#me' }) header.layout = 'mobile' header.authState = 'logged-in' header.logoutIcon = 'https://example.com/logout-icon.svg' @@ -196,6 +231,7 @@ describe('SolidUIHeaderElement', () => { it('does not render account webid on mobile layout', async () => { const header = new Header() + ;(authn.currentUser as jest.Mock).mockReturnValue({ uri: 'https://alice.example/profile/card#me' }) header.layout = 'mobile' header.authState = 'logged-in' header.accountMenu = [ @@ -263,6 +299,7 @@ describe('SolidUIHeaderElement', () => { it('renders helpMenuList inside the help dropdown and dispatches events', async () => { const header = new Header() + ;(authn.currentUser as jest.Mock).mockReturnValue({ uri: 'https://alice.example/profile/card#me' }) const helpMenuClicked = jest.fn() @@ -304,4 +341,35 @@ describe('SolidUIHeaderElement', () => { window.open = originalWindowOpen }) + + it('derives auth state from session on connect', async () => { + const header = new Header() + ;(authn.currentUser as jest.Mock).mockReturnValue({ uri: 'https://alice.example/profile/card#me' }) + + document.body.appendChild(header) + await header.updateComplete + await Promise.resolve() + await header.updateComplete + + expect(authn.checkUser).toHaveBeenCalled() + expect(header.authState).toBe('logged-in') + }) + + it('refreshes auth state when session events fire', async () => { + const header = new Header() + document.body.appendChild(header) + await header.updateComplete + + ;(authn.currentUser as jest.Mock).mockReturnValue({ uri: 'https://alice.example/profile/card#me' }) + ;(authSession.events as any).emit('login') + await Promise.resolve() + await header.updateComplete + expect(header.authState).toBe('logged-in') + + ;(authn.currentUser as jest.Mock).mockReturnValue(null) + ;(authSession.events as any).emit('logout') + await Promise.resolve() + await header.updateComplete + expect(header.authState).toBe('logged-out') + }) }) diff --git a/src/v2/components/loginButton/LoginButton.ts b/src/v2/components/loginButton/LoginButton.ts index e5758112e..5444c94a6 100644 --- a/src/v2/components/loginButton/LoginButton.ts +++ b/src/v2/components/loginButton/LoginButton.ts @@ -377,10 +377,7 @@ export class LoginButton extends LitElement { const locationUrl = new URL(window.location.href) locationUrl.hash = '' - await authSession.login({ - redirectUrl: locationUrl.href, - oidcIssuer: issuerUri - }) + await authSession.login(issuerUri, locationUrl.href) } catch (err: any) { this._errorMsg = err.message || String(err) this.requestUpdate() diff --git a/test/mocks/solid-oidc-client-browser.ts b/test/mocks/solid-oidc-client-browser.ts new file mode 100644 index 000000000..bebc302e6 --- /dev/null +++ b/test/mocks/solid-oidc-client-browser.ts @@ -0,0 +1,73 @@ +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 + } + + off (event: string, listener: Listener): void { + const list = this.listeners[event] || [] + this.listeners[event] = list.filter(item => item !== listener) + } + + 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() + + private eventTarget = new EventTarget() + + addEventListener (type: string, listener: EventListenerOrEventListenerObject | null): void { + if (!listener) return + this.eventTarget.addEventListener(type, listener) + } + + removeEventListener (type: string, listener: EventListenerOrEventListenerObject | null): void { + if (!listener) return + this.eventTarget.removeEventListener(type, listener) + } + + dispatchEvent (event: Event): boolean { + return this.eventTarget.dispatchEvent(event) + } + + async handleIncomingRedirect (): Promise { + + } + + async handleRedirectFromLogin (): Promise { + + } + + async restore (): Promise { + + } + + async login (_idp?: string, _redirectUri?: string): Promise { + } + + 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/tsconfig.json b/tsconfig.json index 20ab8849e..babcfa81d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -63,8 +63,10 @@ "declarations.d.ts" ] /* List of folders to include type definitions from. */, // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ - "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ - // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ + "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ + "preserveSymlinks": true, /* Do not resolve the real path of symlinks. Needed for local linked solid-logic. */ + "baseUrl": ".", /* Base directory to resolve non-absolute module names. Needed for paths mapping. */ + "paths": { "rdflib": ["./node_modules/rdflib"] }, /* Map rdflib to avoid duplicate type identity when linked with solid-logic. */ /* Source Map Options */ // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */