From b9f5e48a0b8276581e7b6e95c3d5db41131a4c43 Mon Sep 17 00:00:00 2001 From: Remo Rothacher Date: Thu, 12 Mar 2026 12:40:04 +0100 Subject: [PATCH 1/5] Feature: Add SSO Proxy authentication support (Cloudflare, Authelia) with global session management and secure Electron login flow. --- src/i18n/locales/en.json | 2 + src/main/features/sso-login.ts | 60 +++++ src/main/index.ts | 26 +- src/preload/browser.ts | 5 + src/preload/index.ts | 2 + src/preload/sso.ts | 8 + src/renderer/api/jellyfin/jellyfin-api.ts | 12 +- .../api/jellyfin/jellyfin-controller.ts | 1 + src/renderer/api/navidrome/navidrome-api.ts | 16 +- src/renderer/api/sso-interceptor.tsx | 229 ++++++++++++++++++ src/renderer/api/subsonic/subsonic-api.ts | 17 +- .../api/subsonic/subsonic-controller.ts | 5 +- .../components/server-required.tsx | 8 +- .../search/components/server-commands.tsx | 8 +- .../servers/components/add-server-form.tsx | 38 +++ .../servers/components/edit-server-form.tsx | 60 ++++- .../components/window/cache-settngs.tsx | 58 ++++- .../components/server-selector-items.tsx | 8 +- .../hooks/use-server-authenticated.ts | 7 + src/renderer/store/auth.store.ts | 19 ++ src/shared/types/domain-types.ts | 8 + 21 files changed, 569 insertions(+), 28 deletions(-) create mode 100644 src/main/features/sso-login.ts create mode 100644 src/preload/sso.ts create mode 100644 src/renderer/api/sso-interceptor.tsx diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index ffd0ccd761..81d086a68e 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -763,6 +763,8 @@ "clearCache_description": "a 'hard clear' of feishin. in addition to clearing feishin's cache, empty the browser cache (saved images and other assets). server credentials and settings are preserved", "clearCache": "clear browser cache", "clearCacheSuccess": "cache cleared successfully", + "clearCookies_description": "clears all browser cookies. this will force you to re-authenticate with any SSO proxy", + "clearCookies": "clear cookies", "clearQueryCache_description": "a 'soft clear' of feishin. this will refresh playlists, track metadata, and reset saved lyrics. settings, server credentials and cached images are preserved", "clearQueryCache": "clear feishin cache", "contextMenu_description": "allows you to hide items that are shown in the menu when you right click on an item. items that are unchecked will be hidden", diff --git a/src/main/features/sso-login.ts b/src/main/features/sso-login.ts new file mode 100644 index 0000000000..9a3659dd29 --- /dev/null +++ b/src/main/features/sso-login.ts @@ -0,0 +1,60 @@ +import { BrowserWindow, session } from 'electron'; + +import { SsoLoginResponse } from '/@/shared/types/domain-types'; + +export const handleSsoLogin = async ( + _event: any, + url: string, + ssoCookieName = 'CF_Authorization', +): Promise => { + if (!url.startsWith('http://') && !url.startsWith('https://')) { + throw new Error('Invalid SSO URL protocol'); + } + + const ssoWindow = new BrowserWindow({ + autoHideMenuBar: true, + height: 800, + title: 'SSO Login', + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + width: 600, + }); + + let success = false; + + return new Promise((resolve) => { + ssoWindow.loadURL(url); + + const checkCookies = async () => { + const cookies = await session.defaultSession.cookies.get({ url }); + const cookieMap: Record = {}; + cookies.forEach((cookie) => { + cookieMap[cookie.name] = cookie.value; + }); + return cookieMap; + }; + + ssoWindow.on('closed', async () => { + const cookies = await checkCookies(); + const finalSuccess = success || !!cookies[ssoCookieName]; + resolve({ cookies, success: finalSuccess }); + }); + + // We could also poll or listen to navigation to see if we reached the app + // but often SSO proxies redirect back to the original URL. + ssoWindow.webContents.on('did-navigate', async (event, navigatedUrl) => { + if (navigatedUrl.startsWith(url)) { + // Potential success, but let the user decide if they are done or wait for a specific cookie + const cookies = await checkCookies(); + // If we see a typical SSO cookie, we might consider resolving early or just wait for window close. + if (cookies[ssoCookieName]) { + success = true; + ssoWindow.close(); + } + } + }); + }); +}; diff --git a/src/main/index.ts b/src/main/index.ts index 5e9252fadc..ae97f181ca 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -15,6 +15,7 @@ import { protocol, Rectangle, screen, + session, shell, Tray, } from 'electron'; @@ -29,7 +30,9 @@ import packageJson from '../../package.json'; import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys'; import { shutdownServer } from './features/core/remote'; import { store } from './features/core/settings'; +import { handleSsoLogin } from './features/sso-login'; import MenuBuilder from './menu'; +import './features'; import { autoUpdaterLogInterface, createLog, @@ -39,7 +42,6 @@ import { isMacOS, isWindows, } from './utils'; -import './features'; import { PlayerType, TitleTheme } from '/@/shared/types/types'; @@ -553,6 +555,26 @@ async function createWindow(first = true): Promise { return mainWindow?.webContents.session.clearCache(); }); + ipcMain.handle('window-clear-cookies', async (_event, url?: string) => { + if (url) { + // SECURITY: Validate URL against configured servers + const authStore = store.get('store_authentication') as any; + const serverList = authStore?.serverList || {}; + const isValidUrl = Object.values(serverList).some((s: any) => s.url === url); + + if (!isValidUrl) { + throw new Error('Unauthorized cookie clearing for unconfigured URL'); + } + + const cookies = await session.defaultSession.cookies.get({ url }); + const removalPromises = cookies.map((cookie) => { + return session.defaultSession.cookies.remove(url, cookie.name); + }); + return Promise.all(removalPromises); + } + return session.defaultSession.clearStorageData({ storages: ['cookies'] }); + }); + ipcMain.handle( 'app-check-for-updates', async (): Promise<{ updateAvailable: boolean; version?: string }> => { @@ -661,6 +683,7 @@ async function createWindow(first = true): Promise { mainWindow.on('closed', () => { ipcMain.removeHandler('window-clear-cache'); + ipcMain.removeHandler('window-clear-cookies'); ipcMain.removeHandler('app-check-for-updates'); mainWindow = null; }); @@ -927,6 +950,7 @@ if (!singleInstance) { }); createWindow(); + ipcMain.handle('sso:login', handleSsoLogin); if (store.get('window_enable_tray', true)) { createTray(); } diff --git a/src/preload/browser.ts b/src/preload/browser.ts index bf7b588127..5939050900 100644 --- a/src/preload/browser.ts +++ b/src/preload/browser.ts @@ -28,8 +28,13 @@ const clearCache = (): Promise => { return ipcRenderer.invoke('window-clear-cache'); }; +const clearCookies = (url?: string): Promise => { + return ipcRenderer.invoke('window-clear-cookies', url); +}; + export const browser = { clearCache, + clearCookies, devtools, exit, maximize, diff --git a/src/preload/index.ts b/src/preload/index.ts index 1f87f40833..48bf53247e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -10,6 +10,7 @@ import { lyrics } from './lyrics'; import { mpris } from './mpris'; import { mpvPlayer, mpvPlayerListener } from './mpv-player'; import { remote } from './remote'; +import { sso } from './sso'; import { utils } from './utils'; // Custom APIs for renderer @@ -24,6 +25,7 @@ const api = { mpvPlayer, mpvPlayerListener, remote, + sso, utils, }; diff --git a/src/preload/sso.ts b/src/preload/sso.ts new file mode 100644 index 0000000000..3c6cde0d8c --- /dev/null +++ b/src/preload/sso.ts @@ -0,0 +1,8 @@ +import { ipcRenderer } from 'electron'; + +import { SsoLoginResponse } from '/@/shared/types/domain-types'; + +export const sso = { + login: (url: string, ssoCookieName?: string): Promise => + ipcRenderer.invoke('sso:login', url, ssoCookieName), +}; diff --git a/src/renderer/api/jellyfin/jellyfin-api.ts b/src/renderer/api/jellyfin/jellyfin-api.ts index 4d1d2f5a08..7be30e9110 100644 --- a/src/renderer/api/jellyfin/jellyfin-api.ts +++ b/src/renderer/api/jellyfin/jellyfin-api.ts @@ -7,6 +7,7 @@ import { z } from 'zod'; import packageJson from '../../../../package.json'; import i18n from '/@/i18n/i18n'; +import { setupAxiosInterceptors } from '/@/renderer/api/sso-interceptor'; import { authenticationFailure } from '/@/renderer/api/utils'; import { useAuthStore } from '/@/renderer/store'; import { getServerUrl } from '/@/renderer/utils/normalize-server-url'; @@ -358,20 +359,21 @@ export const contract = c.router({ }, }); -const axiosClient = axios.create({}); +const axiosClient = axios.create({ withCredentials: true }); axiosClient.defaults.paramsSerializer = (params) => { return qs.stringify(params, { arrayFormat: 'repeat' }); }; +setupAxiosInterceptors(axiosClient); + axiosClient.interceptors.response.use( - (response) => { + async (response) => { return response; }, - (error) => { + async (error) => { if (error.response && error.response.status === 401) { const currentServer = useAuthStore.getState().currentServer; - if (currentServer) { useAuthStore .getState() @@ -459,7 +461,7 @@ export const jfApiClient = (args: { return { body: response?.data, headers: response?.headers as any, - status: response?.status, + status: response?.status ?? 500, }; } throw e; diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index 13052e9d07..fd5cd27e0a 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -52,6 +52,7 @@ const getJellyfinImageRequest = ({ return { cacheKey: ['jellyfin', server.id, baseUrl || '', id, imageSize || ''].join(':'), + credentials: server.isSsoProxy ? 'include' : undefined, headers: server.credential ? { Authorization: createAuthHeader().concat(`, Token="${server.credential}"`) } : { Authorization: createAuthHeader() }, diff --git a/src/renderer/api/navidrome/navidrome-api.ts b/src/renderer/api/navidrome/navidrome-api.ts index 9391eb8fa2..08f5e47d90 100644 --- a/src/renderer/api/navidrome/navidrome-api.ts +++ b/src/renderer/api/navidrome/navidrome-api.ts @@ -6,6 +6,7 @@ import omitBy from 'lodash/omitBy'; import qs from 'qs'; import i18n from '/@/i18n/i18n'; +import { setupAxiosInterceptors } from '/@/renderer/api/sso-interceptor'; import { authenticationFailure } from '/@/renderer/api/utils'; import { useAuthStore } from '/@/renderer/store'; import { getServerUrl } from '/@/renderer/utils/normalize-server-url'; @@ -216,12 +217,14 @@ export const contract = c.router({ }, }); -const axiosClient = axios.create({}); +const axiosClient = axios.create({ withCredentials: true }); axiosClient.defaults.paramsSerializer = (params) => { return qs.stringify(params, { arrayFormat: 'repeat' }); }; +setupAxiosInterceptors(axiosClient); + const parsePath = (fullPath: string) => { const [path, params] = fullPath.split('?'); @@ -271,8 +274,11 @@ const limitedFail = debounce(authenticationFailure, RETRY_DELAY_MS); const TIMEOUT_ERROR = Error(); axiosClient.interceptors.response.use( - (response) => { - const serverId = useAuthStore.getState().currentServer?.id; + async (response) => { + if (!response) return response; + + const currentServer = useAuthStore.getState().currentServer; + const serverId = currentServer?.id; if (serverId) { const headerCredential = response.headers['x-nd-authorization'] as string | undefined; @@ -288,7 +294,7 @@ axiosClient.interceptors.response.use( return response; }, - (error) => { + async (error) => { if (error.response && error.response.status === 401) { const currentServer = useAuthStore.getState().currentServer; @@ -453,7 +459,7 @@ export const ndApiClient = (args: { return { body: { data: response?.data, headers: response?.headers }, headers: response?.headers as any, - status: response?.status, + status: response?.status ?? 500, }; } throw e; diff --git a/src/renderer/api/sso-interceptor.tsx b/src/renderer/api/sso-interceptor.tsx new file mode 100644 index 0000000000..b49256ca15 --- /dev/null +++ b/src/renderer/api/sso-interceptor.tsx @@ -0,0 +1,229 @@ +import { AxiosInstance } from 'axios'; +import isElectron from 'is-electron'; + +import { useAuthStore } from '/@/renderer/store'; +import { logFn } from '/@/renderer/utils/logger'; +import { Button } from '/@/shared/components/button/button'; +import { Group } from '/@/shared/components/group/group'; +import { closeAllModals, openModal } from '/@/shared/components/modal/modal'; +import { Stack } from '/@/shared/components/stack/stack'; +import { Text } from '/@/shared/components/text/text'; +import { ServerListItemWithCredential } from '/@/shared/types/domain-types'; + +export const SSO_CANCELLED_ERROR = 'SSO login cancelled'; +export const SSO_TIMEOUT_ERROR = 'SSO login timeout'; + +let pendingReauth: null | Promise = null; +let abortController = new AbortController(); + +const REAUTH_TIMEOUT = 120000; // 2 minutes + +export const ensureSsoAuth = async ( + server: ServerListItemWithCredential, + isInitialLogin = false, +): Promise => { + if (pendingReauth) return pendingReauth; + + let timedOut = false; + + const reauthLogic = async (): Promise => { + try { + if (isInitialLogin) { + const result = await window.api.sso.login( + server.url, + server.ssoCookieName || 'CF_Authorization', + ); + + if (timedOut) return false; + + if (result && result.success) { + useAuthStore + .getState() + .actions.updateServer(server.id, { ssoCookies: result.cookies }); + return true; + } + return false; + } + + return await new Promise((resolve) => { + let isResolved = false; + + const handleLogin = async () => { + try { + const result = await window.api.sso.login( + server.url, + server.ssoCookieName || 'CF_Authorization', + ); + + if (timedOut) { + resolve(false); + return; + } + + if (result && result.success) { + useAuthStore + .getState() + .actions.updateServer(server.id, { ssoCookies: result.cookies }); + isResolved = true; + closeAllModals(); + resolve(true); + } else { + handleCancel(); + } + } catch (error) { + logFn.error('SSO login error:', error); + handleCancel(); + } + }; + + const handleCancel = () => { + if (isResolved || timedOut) return; + isResolved = true; + + closeAllModals(); + abortController.abort(); + abortController = new AbortController(); + useAuthStore.getState().actions.setCurrentServer(null); + resolve(false); + }; + + openModal({ + children: ( + + + Your SSO session has expired. Please re-authenticate to continue. + + + + + + + ), + closeOnClickOutside: false, + closeOnEscape: false, + onClose: handleCancel, + title: 'Login Required', + withCloseButton: false, + }); + }); + } finally { + if (!timedOut) { + pendingReauth = null; + } + } + }; + + pendingReauth = Promise.race([ + reauthLogic(), + new Promise((_, reject) => + setTimeout(() => { + timedOut = true; + reject(new Error(SSO_TIMEOUT_ERROR)); + }, REAUTH_TIMEOUT), + ), + ]).catch((error) => { + if (error.message === SSO_TIMEOUT_ERROR) { + logFn.error('SSO re-auth timed out'); + closeAllModals(); + abortController.abort(); + abortController = new AbortController(); + useAuthStore.getState().actions.setCurrentServer(null); + } + pendingReauth = null; + throw error; + }); + + return pendingReauth; +}; + +const handleSsoResponse = async ( + axiosInstance: AxiosInstance, + response: any, + server: null | ServerListItemWithCredential, +) => { + if (!isElectron() || !server?.isSsoProxy || !response) { + return response; + } + + const contentType = response.headers?.['content-type'] || ''; + const isHtml = typeof contentType === 'string' && contentType.includes('text/html'); + + if (isHtml && response.status === 200) { + logFn.info(`SSO HTML leak detected for server: ${server.url}`); + const success = await ensureSsoAuth(server); + if (success) { + return axiosInstance.request({ + ...response.config, + signal: abortController.signal, + }); + } + throw new Error(SSO_CANCELLED_ERROR); + } + + return response; +}; + +const handleSsoError = async ( + axiosInstance: AxiosInstance, + error: any, + server: null | ServerListItemWithCredential, +) => { + if (!isElectron() || !server?.isSsoProxy) { + throw error; + } + + const status = error?.response?.status; + if (status === 401 || status === 403) { + const success = await ensureSsoAuth(server); + if (success) { + return axiosInstance.request({ + ...error.config, + signal: abortController.signal, + }); + } + throw new Error(SSO_CANCELLED_ERROR); + } + + throw error; +}; + +export const setupAxiosInterceptors = (axiosInstance: AxiosInstance) => { + axiosInstance.interceptors.request.use((config) => { + // If the request doesn't already have a signal, attach our global one + // so we can abort it if the user switches servers during SSO re-auth. + if (!config.signal) { + config.signal = abortController.signal; + } + return config; + }); + + axiosInstance.interceptors.response.use( + async (response) => { + const currentServer = useAuthStore.getState().currentServer; + + if (currentServer?.isSsoProxy) { + const result = await handleSsoResponse(axiosInstance, response, currentServer); + if (result !== response) return result; + } + + return response; + }, + async (error) => { + const currentServer = useAuthStore.getState().currentServer; + + if (currentServer?.isSsoProxy) { + try { + return await handleSsoError(axiosInstance, error, currentServer); + } catch (ssoError) { + return Promise.reject(ssoError); + } + } + + return Promise.reject(error); + }, + ); +}; diff --git a/src/renderer/api/subsonic/subsonic-api.ts b/src/renderer/api/subsonic/subsonic-api.ts index efa6d9c756..e87fe02d48 100644 --- a/src/renderer/api/subsonic/subsonic-api.ts +++ b/src/renderer/api/subsonic/subsonic-api.ts @@ -5,6 +5,7 @@ import qs from 'qs'; import { z } from 'zod'; import i18n from '/@/i18n/i18n'; +import { setupAxiosInterceptors } from '/@/renderer/api/sso-interceptor'; import { getServerUrl } from '/@/renderer/utils/normalize-server-url'; import { ssType } from '/@/shared/api/subsonic/subsonic-types'; import { hasFeature } from '/@/shared/api/utils'; @@ -331,18 +332,22 @@ export const contract = c.router({ }, }); -const axiosClient = axios.create({}); +const axiosClient = axios.create({ withCredentials: true }); axiosClient.defaults.paramsSerializer = (params) => { return qs.stringify(params, { arrayFormat: 'repeat' }); }; +setupAxiosInterceptors(axiosClient); + axiosClient.interceptors.response.use( - (response) => { + async (response) => { + if (!response) return response; + const data = response.data; - if (data['subsonic-response'].status !== 'ok') { + if (data && data['subsonic-response'] && data['subsonic-response'].status !== 'ok') { // Suppress code related to non-linked lastfm or spotify from Navidrome - if (data['subsonic-response'].error.code !== 0) { + if (data['subsonic-response'].error && data['subsonic-response'].error.code !== 0) { toast.error({ message: data['subsonic-response'].error.message, title: i18n.t('error.genericError', { postProcess: 'sentenceCase' }) as string, @@ -355,7 +360,7 @@ axiosClient.interceptors.response.use( return response; }, - (error) => { + async (error) => { return Promise.reject(error); }, ); @@ -467,7 +472,7 @@ export const ssApiClient = (args: { return { body: response?.data, headers: response?.headers as any, - status: response?.status, + status: response?.status ?? 500, }; } throw e; diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index c4811c24ab..4699f29120 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -53,6 +53,7 @@ const getSubsonicImageRequest = ({ return { cacheKey: ['subsonic', server.id, baseUrl || '', id, imageSize || ''].join(':'), + credentials: server.isSsoProxy ? 'include' : undefined, url: `${url}/rest/getCoverArt.view` + `?id=${id}` + @@ -174,7 +175,7 @@ export const SubsonicController: InternalControllerEndpoint = { }, }); - if (resp.status !== 200) { + if (resp.status !== 200 || !resp.body?.user) { throw new Error('Failed to log in'); } @@ -1920,7 +1921,7 @@ export const SubsonicController: InternalControllerEndpoint = { }, }); - if (res.status !== 200) { + if (res.status !== 200 || !res.body?.user) { throw new Error('Failed to get user info'); } diff --git a/src/renderer/features/action-required/components/server-required.tsx b/src/renderer/features/action-required/components/server-required.tsx index bb5044e3c5..c306ee187d 100644 --- a/src/renderer/features/action-required/components/server-required.tsx +++ b/src/renderer/features/action-required/components/server-required.tsx @@ -3,6 +3,7 @@ import isElectron from 'is-electron'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router'; +import { ensureSsoAuth } from '/@/renderer/api/sso-interceptor'; import { isServerLock } from '/@/renderer/features/action-required/utils/window-properties'; import JellyfinLogo from '/@/renderer/features/servers/assets/jellyfin.png'; import NavidromeLogo from '/@/renderer/features/servers/assets/navidrome.png'; @@ -55,7 +56,12 @@ function ServerSelector() { const currentServer = useCurrentServer(); const { setCurrentServer } = useAuthStoreActions(); - const handleSetCurrentServer = (server: ServerListItemWithCredential) => { + const handleSetCurrentServer = async (server: ServerListItemWithCredential) => { + if (server.isSsoProxy) { + const success = await ensureSsoAuth(server, true); + if (!success) return; + } + navigate(AppRoute.HOME); setCurrentServer(server); }; diff --git a/src/renderer/features/search/components/server-commands.tsx b/src/renderer/features/search/components/server-commands.tsx index 5c946b2e86..a6caaafe61 100644 --- a/src/renderer/features/search/components/server-commands.tsx +++ b/src/renderer/features/search/components/server-commands.tsx @@ -3,6 +3,7 @@ import { Dispatch, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router'; +import { ensureSsoAuth } from '/@/renderer/api/sso-interceptor'; import { isServerLock } from '/@/renderer/features/action-required/utils/window-properties'; import { Command, CommandPalettePages } from '/@/renderer/features/search/components/command'; import { ServerList } from '/@/renderer/features/servers/components/server-list'; @@ -33,7 +34,12 @@ export const ServerCommands = ({ handleClose, setPages, setQuery }: ServerComman }, [handleClose, setPages, setQuery, t]); const handleSelectServer = useCallback( - (server: ServerListItemWithCredential) => { + async (server: ServerListItemWithCredential) => { + if (server.isSsoProxy) { + const success = await ensureSsoAuth(server, true); + if (!success) return; + } + navigate(AppRoute.HOME); setCurrentServer(server); handleClose(); diff --git a/src/renderer/features/servers/components/add-server-form.tsx b/src/renderer/features/servers/components/add-server-form.tsx index 67454c8cfe..85214a45d6 100644 --- a/src/renderer/features/servers/components/add-server-form.tsx +++ b/src/renderer/features/servers/components/add-server-form.tsx @@ -105,6 +105,7 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => { const form = useForm({ initialValues: { + isSsoProxy: false, legacyAuth: isLegacyAuth(), name: (localSettings ? localSettings.env.SERVER_NAME : window.SERVER_NAME) || 'My Server', @@ -113,6 +114,7 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => { preferRemoteUrl: false, remoteUrl: '', savePassword: undefined, + ssoCookieName: '', type: (localSettings ? localSettings.env.SERVER_TYPE @@ -146,6 +148,22 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => { try { setIsLoading(true); + + let ssoCookies: Record | undefined; + if (isElectron() && values.isSsoProxy) { + const loginResult = await window.api.sso.login( + values.url, + values.ssoCookieName || 'CF_Authorization', + ); + if (!loginResult.success) { + setIsLoading(false); + return toast.error({ + message: t('error.authenticationFailed', { postProcess: 'sentenceCase' }), + }); + } + ssoCookies = loginResult.cookies; + } + const data: AuthenticationResponse | undefined = await authFunction( values.url, { @@ -166,7 +184,10 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => { credential: data.credential, id: nanoid(), isAdmin: data.isAdmin, + isSsoProxy: values.isSsoProxy, name: values.name, + ssoCookieName: values.ssoCookieName, + ssoCookies, type: values.type as ServerType, url: values.url.replace(/\/$/, ''), userId: data.userId, @@ -270,6 +291,23 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => { {...form.getInputProps('url')} /> + {isElectron() && ( + <> + + {form.values.isSsoProxy && ( + + )} + + )} | undefined = server.ssoCookies; + if ( + isElectron() && + values.isSsoProxy && + (isSsoProxyChanged || urlChanged || ssoCookieNameChanged) + ) { + const loginResult = await window.api.sso.login( + values.url, + values.ssoCookieName || 'CF_Authorization', + ); + if (!loginResult.success) { + setIsLoading(false); + return toast.error({ + message: t('error.authenticationFailed', { + postProcess: 'sentenceCase', + }), + }); + } + ssoCookies = loginResult.cookies; + } + data = await authFunction( values.url, { @@ -130,7 +162,10 @@ export const EditServerForm = ({ isUpdate, onCancel, password, server }: EditSer credential: data.credential, id: server.id, isAdmin: data.isAdmin, + isSsoProxy: values.isSsoProxy, name: values.name, + ssoCookieName: values.ssoCookieName, + ssoCookies, type: values.type, url: values.url, userId: data.userId, @@ -223,6 +258,29 @@ export const EditServerForm = ({ isUpdate, onCancel, password, server }: EditSer rightSection={form.isDirty('url') && } {...form.getInputProps('url')} /> + {isElectron() && ( + + + + {form.isDirty('isSsoProxy') && } + + {form.values.isSsoProxy && ( + + } + {...form.getInputProps('ssoCookieName')} + /> + )} + + )} { [queryClient, t], ); - const openResetConfirmModal = (full: boolean) => { - const key = full ? 'clearCache' : 'clearQueryCache'; + const clearCookies = useCallback(async () => { + setIsClearing(true); + + try { + if (browser) { + await browser.clearCookies(); + } + + toast.success({ + message: t('setting.clearCacheSuccess', { postProcess: 'sentenceCase' }), + }); + } catch (error) { + console.error(error); + toast.error({ message: (error as Error).message }); + } + + setIsClearing(false); + closeAllModals(); + }, [t]); + + const openResetConfirmModal = (type: 'cookies' | 'full' | 'query') => { + let key = 'clearQueryCache'; + let onConfirm = () => clearCache(false); + + if (type === 'full') { + key = 'clearCache'; + onConfirm = () => clearCache(true); + } else if (type === 'cookies') { + key = 'clearCookies'; + onConfirm = clearCookies; + } + openModal({ children: ( - clearCache(full)}> + {t(`common.areYouSure`, { postProcess: 'sentenceCase' })} ), @@ -61,7 +91,7 @@ export const CacheSettings = memo(() => { control: ( + ), + description: t('setting.clearCookies', { + context: 'description', + postProcess: 'sentenceCase', + }), + isHidden: !browser, + title: t('setting.clearCookies', { postProcess: 'sentenceCase' }), + }, ]; const handleOpenApplicationDirectory = async () => { diff --git a/src/renderer/features/sidebar/components/server-selector-items.tsx b/src/renderer/features/sidebar/components/server-selector-items.tsx index d964a3854f..9766ab0faf 100644 --- a/src/renderer/features/sidebar/components/server-selector-items.tsx +++ b/src/renderer/features/sidebar/components/server-selector-items.tsx @@ -3,6 +3,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router'; +import { ensureSsoAuth } from '/@/renderer/api/sso-interceptor'; import { isServerLock } from '/@/renderer/features/action-required/utils/window-properties'; import JellyfinLogo from '/@/renderer/features/servers/assets/jellyfin.png'; import NavidromeLogo from '/@/renderer/features/servers/assets/navidrome.png'; @@ -30,7 +31,12 @@ export const ServerSelectorItems = () => { : { enabled: false, queryKey: ['disabled'] }, ); - const handleSetCurrentServer = (server: ServerListItemWithCredential) => { + const handleSetCurrentServer = async (server: ServerListItemWithCredential) => { + if (server.isSsoProxy) { + const success = await ensureSsoAuth(server, true); + if (!success) return; + } + navigate(AppRoute.HOME); setCurrentServer(server); setMusicFolderId(undefined); diff --git a/src/renderer/hooks/use-server-authenticated.ts b/src/renderer/hooks/use-server-authenticated.ts index b7a005112c..3d6251fa74 100644 --- a/src/renderer/hooks/use-server-authenticated.ts +++ b/src/renderer/hooks/use-server-authenticated.ts @@ -7,6 +7,7 @@ import { useNavigate } from 'react-router'; import { api } from '/@/renderer/api'; import { controller } from '/@/renderer/api/controller'; +import { SSO_CANCELLED_ERROR } from '/@/renderer/api/sso-interceptor'; import { AppRoute } from '/@/renderer/router/routes'; import { getServerById, useAuthStoreActions, useCurrentServer } from '/@/renderer/store'; import { LogCategory, logFn } from '/@/renderer/utils/logger'; @@ -269,6 +270,12 @@ export const useServerAuthenticated = () => { } } catch (error) { const errorMessage = (error as Error).message || 'Authentication failed'; + + if (errorMessage === SSO_CANCELLED_ERROR) { + setReady(AuthState.INVALID); + return; + } + const isNetwork = isNetworkError(error); // If it's a network error and we haven't exhausted retries, retry diff --git a/src/renderer/store/auth.store.ts b/src/renderer/store/auth.store.ts index 09dc25e6a3..b2f9dde17d 100644 --- a/src/renderer/store/auth.store.ts +++ b/src/renderer/store/auth.store.ts @@ -1,4 +1,5 @@ import merge from 'lodash/merge'; +import omit from 'lodash/omit'; import { nanoid } from 'nanoid/non-secure'; import { devtools, persist } from 'zustand/middleware'; import { immer } from 'zustand/middleware/immer'; @@ -95,6 +96,24 @@ export const useAuthStore = createWithEqualityFn()( { merge: (persistedState, currentState) => merge(currentState, persistedState), name: 'store_authentication', + partialize: (state) => { + const sanitizedServerList = Object.fromEntries( + Object.entries(state.serverList).map(([id, server]) => [ + id, + omit(server, ['ssoCookies']), + ]), + ); + + const sanitizedCurrentServer = state.currentServer + ? (omit(state.currentServer, ['ssoCookies']) as ServerListItemWithCredential) + : null; + + return { + ...state, + currentServer: sanitizedCurrentServer, + serverList: sanitizedServerList, + }; + }, version: 2, }, ), diff --git a/src/shared/types/domain-types.ts b/src/shared/types/domain-types.ts index 6db45a0dd6..3e76d63218 100644 --- a/src/shared/types/domain-types.ts +++ b/src/shared/types/domain-types.ts @@ -87,12 +87,15 @@ export type ServerListItem = { features?: ServerFeatures; id: string; isAdmin?: boolean; + isSsoProxy?: boolean; musicFolderId?: string[]; name: string; preferInstantMix?: boolean; preferRemoteUrl?: boolean; remoteUrl?: string; savePassword?: boolean; + ssoCookieName?: string; + ssoCookies?: Record; type: ServerType; url: string; userId: null | string; @@ -410,6 +413,11 @@ export type Song = { userRating: null | number; }; +export type SsoLoginResponse = { + cookies: Record; + success: boolean; +}; + type BaseEndpointArgs = { apiClientProps: { server?: null | ServerListItemWithCredential; From de8765b05171fd709dabcdb6aec29dbeaa3cd106 Mon Sep 17 00:00:00 2001 From: Remo Rothacher Date: Thu, 12 Mar 2026 12:58:29 +0100 Subject: [PATCH 2/5] fix lint errors --- src/main/features/sso-login.ts | 2 +- src/renderer/api/sso-interceptor.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/features/sso-login.ts b/src/main/features/sso-login.ts index 9a3659dd29..75df9f7954 100644 --- a/src/main/features/sso-login.ts +++ b/src/main/features/sso-login.ts @@ -45,7 +45,7 @@ export const handleSsoLogin = async ( // We could also poll or listen to navigation to see if we reached the app // but often SSO proxies redirect back to the original URL. - ssoWindow.webContents.on('did-navigate', async (event, navigatedUrl) => { + ssoWindow.webContents.on('did-navigate', async (_event, navigatedUrl) => { if (navigatedUrl.startsWith(url)) { // Potential success, but let the user decide if they are done or wait for a specific cookie const cookies = await checkCookies(); diff --git a/src/renderer/api/sso-interceptor.tsx b/src/renderer/api/sso-interceptor.tsx index b49256ca15..4c47b89339 100644 --- a/src/renderer/api/sso-interceptor.tsx +++ b/src/renderer/api/sso-interceptor.tsx @@ -71,7 +71,7 @@ export const ensureSsoAuth = async ( handleCancel(); } } catch (error) { - logFn.error('SSO login error:', error); + logFn.error('SSO login error:', { meta: error }); handleCancel(); } }; From 85bc5dd4918d4a4e2430c4228441164377ffd864 Mon Sep 17 00:00:00 2001 From: Remo Rothacher Date: Thu, 12 Mar 2026 21:20:56 +0100 Subject: [PATCH 3/5] add constants for sso cookie keys --- src/main/features/sso-login.ts | 3 ++- src/renderer/api/sso-interceptor.tsx | 5 +++-- src/renderer/features/servers/components/add-server-form.tsx | 5 +++-- .../features/servers/components/edit-server-form.tsx | 5 +++-- src/shared/constants/sso-cookie-keys.ts | 3 +++ 5 files changed, 14 insertions(+), 7 deletions(-) create mode 100644 src/shared/constants/sso-cookie-keys.ts diff --git a/src/main/features/sso-login.ts b/src/main/features/sso-login.ts index 75df9f7954..60955f1056 100644 --- a/src/main/features/sso-login.ts +++ b/src/main/features/sso-login.ts @@ -1,11 +1,12 @@ import { BrowserWindow, session } from 'electron'; +import { SSO_COOKIE_KEYS } from '/@/shared/constants/sso-cookie-keys'; import { SsoLoginResponse } from '/@/shared/types/domain-types'; export const handleSsoLogin = async ( _event: any, url: string, - ssoCookieName = 'CF_Authorization', + ssoCookieName = SSO_COOKIE_KEYS.CLOUDFLARE_ACCESS, ): Promise => { if (!url.startsWith('http://') && !url.startsWith('https://')) { throw new Error('Invalid SSO URL protocol'); diff --git a/src/renderer/api/sso-interceptor.tsx b/src/renderer/api/sso-interceptor.tsx index 4c47b89339..156d5e6e28 100644 --- a/src/renderer/api/sso-interceptor.tsx +++ b/src/renderer/api/sso-interceptor.tsx @@ -8,6 +8,7 @@ import { Group } from '/@/shared/components/group/group'; import { closeAllModals, openModal } from '/@/shared/components/modal/modal'; import { Stack } from '/@/shared/components/stack/stack'; import { Text } from '/@/shared/components/text/text'; +import { SSO_COOKIE_KEYS } from '/@/shared/constants/sso-cookie-keys'; import { ServerListItemWithCredential } from '/@/shared/types/domain-types'; export const SSO_CANCELLED_ERROR = 'SSO login cancelled'; @@ -31,7 +32,7 @@ export const ensureSsoAuth = async ( if (isInitialLogin) { const result = await window.api.sso.login( server.url, - server.ssoCookieName || 'CF_Authorization', + server.ssoCookieName || SSO_COOKIE_KEYS.CLOUDFLARE_ACCESS, ); if (timedOut) return false; @@ -52,7 +53,7 @@ export const ensureSsoAuth = async ( try { const result = await window.api.sso.login( server.url, - server.ssoCookieName || 'CF_Authorization', + server.ssoCookieName || SSO_COOKIE_KEYS.CLOUDFLARE_ACCESS, ); if (timedOut) { diff --git a/src/renderer/features/servers/components/add-server-form.tsx b/src/renderer/features/servers/components/add-server-form.tsx index 85214a45d6..c955802cae 100644 --- a/src/renderer/features/servers/components/add-server-form.tsx +++ b/src/renderer/features/servers/components/add-server-form.tsx @@ -25,6 +25,7 @@ import { Stack } from '/@/shared/components/stack/stack'; import { TextInput } from '/@/shared/components/text-input/text-input'; import { Text } from '/@/shared/components/text/text'; import { toast } from '/@/shared/components/toast/toast'; +import { SSO_COOKIE_KEYS } from '/@/shared/constants/sso-cookie-keys'; import { useFocusTrap } from '/@/shared/hooks/use-focus-trap'; import { useForm } from '/@/shared/hooks/use-form'; import { AuthenticationResponse, ServerListItemWithCredential } from '/@/shared/types/domain-types'; @@ -153,7 +154,7 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => { if (isElectron() && values.isSsoProxy) { const loginResult = await window.api.sso.login( values.url, - values.ssoCookieName || 'CF_Authorization', + values.ssoCookieName || SSO_COOKIE_KEYS.CLOUDFLARE_ACCESS, ); if (!loginResult.success) { setIsLoading(false); @@ -302,7 +303,7 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => { {form.values.isSsoProxy && ( )} diff --git a/src/renderer/features/servers/components/edit-server-form.tsx b/src/renderer/features/servers/components/edit-server-form.tsx index a79077b602..021f6d9df8 100644 --- a/src/renderer/features/servers/components/edit-server-form.tsx +++ b/src/renderer/features/servers/components/edit-server-form.tsx @@ -16,6 +16,7 @@ import { Stack } from '/@/shared/components/stack/stack'; import { TextInput } from '/@/shared/components/text-input/text-input'; import { toast } from '/@/shared/components/toast/toast'; import { Tooltip } from '/@/shared/components/tooltip/tooltip'; +import { SSO_COOKIE_KEYS } from '/@/shared/constants/sso-cookie-keys'; import { useFocusTrap } from '/@/shared/hooks/use-focus-trap'; import { useForm } from '/@/shared/hooks/use-form'; import { @@ -129,7 +130,7 @@ export const EditServerForm = ({ isUpdate, onCancel, password, server }: EditSer ) { const loginResult = await window.api.sso.login( values.url, - values.ssoCookieName || 'CF_Authorization', + values.ssoCookieName || SSO_COOKIE_KEYS.CLOUDFLARE_ACCESS, ); if (!loginResult.success) { setIsLoading(false); @@ -272,7 +273,7 @@ export const EditServerForm = ({ isUpdate, onCancel, password, server }: EditSer {form.values.isSsoProxy && ( } diff --git a/src/shared/constants/sso-cookie-keys.ts b/src/shared/constants/sso-cookie-keys.ts new file mode 100644 index 0000000000..8bda2b9d69 --- /dev/null +++ b/src/shared/constants/sso-cookie-keys.ts @@ -0,0 +1,3 @@ +export const SSO_COOKIE_KEYS = { + CLOUDFLARE_ACCESS: 'CF_Authorization', +} as const; From d96950ba4adbe4ce1b58a7a6974cacb906add304 Mon Sep 17 00:00:00 2001 From: Remo Rothacher Date: Sat, 14 Mar 2026 00:19:18 +0100 Subject: [PATCH 4/5] feat: enable SSO authentication support for web version --- src/i18n/locales/en.json | 10 ++ src/renderer/api/sso-interceptor.tsx | 115 ++++++++++++------ .../servers/components/add-server-form.tsx | 82 +++++++++---- .../servers/components/edit-server-form.tsx | 98 +++++++++------ 4 files changed, 208 insertions(+), 97 deletions(-) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 81d086a68e..ab7146a0b1 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -318,6 +318,8 @@ "error_savePassword": "an error occurred when trying to save the password", "ignoreCors": "ignore cors ($t(common.restartRequired))", "ignoreSsl": "ignore ssl ($t(common.restartRequired))", + "input_isSsoProxy": "server is behind an SSO proxy (e.g. Cloudflare Access, Authelia)", + "input_isSsoProxyDescription": "the web version requires Feishin to be hosted on the same domain as your music server, or for your SSO provider to be configured with appropriate CORS headers.", "input_legacyAuthentication": "enable legacy authentication", "input_name": "server name", "input_password": "password", @@ -327,8 +329,15 @@ "input_remoteUrl": "public url", "input_remoteUrlPlaceholder": "optional: public url for external features", "input_savePassword": "save password", + "input_ssoCookieName": "SSO cookie name", "input_url": "url", "input_username": "username", + "sso_title": "login required", + "sso_description": "your SSO session has expired. please re-authenticate to continue.", + "sso_login": "login", + "sso_openLoginPage": "open login page", + "sso_confirmLogin": "i've logged in", + "sso_switchServer": "switch server", "success": "server added successfully", "title": "add server" }, @@ -345,6 +354,7 @@ "title": "add to $t(entity.playlist, {\"count\": 1})" }, "createPlaylist": { + "input_description": "$t(common.description)", "input_name": "$t(common.name)", "input_owner": "$t(common.owner)", diff --git a/src/renderer/api/sso-interceptor.tsx b/src/renderer/api/sso-interceptor.tsx index 156d5e6e28..608f03fc77 100644 --- a/src/renderer/api/sso-interceptor.tsx +++ b/src/renderer/api/sso-interceptor.tsx @@ -1,4 +1,5 @@ import { AxiosInstance } from 'axios'; +import { t } from 'i18next'; import isElectron from 'is-electron'; import { useAuthStore } from '/@/renderer/store'; @@ -30,20 +31,26 @@ export const ensureSsoAuth = async ( const reauthLogic = async (): Promise => { try { if (isInitialLogin) { - const result = await window.api.sso.login( - server.url, - server.ssoCookieName || SSO_COOKIE_KEYS.CLOUDFLARE_ACCESS, - ); + if (isElectron()) { + const result = await window.api.sso.login( + server.url, + server.ssoCookieName || SSO_COOKIE_KEYS.CLOUDFLARE_ACCESS, + ); - if (timedOut) return false; + if (timedOut) return false; - if (result && result.success) { - useAuthStore - .getState() - .actions.updateServer(server.id, { ssoCookies: result.cookies }); + if (result && result.success) { + useAuthStore + .getState() + .actions.updateServer(server.id, { ssoCookies: result.cookies }); + return true; + } + return false; + } else { + // Web version: we can't capture cookies, but we can't do much here + // either as this is called during initial login. return true; } - return false; } return await new Promise((resolve) => { @@ -51,25 +58,30 @@ export const ensureSsoAuth = async ( const handleLogin = async () => { try { - const result = await window.api.sso.login( - server.url, - server.ssoCookieName || SSO_COOKIE_KEYS.CLOUDFLARE_ACCESS, - ); - - if (timedOut) { - resolve(false); - return; - } + if (isElectron()) { + const result = await window.api.sso.login( + server.url, + server.ssoCookieName || SSO_COOKIE_KEYS.CLOUDFLARE_ACCESS, + ); - if (result && result.success) { - useAuthStore - .getState() - .actions.updateServer(server.id, { ssoCookies: result.cookies }); - isResolved = true; - closeAllModals(); - resolve(true); + if (timedOut) { + resolve(false); + return; + } + + if (result && result.success) { + useAuthStore.getState().actions.updateServer(server.id, { + ssoCookies: result.cookies, + }); + isResolved = true; + closeAllModals(); + resolve(true); + } else { + handleCancel(); + } } else { - handleCancel(); + window.open(server.url, '_blank', 'noreferrer'); + // We don't resolve here, we wait for the user to click "I've logged in" } } catch (error) { logFn.error('SSO login error:', { meta: error }); @@ -92,22 +104,55 @@ export const ensureSsoAuth = async ( children: ( - Your SSO session has expired. Please re-authenticate to continue. + {t('form.addServer.sso_description', { + postProcess: 'sentenceCase', + })} - + {isElectron() ? ( + + ) : ( + <> + + + + )} ), closeOnClickOutside: false, closeOnEscape: false, onClose: handleCancel, - title: 'Login Required', + title: t('form.addServer.sso_title', { postProcess: 'titleCase' }), withCloseButton: false, }); }); @@ -146,7 +191,7 @@ const handleSsoResponse = async ( response: any, server: null | ServerListItemWithCredential, ) => { - if (!isElectron() || !server?.isSsoProxy || !response) { + if (!server?.isSsoProxy || !response) { return response; } @@ -173,7 +218,7 @@ const handleSsoError = async ( error: any, server: null | ServerListItemWithCredential, ) => { - if (!isElectron() || !server?.isSsoProxy) { + if (!server?.isSsoProxy) { throw error; } diff --git a/src/renderer/features/servers/components/add-server-form.tsx b/src/renderer/features/servers/components/add-server-form.tsx index c955802cae..2afb5cd0df 100644 --- a/src/renderer/features/servers/components/add-server-form.tsx +++ b/src/renderer/features/servers/components/add-server-form.tsx @@ -5,6 +5,7 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { api } from '/@/renderer/api'; +import { ensureSsoAuth } from '/@/renderer/api/sso-interceptor'; import { isLegacyAuth, isServerLock, @@ -151,18 +152,35 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => { setIsLoading(true); let ssoCookies: Record | undefined; - if (isElectron() && values.isSsoProxy) { - const loginResult = await window.api.sso.login( - values.url, - values.ssoCookieName || SSO_COOKIE_KEYS.CLOUDFLARE_ACCESS, - ); - if (!loginResult.success) { - setIsLoading(false); - return toast.error({ - message: t('error.authenticationFailed', { postProcess: 'sentenceCase' }), - }); + if (values.isSsoProxy) { + if (isElectron()) { + const loginResult = await window.api.sso.login( + values.url, + values.ssoCookieName || SSO_COOKIE_KEYS.CLOUDFLARE_ACCESS, + ); + if (!loginResult.success) { + setIsLoading(false); + return toast.error({ + message: t('error.authenticationFailed', { + postProcess: 'sentenceCase', + }), + }); + } + ssoCookies = loginResult.cookies; + } else { + // For web, we trigger the ensureSsoAuth modal to make sure the user is logged in + const dummyServer = { + id: 'temp', + isSsoProxy: true, + ssoCookieName: values.ssoCookieName, + url: values.url, + } as any; + const authenticated = await ensureSsoAuth(dummyServer); + if (!authenticated) { + setIsLoading(false); + return; + } } - ssoCookies = loginResult.cookies; } const data: AuthenticationResponse | undefined = await authFunction( @@ -292,23 +310,35 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => { {...form.getInputProps('url')} /> - {isElectron() && ( - <> - + + {form.values.isSsoProxy && ( + - {form.values.isSsoProxy && ( - - )} - - )} + )} + | undefined = server.ssoCookies; if ( - isElectron() && values.isSsoProxy && (isSsoProxyChanged || urlChanged || ssoCookieNameChanged) ) { - const loginResult = await window.api.sso.login( - values.url, - values.ssoCookieName || SSO_COOKIE_KEYS.CLOUDFLARE_ACCESS, - ); - if (!loginResult.success) { - setIsLoading(false); - return toast.error({ - message: t('error.authenticationFailed', { - postProcess: 'sentenceCase', - }), - }); + if (isElectron()) { + const loginResult = await window.api.sso.login( + values.url, + values.ssoCookieName || SSO_COOKIE_KEYS.CLOUDFLARE_ACCESS, + ); + if (!loginResult.success) { + setIsLoading(false); + return toast.error({ + message: t('error.authenticationFailed', { + postProcess: 'sentenceCase', + }), + }); + } + ssoCookies = loginResult.cookies; + } else { + const dummyServer = { + id: server.id, + isSsoProxy: true, + ssoCookieName: values.ssoCookieName, + url: values.url, + } as any; + const authenticated = await ensureSsoAuth(dummyServer); + if (!authenticated) { + setIsLoading(false); + return; + } } - ssoCookies = loginResult.cookies; } data = await authFunction( @@ -259,29 +273,41 @@ export const EditServerForm = ({ isUpdate, onCancel, password, server }: EditSer rightSection={form.isDirty('url') && } {...form.getInputProps('url')} /> - {isElectron() && ( - - - - {form.isDirty('isSsoProxy') && } - - {form.values.isSsoProxy && ( - - } - {...form.getInputProps('ssoCookieName')} - /> - )} - - )} + + + + {form.isDirty('isSsoProxy') && } + + {form.values.isSsoProxy && ( + + } + {...form.getInputProps('ssoCookieName')} + /> + )} + Date: Sat, 14 Mar 2026 00:59:58 +0100 Subject: [PATCH 5/5] move labels and fix casing --- src/i18n/locales/en.json | 19 ++++++++------- src/renderer/api/sso-interceptor.tsx | 24 +++++-------------- .../servers/components/add-server-form.tsx | 3 --- .../servers/components/edit-server-form.tsx | 3 --- 4 files changed, 16 insertions(+), 33 deletions(-) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index ab7146a0b1..4761046bde 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -318,8 +318,8 @@ "error_savePassword": "an error occurred when trying to save the password", "ignoreCors": "ignore cors ($t(common.restartRequired))", "ignoreSsl": "ignore ssl ($t(common.restartRequired))", - "input_isSsoProxy": "server is behind an SSO proxy (e.g. Cloudflare Access, Authelia)", - "input_isSsoProxyDescription": "the web version requires Feishin to be hosted on the same domain as your music server, or for your SSO provider to be configured with appropriate CORS headers.", + "input_isSsoProxy": "Server is behind an SSO proxy (e.g. Cloudflare Access, Authelia)", + "input_isSsoProxyDescription": "The web version requires Feishin to be hosted on the same domain as your music server, or for your SSO provider to be configured with appropriate CORS headers.", "input_legacyAuthentication": "enable legacy authentication", "input_name": "server name", "input_password": "password", @@ -332,12 +332,6 @@ "input_ssoCookieName": "SSO cookie name", "input_url": "url", "input_username": "username", - "sso_title": "login required", - "sso_description": "your SSO session has expired. please re-authenticate to continue.", - "sso_login": "login", - "sso_openLoginPage": "open login page", - "sso_confirmLogin": "i've logged in", - "sso_switchServer": "switch server", "success": "server added successfully", "title": "add server" }, @@ -354,7 +348,6 @@ "title": "add to $t(entity.playlist, {\"count\": 1})" }, "createPlaylist": { - "input_description": "$t(common.description)", "input_name": "$t(common.name)", "input_owner": "$t(common.owner)", @@ -1090,6 +1083,14 @@ "queryBuilderCustomFields": "custom fields", "queryBuilderCustomFields_description": "add custom fields to use in query builders" }, + "ssoInterceptor": { + "title": "Login Required", + "description": "Your SSO session has expired. Please re-authenticate to continue.", + "login": "Login", + "openLoginPage": "Open Login Page", + "confirmLogin": "I've Logged In", + "switchServer": "Switch Server" + }, "table": { "column": { "album": "album", diff --git a/src/renderer/api/sso-interceptor.tsx b/src/renderer/api/sso-interceptor.tsx index 608f03fc77..63c53ff660 100644 --- a/src/renderer/api/sso-interceptor.tsx +++ b/src/renderer/api/sso-interceptor.tsx @@ -103,22 +103,14 @@ export const ensureSsoAuth = async ( openModal({ children: ( - - {t('form.addServer.sso_description', { - postProcess: 'sentenceCase', - })} - + {t('ssoInterceptor.description')} {isElectron() ? ( ) : ( <> @@ -128,9 +120,7 @@ export const ensureSsoAuth = async ( }} variant="outline" > - {t('form.addServer.sso_openLoginPage', { - postProcess: 'titleCase', - })} + {t('ssoInterceptor.openLoginPage')} )} @@ -152,7 +140,7 @@ export const ensureSsoAuth = async ( closeOnClickOutside: false, closeOnEscape: false, onClose: handleCancel, - title: t('form.addServer.sso_title', { postProcess: 'titleCase' }), + title: t('ssoInterceptor.title'), withCloseButton: false, }); }); diff --git a/src/renderer/features/servers/components/add-server-form.tsx b/src/renderer/features/servers/components/add-server-form.tsx index 2afb5cd0df..6cca915da4 100644 --- a/src/renderer/features/servers/components/add-server-form.tsx +++ b/src/renderer/features/servers/components/add-server-form.tsx @@ -316,13 +316,11 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => { !isElectron() ? t('form.addServer.input', { context: 'isSsoProxyDescription', - postProcess: 'sentenceCase', }) : undefined } label={t('form.addServer.input', { context: 'isSsoProxy', - postProcess: 'titleCase', })} {...form.getInputProps('isSsoProxy', { type: 'checkbox', @@ -332,7 +330,6 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {