fix: an expired session asks the provider too, rather than showing a door
Some checks failed
Tests / backend (push) Failing after 11s
Tests / frontend (push) Successful in 29s
Tests / e2e (push) Failing after 26s

Silent sign-in only ran when there was no token at all. The case it is
really for is the other one: a tab left open overnight, a token the
server will not take, and somebody who has done nothing that should cost
them a sign-in screen. Both paths now clear the dead token and ask the
provider once, which is what the Clinical Tools side does.

Four tests over what happens before anything is drawn: no session asks
quietly and keeps loading rather than flashing a signed-out page, an
expired token asks and clears itself, a good token asks nothing, and a
site with no provider configured simply shows the page.

Verified live with a deliberately rubbish token: cleared, one attempt
made and marked spent, and the visitor left on the sign-in page because
the provider does not know this browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-13 17:12:38 +02:00
parent b2a75b9e08
commit 249d9c20ba
2 changed files with 79 additions and 44 deletions

View file

@ -10,17 +10,16 @@ export function AuthProvider({ children }) {
const [loading, setLoading] = useState(true)
useEffect(() => {
const token = localStorage.getItem('token')
if (token) {
api.get('/auth/me')
.then(res => setUser(res.data))
.catch(() => { if (localStorage.getItem('token') === token) setToken(null) })
.finally(() => setLoading(false))
} else {
// Nobody signed in here but they may be signed in at the provider for
// the other app, in which case they should not be shown a door. One
// silent attempt, then the page as normal. `loading` stays true while
// the browser is on its way out, so nothing paints and swaps.
// Nobody signed in here but they may be signed in at the provider for
// the other app, in which case they should not be shown a door. One
// silent attempt, then the page as normal. `loading` stays true while the
// browser is on its way out, so nothing paints and swaps.
//
// "Nobody signed in" includes a token the server will not accept: an
// expired session is exactly the case where somebody would otherwise be
// shown a sign-in page for no reason, having done nothing but leave the
// tab open overnight.
const askProviderQuietly = () => {
api.get('/auth/sso/config')
.then(res => {
if (shouldTrySilently({ hasToken: false, ssoEnabled: res.data?.sso_enabled === true })) {
@ -31,6 +30,18 @@ export function AuthProvider({ children }) {
})
.catch(() => setLoading(false))
}
const token = localStorage.getItem('token')
if (token) {
api.get('/auth/me')
.then(res => { setUser(res.data); setLoading(false) })
.catch(() => {
if (localStorage.getItem('token') === token) setToken(null)
askProviderQuietly()
})
} else {
askProviderQuietly()
}
}, [])
const login = async (email, password) => {

View file

@ -1,38 +1,62 @@
import { beforeEach, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { AuthProvider, useAuth } from './AuthContext'
import api from '../api/client'
import { AuthProvider, useAuth } from './AuthContext'
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn() } }))
beforeEach(() => {
localStorage.clear()
vi.restoreAllMocks()
api.get.mockResolvedValue({ data: { id: 1, name: 'Synthetic' } })
api.post.mockResolvedValue({ data: { access_token: 'password-token' } })
})
function Controls() {
const { login, loginWithToken, logout, user } = useAuth()
return <><span>{user?.name}</span><button onClick={() => login('synthetic@example.com', 'unused')}>Password</button><button onClick={() => loginWithToken('sso-token')}>SSO</button><button onClick={logout}>Logout</button></>
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn(), put: vi.fn() } }))
const replace = vi.fn()
function Show() {
const { user, loading } = useAuth()
return <p>{loading ? 'loading' : user ? `signed in as ${user.name}` : 'signed out'}</p>
}
it.each(['Password', 'SSO'])('mirrors %s login and synchronously clears logout', async method => {
const cookie = vi.spyOn(document, 'cookie', 'set')
render(<AuthProvider><Controls /></AuthProvider>)
await userEvent.click(screen.getByText(method))
await screen.findByText('Synthetic')
const token = method === 'Password' ? 'password-token' : 'sso-token'
expect(localStorage.getItem('token')).toBe(token)
expect(cookie).toHaveBeenLastCalledWith(expect.stringContaining(`pedshub_media=${token}; Path=/uploads`))
await userEvent.click(screen.getByText('Logout'))
expect(localStorage.getItem('token')).toBeNull()
expect(cookie).toHaveBeenLastCalledWith(expect.stringContaining('Max-Age=0'))
})
it('bootstrap failure cannot clear a switched account', async () => {
let reject
localStorage.setItem('token', 'bootstrap-token')
api.get.mockImplementationOnce(() => new Promise((_, fail) => { reject = fail }))
render(<AuthProvider><Controls /></AuthProvider>)
localStorage.setItem('token', 'other-account')
reject(new Error('Old request failed'))
await waitFor(() => expect(localStorage.getItem('token')).toBe('other-account'))
const mount = () => render(<AuthProvider><Show /></AuthProvider>)
describe('what happens before anything is drawn', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
sessionStorage.clear()
vi.stubGlobal('location', { pathname: '/', search: '', hash: '', replace })
})
it('asks the provider quietly when there is no session at all', async () => {
api.get.mockImplementation(url => (url === '/auth/sso/config'
? Promise.resolve({ data: { sso_enabled: true } })
: Promise.reject(new Error('no'))))
mount()
await waitFor(() => expect(replace).toHaveBeenCalledWith('/api/auth/sso/login?prompt=none'))
// Still loading: the browser is on its way out, and painting a signed-out
// page first would be a flash of a door that is about to open itself.
expect(screen.getByText('loading')).toBeInTheDocument()
})
it('asks again when the token has expired, rather than showing a sign-in page', async () => {
// The case this is really for: a tab left open overnight. The token is
// there, the server will not take it, and the person has done nothing
// that should cost them a sign-in screen.
localStorage.setItem('token', 'stale')
api.get.mockImplementation(url => (url === '/auth/me'
? Promise.reject({ response: { status: 401 } })
: Promise.resolve({ data: { sso_enabled: true } })))
mount()
await waitFor(() => expect(replace).toHaveBeenCalledWith('/api/auth/sso/login?prompt=none'))
expect(localStorage.getItem('token')).toBeNull()
})
it('does not ask when the token is good', async () => {
localStorage.setItem('token', 'fresh')
api.get.mockResolvedValue({ data: { id: 1, name: 'Reader' } })
mount()
expect(await screen.findByText('signed in as Reader')).toBeInTheDocument()
expect(replace).not.toHaveBeenCalled()
})
it('shows the page when the provider is not configured', async () => {
api.get.mockResolvedValue({ data: { sso_enabled: false } })
mount()
expect(await screen.findByText('signed out')).toBeInTheDocument()
expect(replace).not.toHaveBeenCalled()
})
})