fix(ui): handle network errors in checkError for externalized auth

When using externalized authentication (reverse proxy with Authentik,
Authelia, etc.), expired proxy sessions cause CORS-blocked redirects that
surface as TypeErrors instead of HTTP 401s. The existing checkError only
handled status === 401, so these network errors were shown as vague
"NetworkError" notifications instead of triggering re-authentication.

Enhanced checkError to detect network errors when extAuthLogoutURL is
configured and reload the page to let the proxy redirect to the auth
provider. A sessionStorage-based 30-second guard prevents infinite reload
loops. Also extracted an isNetworkError helper to deduplicate the
detection logic already present in the login method.

Added missing removeItem to the localStorage mock in setupTests, and
added comprehensive tests for the new checkError behavior.
This commit is contained in:
Deluan 2026-05-03 13:35:04 -04:00
parent dd2b6865b0
commit 33f92275f2
3 changed files with 130 additions and 6 deletions

View File

@ -26,6 +26,11 @@ function storeAuthenticationInfo(authInfo) {
localStorage.setItem('is-authenticated', 'true')
}
const isNetworkError = (error) => {
const msg = error?.message || ''
return msg === 'Failed to fetch' || msg.includes('NetworkError')
}
const authProvider = {
login: ({ username, password }) => {
let url = baseUrl('/auth/login')
@ -53,10 +58,7 @@ const authProvider = {
return response
})
.catch((error) => {
if (
error.message === 'Failed to fetch' ||
error.stack === 'TypeError: Failed to fetch'
) {
if (isNetworkError(error)) {
throw new Error('errors.network_error')
}
@ -78,11 +80,24 @@ const authProvider = {
? Promise.resolve()
: Promise.reject(),
checkError: ({ status }) => {
if (status === 401) {
checkError: (error) => {
if (error?.status === 401) {
removeItems()
return Promise.reject()
}
if (config.extAuthLogoutURL && isNetworkError(error)) {
const now = Date.now()
const lastReload = parseInt(
sessionStorage.getItem('ext-auth-reload-ts') || '0',
10,
)
if (now - lastReload > 30000) {
sessionStorage.setItem('ext-auth-reload-ts', String(now))
removeItems()
window.location.reload()
return new Promise(() => {})
}
}
return Promise.resolve()
},

106
ui/src/authProvider.test.js Normal file
View File

@ -0,0 +1,106 @@
import { describe, it, beforeEach, afterEach, vi, expect } from 'vitest'
import config from './config'
import authProvider from './authProvider'
describe('authProvider', () => {
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
localStorage.setItem('is-authenticated', 'true')
localStorage.setItem('token', 'test-token')
localStorage.setItem('userId', 'test-user')
localStorage.setItem('role', 'admin')
config.extAuthLogoutURL = ''
})
afterEach(() => {
config.extAuthLogoutURL = ''
})
describe('checkError', () => {
it('rejects and clears storage on 401', async () => {
await expect(authProvider.checkError({ status: 401 })).rejects.toBe(
undefined,
)
expect(localStorage.getItem('is-authenticated')).toBeNull()
})
it('resolves on non-401 HTTP errors', async () => {
await expect(
authProvider.checkError({ status: 500 }),
).resolves.toBeUndefined()
expect(localStorage.getItem('is-authenticated')).toBe('true')
})
it('resolves on network error without extAuth configured', async () => {
config.extAuthLogoutURL = ''
await expect(
authProvider.checkError(new TypeError('Failed to fetch')),
).resolves.toBeUndefined()
expect(localStorage.getItem('is-authenticated')).toBe('true')
})
it('clears auth and sets reload guard on TypeError with extAuth', () => {
config.extAuthLogoutURL = 'https://auth.example.com/logout'
// window.location.reload throws in jsdom, so we catch it
try {
authProvider.checkError(new TypeError('Failed to fetch'))
} catch {
// jsdom "Not implemented: navigation" is expected
}
expect(localStorage.getItem('is-authenticated')).toBeNull()
expect(sessionStorage.getItem('ext-auth-reload-ts')).not.toBeNull()
})
it('clears auth on Firefox NetworkError with extAuth', () => {
config.extAuthLogoutURL = 'https://auth.example.com/logout'
const error = new Error('NetworkError when attempting to fetch resource')
try {
authProvider.checkError(error)
} catch {
// jsdom "Not implemented: navigation" is expected
}
expect(localStorage.getItem('is-authenticated')).toBeNull()
expect(sessionStorage.getItem('ext-auth-reload-ts')).not.toBeNull()
})
it('does not reload-loop within 30 seconds', async () => {
config.extAuthLogoutURL = 'https://auth.example.com/logout'
sessionStorage.setItem(
'ext-auth-reload-ts',
String(Date.now() - 10000),
)
await expect(
authProvider.checkError(new TypeError('Failed to fetch')),
).resolves.toBeUndefined()
expect(localStorage.getItem('is-authenticated')).toBe('true')
})
it('allows reload again after 30 seconds', () => {
config.extAuthLogoutURL = 'https://auth.example.com/logout'
sessionStorage.setItem(
'ext-auth-reload-ts',
String(Date.now() - 31000),
)
try {
authProvider.checkError(new TypeError('Failed to fetch'))
} catch {
// jsdom "Not implemented: navigation" is expected
}
expect(localStorage.getItem('is-authenticated')).toBeNull()
const ts = parseInt(sessionStorage.getItem('ext-auth-reload-ts'), 10)
expect(Date.now() - ts).toBeLessThan(5000)
})
})
describe('checkAuth', () => {
it('resolves when authenticated', async () => {
await expect(authProvider.checkAuth()).resolves.toBeUndefined()
})
it('rejects when not authenticated', async () => {
localStorage.removeItem('is-authenticated')
await expect(authProvider.checkAuth()).rejects.toBe(undefined)
})
})
})

View File

@ -14,6 +14,9 @@ const localStorageMock = (function () {
setItem: function (key, value) {
store[key] = value.toString()
},
removeItem: function (key) {
delete store[key]
},
clear: function () {
store = {}
},