feat: implement cross-tab coordination for token refresh and enhance logout handling
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m57s

This commit is contained in:
2026-03-27 23:02:29 -04:00
parent 89097a390e
commit a4e23aad11
6 changed files with 54 additions and 11 deletions

View File

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

View File

@@ -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<boolean> {
// 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

View File

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

View File

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

View File

@@ -224,6 +224,7 @@ function executeMenuItem(index: number) {
async function signOut() {
try {
await fetch('/api/auth/logout', { method: 'POST' })
logoutParent()
logoutUser()
router.push('/')
} catch {

View File

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