feat: implement long-term user login with refresh tokens
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m23s
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m23s
- Introduced a dual-token system for user authentication: a short-lived access token and a long-lived rotating refresh token. - Created a new RefreshToken model to manage refresh tokens securely. - Updated auth_api.py to handle login, refresh, and logout processes with the new token system. - Enhanced security measures including token rotation and theft detection. - Updated frontend to handle token refresh on 401 errors and adjusted SSE authentication. - Removed CORS middleware as it's unnecessary behind the nginx proxy. - Added tests to ensure functionality and security of the new token system.
This commit is contained in:
@@ -19,15 +19,56 @@ describe('installUnauthorizedFetchInterceptor', () => {
|
||||
globalThis.fetch = originalFetch
|
||||
})
|
||||
|
||||
it('logs out and redirects to /auth on 401 outside auth routes', async () => {
|
||||
it('attempts refresh on 401, retries the original request on success', async () => {
|
||||
const fetchMock = globalThis.fetch as unknown as ReturnType<typeof vi.fn>
|
||||
fetchMock.mockResolvedValue({ status: 401 } as Response)
|
||||
// First call: original request → 401
|
||||
// Second call: refresh → 200 (success)
|
||||
// Third call: retry original → 200
|
||||
fetchMock
|
||||
.mockResolvedValueOnce({ status: 401 } as Response)
|
||||
.mockResolvedValueOnce({ ok: true, status: 200 } as Response)
|
||||
.mockResolvedValueOnce({ status: 200, body: 'retried' } as unknown as Response)
|
||||
|
||||
window.history.pushState({}, '', '/parent/profile')
|
||||
const redirectSpy = vi.fn()
|
||||
|
||||
const { installUnauthorizedFetchInterceptor, setUnauthorizedRedirectHandlerForTests } =
|
||||
await import('../api')
|
||||
const {
|
||||
installUnauthorizedFetchInterceptor,
|
||||
setUnauthorizedRedirectHandlerForTests,
|
||||
resetInterceptorStateForTests,
|
||||
} = await import('../api')
|
||||
resetInterceptorStateForTests()
|
||||
setUnauthorizedRedirectHandlerForTests(redirectSpy)
|
||||
installUnauthorizedFetchInterceptor()
|
||||
|
||||
const result = await fetch('/api/user/profile')
|
||||
|
||||
// Should NOT have logged out
|
||||
expect(mockLogoutUser).not.toHaveBeenCalled()
|
||||
expect(redirectSpy).not.toHaveBeenCalled()
|
||||
// Should have called fetch 3 times: original, refresh, retry
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3)
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(2, '/api/auth/refresh', { method: 'POST' })
|
||||
expect((result as Response).status).toBe(200)
|
||||
})
|
||||
|
||||
it('logs out when refresh also fails with 401', async () => {
|
||||
const fetchMock = globalThis.fetch as unknown as ReturnType<typeof vi.fn>
|
||||
// First call: original → 401
|
||||
// Second call: refresh → 401 (fail)
|
||||
fetchMock
|
||||
.mockResolvedValueOnce({ status: 401 } as Response)
|
||||
.mockResolvedValueOnce({ ok: false, status: 401 } as Response)
|
||||
|
||||
window.history.pushState({}, '', '/parent/profile')
|
||||
const redirectSpy = vi.fn()
|
||||
|
||||
const {
|
||||
installUnauthorizedFetchInterceptor,
|
||||
setUnauthorizedRedirectHandlerForTests,
|
||||
resetInterceptorStateForTests,
|
||||
} = await import('../api')
|
||||
resetInterceptorStateForTests()
|
||||
setUnauthorizedRedirectHandlerForTests(redirectSpy)
|
||||
installUnauthorizedFetchInterceptor()
|
||||
|
||||
@@ -37,15 +78,46 @@ describe('installUnauthorizedFetchInterceptor', () => {
|
||||
expect(redirectSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not redirect when already on auth route', async () => {
|
||||
it('does not attempt refresh for auth endpoint 401s', async () => {
|
||||
const fetchMock = globalThis.fetch as unknown as ReturnType<typeof vi.fn>
|
||||
fetchMock.mockResolvedValue({ status: 401 } as Response)
|
||||
|
||||
window.history.pushState({}, '', '/parent/profile')
|
||||
const redirectSpy = vi.fn()
|
||||
|
||||
const {
|
||||
installUnauthorizedFetchInterceptor,
|
||||
setUnauthorizedRedirectHandlerForTests,
|
||||
resetInterceptorStateForTests,
|
||||
} = await import('../api')
|
||||
resetInterceptorStateForTests()
|
||||
setUnauthorizedRedirectHandlerForTests(redirectSpy)
|
||||
installUnauthorizedFetchInterceptor()
|
||||
|
||||
await fetch('/api/auth/refresh', { method: 'POST' })
|
||||
|
||||
// Should log out immediately without attempting refresh
|
||||
expect(mockLogoutUser).toHaveBeenCalledTimes(1)
|
||||
// Only 1 fetch call — no refresh attempt
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not redirect when already on auth route', async () => {
|
||||
const fetchMock = globalThis.fetch as unknown as ReturnType<typeof vi.fn>
|
||||
// original → 401, refresh → fail
|
||||
fetchMock
|
||||
.mockResolvedValueOnce({ status: 401 } as Response)
|
||||
.mockResolvedValueOnce({ ok: false, status: 401 } as Response)
|
||||
|
||||
window.history.pushState({}, '', '/auth/login')
|
||||
const redirectSpy = vi.fn()
|
||||
|
||||
const { installUnauthorizedFetchInterceptor, setUnauthorizedRedirectHandlerForTests } =
|
||||
await import('../api')
|
||||
const {
|
||||
installUnauthorizedFetchInterceptor,
|
||||
setUnauthorizedRedirectHandlerForTests,
|
||||
resetInterceptorStateForTests,
|
||||
} = await import('../api')
|
||||
resetInterceptorStateForTests()
|
||||
setUnauthorizedRedirectHandlerForTests(redirectSpy)
|
||||
installUnauthorizedFetchInterceptor()
|
||||
|
||||
@@ -55,23 +127,37 @@ describe('installUnauthorizedFetchInterceptor', () => {
|
||||
expect(redirectSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles unauthorized redirect only once even for repeated 401 responses', async () => {
|
||||
it('only makes one refresh call for concurrent 401 responses', async () => {
|
||||
const fetchMock = globalThis.fetch as unknown as ReturnType<typeof vi.fn>
|
||||
fetchMock.mockResolvedValue({ status: 401 } as Response)
|
||||
// Both original calls → 401, then one refresh → 200, then both retries → 200
|
||||
fetchMock
|
||||
.mockResolvedValueOnce({ status: 401 } as Response)
|
||||
.mockResolvedValueOnce({ status: 401 } as Response)
|
||||
.mockResolvedValueOnce({ ok: true, status: 200 } as Response) // refresh
|
||||
.mockResolvedValueOnce({ status: 200 } as Response) // retry 1
|
||||
.mockResolvedValueOnce({ status: 200 } as Response) // retry 2
|
||||
|
||||
window.history.pushState({}, '', '/parent/tasks')
|
||||
const redirectSpy = vi.fn()
|
||||
|
||||
const { installUnauthorizedFetchInterceptor, setUnauthorizedRedirectHandlerForTests } =
|
||||
await import('../api')
|
||||
const {
|
||||
installUnauthorizedFetchInterceptor,
|
||||
setUnauthorizedRedirectHandlerForTests,
|
||||
resetInterceptorStateForTests,
|
||||
} = await import('../api')
|
||||
resetInterceptorStateForTests()
|
||||
setUnauthorizedRedirectHandlerForTests(redirectSpy)
|
||||
installUnauthorizedFetchInterceptor()
|
||||
|
||||
await fetch('/api/task/add', { method: 'PUT' })
|
||||
await fetch('/api/image/list?type=2')
|
||||
const [r1, r2] = await Promise.all([
|
||||
fetch('/api/task/add', { method: 'PUT' }),
|
||||
fetch('/api/image/list?type=2'),
|
||||
])
|
||||
|
||||
expect(mockLogoutUser).toHaveBeenCalledTimes(1)
|
||||
expect(redirectSpy).toHaveBeenCalledTimes(1)
|
||||
expect(mockLogoutUser).not.toHaveBeenCalled()
|
||||
expect(redirectSpy).not.toHaveBeenCalled()
|
||||
expect((r1 as Response).status).toBe(200)
|
||||
expect((r2 as Response).status).toBe(200)
|
||||
})
|
||||
|
||||
it('does not log out for non-401 responses', async () => {
|
||||
@@ -81,8 +167,12 @@ describe('installUnauthorizedFetchInterceptor', () => {
|
||||
window.history.pushState({}, '', '/parent')
|
||||
const redirectSpy = vi.fn()
|
||||
|
||||
const { installUnauthorizedFetchInterceptor, setUnauthorizedRedirectHandlerForTests } =
|
||||
await import('../api')
|
||||
const {
|
||||
installUnauthorizedFetchInterceptor,
|
||||
setUnauthorizedRedirectHandlerForTests,
|
||||
resetInterceptorStateForTests,
|
||||
} = await import('../api')
|
||||
resetInterceptorStateForTests()
|
||||
setUnauthorizedRedirectHandlerForTests(redirectSpy)
|
||||
installUnauthorizedFetchInterceptor()
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ describe('useBackendEvents', () => {
|
||||
await wrapper.setProps({ userId: 'user-1' })
|
||||
|
||||
expect(MockEventSource.instances.length).toBe(1)
|
||||
expect(MockEventSource.instances[0]?.url).toBe('/events?user_id=user-1')
|
||||
expect(MockEventSource.instances[0]?.url).toBe('/api/events')
|
||||
})
|
||||
|
||||
it('reconnects when user id changes and closes previous connection', async () => {
|
||||
@@ -72,7 +72,7 @@ describe('useBackendEvents', () => {
|
||||
|
||||
expect(firstConnection?.close).toHaveBeenCalledTimes(1)
|
||||
expect(MockEventSource.instances.length).toBe(2)
|
||||
expect(MockEventSource.instances[1]?.url).toBe('/events?user_id=user-2')
|
||||
expect(MockEventSource.instances[1]?.url).toBe('/api/events')
|
||||
})
|
||||
|
||||
it('emits parsed backend events on message', async () => {
|
||||
|
||||
@@ -4,10 +4,19 @@ let unauthorizedInterceptorInstalled = false
|
||||
let unauthorizedRedirectHandler: (() => void) | null = null
|
||||
let unauthorizedHandlingInProgress = false
|
||||
|
||||
// Mutex for refresh token requests — prevents concurrent refresh calls
|
||||
let refreshPromise: Promise<boolean> | null = null
|
||||
|
||||
export function setUnauthorizedRedirectHandlerForTests(handler: (() => void) | null): void {
|
||||
unauthorizedRedirectHandler = handler
|
||||
}
|
||||
|
||||
/** Reset interceptor state (for tests). */
|
||||
export function resetInterceptorStateForTests(): void {
|
||||
unauthorizedHandlingInProgress = false
|
||||
refreshPromise = null
|
||||
}
|
||||
|
||||
function handleUnauthorizedResponse(): void {
|
||||
if (unauthorizedHandlingInProgress) return
|
||||
unauthorizedHandlingInProgress = true
|
||||
@@ -21,6 +30,33 @@ function handleUnauthorizedResponse(): void {
|
||||
window.location.assign('/auth')
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to refresh the access token by calling the refresh endpoint.
|
||||
* Returns true if refresh succeeded, false otherwise.
|
||||
* Uses a mutex so concurrent 401s only trigger one refresh call.
|
||||
*/
|
||||
async function attemptTokenRefresh(originalFetch: typeof fetch): Promise<boolean> {
|
||||
if (refreshPromise) return refreshPromise
|
||||
|
||||
refreshPromise = (async () => {
|
||||
try {
|
||||
const res = await originalFetch('/api/auth/refresh', { method: 'POST' })
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
refreshPromise = null
|
||||
}
|
||||
})()
|
||||
|
||||
return refreshPromise
|
||||
}
|
||||
|
||||
/** URLs that should NOT trigger a refresh attempt on 401. */
|
||||
function isAuthUrl(url: string): boolean {
|
||||
return url.includes('/api/auth/refresh') || url.includes('/api/auth/login')
|
||||
}
|
||||
|
||||
export function installUnauthorizedFetchInterceptor(): void {
|
||||
if (unauthorizedInterceptorInstalled || typeof window === 'undefined') return
|
||||
unauthorizedInterceptorInstalled = true
|
||||
@@ -28,9 +64,28 @@ export function installUnauthorizedFetchInterceptor(): void {
|
||||
const originalFetch = globalThis.fetch.bind(globalThis)
|
||||
const wrappedFetch = (async (...args: Parameters<typeof fetch>) => {
|
||||
const response = await originalFetch(...args)
|
||||
|
||||
if (response.status === 401) {
|
||||
// Determine the request URL
|
||||
const requestUrl = typeof args[0] === 'string' ? args[0] : (args[0] as Request).url
|
||||
|
||||
// Don't attempt refresh for auth endpoints themselves
|
||||
if (isAuthUrl(requestUrl)) {
|
||||
handleUnauthorizedResponse()
|
||||
return response
|
||||
}
|
||||
|
||||
// Attempt silent refresh
|
||||
const refreshed = await attemptTokenRefresh(originalFetch)
|
||||
if (refreshed) {
|
||||
// Retry the original request with the new access token cookie
|
||||
return originalFetch(...args)
|
||||
}
|
||||
|
||||
// Refresh failed — log out
|
||||
handleUnauthorizedResponse()
|
||||
}
|
||||
|
||||
return response
|
||||
}) as typeof fetch
|
||||
|
||||
|
||||
@@ -26,7 +26,8 @@ export function useBackendEvents(userId: Ref<string>) {
|
||||
const connect = () => {
|
||||
if (eventSource) eventSource.close()
|
||||
if (userId.value) {
|
||||
eventSource = new EventSource(`/events?user_id=${userId.value}`)
|
||||
// Auth is cookie-based; the browser sends the access_token cookie automatically
|
||||
eventSource = new EventSource(`/api/events`)
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data)
|
||||
|
||||
Reference in New Issue
Block a user