mirror of
https://github.com/navidrome/navidrome.git
synced 2026-06-02 07:01:36 +00:00
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:
parent
dd2b6865b0
commit
33f92275f2
@ -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
106
ui/src/authProvider.test.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -14,6 +14,9 @@ const localStorageMock = (function () {
|
||||
setItem: function (key, value) {
|
||||
store[key] = value.toString()
|
||||
},
|
||||
removeItem: function (key) {
|
||||
delete store[key]
|
||||
},
|
||||
clear: function () {
|
||||
store = {}
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user