fix: redirect expired sessions to login
Invalidate authentication on protected 401 responses and JWT expiration, redirect to the login route, and explain that the session is no longer active. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
c050815900
commit
0d8a25e73f
6 changed files with 147 additions and 8 deletions
|
|
@ -1,5 +1,5 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { api, authStorage } from './client'
|
||||
import { api, authStorage, SESSION_EXPIRED_EVENT } from './client'
|
||||
|
||||
describe('API client', () => {
|
||||
beforeEach(() => {
|
||||
|
|
@ -11,6 +11,7 @@ describe('API client', () => {
|
|||
token: 'signed-jwt',
|
||||
user: { id: 1, email: 'admin@example.com', displayName: 'Admin', role: 'admin' },
|
||||
})
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response('[]', { status: 200 }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
|
|
@ -24,6 +25,30 @@ describe('API client', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('invalidates the session when a protected request is unauthorized', async () => {
|
||||
authStorage.save({
|
||||
token: 'expired-jwt',
|
||||
user: { id: 1, email: 'admin@example.com', displayName: 'Admin', role: 'admin' },
|
||||
})
|
||||
const expired = vi.fn()
|
||||
window.addEventListener(SESSION_EXPIRED_EVENT, expired, { once: true })
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ error: 'session expired' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
await expect(api.jobs.list()).rejects.toMatchObject({ status: 401 })
|
||||
|
||||
expect(authStorage.token()).toBeNull()
|
||||
expect(authStorage.user()).toBeNull()
|
||||
expect(expired).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('uses the backend participant multipart field names', async () => {
|
||||
authStorage.save({
|
||||
token: 'signed-jwt',
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import type {
|
|||
|
||||
const TOKEN_KEY = 'go-guess-token'
|
||||
const USER_KEY = 'go-guess-user'
|
||||
export const SESSION_EXPIRED_EVENT = 'go-guess:session-expired'
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
|
|
@ -45,6 +46,23 @@ export const authStorage = {
|
|||
sessionStorage.removeItem(TOKEN_KEY)
|
||||
sessionStorage.removeItem(USER_KEY)
|
||||
},
|
||||
expiresAt: () => {
|
||||
try {
|
||||
const payload = sessionStorage.getItem(TOKEN_KEY)?.split('.')[1]
|
||||
if (!payload) return null
|
||||
const normalized = payload.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
|
||||
const claims = JSON.parse(atob(padded)) as { exp?: unknown }
|
||||
return typeof claims.exp === 'number' ? claims.exp * 1000 : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export function invalidateSession() {
|
||||
authStorage.clear()
|
||||
window.dispatchEvent(new Event(SESSION_EXPIRED_EVENT))
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
|
|
@ -78,7 +96,7 @@ async function request<T>(
|
|||
} catch {
|
||||
details = undefined
|
||||
}
|
||||
if (response.status === 401 && protectedRequest) authStorage.clear()
|
||||
if (response.status === 401 && protectedRequest) invalidateSession()
|
||||
throw new ApiError(message, response.status, details)
|
||||
}
|
||||
if (response.status === 204) return undefined as T
|
||||
|
|
@ -93,8 +111,10 @@ async function requestBlob(path: string): Promise<Blob> {
|
|||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
})
|
||||
if (!response.ok)
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) invalidateSession()
|
||||
throw new ApiError(`Request failed with status ${response.status}`, response.status)
|
||||
}
|
||||
return response.blob()
|
||||
}
|
||||
|
||||
|
|
@ -115,8 +135,10 @@ async function subscribeToInterviewEvents(
|
|||
},
|
||||
signal,
|
||||
})
|
||||
if (!response.ok || !response.body)
|
||||
if (!response.ok || !response.body) {
|
||||
if (response.status === 401 && protectedRequest) invalidateSession()
|
||||
throw new ApiError(`Live updates failed with status ${response.status}`, response.status)
|
||||
}
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue