Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/features/auth/components/AuthProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useEffect, type ReactNode } from 'react'
import { useAuth } from '../hooks/useAuth'
import { useSessionTimeout } from '../hooks/useSessionTimeout'
import { bootstrapSession, startTokenRefreshLoop } from '../services/session'

/**
Expand All @@ -11,6 +12,8 @@ import { bootstrapSession, startTokenRefreshLoop } from '../services/session'
export default function AuthProvider({ children }: { children: ReactNode }) {
const { isAuthenticated } = useAuth()

useSessionTimeout()

useEffect(() => {
void bootstrapSession()
}, [])
Expand Down
41 changes: 41 additions & 0 deletions src/features/auth/hooks/useSessionTimeout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { useEffect } from 'react'
import { SESSION_CHECK_INTERVAL_MS, getSession, touchSession } from '@/lib/session'
import { endSession } from '../services/session'
import { useAuthStore } from '../stores/useAuthStore'

const ACTIVITY_EVENTS = ['pointerdown', 'keydown', 'touchstart', 'scroll'] as const

/**
* Runtime half of the session timeout policy (#986).
*
* While authenticated, user activity extends the idle window. A periodic check
* tears the session down once it is idle, expired, or invalidated.
*/
export function useSessionTimeout(): void {
const status = useAuthStore((state) => state.status)

useEffect(() => {
if (status !== 'authenticated') return

const onActivity = () => {
touchSession()
}

for (const event of ACTIVITY_EVENTS) {
window.addEventListener(event, onActivity, { passive: true })
}

const interval = window.setInterval(() => {
if (!getSession()) {
void endSession()
}
}, SESSION_CHECK_INTERVAL_MS)

return () => {
for (const event of ACTIVITY_EVENTS) {
window.removeEventListener(event, onActivity)
}
window.clearInterval(interval)
}
}, [status])
}
10 changes: 10 additions & 0 deletions src/features/auth/services/session.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ensureSession, getSessionStatus } from '@/lib/session'
import { ApiError, getErrorMessage, setAuthTokenProvider, setUnauthorizedHandler } from '@/services'
import { queryClient } from '@/stores'
import { getAccessToken, getStoredTokens as readTokens, useAuthStore } from '../stores/useAuthStore'
Expand Down Expand Up @@ -101,6 +102,15 @@ export async function bootstrapSession(): Promise<void> {
return
}

// Enforce idle/absolute timeouts before restoring the session. A missing ID
// is the upgrade path for users who already have tokens in localStorage.
const sessionStatus = getSessionStatus()
if (sessionStatus === 'idle' || sessionStatus === 'expired') {
await endSession()
return
}
if (sessionStatus === 'missing') ensureSession()

useAuthStore.getState().startLoading()

const first = await loadProfile()
Expand Down
42 changes: 41 additions & 1 deletion src/features/auth/stores/useAuthStore.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { __resetSessionForTests, createSession, getSession, isSessionIdActive } from '@/lib/session'
import { getAccessToken, getStoredTokens, useAuthStore } from './useAuthStore'
import type { AuthSession, User } from '../types'

const user = { id: 'u1', email: 'a@b.co', role: 'artist' } as User
const session: AuthSession = { user, tokens: { accessToken: 'access-1', refreshToken: 'refresh-1' } }
const session: AuthSession = {
user,
tokens: { accessToken: 'access-1', refreshToken: 'refresh-1' },
}

beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
__resetSessionForTests()
useAuthStore.getState().clearSession()
})

Expand Down Expand Up @@ -60,4 +66,38 @@ describe('useAuthStore', () => {
useAuthStore.getState().startLoading()
expect(useAuthStore.getState().status).toBe('loading')
})

it('rotates the session id on sign-in and invalidates the pre-auth id (#986)', () => {
const preAuth = createSession()

useAuthStore.getState().setSession(session)

const postAuth = getSession()
expect(postAuth).not.toBeNull()
expect(postAuth?.id).not.toBe(preAuth.id)
expect(isSessionIdActive(preAuth.id)).toBe(false)
expect(isSessionIdActive(postAuth!.id)).toBe(true)
})

it('rotates the session id on token refresh (#986)', () => {
useAuthStore.getState().setSession(session)
const afterLogin = getSession()!

useAuthStore.getState().setTokens({ accessToken: 'access-2', refreshToken: null })

const afterRefresh = getSession()!
expect(afterRefresh.id).not.toBe(afterLogin.id)
expect(isSessionIdActive(afterLogin.id)).toBe(false)
expect(isSessionIdActive(afterRefresh.id)).toBe(true)
})

it('invalidates the session id on logout (#986)', () => {
useAuthStore.getState().setSession(session)
const active = getSession()!

useAuthStore.getState().clearSession()

expect(getSession()).toBeNull()
expect(isSessionIdActive(active.id)).toBe(false)
})
})
29 changes: 23 additions & 6 deletions src/features/auth/stores/useAuthStore.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { invalidateSession, rotateSession } from '@/lib/session'
import type { AuthSession, AuthTokens, SessionStatus, User } from '../types'

interface PersistedAuth {
Expand Down Expand Up @@ -46,19 +47,35 @@ export const useAuthStore = create<AuthStoreState>()(
status: 'unauthenticated',
error: null,
startLoading: () => set({ status: 'loading', error: null }),
setSession: ({ tokens, user }) =>
setSession: ({ tokens, user }) => {
// Sign-in always starts a new session; any pre-authentication identifier
// is invalidated so it cannot be fixated and reused (#986).
rotateSession()
set({
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
user,
status: 'authenticated',
error: null,
}),
setTokens: ({ accessToken, refreshToken }) =>
set((state) => ({ accessToken, refreshToken: refreshToken ?? state.refreshToken })),
})
},
setTokens: ({ accessToken, refreshToken }) => {
// Rotate alongside every token refresh so a leaked identifier does not
// outlive the token pair it belongs to (#986).
rotateSession()
set((state) => ({ accessToken, refreshToken: refreshToken ?? state.refreshToken }))
},
setUser: (user) => set({ user, status: 'authenticated', error: null }),
clearSession: (error = null) =>
set({ accessToken: null, refreshToken: null, user: null, status: 'unauthenticated', error }),
clearSession: (error = null) => {
invalidateSession()
set({
accessToken: null,
refreshToken: null,
user: null,
status: 'unauthenticated',
error,
})
},
}),
{
name: 'lumora-auth',
Expand Down
137 changes: 137 additions & 0 deletions src/lib/session.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
__resetSessionForTests,
configureSessionStorage,
createSession,
ensureSession,
getSession,
getSessionStatus,
invalidateSession,
isSessionIdActive,
rotateSession,
SESSION_ABSOLUTE_TIMEOUT_MS,
SESSION_IDLE_TIMEOUT_MS,
SESSION_STORAGE_KEY,
touchSession,
} from '@/lib/session'

function createMemoryStorage() {
const values = new Map<string, string>()
return {
values,
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => {
values.set(key, value)
},
removeItem: (key: string) => {
values.delete(key)
},
}
}

beforeEach(() => {
__resetSessionForTests()
})

afterEach(() => {
__resetSessionForTests()
})

describe('session identity', () => {
it('creates and persists a session', () => {
const storage = createMemoryStorage()
configureSessionStorage(storage)

const session = createSession(1_000)

expect(session.id).toBeTruthy()
expect(session.issuedAt).toBe(1_000)
expect(session.lastActivityAt).toBe(1_000)
expect(getSession(1_000)?.id).toBe(session.id)
expect(JSON.parse(storage.values.get(SESSION_STORAGE_KEY) ?? '{}')).toEqual(session)
})

it('rotates the identifier and invalidates the previous one', () => {
const first = createSession(1_000)
const second = rotateSession(2_000)

expect(second.id).not.toBe(first.id)
expect(isSessionIdActive(first.id, 2_000)).toBe(false)
expect(isSessionIdActive(second.id, 2_000)).toBe(true)
})

it('prevents a fixated identifier from becoming active after rotation', () => {
const storage = createMemoryStorage()
configureSessionStorage(storage)

const preAuthSession = createSession(1_000)
rotateSession(2_000)

// An attacker writes the pre-authentication record back into storage.
storage.setItem(SESSION_STORAGE_KEY, JSON.stringify(preAuthSession))
// Simulate a reload: drop the in-memory record but keep invalidation memory.
configureSessionStorage(storage)

expect(getSession(2_000)).toBeNull()
expect(storage.values.has(SESSION_STORAGE_KEY)).toBe(false)
expect(isSessionIdActive(preAuthSession.id, 2_000)).toBe(false)
})

it('invalidates the current session on logout', () => {
const session = createSession(1_000)
invalidateSession()

expect(getSession(1_000)).toBeNull()
expect(isSessionIdActive(session.id, 1_000)).toBe(false)
expect(sessionStorage.getItem(SESSION_STORAGE_KEY)).toBeNull()
})

it('ensures a session exists without replacing an active one', () => {
const first = ensureSession(1_000)
expect(ensureSession(1_500).id).toBe(first.id)

const rotated = rotateSession(2_000)
expect(ensureSession(2_000).id).toBe(rotated.id)
expect(ensureSession(2_000).id).not.toBe(first.id)
})
})

describe('session timeouts', () => {
it('expires an idle session and clears it', () => {
createSession(0)

expect(getSessionStatus(SESSION_IDLE_TIMEOUT_MS - 1)).toBe('active')
expect(getSessionStatus(SESSION_IDLE_TIMEOUT_MS)).toBe('idle')
expect(getSession()).toBeNull()
expect(sessionStorage.getItem(SESSION_STORAGE_KEY)).toBeNull()
})

it('extends the idle window when the user is active', () => {
createSession(0)
touchSession(10_000)

expect(getSessionStatus(10_000 + SESSION_IDLE_TIMEOUT_MS - 1)).toBe('active')
expect(getSessionStatus(10_000 + SESSION_IDLE_TIMEOUT_MS)).toBe('idle')
})

it('enforces the absolute timeout regardless of activity', () => {
createSession(0)

// Keep the session active (never idle) right up to the absolute deadline.
for (
let elapsed = SESSION_IDLE_TIMEOUT_MS - 1;
elapsed < SESSION_ABSOLUTE_TIMEOUT_MS;
elapsed += SESSION_IDLE_TIMEOUT_MS - 1
) {
touchSession(elapsed)
}

expect(getSessionStatus(SESSION_ABSOLUTE_TIMEOUT_MS - 1)).toBe('active')
expect(getSessionStatus(SESSION_ABSOLUTE_TIMEOUT_MS)).toBe('expired')
})

it('reports a missing session when nothing is stored', () => {
expect(getSessionStatus()).toBe('missing')
expect(getSession()).toBeNull()
})
})
Loading
Loading