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:
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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('/')
|
||||||
|
|||||||
@@ -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' })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user