Browse Source

Merge pull request #9415 from nocodb/nc-fix/logout-issue

fix: Refresh-token based token generation issues
pull/9430/head
Pranav C 4 months ago committed by GitHub
parent
commit
cc1fd19961
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 1
      packages/nc-gui/components/dlg/TableDescriptionUpdate.vue
  2. 54
      packages/nc-gui/composables/useApi/interceptors.ts
  3. 19
      packages/nc-gui/composables/useGlobal/actions.ts
  4. 16
      packages/nc-gui/composables/useGlobal/index.ts
  5. 1
      packages/nc-gui/composables/useGlobal/types.ts
  6. 17
      packages/nc-gui/store/notification.ts
  7. 1
      packages/nc-gui/utils/ncUtils.ts

1
packages/nc-gui/components/dlg/TableDescriptionUpdate.vue

@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { TableType } from 'nocodb-sdk' import type { TableType } from 'nocodb-sdk'
import type { ComponentPublicInstance } from '@vue/runtime-core'
interface Props { interface Props {
modelValue?: boolean modelValue?: boolean

54
packages/nc-gui/composables/useApi/interceptors.ts

@ -1,10 +1,13 @@
import type { Api } from 'nocodb-sdk' import type { Api } from 'nocodb-sdk'
import { useStorage } from '@vueuse/core'
const DbNotFoundMsg = 'Database config not found' const DbNotFoundMsg = 'Database config not found'
let refreshTokenPromise: Promise<string> | null = null let refreshTokenPromise: Promise<string> | null = null
export function addAxiosInterceptors(api: Api<any>) { export function addAxiosInterceptors(api: Api<any>) {
const isTokenRefreshInProgress = useStorage(TOKEN_REFRESH_PROGRESS_KEY, false)
const state = useGlobal() const state = useGlobal()
const router = useRouter() const router = useRouter()
const route = router.currentRoute const route = router.currentRoute
@ -45,7 +48,10 @@ export function addAxiosInterceptors(api: Api<any>) {
return response return response
}, },
// Handle Error // Handle Error
(error) => { async (error) => {
// if cancel request then throw error
if (error.code === 'ERR_CANCELED') return Promise.reject(error)
if (error.response && error.response.data && error.response.data.msg === DbNotFoundMsg) return router.replace('/base/0') if (error.response && error.response.data && error.response.data.msg === DbNotFoundMsg) return router.replace('/base/0')
// Return any error which is not due to authentication back to the calling service // Return any error which is not due to authentication back to the calling service
@ -55,10 +61,33 @@ export function addAxiosInterceptors(api: Api<any>) {
// Logout user if token refresh didn't work or user is disabled // Logout user if token refresh didn't work or user is disabled
if (error.config.url === '/auth/token/refresh') { if (error.config.url === '/auth/token/refresh') {
state.signOut() state.signOut(undefined, true)
return Promise.reject(error) return Promise.reject(error)
} }
// if no active refresh token request in the current session then check the local storage
if (!refreshTokenPromise && isTokenRefreshInProgress.value) {
// if token refresh is already in progress, wait for it to finish and then retry the request if token is available
await until(isTokenRefreshInProgress).toMatch((v) => !v, { timeout: 5000 })
isTokenRefreshInProgress.value = false
// check if the user is signed in by checking the token presence and retry the request with the new token
if (state.token.value) {
return new Promise((resolve, reject) => {
const config = error.config
config.headers['xc-auth'] = state.token.value
axiosInstance
.request(config)
.then((response) => {
resolve(response)
})
.catch((error) => {
reject(error)
})
})
}
}
let refreshTokenPromiseRes: (token: string) => void let refreshTokenPromiseRes: (token: string) => void
let refreshTokenPromiseRej: (e: Error) => void let refreshTokenPromiseRej: (e: Error) => void
@ -86,21 +115,27 @@ export function addAxiosInterceptors(api: Api<any>) {
// ignore since it could have already been handled and redirected to sign in // ignore since it could have already been handled and redirected to sign in
}) })
} else { } else {
isTokenRefreshInProgress.value = true
refreshTokenPromise = new Promise<string>((resolve, reject) => { refreshTokenPromise = new Promise<string>((resolve, reject) => {
refreshTokenPromiseRes = resolve refreshTokenPromiseRes = resolve
refreshTokenPromiseRej = reject refreshTokenPromiseRej = reject
}) })
// set a catch on the promise to avoid unhandled promise rejection // set a catch on the promise to avoid unhandled promise rejection
refreshTokenPromise.catch(() => { refreshTokenPromise
// ignore .catch(() => {
}) // ignore
})
.finally(() => {
isTokenRefreshInProgress.value = false
})
} }
// Try request again with new token // Try request again with new token
return axiosInstance return axiosInstance
.post('/auth/token/refresh', null, { .post('/auth/token/refresh', null, {
withCredentials: true, withCredentials: true,
cancelToken: undefined,
}) })
.then((token) => { .then((token) => {
// New request with new token // New request with new token
@ -124,8 +159,17 @@ export function addAxiosInterceptors(api: Api<any>) {
}) })
}) })
.catch(async (refreshTokenError) => { .catch(async (refreshTokenError) => {
// skip signout call if request cancelled
if (refreshTokenError.code === 'ERR_CANCELED') {
// reject the refresh token promise and reset
refreshTokenPromiseRej(refreshTokenError)
refreshTokenPromise = null
return Promise.reject(refreshTokenError)
}
await state.signOut({ await state.signOut({
redirectToSignin: !route.value.meta.public, redirectToSignin: !route.value.meta.public,
skipApiCall: true,
}) })
// reject the refresh token promise and reset // reject the refresh token promise and reset

19
packages/nc-gui/composables/useGlobal/actions.ts

@ -1,5 +1,5 @@
import { getActivePinia } from 'pinia' import { getActivePinia } from 'pinia'
import type { Actions, AppInfo, SignOutParams, State } from './types' import type { Actions, AppInfo, State } from './types'
import type { NcProjectType } from '#imports' import type { NcProjectType } from '#imports'
export function useGlobalActions(state: State): Actions { export function useGlobalActions(state: State): Actions {
@ -8,10 +8,17 @@ export function useGlobalActions(state: State): Actions {
} }
/** Sign out by deleting the token from localStorage */ /** Sign out by deleting the token from localStorage */
const signOut: Actions['signOut'] = async ({ redirectToSignin, signinUrl = '/signin' }: SignOutParams = {}) => { const signOut: Actions['signOut'] = async ({
redirectToSignin,
signinUrl = '/signin',
skipApiCall = false,
}: SignOutParams = {}) => {
try { try {
const nuxtApp = useNuxtApp() // call and invalidate refresh token only if user manually triggered logout
await nuxtApp.$api.auth.signout() if (!skipApiCall) {
const nuxtApp = useNuxtApp()
await nuxtApp.$api.auth.signout()
}
} catch { } catch {
// ignore error // ignore error
} finally { } finally {
@ -69,7 +76,9 @@ export function useGlobalActions(state: State): Actions {
}) })
.catch(async () => { .catch(async () => {
if (state.token.value && state.user.value) { if (state.token.value && state.user.value) {
await signOut() await signOut({
skipApiCall: true,
})
message.error(t('msg.error.youHaveBeenSignedOut')) message.error(t('msg.error.youHaveBeenSignedOut'))
} }
}) })

16
packages/nc-gui/composables/useGlobal/index.ts

@ -42,22 +42,6 @@ export const useGlobal = createGlobalState((): UseGlobalReturn => {
const actions = useGlobalActions(state) const actions = useGlobalActions(state)
/** try to refresh token before expiry (5 min before expiry) */
watch(
() =>
!!(
state.jwtPayload.value &&
state.jwtPayload.value.exp &&
state.jwtPayload.value.exp - 5 * 60 < state.timestamp.value / 1000
),
async (expiring: boolean) => {
if (getters.signedIn.value && state.jwtPayload.value && expiring) {
await actions.refreshToken()
}
},
{ immediate: true },
)
watch( watch(
state.jwtPayload, state.jwtPayload,
(nextPayload) => { (nextPayload) => {

1
packages/nc-gui/composables/useGlobal/types.ts

@ -84,6 +84,7 @@ export interface SignOutParams {
redirectToSignin?: boolean redirectToSignin?: boolean
signinUrl?: string signinUrl?: string
skipRedirect?: boolean skipRedirect?: boolean
skipApiCall?: boolean
} }
export interface Actions { export interface Actions {

17
packages/nc-gui/store/notification.ts

@ -2,8 +2,11 @@ import { defineStore } from 'pinia'
import type { NotificationType } from 'nocodb-sdk' import type { NotificationType } from 'nocodb-sdk'
import axios, { type CancelTokenSource } from 'axios' import axios, { type CancelTokenSource } from 'axios'
import { CancelToken } from 'axios' import { CancelToken } from 'axios'
import { useStorage } from '@vueuse/core'
export const useNotification = defineStore('notificationStore', () => { export const useNotification = defineStore('notificationStore', () => {
const isTokenRefreshInProgress = useStorage(TOKEN_REFRESH_PROGRESS_KEY, false)
const readNotifications = ref<NotificationType[]>([]) const readNotifications = ref<NotificationType[]>([])
const unreadNotifications = ref<NotificationType[]>([]) const unreadNotifications = ref<NotificationType[]>([])
@ -172,13 +175,19 @@ export const useNotification = defineStore('notificationStore', () => {
}) })
// function to clear polling and cancel any pending requests // function to clear polling and cancel any pending requests
const clearPolling = () => { const clearPolling = async () => {
if (timeOutId) { if (timeOutId) {
clearTimeout(timeOutId) clearTimeout(timeOutId)
timeOutId = null timeOutId = null
} }
cancelTokenSource?.cancel() // take a reference of the cancel token source and set the current one to null
// so that we can cancel the polling request even if token changes
const source = cancelTokenSource
cancelTokenSource = null cancelTokenSource = null
// wait if refresh token generation is in progress and cancel the polling after that
// set a timeout of 10 seconds to avoid hanging
await until(isTokenRefreshInProgress).toMatch((v) => !v, { timeout: 10000 })
source?.cancel()
} }
const init = async () => { const init = async () => {
@ -186,7 +195,7 @@ export const useNotification = defineStore('notificationStore', () => {
// For playwright, polling will cause the test to hang indefinitely // For playwright, polling will cause the test to hang indefinitely
// as we wait for the networkidle event. So, we disable polling for playwright // as we wait for the networkidle event. So, we disable polling for playwright
if (!(window as any).isPlaywright) { if (!(window as any).isPlaywright) {
clearPolling() clearPolling().catch((e) => console.log(e))
pollNotifications().catch((e) => console.log(e)) pollNotifications().catch((e) => console.log(e))
} }
} }
@ -199,7 +208,7 @@ export const useNotification = defineStore('notificationStore', () => {
if (newToken && newToken !== oldToken) { if (newToken && newToken !== oldToken) {
await init() await init()
} else if (!newToken) { } else if (!newToken) {
clearPolling() clearPolling().catch((e) => console.log(e))
} }
} catch (e) { } catch (e) {
console.error(e) console.error(e)

1
packages/nc-gui/utils/ncUtils.ts

@ -1 +1,2 @@
export const isEeUI = false export const isEeUI = false
export const TOKEN_REFRESH_PROGRESS_KEY = 'nc_token_refresh_progress'

Loading…
Cancel
Save