From 33f92275f2015e514cd4132e075ea55876a2895d Mon Sep 17 00:00:00 2001 From: Deluan Date: Sun, 3 May 2026 13:35:04 -0400 Subject: [PATCH] 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. --- ui/src/authProvider.js | 27 +++++++-- ui/src/authProvider.test.js | 106 ++++++++++++++++++++++++++++++++++++ ui/src/setupTests.js | 3 + 3 files changed, 130 insertions(+), 6 deletions(-) create mode 100644 ui/src/authProvider.test.js diff --git a/ui/src/authProvider.js b/ui/src/authProvider.js index 813a4f5b4..f7ea165f8 100644 --- a/ui/src/authProvider.js +++ b/ui/src/authProvider.js @@ -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() }, diff --git a/ui/src/authProvider.test.js b/ui/src/authProvider.test.js new file mode 100644 index 000000000..3ffb12936 --- /dev/null +++ b/ui/src/authProvider.test.js @@ -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) + }) + }) +}) diff --git a/ui/src/setupTests.js b/ui/src/setupTests.js index ddb999f3c..7cb46e09c 100644 --- a/ui/src/setupTests.js +++ b/ui/src/setupTests.js @@ -14,6 +14,9 @@ const localStorageMock = (function () { setItem: function (key, value) { store[key] = value.toString() }, + removeItem: function (key) { + delete store[key] + }, clear: function () { store = {} },