Skip to content
Merged
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
29 changes: 29 additions & 0 deletions medcat-trainer/webapp/frontend/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@
</div>
</div>
</header>
<transition name="alert">
<div v-if="sessionExpired" class="alert alert-warning session-expired-alert" role="alert">
Your session has expired. Please log in again.
</div>
</transition>
<main class="main-content">
<router-view/>
</main>
Expand All @@ -60,6 +65,7 @@ import Login from '@/components/common/Login.vue'
import EventBus from '@/event-bus'
import { isOidcEnabled, getRuntimeConfig } from './runtimeConfig';
import { getMenuItems } from './plugins/registry'
import { UNAUTHORIZED_EVENT, resetUnauthorizedGuard } from './httpAuth'

export default {
name: 'App',
Expand All @@ -71,6 +77,7 @@ export default {
isAdmin: false,
version: '',
useOidc: isOidcEnabled(),
sessionExpired: false,
}
},
computed: {
Expand Down Expand Up @@ -99,6 +106,8 @@ export default {
}
},
loginSuccessful () {
this.sessionExpired = false
resetUnauthorizedGuard()
if (!this.useOidc) {
this.loginModal = false
this.uname = this.$cookies.get('username')
Expand All @@ -110,6 +119,14 @@ export default {
this.$router.push({ name: 'home' })
}
},
onUnauthorized () {
// A request was rejected with 401: the stored token is no longer valid
// (e.g. server-side session/token cleared). Prompt the user to log back in.
this.uname = null
this.isAdmin = false
this.sessionExpired = true
this.openLogin()
},
updateOidcUser () {
if (this.$keycloak && this.$keycloak.tokenParsed) {
this.uname = this.$keycloak.tokenParsed.preferred_username || null
Expand Down Expand Up @@ -139,6 +156,7 @@ export default {
},
mounted () {
EventBus.$on('login:success', this.loginSuccessful)
EventBus.$on(UNAUTHORIZED_EVENT, this.onUnauthorized)

if (!this.useOidc) {
this.uname = this.$cookies.get('username') || null
Expand All @@ -154,6 +172,7 @@ export default {
},
beforeDestroy () {
EventBus.$off('login:success', this.loginSuccessful)
EventBus.$off(UNAUTHORIZED_EVENT, this.onUnauthorized)
},
created () {
this.$http.get('/api/version/').then(resp => {
Expand All @@ -168,6 +187,16 @@ export default {
min-height: 100vh;
}

.session-expired-alert {
position: fixed;
top: 64px;
left: 50%;
transform: translateX(-50%);
z-index: 60;
margin: 0;
text-align: center;
}

.header-gradient {
background: linear-gradient(135deg, #126cad 0%, #3d0372 50%, #8e1b73 100%);
position: fixed;
Expand Down
53 changes: 53 additions & 0 deletions medcat-trainer/webapp/frontend/src/httpAuth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { AxiosInstance } from 'axios'
import EventBus from './event-bus'

/**
* Emitted when an authenticated request is rejected with a 401, i.e. the
* stored token is no longer valid server-side (e.g. after a DB reset / fresh
* deploy or an expired session). Listeners are responsible for prompting the
* user to re-authenticate.
*/
export const UNAUTHORIZED_EVENT = 'auth:unauthorized'

const AUTH_COOKIES = ['api-token', 'username', 'admin', 'user-id']

// Guard so that a burst of parallel requests all returning 401 only triggers a
// single re-login prompt. Reset via resetUnauthorizedGuard() on login success.
let handlingUnauthorized = false

/** Clear stale auth state and notify the app that re-login is required. */
export function handleUnauthorized(http: AxiosInstance): void {
if (handlingUnauthorized) {
return
}
handlingUnauthorized = true

delete http.defaults.headers.common['Authorization']
for (const name of AUTH_COOKIES) {
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`
}

EventBus.$emit(UNAUTHORIZED_EVENT)
}

/** Re-arm the guard once the user has successfully re-authenticated. */
export function resetUnauthorizedGuard(): void {
handlingUnauthorized = false
}

/**
* Register a global response interceptor that turns any 401 into a single
* re-login prompt. The traditional login request uses its own axios instance,
* so a wrong-password 401 there is unaffected by this handler.
*/
export function registerUnauthorizedInterceptor(http: AxiosInstance): number {
return http.interceptors.response.use(
response => response,
error => {
if (error?.response?.status === 401) {
handleUnauthorized(http)
}
return Promise.reject(error)
}
)
}
2 changes: 2 additions & 0 deletions medcat-trainer/webapp/frontend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { registerUnauthorizedInterceptor } from './httpAuth'
import { initPluginBootstrap } from './plugins/bootstrap'
import PluginSlot from '@/components/plugins/PluginSlot.vue'

Expand Down Expand Up @@ -54,6 +55,7 @@ async function bootstrap() {

const app = createApp(App)
app.config.globalProperties.$http = axios
registerUnauthorizedInterceptor(axios)
app.component("v-select", vSelect)
app.component('vue-simple-context-menu', VueSimpleContextMenu)
app.component('font-awesome-icon', FontAwesomeIcon)
Expand Down
95 changes: 95 additions & 0 deletions medcat-trainer/webapp/frontend/src/tests/httpAuth.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import {
UNAUTHORIZED_EVENT,
handleUnauthorized,
resetUnauthorizedGuard,
registerUnauthorizedInterceptor
} from '@/httpAuth'
import EventBus from '@/event-bus'

// Minimal axios-like stub capturing the registered response interceptor handlers.
const makeHttpStub = () => {
const handlers: {
onFulfilled?: (resp: unknown) => unknown
onRejected?: (error: unknown) => unknown
} = {}
return {
handlers,
defaults: { headers: { common: { Authorization: 'Token abc' } as Record<string, string> } },
interceptors: {
response: {
use: (onFulfilled: (resp: unknown) => unknown, onRejected: (error: unknown) => unknown) => {
handlers.onFulfilled = onFulfilled
handlers.onRejected = onRejected
return 1
}
}
}
}
}

describe('httpAuth unauthorized handling', () => {
beforeEach(() => {
resetUnauthorizedGuard()
// Seed auth cookies as if the user were logged in.
for (const c of ['api-token', 'username', 'admin', 'user-id']) {
document.cookie = `${c}=value; path=/`
}
})

afterEach(() => {
EventBus.$off(UNAUTHORIZED_EVENT)
})

it('clears auth state and emits the unauthorized event', () => {
const http = makeHttpStub()
const onEvent = vi.fn()
EventBus.$on(UNAUTHORIZED_EVENT, onEvent)

handleUnauthorized(http as never)

expect(http.defaults.headers.common['Authorization']).toBeUndefined()
expect(document.cookie).not.toContain('api-token=value')
expect(document.cookie).not.toContain('username=value')
expect(onEvent).toHaveBeenCalledTimes(1)
})

it('only prompts once for a burst of 401s until the guard is reset', () => {
const http = makeHttpStub()
const onEvent = vi.fn()
EventBus.$on(UNAUTHORIZED_EVENT, onEvent)

handleUnauthorized(http as never)
handleUnauthorized(http as never)
handleUnauthorized(http as never)
expect(onEvent).toHaveBeenCalledTimes(1)

resetUnauthorizedGuard()
handleUnauthorized(http as never)
expect(onEvent).toHaveBeenCalledTimes(2)
})

it('interceptor triggers the handler on a 401 response and re-rejects', async () => {
const http = makeHttpStub()
registerUnauthorizedInterceptor(http as never)
const onEvent = vi.fn()
EventBus.$on(UNAUTHORIZED_EVENT, onEvent)

const error = { response: { status: 401 } }
await expect(http.handlers.onRejected!(error)).rejects.toBe(error)
expect(onEvent).toHaveBeenCalledTimes(1)
})

it('interceptor ignores non-401 errors', async () => {
const http = makeHttpStub()
registerUnauthorizedInterceptor(http as never)
const onEvent = vi.fn()
EventBus.$on(UNAUTHORIZED_EVENT, onEvent)

const error = { response: { status: 500 } }
await expect(http.handlers.onRejected!(error)).rejects.toBe(error)
expect(onEvent).not.toHaveBeenCalled()
// A successful response passes through untouched.
expect(http.handlers.onFulfilled!({ ok: true })).toEqual({ ok: true })
})
})
Loading
Loading