Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions medcat-trainer/webapp/frontend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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,
Expand Down Expand Up @@ -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);

Expand All @@ -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))
60 changes: 31 additions & 29 deletions medcat-trainer/webapp/frontend/src/router/index.ts
Original file line number Diff line number Diff line change
@@ -1,51 +1,53 @@
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'
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}


132 changes: 132 additions & 0 deletions medcat-trainer/webapp/frontend/src/tests/utils/storage-cleanup.spec.ts
Original file line number Diff line number Diff line change
@@ -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; Secure; HttpOnly'
document.cookie = 'username=testuser; Secure; HttpOnly'
document.cookie = 'admin=true; Secure; HttpOnly'
document.cookie = '_oauth2_proxy=djIuWDI5aGRYUm9NbDl3Y205NGVTMDVaV05sTjJJeE1qUXdZVE0wTWpVNE1UYzBaVEJqWm1KaU1tWXdPR; Secure; HttpOnly'
document.cookie = '_oauth2_proxy_1=mdlsjjsadfhHLFhBLGnbJlhB>j; Secure; HttpOnly'
document.cookie = 'sessionid=6id701ipjww6rx0gumt0vvz1pnxpy12p; Secure; HttpOnly'
document.cookie = 'AUTH_SESSION_ID=OTI4Mzk4NmUtZWJhNi; Secure; HttpOnly'
document.cookie = 'KC_RESTART=eyJhbGciOiJkaXIiLCJlbmMiOiJBMTI4..; Secure; HttpOnly'
document.cookie = 'KEYCLOAK_IDENTITY=eyJhbGciOiJIUzUxMiIsInR5cCI...; Secure; HttpOnly'
document.cookie = 'KEYCLOAK_SESSION=-9rVzyOy1xEA4sktmgSvv8DriM3ZO4kv-zjrhjuYFkA; Secure; HttpOnly'

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)
})
})
})
28 changes: 28 additions & 0 deletions medcat-trainer/webapp/frontend/src/utils/storage-cleanup.ts
Original file line number Diff line number Diff line change
@@ -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();
}
2 changes: 1 addition & 1 deletion medcat-trainer/webapp/frontend/tsconfig.vitest.json
Original file line number Diff line number Diff line change
@@ -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,
Expand Down