From db8a292aafa75aeb5cab080095af849d5ce3185d Mon Sep 17 00:00:00 2001 From: Michael Stingl Date: Tue, 17 Feb 2026 10:52:14 +0100 Subject: [PATCH] fix: backchannel logout react to session ID Read session ID from OIDC user profile internally in UserManager instead of threading it through updateContext/signInCallback call chain. This avoids signature changes, keeps the embed API stable, and reduces the change surface. The SSE backchannel logout handler now compares the event's session ID against the stored session to only log out the affected client, per the OIDC Back-Channel Logout spec. Co-Authored-By: Claude Opus 4.6 --- packages/web-pkg/src/composables/piniaStores/auth.ts | 7 +++++++ packages/web-runtime/src/container/bootstrap.ts | 5 ++++- packages/web-runtime/src/container/sse/common.ts | 11 +++++++++-- packages/web-runtime/src/container/sse/types.ts | 5 ++++- packages/web-runtime/src/index.ts | 1 + packages/web-runtime/src/services/auth/userManager.ts | 5 +++++ .../tests/unit/container/sse/files.spec.ts | 3 +++ .../tests/unit/container/sse/helpers.spec.ts | 3 +++ .../tests/unit/container/sse/shares.spec.ts | 3 +++ 9 files changed, 39 insertions(+), 4 deletions(-) diff --git a/packages/web-pkg/src/composables/piniaStores/auth.ts b/packages/web-pkg/src/composables/piniaStores/auth.ts index ac4b24e743..091379390e 100644 --- a/packages/web-pkg/src/composables/piniaStores/auth.ts +++ b/packages/web-pkg/src/composables/piniaStores/auth.ts @@ -4,6 +4,7 @@ import { PublicLinkType } from '@opencloud-eu/web-client' export const useAuthStore = defineStore('auth', () => { const accessToken = ref() + const sessionId = ref() const idpContextReady = ref(false) const userContextReady = ref(false) const publicLinkToken = ref() @@ -14,6 +15,9 @@ export const useAuthStore = defineStore('auth', () => { const setAccessToken = (value: string) => { accessToken.value = value } + const setSessionId = (value: string) => { + sessionId.value = value + } const setIdpContextReady = (value: boolean) => { idpContextReady.value = value } @@ -34,6 +38,7 @@ export const useAuthStore = defineStore('auth', () => { const clearUserContext = () => { setAccessToken(null) + setSessionId(null) setIdpContextReady(null) setUserContextReady(null) } @@ -49,6 +54,7 @@ export const useAuthStore = defineStore('auth', () => { return { accessToken, + sessionId, idpContextReady, userContextReady, publicLinkToken, @@ -57,6 +63,7 @@ export const useAuthStore = defineStore('auth', () => { publicLinkContextReady, setAccessToken, + setSessionId, setIdpContextReady, setUserContextReady, setPublicLinkContext, diff --git a/packages/web-runtime/src/container/bootstrap.ts b/packages/web-runtime/src/container/bootstrap.ts index b4cd3ec6e2..651b5b0356 100644 --- a/packages/web-runtime/src/container/bootstrap.ts +++ b/packages/web-runtime/src/container/bootstrap.ts @@ -862,6 +862,7 @@ export const registerSSEEventListeners = ({ previewService, configStore, userStore, + authStore, router }: { language: Language @@ -873,6 +874,7 @@ export const registerSSEEventListeners = ({ previewService: PreviewService configStore: ConfigStore userStore: UserStore + authStore: AuthStore router: Router }): void => { const resourceQueue = new PQueue({ @@ -897,7 +899,8 @@ export const registerSSEEventListeners = ({ previewService, language, router, - resourceQueue + resourceQueue, + authStore } satisfies Partial clientService.sseAuthenticated.addEventListener(MESSAGE_TYPE.ITEM_RENAMED, (msg) => diff --git a/packages/web-runtime/src/container/sse/common.ts b/packages/web-runtime/src/container/sse/common.ts index f2a5275f9d..acef2960a0 100644 --- a/packages/web-runtime/src/container/sse/common.ts +++ b/packages/web-runtime/src/container/sse/common.ts @@ -1,5 +1,12 @@ import { SSEEventOptions } from './types' -export const onSSEBackchannelLogoutEvent = ({ router }: SSEEventOptions) => { - return router.push({ name: 'logout' }) +export const onSSEBackchannelLogoutEvent = ({ router, authStore, sseData }: SSEEventOptions) => { + if (!sseData.sessionid) { + // No session ID in event: log out all clients per OIDC spec + return router.push({ name: 'logout' }) + } + + if (authStore.sessionId === sseData.sessionid) { + return router.push({ name: 'logout' }) + } } diff --git a/packages/web-runtime/src/container/sse/types.ts b/packages/web-runtime/src/container/sse/types.ts index b5ba5fc705..eb7e65a833 100644 --- a/packages/web-runtime/src/container/sse/types.ts +++ b/packages/web-runtime/src/container/sse/types.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { + AuthStore, ClientService, ConfigStore, MessageStore, @@ -19,7 +20,8 @@ export const eventSchema = z.object({ spaceid: z.string().optional(), initiatorid: z.string().optional(), etag: z.string().optional(), - affecteduserids: z.array(z.string()).optional().nullable() + affecteduserids: z.array(z.string()).optional().nullable(), + sessionid: z.string().optional() }) export type EventSchemaType = z.infer @@ -31,6 +33,7 @@ export interface SSEEventOptions { messageStore: MessageStore sharesStore: SharesStore configStore: ConfigStore + authStore: AuthStore clientService: ClientService previewService: PreviewService router: Router diff --git a/packages/web-runtime/src/index.ts b/packages/web-runtime/src/index.ts index 5202fe44d1..d6f2e1d753 100644 --- a/packages/web-runtime/src/index.ts +++ b/packages/web-runtime/src/index.ts @@ -239,6 +239,7 @@ export const bootstrapApp = async (configurationPath: string, appsReadyCallback: userStore, previewService, configStore, + authStore, router }) } diff --git a/packages/web-runtime/src/services/auth/userManager.ts b/packages/web-runtime/src/services/auth/userManager.ts index 78a2fe6fc2..a8e6f57fb7 100644 --- a/packages/web-runtime/src/services/auth/userManager.ts +++ b/packages/web-runtime/src/services/auth/userManager.ts @@ -164,6 +164,11 @@ export class UserManager extends OidcUserManager { this.authStore.setAccessToken(accessToken) + // Sync session ID from OIDC user profile for backchannel logout matching + void this.getUser().then((user) => { + this.authStore.setSessionId(user?.profile?.sid ?? null) + }) + this.updateAccessTokenPromise = (async () => { if (!fetchUserData) { this.authStore.setIdpContextReady(true) diff --git a/packages/web-runtime/tests/unit/container/sse/files.spec.ts b/packages/web-runtime/tests/unit/container/sse/files.spec.ts index d247ea987e..225964a7ec 100644 --- a/packages/web-runtime/tests/unit/container/sse/files.spec.ts +++ b/packages/web-runtime/tests/unit/container/sse/files.spec.ts @@ -1,6 +1,7 @@ import { ClientService, PreviewService, + useAuthStore, useConfigStore, useMessages, useResourcesStore, @@ -442,6 +443,7 @@ const getMocks = ({ const userStore = useUserStore() const sharesStore = useSharesStore() const configStore = useConfigStore() + const authStore = useAuthStore() const clientService = mockDeep({ initiatorId: 'local1' }) const previewService = mockDeep() const router = mockDeep() @@ -458,6 +460,7 @@ const getMocks = ({ userStore, sharesStore, configStore, + authStore, clientService, previewService, resourceQueue, diff --git a/packages/web-runtime/tests/unit/container/sse/helpers.spec.ts b/packages/web-runtime/tests/unit/container/sse/helpers.spec.ts index dcc33dd194..c66e9ccd8a 100644 --- a/packages/web-runtime/tests/unit/container/sse/helpers.spec.ts +++ b/packages/web-runtime/tests/unit/container/sse/helpers.spec.ts @@ -3,6 +3,7 @@ import { createTestingPinia } from '@opencloud-eu/web-test-helpers' import { ClientService, PreviewService, + useAuthStore, useConfigStore, useMessages, useResourcesStore, @@ -63,6 +64,7 @@ const getMocks = ({ const userStore = useUserStore() const configStore = useConfigStore() const sharesStore = useSharesStore() + const authStore = useAuthStore() const clientService = mockDeep() const previewService = mockDeep() const router = mockDeep() @@ -77,6 +79,7 @@ const getMocks = ({ userStore, sharesStore, configStore, + authStore, clientService, previewService, resourceQueue, diff --git a/packages/web-runtime/tests/unit/container/sse/shares.spec.ts b/packages/web-runtime/tests/unit/container/sse/shares.spec.ts index 6c4aad6fce..50a27aaa4c 100644 --- a/packages/web-runtime/tests/unit/container/sse/shares.spec.ts +++ b/packages/web-runtime/tests/unit/container/sse/shares.spec.ts @@ -2,6 +2,7 @@ import { ClientService, eventBus, PreviewService, + useAuthStore, useConfigStore, useMessages, useResourcesStore, @@ -700,6 +701,7 @@ const getMocks = ({ const userStore = useUserStore() const configStore = useConfigStore() userStore.user = mockDeep({ id: '1' }) + const authStore = useAuthStore() const sharesStore = useSharesStore() const clientService = mockDeep({ initiatorId: 'local1' }) const previewService = mockDeep() @@ -737,6 +739,7 @@ const getMocks = ({ userStore, sharesStore, configStore, + authStore, clientService, previewService, resourceQueue,