UDT-002: Logout + Refresh Token con rotación y chain revocation #3
@@ -1,10 +1,92 @@
|
||||
import axios from 'axios'
|
||||
import axios, { AxiosError } from 'axios'
|
||||
import type { AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
const API_URL = import.meta.env['VITE_API_URL'] ?? 'http://localhost:5212'
|
||||
|
||||
export const axiosClient = axios.create({
|
||||
baseURL: API_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
// --- Request interceptor: attach Bearer from authStore
|
||||
axiosClient.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
const token = useAuthStore.getState().accessToken
|
||||
if (token && !config.headers.Authorization) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
// --- Singleton promise queue for refresh
|
||||
// N concurrent 401s share the same refresh promise — only ONE call to /auth/refresh
|
||||
let refreshPromise: Promise<string> | null = null
|
||||
|
||||
async function performRefresh(): Promise<string> {
|
||||
const { refreshToken, accessToken, updateAccess, clearAuth } = useAuthStore.getState()
|
||||
if (!refreshToken || !accessToken) {
|
||||
clearAuth()
|
||||
throw new Error('no tokens available for refresh')
|
||||
}
|
||||
try {
|
||||
// IMPORTANT: use plain axios, NOT axiosClient, to avoid the response interceptor loop
|
||||
const res = await axios.post<{ accessToken: string; refreshToken: string; expiresIn: number }>(
|
||||
`${API_URL}/api/v1/auth/refresh`,
|
||||
{ accessToken, refreshToken },
|
||||
{ headers: { 'Content-Type': 'application/json' } },
|
||||
)
|
||||
const { accessToken: newAccess, refreshToken: newRefresh, expiresIn } = res.data
|
||||
updateAccess(newAccess, newRefresh, Date.now() + expiresIn * 1000)
|
||||
return newAccess
|
||||
} catch (e) {
|
||||
clearAuth()
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.assign('/login')
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
interface RetryConfig extends AxiosRequestConfig {
|
||||
_retry?: boolean
|
||||
}
|
||||
|
||||
// --- Response interceptor: handle 401 with token refresh
|
||||
axiosClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError) => {
|
||||
const original = error.config as RetryConfig | undefined
|
||||
const status = error.response?.status
|
||||
const url = original?.url ?? ''
|
||||
|
||||
// Only attempt refresh for 401s on non-auth endpoints
|
||||
if (
|
||||
status !== 401 ||
|
||||
!original ||
|
||||
original._retry ||
|
||||
url.includes('/auth/refresh') ||
|
||||
url.includes('/auth/login')
|
||||
) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
original._retry = true
|
||||
|
||||
try {
|
||||
// Singleton: if refresh is already in flight, await the same promise
|
||||
if (refreshPromise === null) {
|
||||
refreshPromise = performRefresh().finally(() => {
|
||||
refreshPromise = null
|
||||
})
|
||||
}
|
||||
const newAccess = await refreshPromise
|
||||
|
||||
// Retry original request with new token
|
||||
original.headers = original.headers ?? {}
|
||||
;(original.headers as Record<string, string>).Authorization = `Bearer ${newAccess}`
|
||||
return axiosClient(original)
|
||||
} catch (e) {
|
||||
return Promise.reject(e)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
250
src/web/src/tests/api/axiosClient.test.ts
Normal file
250
src/web/src/tests/api/axiosClient.test.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* T-064 — axiosClient interceptor tests (MSW v2)
|
||||
*
|
||||
* Tests cover:
|
||||
* 1. request_includesBearer_whenAccessTokenPresent
|
||||
* 2. request_noBearer_whenAccessTokenNull
|
||||
* 3. response_401_triggersRefreshAndRetries
|
||||
* 4. response_401_threeParallel_singleRefresh (CRITICAL — singleton promise)
|
||||
* 5. response_401_refreshFails_clearsAuthAndRejects
|
||||
* 6. response_401_onLoginUrl_noRefresh
|
||||
* 7. response_401_onRefreshUrl_noLoop
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach, beforeEach, vi } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { setupServer } from 'msw/node'
|
||||
import { useAuthStore } from '../../stores/authStore'
|
||||
|
||||
const API_URL = 'http://localhost:5000'
|
||||
|
||||
// Helper to reset the refreshPromise singleton between tests
|
||||
// by reimporting the module. We use a direct import instead.
|
||||
async function getAxiosClient() {
|
||||
const mod = await import('../../api/axiosClient')
|
||||
return mod.axiosClient
|
||||
}
|
||||
|
||||
// MSW server
|
||||
const server = setupServer()
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }))
|
||||
afterAll(() => server.close())
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset store to clean slate
|
||||
useAuthStore.setState({
|
||||
user: null,
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
expiresAt: null,
|
||||
})
|
||||
// Reset window.location mock if applied
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers()
|
||||
})
|
||||
|
||||
function setAuth(accessToken: string, refreshToken: string) {
|
||||
useAuthStore.setState({
|
||||
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' },
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresAt: Date.now() + 3600 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
describe('axiosClient', () => {
|
||||
describe('request interceptor', () => {
|
||||
it('request_includesBearer_whenAccessTokenPresent', async () => {
|
||||
setAuth('my-access-token', 'my-refresh-token')
|
||||
|
||||
let capturedAuthHeader: string | null = null
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/test`, ({ request }) => {
|
||||
capturedAuthHeader = request.headers.get('Authorization')
|
||||
return HttpResponse.json({ ok: true })
|
||||
}),
|
||||
)
|
||||
|
||||
const client = await getAxiosClient()
|
||||
await client.get('/api/v1/test')
|
||||
|
||||
expect(capturedAuthHeader).toBe('Bearer my-access-token')
|
||||
})
|
||||
|
||||
it('request_noBearer_whenAccessTokenNull', async () => {
|
||||
// No auth set — accessToken is null
|
||||
|
||||
let capturedAuthHeader: string | null | undefined = undefined
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/test`, ({ request }) => {
|
||||
capturedAuthHeader = request.headers.get('Authorization')
|
||||
return HttpResponse.json({ ok: true })
|
||||
}),
|
||||
)
|
||||
|
||||
const client = await getAxiosClient()
|
||||
await client.get('/api/v1/test')
|
||||
|
||||
expect(capturedAuthHeader).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('response interceptor — 401 handling', () => {
|
||||
it('response_401_triggersRefreshAndRetries', async () => {
|
||||
setAuth('expired-access', 'valid-refresh')
|
||||
|
||||
let requestCount = 0
|
||||
server.use(
|
||||
// Protected endpoint: returns 401 first, then 200 after refresh
|
||||
http.get(`${API_URL}/api/v1/protected`, ({ request }) => {
|
||||
requestCount++
|
||||
const auth = request.headers.get('Authorization')
|
||||
if (auth === 'Bearer new-access-token') {
|
||||
return HttpResponse.json({ data: 'secret' })
|
||||
}
|
||||
return new HttpResponse(null, { status: 401 })
|
||||
}),
|
||||
// Refresh endpoint
|
||||
http.post(`${API_URL}/api/v1/auth/refresh`, () => {
|
||||
return HttpResponse.json({
|
||||
accessToken: 'new-access-token',
|
||||
refreshToken: 'new-refresh-token',
|
||||
expiresIn: 3600,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
const client = await getAxiosClient()
|
||||
const res = await client.get('/api/v1/protected')
|
||||
|
||||
expect(res.data).toEqual({ data: 'secret' })
|
||||
expect(requestCount).toBe(2) // 1st: 401, 2nd: 200 with new token
|
||||
expect(useAuthStore.getState().accessToken).toBe('new-access-token')
|
||||
expect(useAuthStore.getState().refreshToken).toBe('new-refresh-token')
|
||||
})
|
||||
|
||||
it('response_401_threeParallel_singleRefresh', async () => {
|
||||
setAuth('expired-access', 'valid-refresh')
|
||||
|
||||
let refreshCallCount = 0
|
||||
let requestCount = 0
|
||||
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/protected`, ({ request }) => {
|
||||
requestCount++
|
||||
const auth = request.headers.get('Authorization')
|
||||
if (auth === 'Bearer new-access-from-refresh') {
|
||||
return HttpResponse.json({ data: 'ok' })
|
||||
}
|
||||
return new HttpResponse(null, { status: 401 })
|
||||
}),
|
||||
http.post(`${API_URL}/api/v1/auth/refresh`, async () => {
|
||||
refreshCallCount++
|
||||
// Simulate slight delay so concurrent requests queue up
|
||||
await new Promise((r) => setTimeout(r, 20))
|
||||
return HttpResponse.json({
|
||||
accessToken: 'new-access-from-refresh',
|
||||
refreshToken: 'new-refresh-from-refresh',
|
||||
expiresIn: 3600,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
const client = await getAxiosClient()
|
||||
|
||||
// Fire 3 requests simultaneously — all get 401, all should wait on single refresh
|
||||
const [r1, r2, r3] = await Promise.all([
|
||||
client.get('/api/v1/protected'),
|
||||
client.get('/api/v1/protected'),
|
||||
client.get('/api/v1/protected'),
|
||||
])
|
||||
|
||||
expect(r1.data).toEqual({ data: 'ok' })
|
||||
expect(r2.data).toEqual({ data: 'ok' })
|
||||
expect(r3.data).toEqual({ data: 'ok' })
|
||||
|
||||
// CRITICAL: only ONE call to /auth/refresh despite 3 parallel 401s
|
||||
expect(refreshCallCount).toBe(1)
|
||||
})
|
||||
|
||||
it('response_401_refreshFails_clearsAuthAndRejects', async () => {
|
||||
setAuth('expired-access', 'invalid-refresh')
|
||||
|
||||
// Mock window.location.assign to avoid jsdom navigation error
|
||||
const assignMock = vi.fn()
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { ...window.location, assign: assignMock },
|
||||
writable: true,
|
||||
})
|
||||
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/protected`, () => {
|
||||
return new HttpResponse(null, { status: 401 })
|
||||
}),
|
||||
http.post(`${API_URL}/api/v1/auth/refresh`, () => {
|
||||
return new HttpResponse(null, { status: 401 })
|
||||
}),
|
||||
)
|
||||
|
||||
const client = await getAxiosClient()
|
||||
|
||||
await expect(client.get('/api/v1/protected')).rejects.toThrow()
|
||||
|
||||
// Auth should be cleared
|
||||
const state = useAuthStore.getState()
|
||||
expect(state.accessToken).toBeNull()
|
||||
expect(state.user).toBeNull()
|
||||
})
|
||||
|
||||
it('response_401_onLoginUrl_noRefresh', async () => {
|
||||
setAuth('expired-access', 'valid-refresh')
|
||||
|
||||
let refreshCalled = false
|
||||
server.use(
|
||||
http.post(`${API_URL}/api/v1/auth/login`, () => {
|
||||
return new HttpResponse(null, { status: 401 })
|
||||
}),
|
||||
http.post(`${API_URL}/api/v1/auth/refresh`, () => {
|
||||
refreshCalled = true
|
||||
return HttpResponse.json({
|
||||
accessToken: 'new-access',
|
||||
refreshToken: 'new-refresh',
|
||||
expiresIn: 3600,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
const client = await getAxiosClient()
|
||||
|
||||
// Login 401 should NOT trigger refresh — just reject
|
||||
await expect(client.post('/api/v1/auth/login', {})).rejects.toThrow()
|
||||
expect(refreshCalled).toBe(false)
|
||||
})
|
||||
|
||||
it('response_401_onRefreshUrl_noLoop', async () => {
|
||||
setAuth('expired-access', 'valid-refresh')
|
||||
|
||||
let refreshCallCount = 0
|
||||
server.use(
|
||||
http.post(`${API_URL}/api/v1/auth/refresh`, () => {
|
||||
refreshCallCount++
|
||||
return new HttpResponse(null, { status: 401 })
|
||||
}),
|
||||
)
|
||||
|
||||
const client = await getAxiosClient()
|
||||
|
||||
// Calling refresh endpoint that returns 401 should NOT re-trigger refresh
|
||||
await expect(
|
||||
client.post('/api/v1/auth/refresh', { accessToken: 'x', refreshToken: 'y' }),
|
||||
).rejects.toThrow()
|
||||
|
||||
// Should have called /refresh exactly once (the explicit call), no loop
|
||||
expect(refreshCallCount).toBe(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user