From a4e23aad11735c81d9b344197aa2b87d5002c190 Mon Sep 17 00:00:00 2001 From: Ryan Kegel Date: Fri, 27 Mar 2026 23:02:29 -0400 Subject: [PATCH] feat: implement cross-tab coordination for token refresh and enhance logout handling --- backend/api/auth_api.py | 6 ++++- frontend/vue-app/src/common/api.ts | 22 +++++++++++++++++++ .../src/components/BackendEventsListener.vue | 3 ++- .../__tests__/BackendEventsListener.spec.ts | 20 ++++++++++++----- .../src/components/shared/LoginButton.vue | 1 + frontend/vue-app/src/stores/auth.ts | 13 +++++++---- 6 files changed, 54 insertions(+), 11 deletions(-) diff --git a/backend/api/auth_api.py b/backend/api/auth_api.py index c1edeea..7ef2f77 100644 --- a/backend/api/auth_api.py +++ b/backend/api/auth_api.py @@ -99,7 +99,11 @@ def _create_refresh_token(user_id: str, token_family: str | None = None) -> tupl def _set_auth_cookies(resp, access_token: str, raw_refresh_token: str): """Set both access and refresh token cookies on a response.""" expiry_days = current_app.config['REFRESH_TOKEN_EXPIRY_DAYS'] - resp.set_cookie('access_token', access_token, httponly=True, secure=True, samesite='Strict') + resp.set_cookie( + 'access_token', access_token, + httponly=True, secure=True, samesite='Lax', + max_age=ACCESS_TOKEN_EXPIRY_MINUTES * 60, + ) resp.set_cookie( 'refresh_token', raw_refresh_token, httponly=True, secure=True, samesite='Strict', diff --git a/frontend/vue-app/src/common/api.ts b/frontend/vue-app/src/common/api.ts index 70bb877..4e86c6b 100644 --- a/frontend/vue-app/src/common/api.ts +++ b/frontend/vue-app/src/common/api.ts @@ -1,5 +1,10 @@ import { logoutUser } from '@/stores/auth' +const hasLocalStorage = + typeof localStorage !== 'undefined' && typeof localStorage.getItem === 'function' +const CROSS_TAB_REFRESH_KEY = 'lastRefreshAt' +const CROSS_TAB_REFRESH_MIN_INTERVAL = 8_000 // 8s — prevents concurrent multi-tab refresh + let unauthorizedInterceptorInstalled = false let unauthorizedRedirectHandler: (() => void) | null = null let unauthorizedHandlingInProgress = false @@ -15,6 +20,9 @@ export function setUnauthorizedRedirectHandlerForTests(handler: (() => void) | n export function resetInterceptorStateForTests(): void { unauthorizedHandlingInProgress = false refreshPromise = null + if (hasLocalStorage && typeof localStorage.removeItem === 'function') { + localStorage.removeItem(CROSS_TAB_REFRESH_KEY) + } } function handleUnauthorizedResponse(): void { @@ -35,13 +43,27 @@ function handleUnauthorizedResponse(): void { * 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. + * Also uses a localStorage timestamp to coordinate across tabs, preventing + * concurrent refreshes that would trigger false-positive theft detection. */ async function attemptTokenRefresh(originalFetch: typeof fetch): Promise { + // Cross-tab coordination: if another tab refreshed recently, the new cookie + // is already set. Skip the refresh call and let the original request retry. + if (hasLocalStorage && typeof localStorage.getItem === 'function') { + const lastRefresh = localStorage.getItem(CROSS_TAB_REFRESH_KEY) + if (lastRefresh && Date.now() - Number(lastRefresh) < CROSS_TAB_REFRESH_MIN_INTERVAL) { + return true + } + } + if (refreshPromise) return refreshPromise refreshPromise = (async () => { try { const res = await originalFetch('/api/auth/refresh', { method: 'POST' }) + if (res.ok && hasLocalStorage && typeof localStorage.setItem === 'function') { + localStorage.setItem(CROSS_TAB_REFRESH_KEY, String(Date.now())) + } return res.ok } catch { return false diff --git a/frontend/vue-app/src/components/BackendEventsListener.vue b/frontend/vue-app/src/components/BackendEventsListener.vue index dd47010..74071b8 100644 --- a/frontend/vue-app/src/components/BackendEventsListener.vue +++ b/frontend/vue-app/src/components/BackendEventsListener.vue @@ -2,7 +2,7 @@ import { ref, watch, onMounted, onUnmounted } from 'vue' import { useRouter } from 'vue-router' import { useBackendEvents } from '@/common/backendEvents' -import { currentUserId, logoutUser, suppressForceLogout } from '@/stores/auth' +import { currentUserId, logoutParent, logoutUser, suppressForceLogout } from '@/stores/auth' import { eventBus } from '@/common/eventBus' const userId = ref(currentUserId.value) @@ -20,6 +20,7 @@ function handleForceLogout(event: { payload?: { reason?: string } }) { suppressForceLogout.value = false return } + logoutParent() logoutUser() if (event?.payload?.reason === 'account_deleted') { router.push('/') diff --git a/frontend/vue-app/src/components/__tests__/BackendEventsListener.spec.ts b/frontend/vue-app/src/components/__tests__/BackendEventsListener.spec.ts index df4c013..8e1772b 100644 --- a/frontend/vue-app/src/components/__tests__/BackendEventsListener.spec.ts +++ b/frontend/vue-app/src/components/__tests__/BackendEventsListener.spec.ts @@ -5,16 +5,20 @@ import BackendEventsListener from '../BackendEventsListener.vue' // ── Hoisted mocks ──────────────────────────────────────────────────────────── -const { mockLogoutUser, mockSuppressForceLogout, mockCurrentUserId } = vi.hoisted(() => ({ - mockLogoutUser: vi.fn(), - mockSuppressForceLogout: { value: false }, - mockCurrentUserId: { value: 'user-1' }, -})) +const { mockLogoutUser, mockLogoutParent, mockSuppressForceLogout, mockCurrentUserId } = vi.hoisted( + () => ({ + mockLogoutUser: vi.fn(), + mockLogoutParent: vi.fn(), + mockSuppressForceLogout: { value: false }, + mockCurrentUserId: { value: 'user-1' }, + }), +) let capturedForceLogoutHandler: ((event: any) => void) | null = null vi.mock('@/stores/auth', () => ({ currentUserId: mockCurrentUserId, + logoutParent: mockLogoutParent, logoutUser: mockLogoutUser, suppressForceLogout: mockSuppressForceLogout, })) @@ -70,6 +74,7 @@ describe('BackendEventsListener – force_logout', () => { fireForceLogout('password_reset') await flushPromises() + expect(mockLogoutParent).toHaveBeenCalledOnce() expect(mockLogoutUser).toHaveBeenCalledOnce() expect(pushSpy).toHaveBeenCalledWith({ name: 'Login' }) }) @@ -81,6 +86,7 @@ describe('BackendEventsListener – force_logout', () => { fireForceLogout('account_deleted') await flushPromises() + expect(mockLogoutParent).toHaveBeenCalledOnce() expect(mockLogoutUser).toHaveBeenCalledOnce() expect(pushSpy).toHaveBeenCalledWith('/') }) @@ -92,6 +98,7 @@ describe('BackendEventsListener – force_logout', () => { fireForceLogout('something_else') await flushPromises() + expect(mockLogoutParent).toHaveBeenCalledOnce() expect(mockLogoutUser).toHaveBeenCalledOnce() expect(pushSpy).toHaveBeenCalledWith({ name: 'Login' }) }) @@ -103,6 +110,7 @@ describe('BackendEventsListener – force_logout', () => { fireForceLogout() await flushPromises() + expect(mockLogoutParent).toHaveBeenCalledOnce() expect(mockLogoutUser).toHaveBeenCalledOnce() expect(pushSpy).toHaveBeenCalledWith({ name: 'Login' }) }) @@ -115,6 +123,7 @@ describe('BackendEventsListener – force_logout', () => { fireForceLogout('password_reset') await flushPromises() + expect(mockLogoutParent).not.toHaveBeenCalled() expect(mockLogoutUser).not.toHaveBeenCalled() expect(pushSpy).not.toHaveBeenCalled() }) @@ -142,6 +151,7 @@ describe('BackendEventsListener – force_logout', () => { // Second event: should go through normally fireForceLogout('password_reset') await flushPromises() + expect(mockLogoutParent).toHaveBeenCalledOnce() expect(mockLogoutUser).toHaveBeenCalledOnce() expect(pushSpy).toHaveBeenCalledWith({ name: 'Login' }) }) diff --git a/frontend/vue-app/src/components/shared/LoginButton.vue b/frontend/vue-app/src/components/shared/LoginButton.vue index ed09bf9..380ffa7 100644 --- a/frontend/vue-app/src/components/shared/LoginButton.vue +++ b/frontend/vue-app/src/components/shared/LoginButton.vue @@ -224,6 +224,7 @@ function executeMenuItem(index: number) { async function signOut() { try { await fetch('/api/auth/logout', { method: 'POST' }) + logoutParent() logoutUser() router.push('/') } catch { diff --git a/frontend/vue-app/src/stores/auth.ts b/frontend/vue-app/src/stores/auth.ts index a945f22..a0031a0 100644 --- a/frontend/vue-app/src/stores/auth.ts +++ b/frontend/vue-app/src/stores/auth.ts @@ -22,6 +22,7 @@ if (hasLocalStorage) { parentAuthExpiresAt.value = parsed.expiresAt isParentPersistent.value = true isParentAuthenticated.value = true + startParentExpiryWatcher() } else { localStorage.removeItem(PARENT_AUTH_KEY) } @@ -81,6 +82,9 @@ export function authenticateParent(persistent: boolean) { } export function logoutParent() { + if (hasLocalStorage && typeof localStorage.removeItem === 'function') { + localStorage.removeItem(PARENT_AUTH_KEY) + } applyParentLoggedOutState() broadcastParentLogoutEvent() } @@ -89,9 +93,6 @@ function applyParentLoggedOutState() { parentAuthExpiresAt.value = null isParentPersistent.value = false isParentAuthenticated.value = false - if (hasLocalStorage && typeof localStorage.removeItem === 'function') { - localStorage.removeItem(PARENT_AUTH_KEY) - } stopParentExpiryWatcher() } @@ -105,7 +106,11 @@ function broadcastParentLogoutEvent() { export function loginUser() { isUserLoggedIn.value = true - // Always start in child mode after login + // Always start in child mode after login. Also explicitly remove persistent parent + // auth from localStorage so a fresh login always starts in child mode. + if (hasLocalStorage && typeof localStorage.removeItem === 'function') { + localStorage.removeItem(PARENT_AUTH_KEY) + } applyParentLoggedOutState() }