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
|
|
@ -37,7 +37,10 @@ application shell.
|
||||||
|
|
||||||
Vite proxies `/api` to `http://localhost:8080`. Login stores the returned JWT in
|
Vite proxies `/api` to `http://localhost:8080`. Login stores the returned JWT in
|
||||||
`sessionStorage`; protected requests send it as `Authorization: Bearer <token>`. API failures are
|
`sessionStorage`; protected requests send it as `Authorization: Bearer <token>`. API failures are
|
||||||
shown in the UI and are never replaced with demo data.
|
shown in the UI and are never replaced with demo data. A `401` from a protected
|
||||||
|
JSON, file, or event-stream request clears authentication and returns the user to
|
||||||
|
`/login`. The app also schedules logout from the JWT expiration claim so an idle
|
||||||
|
expired session cannot remain on a protected page.
|
||||||
|
|
||||||
Supported routes:
|
Supported routes:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { api, authStorage } from './client'
|
import { api, authStorage, SESSION_EXPIRED_EVENT } from './client'
|
||||||
|
|
||||||
describe('API client', () => {
|
describe('API client', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|
@ -11,6 +11,7 @@ describe('API client', () => {
|
||||||
token: 'signed-jwt',
|
token: 'signed-jwt',
|
||||||
user: { id: 1, email: 'admin@example.com', displayName: 'Admin', role: 'admin' },
|
user: { id: 1, email: 'admin@example.com', displayName: 'Admin', role: 'admin' },
|
||||||
})
|
})
|
||||||
|
|
||||||
const fetchMock = vi.fn().mockResolvedValue(new Response('[]', { status: 200 }))
|
const fetchMock = vi.fn().mockResolvedValue(new Response('[]', { status: 200 }))
|
||||||
vi.stubGlobal('fetch', fetchMock)
|
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 () => {
|
it('uses the backend participant multipart field names', async () => {
|
||||||
authStorage.save({
|
authStorage.save({
|
||||||
token: 'signed-jwt',
|
token: 'signed-jwt',
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import type {
|
||||||
|
|
||||||
const TOKEN_KEY = 'go-guess-token'
|
const TOKEN_KEY = 'go-guess-token'
|
||||||
const USER_KEY = 'go-guess-user'
|
const USER_KEY = 'go-guess-user'
|
||||||
|
export const SESSION_EXPIRED_EVENT = 'go-guess:session-expired'
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
|
|
@ -45,6 +46,23 @@ export const authStorage = {
|
||||||
sessionStorage.removeItem(TOKEN_KEY)
|
sessionStorage.removeItem(TOKEN_KEY)
|
||||||
sessionStorage.removeItem(USER_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>(
|
async function request<T>(
|
||||||
|
|
@ -78,7 +96,7 @@ async function request<T>(
|
||||||
} catch {
|
} catch {
|
||||||
details = undefined
|
details = undefined
|
||||||
}
|
}
|
||||||
if (response.status === 401 && protectedRequest) authStorage.clear()
|
if (response.status === 401 && protectedRequest) invalidateSession()
|
||||||
throw new ApiError(message, response.status, details)
|
throw new ApiError(message, response.status, details)
|
||||||
}
|
}
|
||||||
if (response.status === 204) return undefined as T
|
if (response.status === 204) return undefined as T
|
||||||
|
|
@ -93,8 +111,10 @@ async function requestBlob(path: string): Promise<Blob> {
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
...(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)
|
throw new ApiError(`Request failed with status ${response.status}`, response.status)
|
||||||
|
}
|
||||||
return response.blob()
|
return response.blob()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -115,8 +135,10 @@ async function subscribeToInterviewEvents(
|
||||||
},
|
},
|
||||||
signal,
|
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)
|
throw new ApiError(`Live updates failed with status ${response.status}`, response.status)
|
||||||
|
}
|
||||||
const reader = response.body.getReader()
|
const reader = response.body.getReader()
|
||||||
const decoder = new TextDecoder()
|
const decoder = new TextDecoder()
|
||||||
let buffer = ''
|
let buffer = ''
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,52 @@ describe('Go Guess frontend', () => {
|
||||||
expect(window.location.pathname).toBe('/jobs')
|
expect(window.location.pathname).toBe('/jobs')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('returns to login when the active session expires', async () => {
|
||||||
|
authenticate()
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ error: 'session expired' }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderApp('/jobs')
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByRole('heading', { name: 'Sign in to your workspace' }),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('status')).toHaveTextContent('Your session is no longer active')
|
||||||
|
expect(sessionStorage.getItem('go-guess-token')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects an already expired stored token without waiting for an API response', async () => {
|
||||||
|
const payload = btoa(JSON.stringify({ exp: Math.floor(Date.now() / 1000) - 60 })).replace(
|
||||||
|
/=+$/,
|
||||||
|
'',
|
||||||
|
)
|
||||||
|
sessionStorage.setItem('go-guess-token', `header.${payload}.signature`)
|
||||||
|
sessionStorage.setItem(
|
||||||
|
'go-guess-user',
|
||||||
|
JSON.stringify({
|
||||||
|
id: 1,
|
||||||
|
email: 'admin@example.com',
|
||||||
|
displayName: 'Admin',
|
||||||
|
role: 'admin',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderApp('/calendar')
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByRole('heading', { name: 'Sign in to your workspace' }),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('status')).toHaveTextContent('Your session is no longer active')
|
||||||
|
expect(sessionStorage.getItem('go-guess-token')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
it('filters jobs by title', async () => {
|
it('filters jobs by title', async () => {
|
||||||
authenticate()
|
authenticate()
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ import {
|
||||||
useNavigate,
|
useNavigate,
|
||||||
useParams,
|
useParams,
|
||||||
} from 'react-router-dom'
|
} from 'react-router-dom'
|
||||||
import { api, ApiError, authStorage } from '../api/client'
|
import { api, ApiError, authStorage, invalidateSession, SESSION_EXPIRED_EVENT } from '../api/client'
|
||||||
import {
|
import {
|
||||||
CalendarPage,
|
CalendarPage,
|
||||||
InboxPage,
|
InboxPage,
|
||||||
|
|
@ -168,7 +168,7 @@ function Logo() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function LoginPage({ onLogin }: { onLogin: () => void }) {
|
function LoginPage({ onLogin, sessionExpired }: { onLogin: () => void; sessionExpired: boolean }) {
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
|
@ -210,6 +210,11 @@ function LoginPage({ onLogin }: { onLogin: () => void }) {
|
||||||
<div className="login-card">
|
<div className="login-card">
|
||||||
<span className="eyebrow">Welcome back</span>
|
<span className="eyebrow">Welcome back</span>
|
||||||
<h2 id="login-title">Sign in to your workspace</h2>
|
<h2 id="login-title">Sign in to your workspace</h2>
|
||||||
|
{sessionExpired && (
|
||||||
|
<div className="session-notice" role="status">
|
||||||
|
Your session is no longer active. Sign in again to continue.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<p className="muted">Use your work account to continue.</p>
|
<p className="muted">Use your work account to continue.</p>
|
||||||
<form onSubmit={submit} className="stack-form">
|
<form onSubmit={submit} className="stack-form">
|
||||||
<FormField label="Email address" htmlFor="email">
|
<FormField label="Email address" htmlFor="email">
|
||||||
|
|
@ -2391,17 +2396,43 @@ function NotFound() {
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [authenticated, setAuthenticated] = useState(() => Boolean(authStorage.token()))
|
const [authenticated, setAuthenticated] = useState(() => Boolean(authStorage.token()))
|
||||||
|
const [sessionExpired, setSessionExpired] = useState(false)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function sessionExpired() {
|
||||||
|
setAuthenticated(false)
|
||||||
|
setSessionExpired(true)
|
||||||
|
navigate('/login', { replace: true })
|
||||||
|
}
|
||||||
|
window.addEventListener(SESSION_EXPIRED_EVENT, sessionExpired)
|
||||||
|
return () => window.removeEventListener(SESSION_EXPIRED_EVENT, sessionExpired)
|
||||||
|
}, [navigate])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authenticated) return
|
||||||
|
const expiresAt = authStorage.expiresAt()
|
||||||
|
if (expiresAt === null) return
|
||||||
|
const remaining = expiresAt - Date.now()
|
||||||
|
if (remaining <= 0) {
|
||||||
|
invalidateSession()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const timeout = window.setTimeout(invalidateSession, remaining)
|
||||||
|
return () => window.clearTimeout(timeout)
|
||||||
|
}, [authenticated])
|
||||||
|
|
||||||
function completeLogin() {
|
function completeLogin() {
|
||||||
setAuthenticated(true)
|
setAuthenticated(true)
|
||||||
|
setSessionExpired(false)
|
||||||
navigate('/jobs', { replace: true })
|
navigate('/jobs', { replace: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
authStorage.clear()
|
authStorage.clear()
|
||||||
setAuthenticated(false)
|
setAuthenticated(false)
|
||||||
|
setSessionExpired(false)
|
||||||
}
|
}
|
||||||
if (location.pathname.startsWith('/participant/')) {
|
if (location.pathname.startsWith('/participant/')) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -2412,7 +2443,9 @@ export default function App() {
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (!authenticated) return <LoginPage onLogin={completeLogin} />
|
if (!authenticated) {
|
||||||
|
return <LoginPage onLogin={completeLogin} sessionExpired={sessionExpired} />
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<DataProvider>
|
<DataProvider>
|
||||||
<AppLayout onLogout={logout} />
|
<AppLayout onLogout={logout} />
|
||||||
|
|
|
||||||
|
|
@ -338,6 +338,16 @@ button {
|
||||||
font-size: 1.8rem;
|
font-size: 1.8rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
.session-notice {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.8rem 0.9rem;
|
||||||
|
color: #6f5513;
|
||||||
|
border: 1px solid #e6ce83;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #fff8dc;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
.stack-form {
|
.stack-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue