2 Commits

Author SHA1 Message Date
8080a59de1 fix: update BASE_VERSION to 1.0.7 for release
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 1m48s
2026-04-08 13:31:40 -04:00
a4e23aad11 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
2026-03-27 23:02:29 -04:00
7 changed files with 55 additions and 12 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): def _set_auth_cookies(resp, access_token: str, raw_refresh_token: str):
"""Set both access and refresh token cookies on a response.""" """Set both access and refresh token cookies on a response."""
expiry_days = current_app.config['REFRESH_TOKEN_EXPIRY_DAYS'] 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( resp.set_cookie(
'refresh_token', raw_refresh_token, 'refresh_token', raw_refresh_token,
httponly=True, secure=True, samesite='Strict', httponly=True, secure=True, samesite='Strict',

View File

@@ -2,7 +2,7 @@
# file: config/version.py # file: config/version.py
import os import os
BASE_VERSION = "1.0.6" # update manually when releasing features BASE_VERSION = "1.0.7" # update manually when releasing features
def get_full_version() -> str: def get_full_version() -> str:
""" """

View File

@@ -1,5 +1,10 @@
import { logoutUser } from '@/stores/auth' 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 unauthorizedInterceptorInstalled = false
let unauthorizedRedirectHandler: (() => void) | null = null let unauthorizedRedirectHandler: (() => void) | null = null
let unauthorizedHandlingInProgress = false let unauthorizedHandlingInProgress = false
@@ -15,6 +20,9 @@ export function setUnauthorizedRedirectHandlerForTests(handler: (() => void) | n
export function resetInterceptorStateForTests(): void { export function resetInterceptorStateForTests(): void {
unauthorizedHandlingInProgress = false unauthorizedHandlingInProgress = false
refreshPromise = null refreshPromise = null
if (hasLocalStorage && typeof localStorage.removeItem === 'function') {
localStorage.removeItem(CROSS_TAB_REFRESH_KEY)
}
} }
function handleUnauthorizedResponse(): void { function handleUnauthorizedResponse(): void {
@@ -35,13 +43,27 @@ function handleUnauthorizedResponse(): void {
* Attempt to refresh the access token by calling the refresh endpoint. * Attempt to refresh the access token by calling the refresh endpoint.
* Returns true if refresh succeeded, false otherwise. * Returns true if refresh succeeded, false otherwise.
* Uses a mutex so concurrent 401s only trigger one refresh call. * 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> { 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 if (refreshPromise) return refreshPromise
refreshPromise = (async () => { refreshPromise = (async () => {
try { try {
const res = await originalFetch('/api/auth/refresh', { method: 'POST' }) 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 return res.ok
} catch { } catch {
return false return false

View File

@@ -2,7 +2,7 @@
import { ref, watch, onMounted, onUnmounted } from 'vue' import { ref, watch, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useBackendEvents } from '@/common/backendEvents' 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' import { eventBus } from '@/common/eventBus'
const userId = ref(currentUserId.value) const userId = ref(currentUserId.value)
@@ -20,6 +20,7 @@ function handleForceLogout(event: { payload?: { reason?: string } }) {
suppressForceLogout.value = false suppressForceLogout.value = false
return return
} }
logoutParent()
logoutUser() logoutUser()
if (event?.payload?.reason === 'account_deleted') { if (event?.payload?.reason === 'account_deleted') {
router.push('/') router.push('/')

View File

@@ -5,16 +5,20 @@ import BackendEventsListener from '../BackendEventsListener.vue'
// ── Hoisted mocks ──────────────────────────────────────────────────────────── // ── Hoisted mocks ────────────────────────────────────────────────────────────
const { mockLogoutUser, mockSuppressForceLogout, mockCurrentUserId } = vi.hoisted(() => ({ const { mockLogoutUser, mockLogoutParent, mockSuppressForceLogout, mockCurrentUserId } = vi.hoisted(
mockLogoutUser: vi.fn(), () => ({
mockSuppressForceLogout: { value: false }, mockLogoutUser: vi.fn(),
mockCurrentUserId: { value: 'user-1' }, mockLogoutParent: vi.fn(),
})) mockSuppressForceLogout: { value: false },
mockCurrentUserId: { value: 'user-1' },
}),
)
let capturedForceLogoutHandler: ((event: any) => void) | null = null let capturedForceLogoutHandler: ((event: any) => void) | null = null
vi.mock('@/stores/auth', () => ({ vi.mock('@/stores/auth', () => ({
currentUserId: mockCurrentUserId, currentUserId: mockCurrentUserId,
logoutParent: mockLogoutParent,
logoutUser: mockLogoutUser, logoutUser: mockLogoutUser,
suppressForceLogout: mockSuppressForceLogout, suppressForceLogout: mockSuppressForceLogout,
})) }))
@@ -70,6 +74,7 @@ describe('BackendEventsListener force_logout', () => {
fireForceLogout('password_reset') fireForceLogout('password_reset')
await flushPromises() await flushPromises()
expect(mockLogoutParent).toHaveBeenCalledOnce()
expect(mockLogoutUser).toHaveBeenCalledOnce() expect(mockLogoutUser).toHaveBeenCalledOnce()
expect(pushSpy).toHaveBeenCalledWith({ name: 'Login' }) expect(pushSpy).toHaveBeenCalledWith({ name: 'Login' })
}) })
@@ -81,6 +86,7 @@ describe('BackendEventsListener force_logout', () => {
fireForceLogout('account_deleted') fireForceLogout('account_deleted')
await flushPromises() await flushPromises()
expect(mockLogoutParent).toHaveBeenCalledOnce()
expect(mockLogoutUser).toHaveBeenCalledOnce() expect(mockLogoutUser).toHaveBeenCalledOnce()
expect(pushSpy).toHaveBeenCalledWith('/') expect(pushSpy).toHaveBeenCalledWith('/')
}) })
@@ -92,6 +98,7 @@ describe('BackendEventsListener force_logout', () => {
fireForceLogout('something_else') fireForceLogout('something_else')
await flushPromises() await flushPromises()
expect(mockLogoutParent).toHaveBeenCalledOnce()
expect(mockLogoutUser).toHaveBeenCalledOnce() expect(mockLogoutUser).toHaveBeenCalledOnce()
expect(pushSpy).toHaveBeenCalledWith({ name: 'Login' }) expect(pushSpy).toHaveBeenCalledWith({ name: 'Login' })
}) })
@@ -103,6 +110,7 @@ describe('BackendEventsListener force_logout', () => {
fireForceLogout() fireForceLogout()
await flushPromises() await flushPromises()
expect(mockLogoutParent).toHaveBeenCalledOnce()
expect(mockLogoutUser).toHaveBeenCalledOnce() expect(mockLogoutUser).toHaveBeenCalledOnce()
expect(pushSpy).toHaveBeenCalledWith({ name: 'Login' }) expect(pushSpy).toHaveBeenCalledWith({ name: 'Login' })
}) })
@@ -115,6 +123,7 @@ describe('BackendEventsListener force_logout', () => {
fireForceLogout('password_reset') fireForceLogout('password_reset')
await flushPromises() await flushPromises()
expect(mockLogoutParent).not.toHaveBeenCalled()
expect(mockLogoutUser).not.toHaveBeenCalled() expect(mockLogoutUser).not.toHaveBeenCalled()
expect(pushSpy).not.toHaveBeenCalled() expect(pushSpy).not.toHaveBeenCalled()
}) })
@@ -142,6 +151,7 @@ describe('BackendEventsListener force_logout', () => {
// Second event: should go through normally // Second event: should go through normally
fireForceLogout('password_reset') fireForceLogout('password_reset')
await flushPromises() await flushPromises()
expect(mockLogoutParent).toHaveBeenCalledOnce()
expect(mockLogoutUser).toHaveBeenCalledOnce() expect(mockLogoutUser).toHaveBeenCalledOnce()
expect(pushSpy).toHaveBeenCalledWith({ name: 'Login' }) expect(pushSpy).toHaveBeenCalledWith({ name: 'Login' })
}) })

View File

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

View File

@@ -22,6 +22,7 @@ if (hasLocalStorage) {
parentAuthExpiresAt.value = parsed.expiresAt parentAuthExpiresAt.value = parsed.expiresAt
isParentPersistent.value = true isParentPersistent.value = true
isParentAuthenticated.value = true isParentAuthenticated.value = true
startParentExpiryWatcher()
} else { } else {
localStorage.removeItem(PARENT_AUTH_KEY) localStorage.removeItem(PARENT_AUTH_KEY)
} }
@@ -81,6 +82,9 @@ export function authenticateParent(persistent: boolean) {
} }
export function logoutParent() { export function logoutParent() {
if (hasLocalStorage && typeof localStorage.removeItem === 'function') {
localStorage.removeItem(PARENT_AUTH_KEY)
}
applyParentLoggedOutState() applyParentLoggedOutState()
broadcastParentLogoutEvent() broadcastParentLogoutEvent()
} }
@@ -89,9 +93,6 @@ function applyParentLoggedOutState() {
parentAuthExpiresAt.value = null parentAuthExpiresAt.value = null
isParentPersistent.value = false isParentPersistent.value = false
isParentAuthenticated.value = false isParentAuthenticated.value = false
if (hasLocalStorage && typeof localStorage.removeItem === 'function') {
localStorage.removeItem(PARENT_AUTH_KEY)
}
stopParentExpiryWatcher() stopParentExpiryWatcher()
} }
@@ -105,7 +106,11 @@ function broadcastParentLogoutEvent() {
export function loginUser() { export function loginUser() {
isUserLoggedIn.value = true 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() applyParentLoggedOutState()
} }