fix(auth): recover live sessions when an access token expires

A tab that slept past the 15 minute access-token lifetime woke up holding a token every consumer
then handed to the server independently. socket.io reconnected all seven namespaces with it, each
gateway rejected the handshake and closed the socket, and because a server-initiated close reports
`io server disconnect` the client never reconnects from it, so live updates stayed dead until the
page was reloaded. The dashboard widgets fetch once on mount with no retry, so whatever failed in
that window sat on "Failed to load" for the life of the page.

Make a valid token the thing consumers ask for rather than something they hope is still good.
Decode `exp` on the client and renew inside a 30 second skew before the token is used, so websocket
handshakes, uploads and `api()` requests no longer discover the expiry one 401 at a time. Route
every authenticated namespace through a shared factory that resolves the token per handshake and,
on an auth rejection, refreshes and reconnects with bounded exponential backoff.

Let the server say which rejections are worth retrying. Gateways now answer an authentication
failure with an `unauthorized` event before closing, and stay silent on a permission denial, which
a fresh token would not fix. Classifying that needed the gateways to stop throwing bare `Error` for
both cases, so the handshake paths now throw `UnauthorizedException` or `ForbiddenException` as the
project's conventions already require, and jsonwebtoken's own `TokenExpiredError` counts as an
authentication failure since it is the single most common way a slept-through handshake fails.

Collapse the twelve identical widget composables onto one loader that reloads a failed widget once
a rejected request is followed by a successful refresh.

Closes #942
This commit is contained in:
neon
2026-08-09 03:23:58 +00:00
parent d8105d4820
commit 54cb7dbda0
43 changed files with 927 additions and 344 deletions
@@ -1,6 +1,7 @@
import { ref } from 'vue'
import { io, Socket } from 'socket.io-client'
import { api, getAccessToken } from '@/lib/api'
import { Socket } from 'socket.io-client'
import { api } from '@/lib/api'
import { createAuthenticatedSocket } from '@/lib/socket'
import type { BookDockSummary } from '@bookorbit/types'
const summary = ref<BookDockSummary>({ pending: 0, ready: 0, error: 0, total: 0, paused: false })
@@ -37,9 +38,7 @@ async function restFetchSummary() {
function getSocket(): Socket {
if (!socket) {
socket = io('/book-dock', {
auth: (cb: (data: object) => void) => cb({ token: getAccessToken() }),
transports: ['websocket'],
socket = createAuthenticatedSocket('/book-dock', {
reconnection: true,
reconnectionDelay: 1000,
reconnectionDelayMax: 10000,
@@ -1,5 +1,5 @@
import { ref } from 'vue'
import { getAccessToken } from '@/lib/api'
import { getValidToken } from '@/lib/api'
import type { BookDockFile } from '@bookorbit/types'
import { useAppInfo } from '@/features/settings/composables/useAppInfo'
@@ -29,7 +29,8 @@ function validateFile(file: File): string | null {
return null
}
function uploadSingle(item: UploadItem): Promise<void> {
async function uploadSingle(item: UploadItem): Promise<void> {
const token = await getValidToken()
return new Promise((resolve) => {
const formData = new FormData()
formData.append('file', item.file)
@@ -37,7 +38,6 @@ function uploadSingle(item: UploadItem): Promise<void> {
const xhr = new XMLHttpRequest()
xhr.open('POST', '/api/v1/book-dock/upload')
const token = getAccessToken()
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
xhr.upload.onprogress = (e) => {
@@ -1,7 +1,7 @@
import { ref } from 'vue'
import { io, Socket } from 'socket.io-client'
import { Socket } from 'socket.io-client'
import type { BookMetadataFetchStatusEvent } from '@bookorbit/types'
import { getAccessToken } from '@/lib/api'
import { createAuthenticatedSocket } from '@/lib/socket'
const status = ref<BookMetadataFetchStatusEvent>({
queued: 0,
@@ -18,9 +18,7 @@ let socket: Socket | null = null
function getSocket(): Socket {
if (!socket) {
socket = io('/book-metadata-fetch', {
auth: (cb: (data: object) => void) => cb({ token: getAccessToken() }),
transports: ['websocket'],
socket = createAuthenticatedSocket('/book-metadata-fetch', {
reconnection: true,
reconnectionDelay: 1000,
reconnectionDelayMax: 10000,
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@/lib/api', () => ({
getAccessToken: vi.fn<() => string>().mockReturnValue('test-token'),
getValidToken: vi.fn<() => Promise<string>>().mockResolvedValue('test-token'),
}))
vi.mock('@/features/settings/composables/useAppInfo', () => ({
@@ -23,6 +23,12 @@ interface MockXhrInstance {
const mockXhrInstances: MockXhrInstance[] = []
/** The upload resolves a valid token before opening the request, so the XHR appears a tick later. */
async function nextXhr(): Promise<MockXhrInstance> {
await vi.waitFor(() => expect(mockXhrInstances[0]).toBeDefined())
return mockXhrInstances[0]!
}
const defaultResponseText = JSON.stringify({
id: 55,
format: 'epub',
@@ -153,7 +159,7 @@ describe('useAddBookFile', () => {
addFiles([makeFile('book.epub')])
const uploadPromise = startUpload(7)
const xhr = mockXhrInstances[0]
const xhr = await nextXhr()
xhr.onerror?.()
await uploadPromise
@@ -215,7 +221,7 @@ describe('useAddBookFile', () => {
addFiles([makeFile('book.epub')])
const uploadPromise = startUpload(42)
const xhr = mockXhrInstances[0]
const xhr = await nextXhr()
xhr.onload?.()
await uploadPromise
@@ -227,7 +233,7 @@ describe('useAddBookFile', () => {
addFiles([makeFile('book.epub')])
const uploadPromise = startUpload(42)
const xhr = mockXhrInstances[0]
const xhr = await nextXhr()
xhr.onload?.()
await uploadPromise
@@ -239,7 +245,7 @@ describe('useAddBookFile', () => {
addFiles([makeFile('book.epub')])
const uploadPromise = startUpload(7)
const xhr = mockXhrInstances[0]
const xhr = await nextXhr()
xhr.status = 201
xhr.onload?.()
await uploadPromise
@@ -253,7 +259,7 @@ describe('useAddBookFile', () => {
addFiles([makeFile('book.epub')])
const uploadPromise = startUpload(7)
const xhr = mockXhrInstances[0]
const xhr = await nextXhr()
xhr.status = 409
xhr.responseText = JSON.stringify({ message: 'File already attached' })
xhr.onload?.()
@@ -268,7 +274,7 @@ describe('useAddBookFile', () => {
addFiles([makeFile('book.epub')])
const uploadPromise = startUpload(7)
const xhr = mockXhrInstances[0]
const xhr = await nextXhr()
xhr.onerror?.()
await uploadPromise
@@ -281,7 +287,7 @@ describe('useAddBookFile', () => {
addFiles([makeFile('bad.xyz'), makeFile('book.epub')])
const uploadPromise = startUpload(7)
const xhr = mockXhrInstances[0]
const xhr = await nextXhr()
xhr.onload?.()
await uploadPromise
@@ -294,7 +300,7 @@ describe('useAddBookFile', () => {
addFiles([makeFile('book.epub')])
const uploadPromise = startUpload(7)
const xhr = mockXhrInstances[0]
const xhr = await nextXhr()
xhr.upload.onprogress?.({ lengthComputable: true, loaded: 50, total: 100 } as ProgressEvent)
expect(files.value[0].progress).toBe(50)
@@ -315,7 +321,7 @@ describe('useAddBookFile', () => {
addFiles([makeFile('book.epub')])
const uploadPromise = startUpload(7)
const xhr = mockXhrInstances[0]
const xhr = await nextXhr()
xhr.status = 201
xhr.responseText = 'not-json'
xhr.onload?.()
@@ -330,7 +336,7 @@ describe('useAddBookFile', () => {
addFiles([makeFile('book.epub')])
const uploadPromise = startUpload(7)
const xhr = mockXhrInstances[0]
const xhr = await nextXhr()
xhr.status = 500
xhr.responseText = 'Internal Server Error'
xhr.onload?.()
@@ -347,7 +353,7 @@ describe('useAddBookFile', () => {
addFiles([makeFile('book.epub')])
const uploadPromise = startUpload(42, { renameAfter: true })
const xhr = mockXhrInstances[0]
const xhr = await nextXhr()
xhr.status = 201
xhr.onload?.()
await uploadPromise
@@ -360,7 +366,7 @@ describe('useAddBookFile', () => {
addFiles([makeFile('book.epub')])
const uploadPromise = startUpload(42, { renameAfter: false })
const xhr = mockXhrInstances[0]
const xhr = await nextXhr()
xhr.onload?.()
await uploadPromise
@@ -372,7 +378,7 @@ describe('useAddBookFile', () => {
addFiles([makeFile('book.epub')])
const uploadPromise = startUpload(42)
const xhr = mockXhrInstances[0]
const xhr = await nextXhr()
xhr.onload?.()
await uploadPromise
@@ -384,7 +390,7 @@ describe('useAddBookFile', () => {
addFiles([makeFile('book.epub')])
const uploadPromise = startUpload(42, { renameAfter: true })
const xhr = mockXhrInstances[0]
const xhr = await nextXhr()
xhr.status = 409
xhr.responseText = JSON.stringify({ message: 'Already exists' })
xhr.onload?.()
@@ -400,7 +406,7 @@ describe('useAddBookFile', () => {
addFiles([makeFile('book.epub')])
const uploadPromise = startUpload(42, { renameAfter: true })
const xhr = mockXhrInstances[0]
const xhr = await nextXhr()
xhr.status = 201
xhr.onload?.()
@@ -1,5 +1,5 @@
import { computed, ref } from 'vue'
import { getAccessToken } from '@/lib/api'
import { getValidToken } from '@/lib/api'
import type { AddBookFileResult } from '@bookorbit/types'
import { useAppInfo } from '@/features/settings/composables/useAppInfo'
@@ -34,7 +34,8 @@ function validateFile(file: File): string | null {
return null
}
function uploadSingle(item: BookFileUploadItem, bookId: number): Promise<void> {
async function uploadSingle(item: BookFileUploadItem, bookId: number): Promise<void> {
const token = await getValidToken()
return new Promise((resolve) => {
const formData = new FormData()
formData.append('file', item.file)
@@ -42,7 +43,6 @@ function uploadSingle(item: BookFileUploadItem, bookId: number): Promise<void> {
const xhr = new XMLHttpRequest()
xhr.open('POST', `/api/v1/books/${bookId}/files`)
const token = getAccessToken()
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
xhr.upload.onprogress = (e) => {
@@ -84,7 +84,7 @@ function uploadSingle(item: BookFileUploadItem, bookId: number): Promise<void> {
}
async function triggerRename(bookId: number): Promise<void> {
const token = getAccessToken()
const token = await getValidToken()
await fetch(`/api/v1/books/${bookId}/rename-files`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
@@ -0,0 +1,95 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { defineComponent } from 'vue'
import { useWidgetData } from '../useWidgetData'
const { recoveryListeners } = vi.hoisted(() => ({ recoveryListeners: new Set<() => void>() }))
vi.mock('@/lib/api', () => ({
onAuthRecovered: (listener: () => void) => {
recoveryListeners.add(listener)
return () => recoveryListeners.delete(listener)
},
}))
function recoverSession(): void {
for (const listener of new Set(recoveryListeners)) listener()
}
function mountWidget<T>(fetcher: () => Promise<T>) {
let widget!: ReturnType<typeof useWidgetData<T>>
const wrapper = mount(
defineComponent({
setup() {
widget = useWidgetData(fetcher)
return () => null
},
}),
)
return { wrapper, widget }
}
describe('useWidgetData', () => {
beforeEach(() => {
recoveryListeners.clear()
})
afterEach(() => {
vi.clearAllMocks()
})
it('loads on mount', async () => {
const fetcher = vi.fn<() => Promise<{ streak: number }>>().mockResolvedValue({ streak: 7 })
const { widget } = mountWidget(fetcher)
await vi.waitFor(() => expect(widget.loading.value).toBe(false))
expect(widget.data.value).toEqual({ streak: 7 })
expect(widget.error.value).toBe(false)
})
it('reports a failure without leaving the widget stuck loading', async () => {
const fetcher = vi.fn<() => Promise<{ streak: number }>>().mockRejectedValue(new Error('Session expired'))
const { widget } = mountWidget(fetcher)
await vi.waitFor(() => expect(widget.loading.value).toBe(false))
expect(widget.error.value).toBe(true)
expect(widget.data.value).toBeNull()
})
it('reloads a failed widget once the session comes back', async () => {
const fetcher = vi.fn<() => Promise<{ streak: number }>>().mockRejectedValueOnce(new Error('Session expired')).mockResolvedValue({ streak: 7 })
const { widget } = mountWidget(fetcher)
await vi.waitFor(() => expect(widget.error.value).toBe(true))
recoverSession()
await vi.waitFor(() => expect(widget.error.value).toBe(false))
expect(fetcher).toHaveBeenCalledTimes(2)
expect(widget.data.value).toEqual({ streak: 7 })
})
it('leaves a widget that loaded alone', async () => {
const fetcher = vi.fn<() => Promise<{ streak: number }>>().mockResolvedValue({ streak: 7 })
const { widget } = mountWidget(fetcher)
await vi.waitFor(() => expect(widget.loading.value).toBe(false))
recoverSession()
await Promise.resolve()
expect(fetcher).toHaveBeenCalledTimes(1)
})
it('stops listening when the widget goes away', async () => {
const fetcher = vi.fn<() => Promise<{ streak: number }>>().mockRejectedValue(new Error('Session expired'))
const { wrapper, widget } = mountWidget(fetcher)
await vi.waitFor(() => expect(widget.error.value).toBe(true))
wrapper.unmount()
recoverSession()
await Promise.resolve()
expect(fetcher).toHaveBeenCalledTimes(1)
expect(recoveryListeners.size).toBe(0)
})
})
@@ -1,25 +1,6 @@
import { onMounted, ref } from 'vue'
import type { CurrentlyReadingWidgetData } from '@bookorbit/types'
import { fetchCurrentlyReading } from '../api/dashboard-widget.api'
import { useWidgetData } from './useWidgetData'
export function useCurrentlyReadingWidget() {
const data = ref<CurrentlyReadingWidgetData | null>(null)
const loading = ref(true)
const error = ref(false)
async function load() {
loading.value = true
error.value = false
try {
data.value = await fetchCurrentlyReading()
} catch {
error.value = true
} finally {
loading.value = false
}
}
onMounted(load)
return { data, loading, error, refresh: load }
return useWidgetData(fetchCurrentlyReading)
}
@@ -1,25 +1,6 @@
import { onMounted, ref } from 'vue'
import type { DiversityScoreWidgetData } from '@bookorbit/types'
import { fetchDiversityScore } from '../api/dashboard-widget.api'
import { useWidgetData } from './useWidgetData'
export function useDiversityScoreWidget() {
const data = ref<DiversityScoreWidgetData | null>(null)
const loading = ref(true)
const error = ref(false)
async function load() {
loading.value = true
error.value = false
try {
data.value = await fetchDiversityScore()
} catch {
error.value = true
} finally {
loading.value = false
}
}
onMounted(load)
return { data, loading, error, refresh: load }
return useWidgetData(fetchDiversityScore)
}
@@ -1,25 +1,6 @@
import { onMounted, ref } from 'vue'
import type { HighlightOfTheDayWidgetData } from '@bookorbit/types'
import { fetchHighlightOfTheDay } from '../api/dashboard-widget.api'
import { useWidgetData } from './useWidgetData'
export function useHighlightOfTheDayWidget() {
const data = ref<HighlightOfTheDayWidgetData | null>(null)
const loading = ref(true)
const error = ref(false)
async function load() {
loading.value = true
error.value = false
try {
data.value = await fetchHighlightOfTheDay()
} catch {
error.value = true
} finally {
loading.value = false
}
}
onMounted(load)
return { data, loading, error, refresh: load }
return useWidgetData(fetchHighlightOfTheDay)
}
@@ -1,25 +1,6 @@
import { onMounted, ref } from 'vue'
import type { LibraryOverviewWidgetData } from '@bookorbit/types'
import { fetchLibraryOverview } from '../api/dashboard-widget.api'
import { useWidgetData } from './useWidgetData'
export function useLibraryOverviewWidget() {
const data = ref<LibraryOverviewWidgetData | null>(null)
const loading = ref(true)
const error = ref(false)
async function load() {
loading.value = true
error.value = false
try {
data.value = await fetchLibraryOverview()
} catch {
error.value = true
} finally {
loading.value = false
}
}
onMounted(load)
return { data, loading, error, refresh: load }
return useWidgetData(fetchLibraryOverview)
}
@@ -1,25 +1,6 @@
import { onMounted, ref } from 'vue'
import type { LongWaitWidgetData } from '@bookorbit/types'
import { fetchLongWait } from '../api/dashboard-widget.api'
import { useWidgetData } from './useWidgetData'
export function useLongWaitWidget() {
const data = ref<LongWaitWidgetData | null>(null)
const loading = ref(true)
const error = ref(false)
async function load() {
loading.value = true
error.value = false
try {
data.value = await fetchLongWait()
} catch {
error.value = true
} finally {
loading.value = false
}
}
onMounted(load)
return { data, loading, error, refresh: load }
return useWidgetData(fetchLongWait)
}
@@ -1,25 +1,6 @@
import { onMounted, ref } from 'vue'
import type { MonthlyChallengeWidgetData } from '@bookorbit/types'
import { fetchMonthlyChallenge } from '../api/dashboard-widget.api'
import { useWidgetData } from './useWidgetData'
export function useMonthlyChallengeWidget() {
const data = ref<MonthlyChallengeWidgetData | null>(null)
const loading = ref(true)
const error = ref(false)
async function load() {
loading.value = true
error.value = false
try {
data.value = await fetchMonthlyChallenge()
} catch {
error.value = true
} finally {
loading.value = false
}
}
onMounted(load)
return { data, loading, error, refresh: load }
return useWidgetData(fetchMonthlyChallenge)
}
@@ -1,25 +1,6 @@
import { onMounted, ref } from 'vue'
import type { NeglectedGemsWidgetData } from '@bookorbit/types'
import { fetchNeglectedGems } from '../api/dashboard-widget.api'
import { useWidgetData } from './useWidgetData'
export function useNeglectedGemsWidget() {
const data = ref<NeglectedGemsWidgetData | null>(null)
const loading = ref(true)
const error = ref(false)
async function load() {
loading.value = true
error.value = false
try {
data.value = await fetchNeglectedGems()
} catch {
error.value = true
} finally {
loading.value = false
}
}
onMounted(load)
return { data, loading, error, refresh: load }
return useWidgetData(fetchNeglectedGems)
}
@@ -1,25 +1,6 @@
import { onMounted, ref } from 'vue'
import type { ReadingDnaWidgetData } from '@bookorbit/types'
import { fetchReadingDna } from '../api/dashboard-widget.api'
import { useWidgetData } from './useWidgetData'
export function useReadingDnaWidget() {
const data = ref<ReadingDnaWidgetData | null>(null)
const loading = ref(true)
const error = ref(false)
async function load() {
loading.value = true
error.value = false
try {
data.value = await fetchReadingDna()
} catch {
error.value = true
} finally {
loading.value = false
}
}
onMounted(load)
return { data, loading, error, refresh: load }
return useWidgetData(fetchReadingDna)
}
@@ -1,25 +1,6 @@
import { onMounted, ref } from 'vue'
import type { ReadingGoalWidgetData } from '@bookorbit/types'
import { fetchReadingGoal } from '../api/dashboard-widget.api'
import { useWidgetData } from './useWidgetData'
export function useReadingGoalWidget() {
const data = ref<ReadingGoalWidgetData | null>(null)
const loading = ref(true)
const error = ref(false)
async function load() {
loading.value = true
error.value = false
try {
data.value = await fetchReadingGoal()
} catch {
error.value = true
} finally {
loading.value = false
}
}
onMounted(load)
return { data, loading, error, refresh: load }
return useWidgetData(fetchReadingGoal)
}
@@ -1,25 +1,6 @@
import { onMounted, ref } from 'vue'
import type { ReadingRhythmWidgetData } from '@bookorbit/types'
import { fetchReadingRhythm } from '../api/dashboard-widget.api'
import { useWidgetData } from './useWidgetData'
export function useReadingRhythmWidget() {
const data = ref<ReadingRhythmWidgetData | null>(null)
const loading = ref(true)
const error = ref(false)
async function load() {
loading.value = true
error.value = false
try {
data.value = await fetchReadingRhythm()
} catch {
error.value = true
} finally {
loading.value = false
}
}
onMounted(load)
return { data, loading, error, refresh: load }
return useWidgetData(fetchReadingRhythm)
}
@@ -1,25 +1,6 @@
import { onMounted, ref } from 'vue'
import type { ReadingStreakWidgetData } from '@bookorbit/types'
import { fetchReadingStreak } from '../api/dashboard-widget.api'
import { useWidgetData } from './useWidgetData'
export function useReadingStreakWidget() {
const data = ref<ReadingStreakWidgetData | null>(null)
const loading = ref(true)
const error = ref(false)
async function load() {
loading.value = true
error.value = false
try {
data.value = await fetchReadingStreak()
} catch {
error.value = true
} finally {
loading.value = false
}
}
onMounted(load)
return { data, loading, error, refresh: load }
return useWidgetData(fetchReadingStreak)
}
@@ -0,0 +1,45 @@
import { getCurrentScope, onMounted, onScopeDispose, ref, type Ref } from 'vue'
import { onAuthRecovered } from '@/lib/api'
export interface WidgetData<T> {
data: Ref<T | null>
loading: Ref<boolean>
error: Ref<boolean>
refresh: () => Promise<void>
}
/**
* Loads one dashboard widget, and reloads it if the session comes back.
*
* Fetching on mount and stopping there is what turned a few seconds of expired token into a
* dashboard of "Failed to load" tiles that only a page reload could clear: the widgets have no
* polling, and most have no retry control either. A widget that failed now retries itself once a
* request has been rejected as unauthenticated and a later refresh has recovered the session.
*/
export function useWidgetData<T>(fetcher: () => Promise<T>): WidgetData<T> {
const data = ref<T | null>(null) as Ref<T | null>
const loading = ref(true)
const error = ref(false)
async function load(): Promise<void> {
loading.value = true
error.value = false
try {
data.value = await fetcher()
} catch {
error.value = true
} finally {
loading.value = false
}
}
onMounted(load)
const stopListening = onAuthRecovered(() => {
if (error.value) void load()
})
if (getCurrentScope()) onScopeDispose(stopListening)
return { data, loading, error, refresh: load }
}
@@ -1,25 +1,6 @@
import { onMounted, ref } from 'vue'
import type { YearProjectionWidgetData } from '@bookorbit/types'
import { fetchYearProjection } from '../api/dashboard-widget.api'
import { useWidgetData } from './useWidgetData'
export function useYearProjectionWidget() {
const data = ref<YearProjectionWidgetData | null>(null)
const loading = ref(true)
const error = ref(false)
async function load() {
loading.value = true
error.value = false
try {
data.value = await fetchYearProjection()
} catch {
error.value = true
} finally {
loading.value = false
}
}
onMounted(load)
return { data, loading, error, refresh: load }
return useWidgetData(fetchYearProjection)
}
@@ -1,5 +1,5 @@
import { computed, ref } from 'vue'
import { getAccessToken } from '@/lib/api'
import { getValidToken } from '@/lib/api'
import type { UploadResult } from '@bookorbit/types'
import { useAppInfo } from '@/features/settings/composables/useAppInfo'
@@ -34,7 +34,8 @@ function validateFile(file: File): string | null {
return null
}
function uploadSingle(item: FileUploadItem, url: string): Promise<void> {
async function uploadSingle(item: FileUploadItem, url: string): Promise<void> {
const token = await getValidToken()
return new Promise((resolve) => {
const formData = new FormData()
formData.append('file', item.file)
@@ -42,7 +43,6 @@ function uploadSingle(item: FileUploadItem, url: string): Promise<void> {
const xhr = new XMLHttpRequest()
xhr.open('POST', url)
const token = getAccessToken()
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
xhr.upload.onprogress = (e) => {
@@ -1,7 +1,8 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@/lib/api', () => ({
getAccessToken: vi.fn<() => string | null>().mockReturnValue('test-token'),
getValidToken: vi.fn<() => Promise<string | null>>().mockResolvedValue('test-token'),
refreshAccessToken: vi.fn<() => Promise<string>>().mockResolvedValue('test-token'),
}))
type SocketEventHandler = (...args: unknown[]) => void
@@ -1,7 +1,7 @@
import { ref } from 'vue'
import { io, Socket } from 'socket.io-client'
import { Socket } from 'socket.io-client'
import type { MigrationProgressEvent } from '@bookorbit/types'
import { getAccessToken } from '@/lib/api'
import { createAuthenticatedSocket } from '@/lib/socket'
let socket: Socket | null = null
const progressMap = ref<Map<number, MigrationProgressEvent>>(new Map())
@@ -9,11 +9,7 @@ const subscribedRuns = new Set<number>()
function getSocket(): Socket {
if (!socket) {
socket = io('/migration', {
auth: (cb: (data: object) => void) => cb({ token: getAccessToken() }),
transports: ['websocket'],
autoConnect: true,
})
socket = createAuthenticatedSocket('/migration', { autoConnect: true })
socket.on('migration:progress', (event: MigrationProgressEvent) => {
progressMap.value.set(event.runId, event)
@@ -6,7 +6,8 @@ const mockApi = vi.fn<(...args: unknown[]) => unknown>()
const mockGetAccessToken = vi.fn<(...args: unknown[]) => unknown>().mockReturnValue('token')
vi.mock('@/lib/api', () => ({
api: (...args: unknown[]) => mockApi(...args),
getAccessToken: () => mockGetAccessToken(),
getValidToken: () => Promise.resolve(mockGetAccessToken()),
refreshAccessToken: () => Promise.resolve(mockGetAccessToken()),
}))
const mockIo = vi.fn<(...args: unknown[]) => unknown>()
@@ -1,7 +1,8 @@
import { computed, ref } from 'vue'
import { io, Socket } from 'socket.io-client'
import { Socket } from 'socket.io-client'
import { formatRelativeTime as formatLocaleRelativeTime } from '@/i18n/formatters'
import { api, getAccessToken } from '@/lib/api'
import { api } from '@/lib/api'
import { createAuthenticatedSocket } from '@/lib/socket'
import type { AchievementRarity, NotificationItem, NotificationPage } from '@bookorbit/types'
import { NotificationType } from '@bookorbit/types'
import { showAchievementToast } from '@/features/achievements/utils/achievementToast'
@@ -80,9 +81,7 @@ function requestUnreadCount(): Promise<void> {
function getSocket(): Socket {
if (!socket) {
socket = io('/notifications', {
auth: (cb: (data: object) => void) => cb({ token: getAccessToken() }),
transports: ['websocket'],
socket = createAuthenticatedSocket('/notifications', {
reconnection: true,
reconnectionDelay: 1000,
reconnectionDelayMax: 10000,
@@ -1,8 +1,8 @@
import { ref } from 'vue'
import { io, Socket } from 'socket.io-client'
import { Socket } from 'socket.io-client'
import { toast } from 'vue-sonner'
import type { CoverRefreshedEvent, CoverRefreshProgressEvent, ScanProgressEvent } from '@bookorbit/types'
import { getAccessToken } from '@/lib/api'
import { createAuthenticatedSocket } from '@/lib/socket'
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
let socket: Socket | null = null
@@ -12,12 +12,7 @@ const subscribedLibraries = new Set<number>()
function getSocket(): Socket {
if (!socket) {
// Use a callback so getAccessToken() is called at (re)connect time, not just once at creation.
socket = io('/scan', {
auth: (cb: (data: object) => void) => cb({ token: getAccessToken() }),
transports: ['websocket'],
autoConnect: true,
})
socket = createAuthenticatedSocket('/scan', { autoConnect: true })
socket.on('scan:progress', (event: ScanProgressEvent) => {
progressMap.value.set(event.libraryId, event)
@@ -1,7 +1,7 @@
import { ref } from 'vue'
import { io, Socket } from 'socket.io-client'
import { Socket } from 'socket.io-client'
import type { AuthorEnrichmentStatusEvent } from '@bookorbit/types'
import { getAccessToken } from '@/lib/api'
import { createAuthenticatedSocket } from '@/lib/socket'
const status = ref<AuthorEnrichmentStatusEvent>({
queued: 0,
@@ -22,9 +22,7 @@ let socket: Socket | null = null
function getSocket(): Socket {
if (!socket) {
socket = io('/authors-enrichment', {
auth: (cb: (data: object) => void) => cb({ token: getAccessToken() }),
transports: ['websocket'],
socket = createAuthenticatedSocket('/authors-enrichment', {
reconnection: true,
reconnectionDelay: 1000,
reconnectionDelayMax: 10000,
+175 -1
View File
@@ -1,6 +1,19 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { api, refreshAccessToken, setAccessToken, setOnAuthFailure } from '@/lib/api'
import { api, getValidToken, onAuthRecovered, refreshAccessToken, setAccessToken, setOnAuthFailure } from '@/lib/api'
/** A token shaped like a real JWT, so the client can read `exp` out of it. Never verified here. */
function signedToken(expiresInSeconds: number): string {
const payload = btoa(JSON.stringify({ sub: 1, ver: 1, exp: Math.floor(Date.now() / 1000) + expiresInSeconds }))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
return `header.${payload}.signature`
}
function urlOf(input: RequestInfo | URL): string {
return typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
}
describe('api wrapper', () => {
const originalFetch = globalThis.fetch
@@ -85,4 +98,165 @@ describe('api wrapper', () => {
await expect(api('/api/v1/books/1')).rejects.toThrow('Session expired')
expect(onFail).toHaveBeenCalledTimes(1)
})
describe('expiry awareness', () => {
it('renews an expired token before the request, so the server never sees it', async () => {
const calls: { url: string; auth: string | null }[] = []
const fetchMock = vi.fn<typeof fetch>((input: RequestInfo | URL, init?: RequestInit) => {
const url = urlOf(input)
calls.push({ url, auth: new Headers(init?.headers).get('Authorization') })
if (url.endsWith('/api/v1/auth/refresh')) {
return Promise.resolve(new Response(JSON.stringify({ accessToken: signedToken(900) }), { status: 200 }))
}
return Promise.resolve(new Response(JSON.stringify({ ok: true }), { status: 200 }))
})
globalThis.fetch = fetchMock as never
setAccessToken(signedToken(-60))
const res = await api('/api/v1/dashboard/widgets/reading-streak')
expect(res.status).toBe(200)
// Refresh first, then the request. No 401 round trip, so no widget ever sees a failure.
expect(calls.map((c) => c.url)).toEqual(['/api/v1/auth/refresh', '/api/v1/dashboard/widgets/reading-streak'])
expect(calls[1]!.auth).not.toBe(`Bearer ${calls[0]!.auth}`)
})
it('renews a token that is valid but about to expire', async () => {
const urls: string[] = []
const fetchMock = vi.fn<typeof fetch>((input: RequestInfo | URL) => {
urls.push(urlOf(input))
if (urlOf(input).endsWith('/api/v1/auth/refresh')) {
return Promise.resolve(new Response(JSON.stringify({ accessToken: signedToken(900) }), { status: 200 }))
}
return Promise.resolve(new Response('', { status: 200 }))
})
globalThis.fetch = fetchMock as never
// Inside the 30s skew: still valid, not worth sending.
setAccessToken(signedToken(5))
await api('/api/v1/books/1')
expect(urls).toEqual(['/api/v1/auth/refresh', '/api/v1/books/1'])
})
it('leaves a token with plenty of life alone', async () => {
const urls: string[] = []
const fetchMock = vi.fn<typeof fetch>((input: RequestInfo | URL) => {
urls.push(urlOf(input))
return Promise.resolve(new Response('', { status: 200 }))
})
globalThis.fetch = fetchMock as never
setAccessToken(signedToken(900))
await api('/api/v1/books/1')
expect(urls).toEqual(['/api/v1/books/1'])
})
it('treats an undecodable token as unknown expiry and lets the 401 path decide', async () => {
const urls: string[] = []
let refreshed = false
const fetchMock = vi.fn<typeof fetch>((input: RequestInfo | URL) => {
const url = urlOf(input)
urls.push(url)
if (url.endsWith('/api/v1/auth/refresh')) {
refreshed = true
return Promise.resolve(new Response(JSON.stringify({ accessToken: signedToken(900) }), { status: 200 }))
}
return Promise.resolve(new Response('', { status: refreshed ? 200 : 401 }))
})
globalThis.fetch = fetchMock as never
setAccessToken('not-a-jwt')
await api('/api/v1/books/1')
expect(urls).toEqual(['/api/v1/books/1', '/api/v1/auth/refresh', '/api/v1/books/1'])
})
it('getValidToken renews a stale token for websocket handshakes and uploads', async () => {
const fresh = signedToken(900)
globalThis.fetch = vi.fn<typeof fetch>((input: RequestInfo | URL) => {
if (urlOf(input).endsWith('/api/v1/auth/refresh')) {
return Promise.resolve(new Response(JSON.stringify({ accessToken: fresh }), { status: 200 }))
}
return Promise.resolve(new Response('', { status: 200 }))
}) as never
setAccessToken(signedToken(-60))
await expect(getValidToken()).resolves.toBe(fresh)
})
it('getValidToken reports no token rather than refreshing when nobody is signed in', async () => {
const fetchMock = vi.fn<typeof fetch>(() => Promise.resolve(new Response('', { status: 200 })))
globalThis.fetch = fetchMock as never
setAccessToken(null)
await expect(getValidToken()).resolves.toBeNull()
expect(fetchMock).not.toHaveBeenCalled()
})
it('backs off after a failed proactive refresh instead of retrying on every call', async () => {
const stale = signedToken(-60)
const fetchMock = vi.fn<typeof fetch>(() => Promise.resolve(new Response('', { status: 401 })))
globalThis.fetch = fetchMock as never
setAccessToken(stale)
// The refresh fails, so the caller is handed the stale token to try anyway.
await expect(getValidToken()).resolves.toBe(stale)
expect(fetchMock).toHaveBeenCalledTimes(1)
await expect(getValidToken()).resolves.toBe(stale)
expect(fetchMock).toHaveBeenCalledTimes(1)
})
})
describe('auth recovery signal', () => {
it('fires once a rejected request is followed by a successful refresh', async () => {
let refreshed = false
globalThis.fetch = vi.fn<typeof fetch>((input: RequestInfo | URL) => {
if (urlOf(input).endsWith('/api/v1/auth/refresh')) {
refreshed = true
return Promise.resolve(new Response(JSON.stringify({ accessToken: signedToken(900) }), { status: 200 }))
}
return Promise.resolve(new Response('', { status: refreshed ? 200 : 401 }))
}) as never
const recovered = vi.fn<() => void>()
const stop = onAuthRecovered(recovered)
await api('/api/v1/books/1')
stop()
expect(recovered).toHaveBeenCalledTimes(1)
})
it('stays quiet for a routine refresh, so healthy views do not reload', async () => {
globalThis.fetch = vi.fn<typeof fetch>(() =>
Promise.resolve(new Response(JSON.stringify({ accessToken: signedToken(900) }), { status: 200 })),
) as never
const recovered = vi.fn<() => void>()
const stop = onAuthRecovered(recovered)
await refreshAccessToken()
stop()
expect(recovered).not.toHaveBeenCalled()
})
it('stops notifying a listener that has unsubscribed', async () => {
let refreshed = false
globalThis.fetch = vi.fn<typeof fetch>((input: RequestInfo | URL) => {
if (urlOf(input).endsWith('/api/v1/auth/refresh')) {
refreshed = true
return Promise.resolve(new Response(JSON.stringify({ accessToken: signedToken(900) }), { status: 200 }))
}
return Promise.resolve(new Response('', { status: refreshed ? 200 : 401 }))
}) as never
const recovered = vi.fn<() => void>()
onAuthRecovered(recovered)()
await api('/api/v1/books/1')
expect(recovered).not.toHaveBeenCalled()
})
})
})
+172
View File
@@ -0,0 +1,172 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createAuthenticatedSocket } from '@/lib/socket'
import { getValidToken, refreshAccessToken } from '@/lib/api'
const listeners = new Map<string, ((...args: unknown[]) => void)[]>()
const connect = vi.fn<() => void>()
const ioOptions: Record<string, unknown>[] = []
const fakeSocket = {
on(event: string, handler: (...args: unknown[]) => void) {
const existing = listeners.get(event) ?? []
existing.push(handler)
listeners.set(event, existing)
return fakeSocket
},
connect,
}
vi.mock('socket.io-client', () => ({
io: (_namespace: string, options: Record<string, unknown>) => {
ioOptions.push(options)
return fakeSocket
},
}))
vi.mock('@/lib/api', () => ({
getValidToken: vi.fn<() => Promise<string | null>>(),
refreshAccessToken: vi.fn<() => Promise<string>>(),
}))
function fire(event: string, ...args: unknown[]): void {
for (const handler of listeners.get(event) ?? []) handler(...args)
}
/** Rejection as the gateways perform it: tell the client why, then close the connection. */
function rejectHandshake(): void {
fire('unauthorized', { reason: 'invalid_token' })
fire('disconnect', 'io server disconnect')
}
describe('createAuthenticatedSocket', () => {
beforeEach(() => {
vi.useFakeTimers()
listeners.clear()
ioOptions.length = 0
connect.mockClear()
vi.mocked(getValidToken).mockResolvedValue('fresh-token')
vi.mocked(refreshAccessToken).mockResolvedValue('fresh-token')
})
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
it('resolves the token per handshake instead of capturing it at creation', async () => {
createAuthenticatedSocket('/notifications')
const auth = ioOptions[0]!.auth as (cb: (data: object) => void) => void
const handshake = vi.fn<(data: object) => void>()
auth(handshake)
await vi.waitFor(() => expect(handshake).toHaveBeenCalled())
expect(getValidToken).toHaveBeenCalled()
expect(handshake).toHaveBeenCalledWith({ token: 'fresh-token' })
})
it('re-authenticates and reconnects after the server rejects the handshake', async () => {
createAuthenticatedSocket('/book-dock')
rejectHandshake()
expect(connect).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1_000)
expect(refreshAccessToken).toHaveBeenCalledTimes(1)
expect(connect).toHaveBeenCalledTimes(1)
})
it('stays down when the server closed the socket without inviting a retry', async () => {
createAuthenticatedSocket('/book-dock')
// A permission denial closes the socket silently: a fresh token would be refused the same way.
fire('disconnect', 'io server disconnect')
await vi.advanceTimersByTimeAsync(60_000)
expect(refreshAccessToken).not.toHaveBeenCalled()
expect(connect).not.toHaveBeenCalled()
})
it('leaves every other disconnect reason to socket.io', async () => {
createAuthenticatedSocket('/scan')
fire('disconnect', 'transport close')
fire('disconnect', 'ping timeout')
await vi.advanceTimersByTimeAsync(60_000)
expect(connect).not.toHaveBeenCalled()
})
it('backs off exponentially across repeated rejections', async () => {
createAuthenticatedSocket('/notifications')
rejectHandshake()
await vi.advanceTimersByTimeAsync(1_000)
expect(connect).toHaveBeenCalledTimes(1)
rejectHandshake()
await vi.advanceTimersByTimeAsync(1_000)
expect(connect).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1_000)
expect(connect).toHaveBeenCalledTimes(2)
})
it('gives up rather than hammering a server that keeps rejecting', async () => {
createAuthenticatedSocket('/notifications')
for (let attempt = 0; attempt < 10; attempt++) {
rejectHandshake()
await vi.advanceTimersByTimeAsync(60_000)
}
expect(connect).toHaveBeenCalledTimes(6)
})
it('retries when the refresh itself fails, so a brief outage is not terminal', async () => {
vi.mocked(refreshAccessToken).mockRejectedValueOnce(new Error('refresh failed'))
createAuthenticatedSocket('/book-dock')
rejectHandshake()
await vi.advanceTimersByTimeAsync(1_000)
expect(connect).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(2_000)
expect(refreshAccessToken).toHaveBeenCalledTimes(2)
expect(connect).toHaveBeenCalledTimes(1)
})
it('forgets the backoff once a connection has held', async () => {
createAuthenticatedSocket('/notifications')
rejectHandshake()
await vi.advanceTimersByTimeAsync(1_000)
expect(connect).toHaveBeenCalledTimes(1)
fire('connect')
await vi.advanceTimersByTimeAsync(5_000)
// A healthy connection resets the ladder, so the next rejection waits 1s again rather than 2s.
rejectHandshake()
await vi.advanceTimersByTimeAsync(1_000)
expect(connect).toHaveBeenCalledTimes(2)
})
it('does not reset the backoff for a connection the server closes immediately', async () => {
createAuthenticatedSocket('/notifications')
rejectHandshake()
await vi.advanceTimersByTimeAsync(1_000)
expect(connect).toHaveBeenCalledTimes(1)
// The namespace accepts, then handleConnection rejects: `connect` fires before the close.
fire('connect')
rejectHandshake()
await vi.advanceTimersByTimeAsync(1_000)
expect(connect).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1_000)
expect(connect).toHaveBeenCalledTimes(2)
})
})
+83 -1
View File
@@ -1,17 +1,93 @@
import type { RefreshResponse } from '@bookorbit/types'
/**
* Refresh this far ahead of `exp`. A token that survives the check still has to travel, reach a
* gateway and be verified there, so "not expired yet" is not the same as "safe to hand out".
*/
const EXPIRY_SKEW_MS = 30_000
/** After a failed proactive refresh, stop trying for this long. Reactive 401 refreshes ignore it. */
const REFRESH_COOLDOWN_MS = 5_000
let _accessToken: string | null = null
let _accessTokenExpiresAt: number | null = null
let _onAuthFailure: (() => void) | null = null
let _refreshPromise: Promise<string> | null = null
let _refreshCooldownUntil = 0
let _sawAuthFailure = false
const _authRecoveryListeners = new Set<() => void>()
/**
* Reads `exp` out of a JWT without verifying it. The signature is the server's business; the client
* only needs to know when the token stops being worth sending. Anything unparseable reports `null`,
* which is treated as "expiry unknown" and leaves the 401 path in charge.
*/
function readExpiry(token: string | null): number | null {
const payload = token?.split('.')[1]
if (!payload) return null
try {
const base64 = payload.replace(/-/g, '+').replace(/_/g, '/')
const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '=')
const exp: unknown = (JSON.parse(atob(padded)) as { exp?: unknown }).exp
return typeof exp === 'number' ? exp * 1000 : null
} catch {
return null
}
}
export function setAccessToken(token: string | null): void {
_accessToken = token
_accessTokenExpiresAt = readExpiry(token)
_sawAuthFailure = false
_refreshCooldownUntil = 0
}
export function getAccessToken(): string | null {
return _accessToken
}
/** A token with an unknown expiry is never stale: only a decoded `exp` can condemn it. */
function isTokenStale(): boolean {
if (_accessTokenExpiresAt === null) return false
return Date.now() >= _accessTokenExpiresAt - EXPIRY_SKEW_MS
}
/**
* Renews an expired token before it is used, so callers stop discovering the expiry one 401 at a
* time. Deliberately silent: a proactive refresh that fails changes nothing, and the 401 path still
* decides whether the session is actually gone.
*/
async function ensureFreshToken(): Promise<void> {
if (!_accessToken || !isTokenStale()) return
if (Date.now() < _refreshCooldownUntil) return
try {
await refreshAccessToken()
} catch {
_refreshCooldownUntil = Date.now() + REFRESH_COOLDOWN_MS
}
}
/**
* The token every non-`api()` consumer should use: websocket handshakes, upload requests, the
* reader's token fetcher. `getAccessToken()` returns whatever is in memory, which after a sleeping
* tab wakes up is routinely a token the server will reject.
*/
export async function getValidToken(): Promise<string | null> {
await ensureFreshToken()
return _accessToken
}
/**
* Fires after a request was rejected as unauthenticated and a later refresh got the session back.
* Views that gave up during the outage use it to reload; a routine refresh does not fire it.
*/
export function onAuthRecovered(listener: () => void): () => void {
_authRecoveryListeners.add(listener)
return () => {
_authRecoveryListeners.delete(listener)
}
}
export function setOnAuthFailure(fn: () => void): void {
_onAuthFailure = fn
}
@@ -26,7 +102,11 @@ async function attemptRefresh(): Promise<string> {
const res = await rawFetch('/api/v1/auth/refresh', { method: 'POST' })
if (!res.ok) throw new Error('refresh failed')
const data: RefreshResponse = await res.json()
_accessToken = data.accessToken
const recovered = _sawAuthFailure
setAccessToken(data.accessToken)
if (recovered) {
for (const listener of _authRecoveryListeners) listener()
}
return data.accessToken
}
@@ -42,9 +122,11 @@ export async function refreshAccessToken(): Promise<string> {
export async function api(input: RequestInfo | URL, init?: RequestInit & { _isRetry?: boolean }): Promise<Response> {
const isRetry = init?._isRetry
const { _isRetry: _, ...rest } = init ?? {}
if (!isRetry) await ensureFreshToken()
const res = await rawFetch(input, rest)
if (res.status !== 401) return res
_sawAuthFailure = true
if (isRetry) {
_onAuthFailure?.()
+85
View File
@@ -0,0 +1,85 @@
import { io, type ManagerOptions, type Socket, type SocketOptions } from 'socket.io-client'
import { getValidToken, refreshAccessToken } from '@/lib/api'
const REAUTH_BASE_DELAY_MS = 1_000
const REAUTH_MAX_DELAY_MS = 30_000
const REAUTH_MAX_ATTEMPTS = 6
/** A connection that lasts this long counts as healthy, so the next rejection starts a fresh backoff. */
const STABLE_CONNECTION_MS = 5_000
/**
* A gateway that rejects a handshake over the token answers with `unauthorized` and then disconnects.
* socket.io reports that as `io server disconnect` and, by contract, never reconnects from it: the
* namespace stays dead for the rest of the page's life unless something calls `connect()` again.
* That is the difference between "one stale token" and "no live updates until you reload", so every
* authenticated namespace goes through here rather than calling `io()` directly.
*
* Only `unauthorized` earns a retry. A gateway that closes the socket without it turned the user
* away for a reason a new token will not change, such as a missing permission, and reconnecting
* would just repeat the rejection.
*/
export function createAuthenticatedSocket(namespace: string, options: Partial<ManagerOptions & SocketOptions> = {}): Socket {
const socket = io(namespace, {
transports: ['websocket'],
...options,
// Resolved per handshake, and awaited: a woken tab holds a token the server already rejects.
auth: (cb: (data: object) => void) => {
void getValidToken().then((token) => cb({ token }))
},
})
let attempts = 0
let retryTimer: ReturnType<typeof setTimeout> | null = null
let stableTimer: ReturnType<typeof setTimeout> | null = null
let rejectedForAuth = false
function clearStableTimer(): void {
if (stableTimer === null) return
clearTimeout(stableTimer)
stableTimer = null
}
function scheduleReauth(): void {
if (retryTimer !== null || attempts >= REAUTH_MAX_ATTEMPTS) return
const delay = Math.min(REAUTH_BASE_DELAY_MS * 2 ** attempts, REAUTH_MAX_DELAY_MS)
attempts += 1
retryTimer = setTimeout(() => {
retryTimer = null
void refreshAccessToken().then(
() => socket.connect(),
// A refresh that fails here may be the session ending or the server being briefly
// unreachable. Back off and try again; `api()` owns the redirect to login either way.
() => scheduleReauth(),
)
}, delay)
}
socket.on('connect', () => {
if (retryTimer !== null) {
clearTimeout(retryTimer)
retryTimer = null
}
clearStableTimer()
stableTimer = setTimeout(() => {
attempts = 0
stableTimer = null
}, STABLE_CONNECTION_MS)
})
socket.on('unauthorized', () => {
rejectedForAuth = true
})
socket.on('disconnect', (reason: string) => {
clearStableTimer()
const needsReauth = rejectedForAuth
rejectedForAuth = false
// Every other reason is socket.io's own to retry, and it already does.
if (reason !== 'io server disconnect' || !needsReauth) return
scheduleReauth()
})
return socket
}
@@ -0,0 +1,67 @@
import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common';
import type { Socket } from 'socket.io';
import { rejectSocketConnection, WS_UNAUTHORIZED_EVENT } from './ws-auth.utils';
function makeClient() {
const order: string[] = [];
return {
emit: vi.fn(() => order.push('emit')),
disconnect: vi.fn(() => order.push('disconnect')),
order,
};
}
/** jsonwebtoken's errors, reproduced by shape so the test does not depend on the package. */
function jwtError(name: 'TokenExpiredError' | 'JsonWebTokenError' | 'NotBeforeError', message: string): Error {
const error = new Error(message);
error.name = name;
return error;
}
describe('rejectSocketConnection', () => {
it.each(['TokenExpiredError', 'JsonWebTokenError', 'NotBeforeError'] as const)('invites a retry when JwtService rejects with %s', (name) => {
const client = makeClient();
rejectSocketConnection(client as unknown as Socket, jwtError(name, 'jwt expired'));
expect(client.emit).toHaveBeenCalledWith(WS_UNAUTHORIZED_EVENT, { reason: 'invalid_token' });
expect(client.disconnect).toHaveBeenCalledTimes(1);
});
it('tells the client to re-authenticate when the token was the problem', () => {
const client = makeClient();
rejectSocketConnection(client as unknown as Socket, new UnauthorizedException('jwt expired'));
expect(client.emit).toHaveBeenCalledWith(WS_UNAUTHORIZED_EVENT, { reason: 'invalid_token' });
expect(client.disconnect).toHaveBeenCalledTimes(1);
});
it('sends the reason before closing, or the client never receives it', () => {
const client = makeClient();
rejectSocketConnection(client as unknown as Socket, new UnauthorizedException('no token'));
expect(client.order).toEqual(['emit', 'disconnect']);
});
it('closes silently on a permission denial, which a fresh token would not fix', () => {
const client = makeClient();
rejectSocketConnection(client as unknown as Socket, new ForbiddenException('Missing permission: notification_access'));
expect(client.emit).not.toHaveBeenCalled();
expect(client.disconnect).toHaveBeenCalledTimes(1);
});
it('closes silently on an unexpected failure rather than inviting a reconnect loop', () => {
const client = makeClient();
rejectSocketConnection(client as unknown as Socket, new BadRequestException('boom'));
rejectSocketConnection(client as unknown as Socket, new Error('database unavailable'));
expect(client.emit).not.toHaveBeenCalled();
expect(client.disconnect).toHaveBeenCalledTimes(2);
});
});
+35
View File
@@ -0,0 +1,35 @@
import { UnauthorizedException } from '@nestjs/common';
import type { Socket } from 'socket.io';
export const WS_UNAUTHORIZED_EVENT = 'unauthorized';
/**
* `JwtService.verify` rejects with jsonwebtoken's own error types rather than an HTTP exception, and
* `TokenExpiredError` is the single most common way a handshake fails: the tab slept through the
* token's lifetime. Matched by name so this stays independent of the jsonwebtoken package.
*/
const JWT_FAILURE_NAMES = new Set(['TokenExpiredError', 'JsonWebTokenError', 'NotBeforeError']);
function isAuthenticationFailure(cause: unknown): boolean {
if (cause instanceof UnauthorizedException) return true;
return cause instanceof Error && JWT_FAILURE_NAMES.has(cause.name);
}
/**
* Closes a socket whose handshake was refused, telling the client first when a retry could work.
*
* socket.io reports a server-initiated close as `io server disconnect` and, by contract, never
* reconnects from it. Without a signal the client cannot tell "your access token went stale while
* the tab slept, get a fresh one and come back" apart from any other refusal, so it either gives up
* on live updates until the page is reloaded or reconnects in a loop with the same dead token.
*
* Only an authentication failure is worth another handshake. A permission denial, or anything
* unexpected, closes the socket silently: a fresh token would be refused in exactly the same way.
* The payload stays vague because the client's only useful response is to re-authenticate.
*/
export function rejectSocketConnection(client: Socket, cause: unknown): void {
if (isAuthenticationFailure(cause)) {
client.emit(WS_UNAUTHORIZED_EVENT, { reason: 'invalid_token' });
}
client.disconnect();
}
@@ -1,6 +1,7 @@
import { AuthorEnrichmentGateway } from './author-enrichment.gateway';
import { AUTHOR_ENRICHMENT_STATUS_EVENT } from './author-enrichment.gateway';
import { Permission } from '@bookorbit/types';
import { WS_UNAUTHORIZED_EVENT } from '../../common/utils/ws-auth.utils';
function makeGateway() {
const jwtService = { verify: vi.fn() };
@@ -111,7 +112,8 @@ describe('AuthorEnrichmentGateway', () => {
await gateway.handleConnection(client);
expect(client.disconnect).toHaveBeenCalledTimes(1);
expect(client.emit).not.toHaveBeenCalled();
// Told to re-authenticate, but given no status snapshot.
expect(client.emit).toHaveBeenCalledExactlyOnceWith(WS_UNAUTHORIZED_EVENT, { reason: 'invalid_token' });
});
it('handleConnection emits snapshot when user has required permission', async () => {
@@ -1,4 +1,4 @@
import { Logger } from '@nestjs/common';
import { ForbiddenException, Logger, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
@@ -11,6 +11,7 @@ import { AuthService } from '../auth/auth.service';
import { AuthorEnrichmentRepository } from './author-enrichment.repository';
import { AuthorEnrichmentSessionService } from './author-enrichment-session.service';
import { AuthorEnrichmentConfigService } from './author-enrichment-config.service';
import { rejectSocketConnection } from '../../common/utils/ws-auth.utils';
export const AUTHOR_ENRICHMENT_STATUS_EVENT = 'author-enrichment:status';
@@ -44,11 +45,11 @@ export class AuthorEnrichmentGateway implements OnGatewayInit, OnGatewayConnecti
const event = 'author.enrichment.socket';
try {
const token = client.handshake.auth?.token as string | undefined;
if (!token) throw new Error('No token provided');
if (!token) throw new UnauthorizedException('No token provided');
const payload = this.jwtService.verify<{ sub: number; ver: number }>(token, { algorithms: ['HS256'] });
const user = await this.authService.validateUser(payload.sub, payload.ver);
if (!user) throw new Error('User not found or token revoked');
if (!user) throw new UnauthorizedException('User not found or token revoked');
this.assertCanViewStatus(user);
this.logger.debug(`[${event}] [start] userId=${user.id} socketId=${client.id} - websocket connected`);
@@ -58,7 +59,7 @@ export class AuthorEnrichmentGateway implements OnGatewayInit, OnGatewayConnecti
const errorClass = err instanceof Error ? err.name : 'Error';
const message = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(`[${event}] [fail] socketId=${client.id} errorClass=${errorClass} error="${message}" - websocket rejected`);
client.disconnect();
rejectSocketConnection(client, err);
}
}
@@ -74,6 +75,6 @@ export class AuthorEnrichmentGateway implements OnGatewayInit, OnGatewayConnecti
if (user.isSuperuser) return;
if (user.permissions.includes(Permission.ManageAppSettings)) return;
if (user.permissions.includes(Permission.ManageMetadataConfig)) return;
throw new Error('Missing permission: manage_app_settings or manage_metadata_config');
throw new ForbiddenException('Missing permission: manage_app_settings or manage_metadata_config');
}
}
@@ -14,6 +14,7 @@ describe('BookDockGateway', () => {
id: 'socket-1',
handshake: { auth: {} },
data: {},
emit: vi.fn(),
disconnect: vi.fn(),
} as any;
@@ -30,6 +31,7 @@ describe('BookDockGateway', () => {
id: 'socket-2',
handshake: { auth: { token: 'jwt' } },
data: {},
emit: vi.fn(),
disconnect: vi.fn(),
} as any;
@@ -48,6 +50,7 @@ describe('BookDockGateway', () => {
id: 'socket-3',
handshake: { auth: { token: 'jwt' } },
data: {},
emit: vi.fn(),
disconnect: vi.fn(),
} as any;
@@ -1,10 +1,11 @@
import { Logger } from '@nestjs/common';
import { Logger, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { OnGatewayConnection, OnGatewayDisconnect, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import type { BookDockSummary } from '@bookorbit/types';
import { AuthService } from '../auth/auth.service';
import { rejectSocketConnection } from '../../common/utils/ws-auth.utils';
@WebSocketGateway({ namespace: '/book-dock', cors: { origin: process.env.CLIENT_URL ?? 'http://localhost:5173' } })
export class BookDockGateway implements OnGatewayConnection, OnGatewayDisconnect {
@@ -19,15 +20,15 @@ export class BookDockGateway implements OnGatewayConnection, OnGatewayDisconnect
async handleConnection(client: Socket): Promise<void> {
try {
const token = client.handshake.auth?.token as string | undefined;
if (!token) throw new Error('No token provided');
if (!token) throw new UnauthorizedException('No token provided');
const payload = this.jwtService.verify<{ sub: number; ver: number }>(token, { algorithms: ['HS256'] });
const user = await this.authService.validateUser(payload.sub, payload.ver);
if (!user) throw new Error('User not found or token revoked');
if (!user) throw new UnauthorizedException('User not found or token revoked');
(client.data as Record<string, unknown>).user = user;
this.logger.debug(`WS connected: user=${user.id} socket=${client.id}`);
} catch (err) {
this.logger.warn(`WS rejected: ${(err as Error).message} socket=${client.id}`);
client.disconnect();
rejectSocketConnection(client, err);
}
}
@@ -9,6 +9,7 @@ import { AuthService } from '../auth/auth.service';
import { BookMetadataFetchQueueRepository } from './book-metadata-fetch-queue.repository';
import { BookMetadataFetchConfigService } from './book-metadata-fetch-config.service';
import { BookMetadataFetchSessionService } from './book-metadata-fetch-session.service';
import { rejectSocketConnection } from '../../common/utils/ws-auth.utils';
export const BOOK_METADATA_FETCH_STATUS_EVENT = 'book-metadata-fetch:status';
@@ -42,7 +43,7 @@ export class BookMetadataFetchGateway implements OnGatewayConnection, OnGatewayD
client.emit(BOOK_METADATA_FETCH_STATUS_EVENT, { ...summary, paused, ...this.session.getSnapshot() } satisfies BookMetadataFetchStatusEvent);
} catch (err) {
this.logger.warn(`WS rejected: ${(err as Error).message} socket=${client.id}`);
client.disconnect();
rejectSocketConnection(client, err);
}
}
@@ -48,6 +48,7 @@ function makeClient() {
id: 'socket-1',
handshake: { auth: { token: 'token-1' } },
data: {},
emit: vi.fn(),
disconnect: vi.fn(),
join: vi.fn(),
emit: vi.fn(),
@@ -1,4 +1,4 @@
import { Logger } from '@nestjs/common';
import { ForbiddenException, Logger, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, SubscribeMessage, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
@@ -10,6 +10,7 @@ import type { RequestUser } from '../../common/types/request-user';
import { AuthService } from '../auth/auth.service';
import { MigrationRepository } from './migration.repository';
import { sanitizeRunForApi } from './core/api-sanitizers';
import { rejectSocketConnection } from '../../common/utils/ws-auth.utils';
@WebSocketGateway({ namespace: '/migration', cors: { credentials: true } })
export class MigrationProgressGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
@@ -38,10 +39,10 @@ export class MigrationProgressGateway implements OnGatewayInit, OnGatewayConnect
async handleConnection(client: Socket): Promise<void> {
try {
const token = client.handshake.auth?.token as string | undefined;
if (!token) throw new Error('No token provided');
if (!token) throw new UnauthorizedException('No token provided');
const payload = this.jwtService.verify<{ sub: number; ver: number }>(token, { algorithms: ['HS256'] });
const user = await this.authService.validateUser(payload.sub, payload.ver);
if (!user) throw new Error('User not found or token revoked');
if (!user) throw new UnauthorizedException('User not found or token revoked');
this.assertCanViewMigrationProgress(user);
(client.data as Record<string, unknown>).user = user;
this.logger.debug(`[migration.ws_connection] [start] userId=${user.id} socketId=${client.id} - websocket connected`);
@@ -49,7 +50,7 @@ export class MigrationProgressGateway implements OnGatewayInit, OnGatewayConnect
this.logger.warn(
`[migration.ws_connection] [fail] socketId=${client.id} errorClass=${err instanceof Error ? err.name : 'Error'} error="${sanitizeLogValue(err instanceof Error ? err.message : String(err))}" - websocket rejected`,
);
client.disconnect();
rejectSocketConnection(client, err);
}
}
@@ -105,6 +106,6 @@ export class MigrationProgressGateway implements OnGatewayInit, OnGatewayConnect
private assertCanViewMigrationProgress(user: RequestUser): void {
if (user.isSuperuser) return;
if (user.permissions.includes(Permission.ManageAppSettings)) return;
throw new Error('Missing permission: manage_app_settings');
throw new ForbiddenException('Missing permission: manage_app_settings');
}
}
@@ -1,4 +1,5 @@
import { Permission } from '@bookorbit/types';
import { WS_UNAUTHORIZED_EVENT } from '../../common/utils/ws-auth.utils';
import type { RequestUser } from '../../common/types/request-user';
import { NotificationGateway } from './notification.gateway';
@@ -64,3 +65,50 @@ describe('NotificationGateway', () => {
expect(() => (gateway as any).assertHasAccess(user)).toThrow('Missing permission: notification_access');
});
});
/** What `JwtService.verify` throws for a token that outlived a sleeping tab. */
function expiredTokenError(): Error {
const error = new Error('jwt expired');
error.name = 'TokenExpiredError';
return error;
}
describe('NotificationGateway handshake rejection', () => {
function makeConnectingGateway(overrides: { verify?: () => unknown; user?: RequestUser | null } = {}) {
const jwtService = {
verify:
overrides.verify ??
vi.fn(() => {
throw expiredTokenError();
}),
};
const userService = { findByIdWithPermissions: vi.fn().mockResolvedValue(overrides.user ?? null) };
const notificationRepo = { countUnread: vi.fn().mockResolvedValue(0) };
const config = { get: vi.fn().mockReturnValue('http://localhost:5173') };
const gateway = new NotificationGateway(jwtService as never, userService as never, notificationRepo as never, config as never);
const client = { id: 'sock-1', handshake: { auth: { token: 'jwt' } }, data: {}, join: vi.fn(), emit: vi.fn(), disconnect: vi.fn() };
return { gateway, client };
}
it('invites the client to re-authenticate when the token has expired', async () => {
const { gateway, client } = makeConnectingGateway();
await gateway.handleConnection(client as never);
expect(client.emit).toHaveBeenCalledWith(WS_UNAUTHORIZED_EVENT, { reason: 'invalid_token' });
expect(client.disconnect).toHaveBeenCalledTimes(1);
});
it('closes without an invitation when the user simply lacks notification access', async () => {
const { gateway, client } = makeConnectingGateway({
verify: vi.fn(() => ({ sub: 7, ver: 1 })),
user: makeUser({ permissions: [] }),
});
await gateway.handleConnection(client as never);
// A new token would be refused the same way, so the client must not retry.
expect(client.emit).not.toHaveBeenCalled();
expect(client.disconnect).toHaveBeenCalledTimes(1);
});
});
@@ -1,4 +1,4 @@
import { Logger } from '@nestjs/common';
import { ForbiddenException, Logger, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
@@ -10,6 +10,7 @@ import type { RequestUser } from '../../common/types/request-user';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { UserService } from '../user/user.service';
import { NotificationRepository } from './notification.repository';
import { rejectSocketConnection } from '../../common/utils/ws-auth.utils';
@WebSocketGateway({ namespace: '/notifications', cors: { credentials: true } })
export class NotificationGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
@@ -39,12 +40,12 @@ export class NotificationGateway implements OnGatewayInit, OnGatewayConnection,
const event = 'notification.socket';
try {
const token = client.handshake.auth?.token as string | undefined;
if (!token) throw new Error('No token provided');
if (!token) throw new UnauthorizedException('No token provided');
const payload = this.jwtService.verify<{ sub: number; ver: number }>(token, { algorithms: ['HS256'] });
const user = await this.userService.findByIdWithPermissions(payload.sub);
if (!user || !user.active) throw new Error('User not found or inactive');
if (user.tokenVersion !== payload.ver) throw new Error('Token revoked');
if (!user || !user.active) throw new UnauthorizedException('User not found or inactive');
if (user.tokenVersion !== payload.ver) throw new UnauthorizedException('Token revoked');
this.assertHasAccess(user);
@@ -57,7 +58,7 @@ export class NotificationGateway implements OnGatewayInit, OnGatewayConnection,
const errorClass = err instanceof Error ? err.name : 'Error';
const message = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(`[${event}] [fail] socketId=${client.id} errorClass=${errorClass} error="${message}" - websocket rejected`);
client.disconnect();
rejectSocketConnection(client, err);
}
}
@@ -91,10 +92,10 @@ export class NotificationGateway implements OnGatewayInit, OnGatewayConnection,
private assertHasAccess(user: RequestUser): void {
if (user.permissions.includes(Permission.DemoRestricted)) {
throw new Error('Demo-restricted account cannot access notifications');
throw new ForbiddenException('Demo-restricted account cannot access notifications');
}
if (user.isSuperuser) return;
if (user.permissions.includes(Permission.NotificationAccess)) return;
throw new Error('Missing permission: notification_access');
throw new ForbiddenException('Missing permission: notification_access');
}
}
@@ -103,6 +103,7 @@ describe('handleConnection', () => {
data: {},
disconnect,
join,
emit: vi.fn(),
} as any;
await gateway.handleConnection(client);
@@ -120,11 +121,13 @@ describe('handleConnection', () => {
throw new Error('bad token');
});
const disconnect = vi.fn();
const emit = vi.fn();
const client = {
id: 'sock-2',
handshake: { auth: {} },
data: {},
disconnect,
emit,
} as any;
await gateway.handleConnection(client);
+5 -4
View File
@@ -1,4 +1,4 @@
import { Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Logger, OnModuleDestroy, OnModuleInit, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, SubscribeMessage, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
@@ -23,6 +23,7 @@ import {
type BookProgressChangedPayload,
} from '../achievement/achievement-events.service';
import { ScanJobStore } from './scan-job-store.service';
import { rejectSocketConnection } from '../../common/utils/ws-auth.utils';
@WebSocketGateway({ namespace: '/scan', cors: { credentials: true } })
export class ScanGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect, OnModuleInit, OnModuleDestroy {
@@ -63,10 +64,10 @@ export class ScanGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
async handleConnection(client: Socket): Promise<void> {
try {
const token = client.handshake.auth?.token as string | undefined;
if (!token) throw new Error('No token provided');
if (!token) throw new UnauthorizedException('No token provided');
const payload = this.jwtService.verify<{ sub: number; ver: number }>(token, { algorithms: ['HS256'] });
const user = await this.authService.validateUser(payload.sub, payload.ver);
if (!user) throw new Error('User not found or token revoked');
if (!user) throw new UnauthorizedException('User not found or token revoked');
(client.data as Record<string, unknown>).user = user;
await client.join(`user:${user.id}`);
this.logger.debug(`[scanner.ws_connection] [start] userId=${user.id} socketId=${client.id} - websocket connected`);
@@ -74,7 +75,7 @@ export class ScanGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
this.logger.warn(
`[scanner.ws_connection] [fail] socketId=${client.id} errorClass=${err instanceof Error ? err.name : 'Error'} error="${sanitizeLogValue(err instanceof Error ? err.message : String(err))}" - websocket rejected`,
);
client.disconnect();
rejectSocketConnection(client, err);
}
}