diff --git a/jest.config.mjs b/jest.config.mjs index 26b8c33d5..f7f7fac63 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -1,3 +1,14 @@ +import { existsSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const localSolidLogicSrc = path.resolve(__dirname, '../solid-logic/src') +const solidLogicMapper = existsSync(localSolidLogicSrc) + ? localSolidLogicSrc + : '/node_modules/solid-logic/src' + export default { // verbose: true, // Uncomment for detailed test output collectCoverage: true, @@ -15,7 +26,9 @@ export default { ], setupFilesAfterEnv: ['./test/helpers/setup.ts'], moduleNameMapper: { - '^.+\\.css$': '/__mocks__/styleMock.js' + '^.+\\.css$': '/__mocks__/styleMock.js', + '^solid-logic$': solidLogicMapper, + '^@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 6f7dfe74a..fdbace809 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/auth/loginButton/LoginButton.ts b/src/v2/components/auth/loginButton/LoginButton.ts index e1aa22cd9..bb27879a6 100644 --- a/src/v2/components/auth/loginButton/LoginButton.ts +++ b/src/v2/components/auth/loginButton/LoginButton.ts @@ -419,10 +419,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/src/v2/components/layout/footer/Footer.ts b/src/v2/components/layout/footer/Footer.ts index 2d0158fc0..cdbe3ff71 100644 --- a/src/v2/components/layout/footer/Footer.ts +++ b/src/v2/components/layout/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/layout/header/Header.ts b/src/v2/components/layout/header/Header.ts index 5a89b35f4..ac5fa7143 100644 --- a/src/v2/components/layout/header/Header.ts +++ b/src/v2/components/layout/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, performServerSideLogout } from 'solid-logic' import '../../auth/loginButton/index' import '../../auth/signupButton/index' import { ifDefined } from 'lit/directives/if-defined.js' @@ -10,6 +10,36 @@ const DEFAULT_SOLID_ICON_URL = 'https://solidproject.org/assets/img/solid-emblem const DEFAULT_SIGNUP_URL = 'https://solidproject.org/get_a_pod' const DEFAULT_LOGGEDIN_MENU_BUTTON_AVATAR = icons.iconBase + 'emptyProfileAvatar.png' +async function clearPersistedAuthState (): Promise { + if (typeof window === 'undefined') { + return + } + + const explicitKeys = ['loginIssuer', 'preLoginRedirectHash'] + for (const key of explicitKeys) { + window.localStorage.removeItem(key) + window.sessionStorage.removeItem(key) + } + + if (typeof indexedDB === 'undefined') { + return + } + + const databases = ['soidc', 'solid-client-authn-store', 'solid-client-authn'] + for (const dbName of databases) { + await new Promise((resolve) => { + try { + const request = indexedDB.deleteDatabase(dbName) + request.onsuccess = () => resolve() + request.onerror = () => resolve() + request.onblocked = () => resolve() + } catch (_err) { + resolve() + } + }) + } +} + export type HeaderAuthState = 'logged-out' | 'logged-in' export type HeaderMenuItem = { @@ -47,7 +77,8 @@ export class Header extends LitElement { accountMenuOpen: { state: true }, helpMenuOpen: { state: true }, hasSlottedAccountMenu: { state: true }, - hasSlottedHelpMenu: { state: true } + hasSlottedHelpMenu: { state: true }, + authResolved: { state: true } } static styles = css` @@ -510,6 +541,14 @@ export class Header extends LitElement { declare helpMenuOpen: boolean declare hasSlottedAccountMenu: boolean declare hasSlottedHelpMenu: boolean + declare authResolved: boolean + private _refreshPromise: Promise | null = null + + private readonly handleAuthSessionChange = () => { + this.refreshAuthStateFromSession().catch(() => { + // Keep auth event handling resilient on transient refresh failures. + }) + } constructor () { super() @@ -534,20 +573,59 @@ export class Header extends LitElement { this.helpMenuOpen = false this.hasSlottedAccountMenu = false this.hasSlottedHelpMenu = false + this.authResolved = false } connectedCallback () { 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().catch(() => { + // Keep initial header render resilient on transient refresh failures. + }) } 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 () { + if (!this._refreshPromise) { + this._refreshPromise = (async () => { + try { + await authn.checkUser() + // Some auth stacks resolve session state asynchronously after first check. + if (!authn.currentUser()) { + await authn.checkUser() + } + } catch (_err) { + // Keep rendering even if session refresh cannot complete. + } + })() + } + + try { + await this._refreshPromise + } finally { + this._refreshPromise = null + } + + this.authState = authn.currentUser() ? 'logged-in' : 'logged-out' + this.authResolved = true + } + private handleHelpMenuClick (item: HeaderMenuItem, event: MouseEvent) { event.preventDefault() this.helpMenuOpen = false @@ -665,8 +743,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 +754,25 @@ 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 clearPersistedAuthState() + + const redirectedToServerLogout = await performServerSideLogout({ + issuer, + postLogoutRedirectPath: '/' + }) + if (redirectedToServerLogout) { + return + } + + await this.refreshAuthStateFromSession() this.dispatchEvent(new CustomEvent('logout-select', { detail: { role: 'logout' }, bubbles: true, @@ -790,6 +881,10 @@ export class Header extends LitElement { } private renderUserArea () { + if (!this.authResolved) { + return html`
` + } + if (this.authState === 'logged-out') { return this.renderLoggedOutActions() } diff --git a/src/v2/components/layout/header/header.test.ts b/src/v2/components/layout/header/header.test.ts index db621f23b..3af816087 100644 --- a/src/v2/components/layout/header/header.test.ts +++ b/src/v2/components/layout/header/header.test.ts @@ -1,9 +1,46 @@ 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) + }, + performServerSideLogout: jest.fn(async () => false), + 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', () => { + async function waitForAuthRefresh (header: Header): Promise { + await Promise.resolve() + await Promise.resolve() + await header.updateComplete + } + 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, @@ -22,6 +59,7 @@ describe('SolidUIHeaderElement', () => { header.setAttribute('help-icon', 'https://example.com/help.png') header.setAttribute('brand-link', '/home') header.authState = 'logged-out' + header.authResolved = true header.helpMenuList = [{ label: 'Help', action: 'open-help' }] header.innerHTML = '' @@ -52,6 +90,7 @@ describe('SolidUIHeaderElement', () => { const authActionSelected = jest.fn() header.authState = 'logged-out' + header.authResolved = true header.loginAction = { label: 'Log in', action: 'login', icon: 'https://example.com/login-icon.svg' } header.signUpAction = { label: 'Sign Up', url: '/signup', icon: 'https://example.com/signup-icon.svg' } header.loginIcon = 'https://example.com/login-icon-top.svg' @@ -77,6 +116,7 @@ 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 waitForAuthRefresh(header) expect(authActionSelected).toHaveBeenCalledWith({ role: 'login' @@ -86,6 +126,7 @@ describe('SolidUIHeaderElement', () => { it('does not show login or signup icons on mobile layout', async () => { const header = new Header() header.authState = 'logged-out' + header.authResolved = true header.layout = 'mobile' header.loginAction = { label: 'Log in', action: 'login', icon: 'https://example.com/login-icon.svg' } header.signUpAction = { label: 'Sign Up', url: '/signup', icon: 'https://example.com/signup-icon.svg' } @@ -105,8 +146,10 @@ 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.authResolved = true header.accountAvatar = '' header.accountAvatarFallback = 'https://example.com/fallback-avatar.png' @@ -123,8 +166,10 @@ 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.authResolved = true header.accountIcon = 'https://example.com/account-icon.svg' header.accountAvatar = 'https://example.com/avatar.png' header.logoutIcon = 'https://example.com/logout-icon.svg' @@ -173,8 +218,10 @@ 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.authResolved = true header.logoutIcon = 'https://example.com/logout-icon.svg' header.logoutLabel = 'Log Out' @@ -196,8 +243,10 @@ 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.authResolved = true header.accountMenu = [ { label: 'Personal Pod', webid: 'https://pod.example/profile/card#me', action: 'switch-personal' } ] @@ -263,10 +312,12 @@ 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() header.authState = 'logged-in' + header.authResolved = true header.helpIcon = '' header.helpMenuList = [{ label: 'Docs', url: 'https://example.com/docs', target: '_blank' }] @@ -304,4 +355,53 @@ 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 waitForAuthRefresh(header) + + expect(authn.checkUser).toHaveBeenCalled() + expect(header.authState).toBe('logged-in') + }) + + it('retries session resolution once before settling logged-out state', async () => { + const header = new Header() + let callCount = 0 + ;(authn.currentUser as jest.Mock).mockImplementation(() => { + return callCount >= 2 ? { uri: 'https://alice.example/profile/card#me' } : null + }) + ;(authn.checkUser as jest.Mock).mockImplementation(async () => { + callCount += 1 + return callCount >= 2 ? { uri: 'https://alice.example/profile/card#me' } : null + }) + + document.body.appendChild(header) + await header.updateComplete + await Promise.resolve() + await header.updateComplete + + expect(authn.checkUser).toHaveBeenCalledTimes(2) + expect(header.authResolved).toBe(true) + 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 waitForAuthRefresh(header) + expect(header.authState).toBe('logged-in') + + ;(authn.currentUser as jest.Mock).mockReturnValue(null) + ;(authSession.events as any).emit('logout') + await waitForAuthRefresh(header) + expect(header.authState).toBe('logged-out') + }) }) 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. */