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 = {} },