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'
|
const API_URL = import.meta.env['VITE_API_URL'] ?? 'http://localhost:5212'
|
||||||
|
|
||||||
export const axiosClient = axios.create({
|
export const axiosClient = axios.create({
|
||||||
baseURL: API_URL,
|
baseURL: API_URL,
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'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