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
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m57s
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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('/')
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
|
||||
@@ -224,6 +224,7 @@ function executeMenuItem(index: number) {
|
||||
async function signOut() {
|
||||
try {
|
||||
await fetch('/api/auth/logout', { method: 'POST' })
|
||||
logoutParent()
|
||||
logoutUser()
|
||||
router.push('/')
|
||||
} catch {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user