fix(frontend): ignore expired responses from prior sessions

This commit is contained in:
2026-10-09 09:55:11 +08:00
parent fb1b161c0d
commit 3e44779578
4 changed files with 45 additions and 4 deletions
+9 -1
View File
@@ -3,11 +3,19 @@ import type { paths } from './generated/schema'
import { boundedFetch } from './transport'
let expired: () => void = () => {}
let authorizationVersion = 0
export function advanceAuthorization() {
authorizationVersion++
}
export function onUnauthorized(handler: () => void) {
expired = handler
}
export const api = createClient<paths>({
baseUrl: import.meta.env.VITE_API_BASE_URL || '',
fetch: boundedFetch(globalThis.fetch.bind(globalThis), () => expired()),
fetch: boundedFetch(
globalThis.fetch.bind(globalThis),
() => expired(),
() => authorizationVersion,
),
})
+7 -2
View File
@@ -15,8 +15,13 @@ export class ApiError extends Error {
}
}
export function boundedFetch(fetcher: typeof fetch, unauthorized: () => void): typeof fetch {
export function boundedFetch(
fetcher: typeof fetch,
unauthorized: () => void,
authorizationVersion: () => number = () => 0,
): typeof fetch {
return async (input, init) => {
const version = authorizationVersion()
const request = new Request(input, init)
try {
const response = await fetcher(
@@ -26,7 +31,7 @@ export function boundedFetch(fetcher: typeof fetch, unauthorized: () => void): t
signal: AbortSignal.any([request.signal, AbortSignal.timeout(10000)]),
}),
)
if (response.status === 401) unauthorized()
if (response.status === 401 && version === authorizationVersion()) unauthorized()
return response
} catch (error) {
if (request.signal.aborted) throw error
+4 -1
View File
@@ -1,5 +1,5 @@
import { readonly, ref } from 'vue'
import { api, onUnauthorized } from '../api/client'
import { advanceAuthorization, api, onUnauthorized } from '../api/client'
import { ApiError, csrfHeaders, unwrap } from '../api/transport'
import type { components } from '../api/generated/schema'
@@ -7,6 +7,7 @@ const user = ref<components['schemas']['CurrentUser'] | null>(null)
let generation = 0
let pending: Promise<boolean> | undefined
onUnauthorized(() => {
advanceAuthorization()
generation++
user.value = null
})
@@ -30,6 +31,7 @@ async function refresh() {
}
async function login(username: string, password: string) {
advanceAuthorization()
const current = ++generation
user.value = null
const data = unwrap(await api.POST('/api/v1/auth/login', { body: { username, password } }))
@@ -37,6 +39,7 @@ async function login(username: string, password: string) {
}
async function logout() {
advanceAuthorization()
const response = await api.POST('/api/v1/auth/logout', { headers: csrfHeaders() })
if (!response.response.ok && response.response.status !== 401)
throw new ApiError(response.response.status)
+25
View File
@@ -193,3 +193,28 @@ test('transport expires identity on401, preserves cancellation and never fills f
)
await assert.rejects(offline('http://localhost/api'), /网络中断/)
})
test('an old401 cannot expire a newly authenticated session', async () => {
let version = 0,
expired = 0
let complete: (response: Response) => void = () => {}
const transport = boundedFetch(
async () =>
new Promise<Response>((resolve) => {
complete = resolve
}),
() => {
expired++
},
() => version,
)
const old = transport('http://localhost/api/v1/auth/me')
version++ // A later login owns the browser identity.
complete(new Response('{}', { status: 401 }))
await old
assert.equal(expired, 0)
const current = transport('http://localhost/api/v1/auth/me')
complete(new Response('{}', { status: 401 }))
await current
assert.equal(expired, 1)
})