diff --git a/medcat-trainer/webapp/frontend/src/main.ts b/medcat-trainer/webapp/frontend/src/main.ts index 713cb7c44..fd26d6c26 100644 --- a/medcat-trainer/webapp/frontend/src/main.ts +++ b/medcat-trainer/webapp/frontend/src/main.ts @@ -11,7 +11,7 @@ import '@/assets/main.css' import { createApp } from 'vue' import App from './App.vue' -import router from './router' +import { initialiseRouter } from './router' import axios from 'axios' import VueCookies from 'vue-cookies' import vSelect from 'vue-select' @@ -24,6 +24,7 @@ import * as components from 'vuetify/components' import * as directives from 'vuetify/directives' import {authPlugin} from "./auth"; import { loadRuntimeConfig, isOidcEnabled } from './runtimeConfig'; +import { performStartupCleanup } from './utils/storage-cleanup'; const theme ={ dark: false, @@ -55,7 +56,6 @@ async function bootstrap() { app.component("v-select", vSelect) app.component('vue-simple-context-menu', VueSimpleContextMenu) app.component('font-awesome-icon', FontAwesomeIcon) - app.use(router) app.use(VueCookies, { expires: '7d'}) app.use(vuetify); @@ -81,9 +81,16 @@ async function bootstrap() { } app.config.compilerOptions.whitespace = 'preserve' + + // Router is initialised and created after keycloak initialisation as workaround to URL fragments not being removed after successful login + // See: https://github.com/keycloak/keycloak/issues/14742#issuecomment-1663069438 + app.use(initialiseRouter()) app.mount('#app') } +// Clear app storage before the application is bootstrapped +// This prevents stale auth state from being used +performStartupCleanup(); bootstrap() .then(() => console.log('[Bootstrap] Application started successfully')) .catch(error => console.error('[Bootstrap] Failed to start application:', error)) diff --git a/medcat-trainer/webapp/frontend/src/router/index.ts b/medcat-trainer/webapp/frontend/src/router/index.ts index 15eb3f23b..9bec0118c 100644 --- a/medcat-trainer/webapp/frontend/src/router/index.ts +++ b/medcat-trainer/webapp/frontend/src/router/index.ts @@ -1,4 +1,4 @@ -import { createRouter, createWebHistory } from 'vue-router' +import {createRouter, createWebHistory} from 'vue-router' import Home from '../views/Home.vue' import TrainAnnotations from '../views/TrainAnnotations.vue' import Demo from '../views/Demo.vue' @@ -6,46 +6,48 @@ import Metrics from '../views/Metrics.vue' import MetricsHome from '../views/MetricsHome.vue' import ConceptDatabase from '../views/ConceptDatabase.vue' - -const router = createRouter({ - history: createWebHistory(import.meta.env.BASE_URL), - routes: [ +const initialiseRouter = () => { + return createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes: [ { - path: '/train-annotations/:projectId/:docId?', - name: 'train-annotations', - component: TrainAnnotations, - props: true, - // query: true + path: '/train-annotations/:projectId/:docId?', + name: 'train-annotations', + component: TrainAnnotations, + props: true, + // query: true }, { - path: '/metrics-reports/', - name: 'metrics-reports', - component: MetricsHome, + path: '/metrics-reports/', + name: 'metrics-reports', + component: MetricsHome, }, { - path: '/metrics/:reportId/', - name: 'metrics', - component: Metrics, - props: router => ({reportId: parseInt(router.params.reportId)}) + path: '/metrics/:reportId/', + name: 'metrics', + component: Metrics, + props: router => ({reportId: parseInt(router.params.reportId)}) }, { - path: '/demo', - name: 'demo', - component: Demo + path: '/demo', + name: 'demo', + component: Demo }, { - path: '/model-explore', - name: 'model-explore', - component: ConceptDatabase + path: '/model-explore', + name: 'model-explore', + component: ConceptDatabase }, { - path: '/:pathMatch(.*)', - name: 'home', - component: Home + path: '/:pathMatch(.*)', + name: 'home', + component: Home } - ] -}) + ] + }) +} + -export default router +export {initialiseRouter} diff --git a/medcat-trainer/webapp/frontend/src/tests/utils/storage-cleanup.spec.ts b/medcat-trainer/webapp/frontend/src/tests/utils/storage-cleanup.spec.ts new file mode 100644 index 000000000..a45607b89 --- /dev/null +++ b/medcat-trainer/webapp/frontend/src/tests/utils/storage-cleanup.spec.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { performStartupCleanup } from '@/utils/storage-cleanup' + +describe('storageCleanup', () => { + let consoleLogSpy: any + let localStorageMock: { [key: string]: any } + let sessionStorageMock: { [key: string]: any } + + beforeEach(() => { + // Mock console.log + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + // Mock localStorage with proper Object.keys() support + localStorageMock = {} + const localStorageProxy = new Proxy(localStorageMock, { + get(target, prop) { + if (prop === 'getItem') return (key: string) => target[key] || null + if (prop === 'setItem') return (key: string, value: string) => { target[key] = value } + if (prop === 'removeItem') return (key: string) => { delete target[key] } + if (prop === 'clear') return () => { Object.keys(target).forEach(k => delete target[k]) } + if (prop === 'key') return (index: number) => Object.keys(target)[index] || null + if (prop === 'length') return Object.keys(target).length + return target[prop as string] + }, + ownKeys(target) { + return Object.keys(target) + }, + getOwnPropertyDescriptor(target, prop) { + return { + enumerable: true, + configurable: true, + value: target[prop as string] + } + } + }) + vi.stubGlobal('localStorage', localStorageProxy) + + // Mock sessionStorage + sessionStorageMock = {} + const sessionStorageProxy = { + getItem: (key: string) => sessionStorageMock[key] || null, + setItem: (key: string, value: string) => { sessionStorageMock[key] = value }, + removeItem: (key: string) => { delete sessionStorageMock[key] }, + clear: () => { sessionStorageMock = {} }, + key: (index: number) => Object.keys(sessionStorageMock)[index] || null, + get length() { return Object.keys(sessionStorageMock).length } + } as Storage + vi.stubGlobal('sessionStorage', sessionStorageProxy) + + // Mock document.cookie + let cookieStore: string[] = [] + Object.defineProperty(document, 'cookie', { + get: () => cookieStore.join('; '), + set: (value: string) => { + if (value.includes('expires=Thu, 01 Jan 1970')) { + // Cookie deletion + const name = value.split('=')[0] + cookieStore = cookieStore.filter(c => !c.startsWith(name + '=')) + } else { + cookieStore.push(value) + } + }, + configurable: true + }) + + // Mock window.location and URL + delete (window as any).location + window.location = { + href: 'https://example.com/', + hostname: 'example.com' + } as any + + // Mock window.history.replaceState + window.history.replaceState = vi.fn() + }) + + afterEach(() => { + consoleLogSpy.mockRestore() + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + describe('performStartupCleanup', () => { + it('should log startup cleanup message', () => { + performStartupCleanup() + expect(consoleLogSpy).toHaveBeenCalledWith('[StorageCleanup] Performing startup cleanup') + }) + + it('should clear application cookies', () => { + // Set some cookies + document.cookie = 'api-token=test123' + document.cookie = 'username=testuser' + document.cookie = 'admin=true' + document.cookie = '_oauth2_proxy=djIuWDI5aGRYUm9NbDl3Y205NGVTMDVaV05sTjJJeE1qUXdZVE0wTWpVNE1UYzBaVEJqWm1KaU1tWXdPR' + document.cookie = '_oauth2_proxy_1=mdlsjjsadfhHLFhBLGnbJlhB>j' + document.cookie = 'sessionid=6id701ipjww6rx0gumt0vvz1pnxpy12p' + document.cookie = 'AUTH_SESSION_ID=OTI4Mzk4NmUtZWJhNi' + document.cookie = 'KC_RESTART=eyJhbGciOiJkaXIiLCJlbmMiOiJBMTI4..' + document.cookie = 'KEYCLOAK_IDENTITY=eyJhbGciOiJIUzUxMiIsInR5cCI...' + document.cookie = 'KEYCLOAK_SESSION=-9rVzyOy1xEA4sktmgSvv8DriM3ZO4kv-zjrhjuYFkA' + + performStartupCleanup() + + // Cookies should be cleared (setting them with expired date) + // We can't easily verify the exact cookie string, but we can check the function runs + expect(consoleLogSpy).toHaveBeenCalled() + }) + + it('should clear all sessionStorage', () => { + // Add some sessionStorage items + sessionStorage.setItem('session-key1', 'value1') + sessionStorage.setItem('session-key2', 'value2') + + expect(Object.keys(sessionStorageMock)).toHaveLength(2) + + performStartupCleanup() + + expect(Object.keys(sessionStorageMock)).toHaveLength(0) + }) + + + it('should handle localStorage with no Keycloak items', () => { + localStorage.setItem('normal-key', 'normal-value') + + performStartupCleanup() + + // Normal key should remain untouched + expect(localStorageMock['normal-key']).toBe('normal-value') + expect(Object.keys(localStorageMock)).toHaveLength(1) + }) + }) +}) diff --git a/medcat-trainer/webapp/frontend/src/utils/storage-cleanup.ts b/medcat-trainer/webapp/frontend/src/utils/storage-cleanup.ts new file mode 100644 index 000000000..34e340ef6 --- /dev/null +++ b/medcat-trainer/webapp/frontend/src/utils/storage-cleanup.ts @@ -0,0 +1,28 @@ +/** + * Utility to clear application-specific browser storage on startup to prevent auth state conflicts + */ + +function clearAuthRelatedCookies() { + console.debug('[StorageCleanup] Clearing auth-related cookies') + // Omit keycloak cookies ( 'AUTH_SESSION_ID', 'KC_RESTART', 'KEYCLOAK_IDENTITY', 'KEYCLOAK_SESSION',) as removing them breaks the oauth callback flow when OIDC auth is enabled + const cookies = [ + 'api-token', 'username', 'admin', 'user-id', + 'sessionid', + '_oauth2_proxy', '_oauth2_proxy_csrf', '_oauth2_proxy_1', '_oauth2_proxy_2' + ] + cookies.forEach(name => { + document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;` + }) +} + +function clearSessionStorage() { + console.debug('[StorageCleanup] Clearing sessionStorage') + sessionStorage.clear() +} + + +export function performStartupCleanup(): void { + console.log('[StorageCleanup] Performing startup cleanup') + clearAuthRelatedCookies(); + clearSessionStorage(); +} diff --git a/medcat-trainer/webapp/frontend/tsconfig.vitest.json b/medcat-trainer/webapp/frontend/tsconfig.vitest.json index 1f7f6ce5f..b1c819b68 100644 --- a/medcat-trainer/webapp/frontend/tsconfig.vitest.json +++ b/medcat-trainer/webapp/frontend/tsconfig.vitest.json @@ -1,6 +1,6 @@ { "extends": "./tsconfig.app.json", - "include": ["env.d.ts", "src/tests/**/*.ts"], + "include": ["env.d.ts", "src/tests/**/*.ts", "src/utils/**/*.ts"], "exclude": [], "compilerOptions": { "composite": true,