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

- 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:
2026-03-01 19:27:25 -05:00
parent d7316bb00a
commit ebaef16daf
32 changed files with 713 additions and 201 deletions

View File

@@ -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()

View File

@@ -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 () => {

View File

@@ -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

View File

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