refactor the logic of refreshing access_token (#10068)

This commit is contained in:
NFish
2024-11-05 12:38:31 +08:00
committed by GitHub
parent de5dfd99f6
commit 302f4407f6
7 changed files with 171 additions and 185 deletions

View File

@@ -1,3 +1,4 @@
import { refreshAccessTokenOrRelogin } from './refresh-token'
import { API_PREFIX, IS_CE_EDITION, PUBLIC_API_PREFIX } from '@/config'
import Toast from '@/app/components/base/toast'
import type { AnnotationReply, MessageEnd, MessageReplace, ThoughtItem } from '@/app/components/base/chat/chat/type'
@@ -356,39 +357,8 @@ const baseFetch = <T>(
if (!/^(2|3)\d{2}$/.test(String(res.status))) {
const bodyJson = res.json()
switch (res.status) {
case 401: {
if (isPublicAPI) {
return bodyJson.then((data: ResponseError) => {
if (data.code === 'web_sso_auth_required')
requiredWebSSOLogin()
if (data.code === 'unauthorized') {
removeAccessToken()
globalThis.location.reload()
}
return Promise.reject(data)
})
}
const loginUrl = `${globalThis.location.origin}/signin`
bodyJson.then((data: ResponseError) => {
if (data.code === 'init_validate_failed' && IS_CE_EDITION && !silent)
Toast.notify({ type: 'error', message: data.message, duration: 4000 })
else if (data.code === 'not_init_validated' && IS_CE_EDITION)
globalThis.location.href = `${globalThis.location.origin}/init`
else if (data.code === 'not_setup' && IS_CE_EDITION)
globalThis.location.href = `${globalThis.location.origin}/install`
else if (location.pathname !== '/signin' || !IS_CE_EDITION)
globalThis.location.href = loginUrl
else if (!silent)
Toast.notify({ type: 'error', message: data.message })
}).catch(() => {
// Handle any other errors
globalThis.location.href = loginUrl
})
break
}
case 401:
return Promise.reject(resClone)
case 403:
bodyJson.then((data: ResponseError) => {
if (!silent)
@@ -484,7 +454,9 @@ export const upload = (options: any, isPublicAPI?: boolean, url?: string, search
export const ssePost = (
url: string,
fetchOptions: FetchOptionType,
{
otherOptions: IOtherOptions,
) => {
const {
isPublicAPI = false,
onData,
onCompleted,
@@ -507,8 +479,7 @@ export const ssePost = (
onTextReplace,
onError,
getAbortController,
}: IOtherOptions,
) => {
} = otherOptions
const abortController = new AbortController()
const options = Object.assign({}, baseOptions, {
@@ -532,21 +503,29 @@ export const ssePost = (
globalThis.fetch(urlWithPrefix, options as RequestInit)
.then((res) => {
if (!/^(2|3)\d{2}$/.test(String(res.status))) {
res.json().then((data: any) => {
if (isPublicAPI) {
if (data.code === 'web_sso_auth_required')
requiredWebSSOLogin()
if (res.status === 401) {
refreshAccessTokenOrRelogin(TIME_OUT).then(() => {
ssePost(url, fetchOptions, otherOptions)
}).catch(() => {
res.json().then((data: any) => {
if (isPublicAPI) {
if (data.code === 'web_sso_auth_required')
requiredWebSSOLogin()
if (data.code === 'unauthorized') {
removeAccessToken()
globalThis.location.reload()
}
if (res.status === 401)
return
}
Toast.notify({ type: 'error', message: data.message || 'Server Error' })
})
onError?.('Server Error')
if (data.code === 'unauthorized') {
removeAccessToken()
globalThis.location.reload()
}
}
})
})
}
else {
res.json().then((data) => {
Toast.notify({ type: 'error', message: data.message || 'Server Error' })
})
onError?.('Server Error')
}
return
}
return handleStream(res, (str: string, isFirstMessage: boolean, moreInfo: IOnDataMoreInfo) => {
@@ -568,7 +547,54 @@ export const ssePost = (
// base request
export const request = <T>(url: string, options = {}, otherOptions?: IOtherOptions) => {
return baseFetch<T>(url, options, otherOptions || {})
return new Promise<T>((resolve, reject) => {
const otherOptionsForBaseFetch = otherOptions || {}
baseFetch<T>(url, options, otherOptionsForBaseFetch).then(resolve).catch((errResp) => {
if (errResp?.status === 401) {
return refreshAccessTokenOrRelogin(TIME_OUT).then(() => {
baseFetch<T>(url, options, otherOptionsForBaseFetch).then(resolve).catch(reject)
}).catch(() => {
const {
isPublicAPI = false,
silent,
} = otherOptionsForBaseFetch
const bodyJson = errResp.json()
if (isPublicAPI) {
return bodyJson.then((data: ResponseError) => {
if (data.code === 'web_sso_auth_required')
requiredWebSSOLogin()
if (data.code === 'unauthorized') {
removeAccessToken()
globalThis.location.reload()
}
return Promise.reject(data)
})
}
const loginUrl = `${globalThis.location.origin}/signin`
bodyJson.then((data: ResponseError) => {
if (data.code === 'init_validate_failed' && IS_CE_EDITION && !silent)
Toast.notify({ type: 'error', message: data.message, duration: 4000 })
else if (data.code === 'not_init_validated' && IS_CE_EDITION)
globalThis.location.href = `${globalThis.location.origin}/init`
else if (data.code === 'not_setup' && IS_CE_EDITION)
globalThis.location.href = `${globalThis.location.origin}/install`
else if (location.pathname !== '/signin' || !IS_CE_EDITION)
globalThis.location.href = loginUrl
else if (!silent)
Toast.notify({ type: 'error', message: data.message })
}).catch(() => {
// Handle any other errors
globalThis.location.href = loginUrl
})
})
}
else {
reject(errResp)
}
})
})
}
// request methods

View File

@@ -0,0 +1,75 @@
import { apiPrefix } from '@/config'
import { fetchWithRetry } from '@/utils'
let isRefreshing = false
function waitUntilTokenRefreshed() {
return new Promise<void>((resolve, reject) => {
function _check() {
const isRefreshingSign = localStorage.getItem('is_refreshing')
if ((isRefreshingSign && isRefreshingSign === '1') || isRefreshing) {
setTimeout(() => {
_check()
}, 1000)
}
else {
resolve()
}
}
_check()
})
}
// only one request can send
async function getNewAccessToken(): Promise<void> {
try {
const isRefreshingSign = localStorage.getItem('is_refreshing')
if ((isRefreshingSign && isRefreshingSign === '1') || isRefreshing) {
await waitUntilTokenRefreshed()
}
else {
globalThis.localStorage.setItem('is_refreshing', '1')
isRefreshing = true
const refresh_token = globalThis.localStorage.getItem('refresh_token')
// Do not use baseFetch to refresh tokens.
// If a 401 response occurs and baseFetch itself attempts to refresh the token,
// it can lead to an infinite loop if the refresh attempt also returns 401.
// To avoid this, handle token refresh separately in a dedicated function
// that does not call baseFetch and uses a single retry mechanism.
const [error, ret] = await fetchWithRetry(globalThis.fetch(`${apiPrefix}/refresh-token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json;utf-8',
},
body: JSON.stringify({ refresh_token }),
}))
if (error) {
return Promise.reject(error)
}
else {
if (ret.status === 401)
return Promise.reject(ret)
const { data } = await ret.json()
globalThis.localStorage.setItem('console_token', data.access_token)
globalThis.localStorage.setItem('refresh_token', data.refresh_token)
}
}
}
catch (error) {
console.error(error)
return Promise.reject(error)
}
finally {
isRefreshing = false
globalThis.localStorage.removeItem('is_refreshing')
}
}
export async function refreshAccessTokenOrRelogin(timeout: number) {
return Promise.race([new Promise<void>((resolve, reject) => setTimeout(() => {
isRefreshing = false
globalThis.localStorage.removeItem('is_refreshing')
reject(new Error('request timeout'))
}, timeout)), getNewAccessToken()])
}