Skip to content

Commit 7039622

Browse files
committed
fix(auth): sync header auth state with session and enforce server logout endpoints
1. derive header auth state from auth session checks/events 2. call end_session and NSS well-known logout on logout 3. add/update header tests for session-driven state transitions
1 parent fc3319e commit 7039622

3 files changed

Lines changed: 132 additions & 4 deletions

File tree

src/login/login.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -713,6 +713,12 @@ authSession.events.on('logout', async () => {
713713
await fetch(openidConfiguration.end_session_endpoint, { credentials: 'include' })
714714
}
715715
}
716+
717+
try {
718+
await fetch('/.well-known/solid/logout', { credentials: 'include' })
719+
} catch (_err) {
720+
// Not all deployments expose NSS-compatible well-known logout endpoint.
721+
}
716722
} catch (_err) {
717723
// Do nothing
718724
}

src/v2/components/header/Header.ts

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { LitElement, html, css } from 'lit'
22
import { icons } from '../../../iconBase'
3-
import { authSession } from 'solid-logic'
3+
import { authSession, authn } from 'solid-logic'
44
import '../loginButton/index'
55
import '../signupButton/index'
66
import { ifDefined } from 'lit/directives/if-defined.js'
@@ -510,6 +510,9 @@ export class Header extends LitElement {
510510
declare helpMenuOpen: boolean
511511
declare hasSlottedAccountMenu: boolean
512512
declare hasSlottedHelpMenu: boolean
513+
private readonly handleAuthSessionChange = () => {
514+
this.refreshAuthStateFromSession()
515+
}
513516

514517
constructor () {
515518
super()
@@ -540,14 +543,34 @@ export class Header extends LitElement {
540543
super.connectedCallback()
541544
document.addEventListener('click', this.handleDocumentClick)
542545
window.addEventListener('keydown', this.handleWindowKeydown)
546+
if (typeof authSession.events?.on === 'function') {
547+
authSession.events.on('login', this.handleAuthSessionChange)
548+
authSession.events.on('logout', this.handleAuthSessionChange)
549+
authSession.events.on('sessionRestore', this.handleAuthSessionChange)
550+
}
551+
this.refreshAuthStateFromSession()
543552
}
544553

545554
disconnectedCallback () {
546555
document.removeEventListener('click', this.handleDocumentClick)
547556
window.removeEventListener('keydown', this.handleWindowKeydown)
557+
if (typeof authSession.events?.off === 'function') {
558+
authSession.events.off('login', this.handleAuthSessionChange)
559+
authSession.events.off('logout', this.handleAuthSessionChange)
560+
authSession.events.off('sessionRestore', this.handleAuthSessionChange)
561+
}
548562
super.disconnectedCallback()
549563
}
550564

565+
private async refreshAuthStateFromSession () {
566+
try {
567+
await authn.checkUser()
568+
} catch (_err) {
569+
// Keep rendering even if session refresh cannot complete.
570+
}
571+
this.authState = authn.currentUser() ? 'logged-in' : 'logged-out'
572+
}
573+
551574
private handleHelpMenuClick (item: HeaderMenuItem, event: MouseEvent) {
552575
event.preventDefault()
553576
this.helpMenuOpen = false
@@ -665,8 +688,8 @@ export class Header extends LitElement {
665688
`
666689
}
667690

668-
private handleLoginSuccess () {
669-
this.authState = 'logged-in'
691+
private async handleLoginSuccess () {
692+
await this.refreshAuthStateFromSession()
670693
this.dispatchEvent(new CustomEvent('auth-action-select', {
671694
detail: { role: 'login' },
672695
bubbles: true,
@@ -676,19 +699,50 @@ export class Header extends LitElement {
676699

677700
private async handleLogout () {
678701
this.accountMenuOpen = false
702+
const issuer = window.localStorage.getItem('loginIssuer') || ''
703+
679704
try {
680705
await authSession.logout()
681706
} catch (_err) {
682707
// logout errors are non-fatal — proceed to clear state
683708
}
684-
this.authState = 'logged-out'
709+
710+
await this.performServerLogout(issuer)
711+
712+
await this.refreshAuthStateFromSession()
685713
this.dispatchEvent(new CustomEvent('logout-select', {
686714
detail: { role: 'logout' },
687715
bubbles: true,
688716
composed: true
689717
}))
690718
}
691719

720+
private async performServerLogout (issuer: string) {
721+
// Best-effort server logout for cookie-backed sessions on NSS-like servers.
722+
try {
723+
if (issuer) {
724+
const wellKnownUri = new URL(issuer)
725+
wellKnownUri.pathname = '/.well-known/openid-configuration'
726+
const wellKnownResult = await fetch(wellKnownUri.toString(), { credentials: 'include' })
727+
728+
if (wellKnownResult.status === 200) {
729+
const openidConfiguration = await wellKnownResult.json()
730+
if (openidConfiguration && openidConfiguration.end_session_endpoint) {
731+
await fetch(openidConfiguration.end_session_endpoint, { credentials: 'include' })
732+
}
733+
}
734+
}
735+
} catch (_err) {
736+
// Continue with local logout state even if remote IdP logout is unavailable.
737+
}
738+
739+
try {
740+
await fetch('/.well-known/solid/logout', { credentials: 'include' })
741+
} catch (_err) {
742+
// Not all deployments expose NSS-compatible well-known logout.
743+
}
744+
}
745+
692746
private renderAccountMenuItem (item: HeaderAccountMenuItem) {
693747
const content = html`
694748
${this.renderLoggedInAvatar(item.avatar, 'account-menu-avatar')}

src/v2/components/header/header.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,39 @@
11
import { Header } from './Header'
22
import './index'
3+
import { authn, authSession } from 'solid-logic'
4+
5+
type Listener = () => void
6+
const mockSessionListeners = new Map<string, Set<Listener>>()
7+
8+
jest.mock('solid-logic', () => ({
9+
authn: {
10+
checkUser: jest.fn(async () => null),
11+
currentUser: jest.fn(() => null)
12+
},
13+
authSession: {
14+
logout: jest.fn(async () => undefined),
15+
events: {
16+
on: jest.fn((event: string, handler: Listener) => {
17+
if (!mockSessionListeners.has(event)) mockSessionListeners.set(event, new Set())
18+
mockSessionListeners.get(event)?.add(handler)
19+
}),
20+
off: jest.fn((event: string, handler: Listener) => {
21+
mockSessionListeners.get(event)?.delete(handler)
22+
}),
23+
emit: jest.fn((event: string) => {
24+
mockSessionListeners.get(event)?.forEach(handler => handler())
25+
})
26+
}
27+
}
28+
}))
329

430
describe('SolidUIHeaderElement', () => {
531
beforeEach(() => {
632
document.body.innerHTML = ''
33+
jest.clearAllMocks()
34+
mockSessionListeners.clear()
35+
;(authn.currentUser as jest.Mock).mockReturnValue(null)
36+
;(authn.checkUser as jest.Mock).mockResolvedValue(null)
737
Object.defineProperty(window, 'open', {
838
configurable: true,
939
writable: true,
@@ -77,6 +107,8 @@ describe('SolidUIHeaderElement', () => {
77107
expect(signUpLink.getAttribute('icon')).toBe('https://example.com/signup-icon-top.svg')
78108

79109
loginButton.dispatchEvent(new CustomEvent('login-success', { bubbles: true, composed: true }))
110+
await Promise.resolve()
111+
await header.updateComplete
80112

81113
expect(authActionSelected).toHaveBeenCalledWith({
82114
role: 'login'
@@ -105,6 +137,7 @@ describe('SolidUIHeaderElement', () => {
105137

106138
it('uses a custom fallback avatar when no accountAvatar is configured', async () => {
107139
const header = new Header()
140+
;(authn.currentUser as jest.Mock).mockReturnValue({ uri: 'https://alice.example/profile/card#me' })
108141

109142
header.authState = 'logged-in'
110143
header.accountAvatar = ''
@@ -123,6 +156,7 @@ describe('SolidUIHeaderElement', () => {
123156
it('renders an accounts dropdown with avatar when logged in', async () => {
124157
const header = new Header()
125158
const accountMenuSelected = jest.fn()
159+
;(authn.currentUser as jest.Mock).mockReturnValue({ uri: 'https://alice.example/profile/card#me' })
126160

127161
header.authState = 'logged-in'
128162
header.accountIcon = 'https://example.com/account-icon.svg'
@@ -173,6 +207,7 @@ describe('SolidUIHeaderElement', () => {
173207

174208
it('does not render the logout icon on mobile layout', async () => {
175209
const header = new Header()
210+
;(authn.currentUser as jest.Mock).mockReturnValue({ uri: 'https://alice.example/profile/card#me' })
176211
header.layout = 'mobile'
177212
header.authState = 'logged-in'
178213
header.logoutIcon = 'https://example.com/logout-icon.svg'
@@ -196,6 +231,7 @@ describe('SolidUIHeaderElement', () => {
196231

197232
it('does not render account webid on mobile layout', async () => {
198233
const header = new Header()
234+
;(authn.currentUser as jest.Mock).mockReturnValue({ uri: 'https://alice.example/profile/card#me' })
199235
header.layout = 'mobile'
200236
header.authState = 'logged-in'
201237
header.accountMenu = [
@@ -263,6 +299,7 @@ describe('SolidUIHeaderElement', () => {
263299

264300
it('renders helpMenuList inside the help dropdown and dispatches events', async () => {
265301
const header = new Header()
302+
;(authn.currentUser as jest.Mock).mockReturnValue({ uri: 'https://alice.example/profile/card#me' })
266303

267304
const helpMenuClicked = jest.fn()
268305

@@ -304,4 +341,35 @@ describe('SolidUIHeaderElement', () => {
304341

305342
window.open = originalWindowOpen
306343
})
344+
345+
it('derives auth state from session on connect', async () => {
346+
const header = new Header()
347+
;(authn.currentUser as jest.Mock).mockReturnValue({ uri: 'https://alice.example/profile/card#me' })
348+
349+
document.body.appendChild(header)
350+
await header.updateComplete
351+
await Promise.resolve()
352+
await header.updateComplete
353+
354+
expect(authn.checkUser).toHaveBeenCalled()
355+
expect(header.authState).toBe('logged-in')
356+
})
357+
358+
it('refreshes auth state when session events fire', async () => {
359+
const header = new Header()
360+
document.body.appendChild(header)
361+
await header.updateComplete
362+
363+
;(authn.currentUser as jest.Mock).mockReturnValue({ uri: 'https://alice.example/profile/card#me' })
364+
;(authSession.events as any).emit('login')
365+
await Promise.resolve()
366+
await header.updateComplete
367+
expect(header.authState).toBe('logged-in')
368+
369+
;(authn.currentUser as jest.Mock).mockReturnValue(null)
370+
;(authSession.events as any).emit('logout')
371+
await Promise.resolve()
372+
await header.updateComplete
373+
expect(header.authState).toBe('logged-out')
374+
})
307375
})

0 commit comments

Comments
 (0)