Skip to content
13 changes: 13 additions & 0 deletions src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,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",
Expand All @@ -331,6 +333,7 @@
"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",
"success": "server added successfully",
Expand Down Expand Up @@ -767,6 +770,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",
Expand Down Expand Up @@ -1094,6 +1099,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",
Expand Down
61 changes: 61 additions & 0 deletions src/main/features/sso-login.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
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 = SSO_COOKIE_KEYS.CLOUDFLARE_ACCESS,
): Promise<SsoLoginResponse> => {
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<string, string> = {};
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();
}
}
});
});
};
26 changes: 25 additions & 1 deletion src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
protocol,
Rectangle,
screen,
session,
shell,
Tray,
} from 'electron';
Expand All @@ -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,
Expand All @@ -39,7 +42,6 @@ import {
isMacOS,
isWindows,
} from './utils';
import './features';

import { PlayerType, TitleTheme } from '/@/shared/types/types';

Expand Down Expand Up @@ -558,6 +560,26 @@ async function createWindow(first = true): Promise<void> {
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 }> => {
Expand Down Expand Up @@ -666,6 +688,7 @@ async function createWindow(first = true): Promise<void> {

mainWindow.on('closed', () => {
ipcMain.removeHandler('window-clear-cache');
ipcMain.removeHandler('window-clear-cookies');
ipcMain.removeHandler('app-check-for-updates');
mainWindow = null;
});
Expand Down Expand Up @@ -932,6 +955,7 @@ if (!singleInstance) {
});

createWindow();
ipcMain.handle('sso:login', handleSsoLogin);
if (store.get('window_enable_tray', true)) {
createTray();
}
Expand Down
5 changes: 5 additions & 0 deletions src/preload/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,13 @@ const clearCache = (): Promise<void> => {
return ipcRenderer.invoke('window-clear-cache');
};

const clearCookies = (url?: string): Promise<void> => {
return ipcRenderer.invoke('window-clear-cookies', url);
};

export const browser = {
clearCache,
clearCookies,
devtools,
exit,
maximize,
Expand Down
2 changes: 2 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,6 +25,7 @@ const api = {
mpvPlayer,
mpvPlayerListener,
remote,
sso,
utils,
};

Expand Down
8 changes: 8 additions & 0 deletions src/preload/sso.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { ipcRenderer } from 'electron';

import { SsoLoginResponse } from '/@/shared/types/domain-types';

export const sso = {
login: (url: string, ssoCookieName?: string): Promise<SsoLoginResponse> =>
ipcRenderer.invoke('sso:login', url, ssoCookieName),
};
12 changes: 7 additions & 5 deletions src/renderer/api/jellyfin/jellyfin-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/renderer/api/jellyfin/jellyfin-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() },
Expand Down
16 changes: 11 additions & 5 deletions src/renderer/api/navidrome/navidrome-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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('?');

Expand Down Expand Up @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading