UDT-002: Logout + Refresh Token con rotación y chain revocation #3

Merged
dmolinari merged 36 commits from feature/UDT-002 into main 2026-04-14 17:37:47 +00:00
2 changed files with 336 additions and 4 deletions
Showing only changes of commit bdaaaffaf6 - Show all commits

View File

@@ -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)
}
},
)

View 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)
})
})
})