fix: 修复未登录态授权刷新循环
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-20 22:01:04 +08:00
parent 75944b1f1f
commit 39c7f0735f
5 changed files with 103 additions and 13 deletions

View File

@@ -157,6 +157,24 @@ describe('apiClient', () => {
);
});
it('does not refresh or emit auth changes for 401 responses without auth context', async () => {
fetchMock.mockResolvedValueOnce(createResponseMock({ status: 401 }));
const response = await fetchWithApiAuth('/api/runtime/protected', {
method: 'GET',
});
expect(response.status).toBe(401);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
'/api/runtime/protected',
expect.objectContaining({
credentials: 'same-origin',
}),
);
expect(window.dispatchEvent).not.toHaveBeenCalled();
});
it('retries transient get requests before unwrapping the response envelope', async () => {
fetchMock
.mockRejectedValueOnce(new TypeError('network unavailable'))

View File

@@ -351,12 +351,15 @@ export function setStoredAccessToken(
}
const nextToken = token.trim();
const previousToken = getStoredAccessToken();
if (nextToken) {
window.localStorage.setItem(ACCESS_TOKEN_KEY, nextToken);
} else {
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
}
if (options.emit !== false) {
// 只有登录态令牌真的发生变化时才广播事件,避免无意义的鉴权重算。
if (options.emit !== false && previousToken !== nextToken) {
emitAuthStateChange();
}
}
@@ -370,8 +373,11 @@ export function clearStoredAccessToken(
return;
}
const previousToken = getStoredAccessToken();
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
if (options.emit !== false) {
// 未登录态下重复清空 token 不应触发状态刷新,否则会放大 401 循环。
if (options.emit !== false && previousToken) {
emitAuthStateChange();
}
}
@@ -487,14 +493,20 @@ export async function fetchWithApiAuth(
for (;;) {
try {
const requestHeaders = withAuthorizationHeaders(init.headers, options);
const hasAuthHeader = Boolean(
requestHeaders.Authorization?.trim() ||
requestHeaders.authorization?.trim(),
);
const response = await fetch(input, {
credentials: 'same-origin',
...init,
headers: withAuthorizationHeaders(init.headers, options),
headers: requestHeaders,
});
if (
response.status === 401 &&
hasAuthHeader &&
!options.skipAuth &&
!options.skipRefresh &&
!refreshAttempted