From 713df374d9ea344925b137d499adaacccbb9465d Mon Sep 17 00:00:00 2001 From: satriadhikara <84092642+satriadhikara@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:16:13 +0700 Subject: [PATCH] fix(ui): refresh expired OIDC tokens before API requests The Axios request interceptor read canRefresh from the useOidcAuth() return value and tested the loggedIn ref itself, so it never refreshed and kept sending expired access tokens (401 on save). Check the session's expireAt with a 30s margin before each authenticated request. Refresh through the session endpoint, which refreshes server-side or clears a dead session without navigating away, and share one pending call between concurrent requests. When the session cannot be refreshed, ask the user to sign in again. Keep the add/edit tuple dialog input as a draft in sessionStorage so it survives the dialog closing on session loss and the sign-in redirect. The draft is restored when the same dialog is reopened and cleared on save or cancel. Closes #124 --- dbrepo-ui/components/dialogs/EditTuple.vue | 35 +++++++++++ dbrepo-ui/composables/axios-instance.ts | 42 ++++++------- dbrepo-ui/locales/en-US.json | 3 +- dbrepo-ui/tests/axios-instance.test.ts | 72 ++++++++++++++++++++++ 4 files changed, 128 insertions(+), 24 deletions(-) create mode 100644 dbrepo-ui/tests/axios-instance.test.ts diff --git a/dbrepo-ui/components/dialogs/EditTuple.vue b/dbrepo-ui/components/dialogs/EditTuple.vue index 81a2f3e12a..16cbec37a7 100644 --- a/dbrepo-ui/components/dialogs/EditTuple.vue +++ b/dbrepo-ui/components/dialogs/EditTuple.vue @@ -316,6 +316,7 @@ export default { mounted () { this.fetchContainer() this.oldTuple = Object.assign({}, this.tuple) + this.restoreDraft() this.validate() }, computed: { @@ -337,6 +338,10 @@ export default { } return this.table.constraints.primary_key.map(pk => pk.column) }, + draftKey () { + const row = this.edit ? this.primaryKeyColumns.map(pk => this.oldTuple[pk.internal_name]) : 'new' + return `dbrepo-tuple-draft:${this.$route.params.database_id}:${this.$route.params.table_id}:${JSON.stringify(row)}` + }, title () { return (this.edit ? this.$t('toolbars.table.data.edit') : this.$t('toolbars.table.data.add')) + ' ' + this.$t('toolbars.table.data.tuple') }, @@ -352,6 +357,7 @@ export default { watch: { tuple: { handler () { + this.saveDraft() this.validate() }, deep: true @@ -362,8 +368,35 @@ export default { console.debug('validate form') this.$refs.form.validate() }, + /* keep unsaved input in this tab so it survives the dialog closing when the session expires and the sign-in redirect */ + saveDraft () { + try { + sessionStorage.setItem(this.draftKey, JSON.stringify(this.tuple)) + } catch (error) { + console.warn('Failed to save tuple draft', error) + } + }, + restoreDraft () { + try { + const draft = sessionStorage.getItem(this.draftKey) + if (draft) { + console.debug('restore tuple draft', this.draftKey) + Object.assign(this.tuple, JSON.parse(draft)) + } + } catch (error) { + console.warn('Failed to restore tuple draft', error) + } + }, + clearDraft () { + try { + sessionStorage.removeItem(this.draftKey) + } catch (error) { + console.warn('Failed to clear tuple draft', error) + } + }, cancel () { this.menu = false + this.clearDraft() this.$emit('close', { success: false }) }, hint (column) { @@ -447,6 +480,7 @@ export default { .then(() => { const toast = useToastInstance() toast.success(this.$t('success.data.update')) + this.clearDraft() this.$emit('close', { success: true }) this.loading = false }) @@ -480,6 +514,7 @@ export default { .then(() => { const toast = useToastInstance() toast.success(this.$t('success.data.add')) + this.clearDraft() this.$emit('close', { success: true }) this.loading = false }) diff --git a/dbrepo-ui/composables/axios-instance.ts b/dbrepo-ui/composables/axios-instance.ts index 31746818e9..d0e9a854bb 100644 --- a/dbrepo-ui/composables/axios-instance.ts +++ b/dbrepo-ui/composables/axios-instance.ts @@ -1,24 +1,10 @@ import axios, {type AxiosInstance} from 'axios' -let instance: AxiosInstance | null = null; - -function tokenToExpiryDate(token: string): number { - if (!token) { - return -1 - } - const exp: number = jwtDecode(token).exp - if (exp) { - return exp * 1000 - } - return -1 -} +/* refresh this many seconds before the access token expires to tolerate clock skew and request latency */ +const EXPIRY_MARGIN_SECONDS = 30 -function isExpiredToken(token: string): boolean { - if (!token) { - return false - } - return tokenToExpiryDate(token) < Date.now() -} +let instance: AxiosInstance | null = null; +let pendingRefresh: Promise | null = null; export const useAxiosInstance = () => { const config = useRuntimeConfig() @@ -34,14 +20,24 @@ export const useAxiosInstance = () => { baseURL: config.public.api.client }); instance.interceptors.request.use(async (config) => { - const { loggedIn, user, canRefresh, refresh } = useOidcAuth() - if (!loggedIn) { + const { loggedIn, user, fetch } = useOidcAuth() + if (!loggedIn.value) { return config } - if (canRefresh) { - await refresh() + if (user.value!.expireAt - EXPIRY_MARGIN_SECONDS <= Date.now() / 1000) { + /* the session endpoint refreshes the token server-side, or clears the session when it cannot; concurrent requests share one call */ + pendingRefresh ??= fetch() + .then(() => { + if (!loggedIn.value) { + useToastInstance().error(useNuxtApp().$i18n.t('error.auth.expired')) + } + }) + .finally(() => { + pendingRefresh = null + }) + await pendingRefresh } - const { accessToken } = user.value + const accessToken = user.value?.accessToken if (!accessToken) { return config } diff --git a/dbrepo-ui/locales/en-US.json b/dbrepo-ui/locales/en-US.json index 12eff9307e..0b245e22c0 100644 --- a/dbrepo-ui/locales/en-US.json +++ b/dbrepo-ui/locales/en-US.json @@ -1230,7 +1230,8 @@ }, "auth": { "connection": "Failed to contact auth service", - "invalid": "Failed to authenticate in auth service" + "invalid": "Failed to authenticate in auth service", + "expired": "Your session has expired. Please sign in again." }, "analyse": { "invalid": "Failed to analyse dataset", diff --git a/dbrepo-ui/tests/axios-instance.test.ts b/dbrepo-ui/tests/axios-instance.test.ts new file mode 100644 index 0000000000..86cfb2140c --- /dev/null +++ b/dbrepo-ui/tests/axios-instance.test.ts @@ -0,0 +1,72 @@ +import {beforeEach, expect, mock, test} from 'bun:test' + +/* stand-ins for the Nuxt auto-imports used by the interceptor, shaped like nuxt-oidc-auth's useOidcAuth() */ +const session: { value: any } = { value: undefined } +const fetch = mock(async () => {}) +const toastError = mock((message: string) => {}) +const g = globalThis as any +g.useRuntimeConfig = () => ({ public: { api: { client: 'http://localhost' } } }) +g.useOidcAuth = () => ({ + loggedIn: { get value() { return Boolean(session.value?.expireAt) } }, + user: { get value() { return session.value } }, + fetch +}) +g.useToastInstance = () => ({ error: toastError }) +g.useNuxtApp = () => ({ $i18n: { t: (key: string) => key } }) + +const { useAxiosInstance } = await import('../composables/axios-instance') +const axios = useAxiosInstance() +/* echo the Authorization header instead of sending the request */ +axios.defaults.adapter = async (config) => + ({ data: config.headers.Authorization ?? null, status: 200, statusText: 'OK', headers: {}, config }) + +const now = () => Math.trunc(Date.now() / 1000) +const authHeaders = async (count: number) => + (await Promise.all(Array.from({ length: count }, () => axios.get('/api/v1/database')))).map(r => r.data) + +beforeEach(() => { + fetch.mockReset() + toastError.mockReset() +}) + +test('valid token is sent without refresh', async () => { + session.value = { expireAt: now() + 300, canRefresh: true, accessToken: 'valid' } + expect(await authHeaders(1)).toEqual(['Bearer valid']) + expect(fetch).not.toHaveBeenCalled() +}) + +test('expired token is refreshed once for concurrent requests', async () => { + session.value = { expireAt: now() - 10, canRefresh: true, accessToken: 'expired' } + fetch.mockImplementation(async () => { + await Bun.sleep(20) + session.value = { expireAt: now() + 300, canRefresh: true, accessToken: 'refreshed' } + }) + expect(await authHeaders(3)).toEqual(['Bearer refreshed', 'Bearer refreshed', 'Bearer refreshed']) + expect(fetch).toHaveBeenCalledTimes(1) +}) + +test('token inside the expiry margin is refreshed', async () => { + session.value = { expireAt: now() + 10, canRefresh: true, accessToken: 'expiring' } + fetch.mockImplementation(async () => { + session.value = { expireAt: now() + 300, canRefresh: true, accessToken: 'refreshed' } + }) + expect(await authHeaders(1)).toEqual(['Bearer refreshed']) + expect(fetch).toHaveBeenCalledTimes(1) +}) + +test('failed refresh drops the stale token and asks to sign in once', async () => { + session.value = { expireAt: now() - 10, canRefresh: true, accessToken: 'expired' } + fetch.mockImplementation(async () => { + session.value = {} + }) + expect(await authHeaders(2)).toEqual([null, null]) + expect(fetch).toHaveBeenCalledTimes(1) + expect(toastError).toHaveBeenCalledTimes(1) + expect(toastError).toHaveBeenCalledWith('error.auth.expired') +}) + +test('signed-out requests are sent without token or refresh', async () => { + session.value = {} + expect(await authHeaders(1)).toEqual([null]) + expect(fetch).not.toHaveBeenCalled() +})