From 79e16e791e9f93095db44cc0bae4bf06203b0711 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Fri, 18 Sep 2026 14:23:13 +0200 Subject: [PATCH] feat(auth): two-step login with SSO (SAML2 and OIDC) Rebased onto main after the eslint 9 migration (#220). The three original commits are squashed into one: they were authored against the unformatted tree, so replaying them individually onto the reformatted main conflicted in six files with nothing but whitespace. Formatting both sides with the same config first and transferring the resulting diff keeps the change set identical to what was reviewed (+515/-116 against main, vs +458/-106 before) without hand-resolving formatting noise. Squashed from: 37dfa9c feat(auth): two-step login with Single Sign-On support 7a3f336 feat(auth): extend SSO support to OIDC bf1b88b feat(auth): show a clear error when an SSO user is not enabled on CTI Conflict resolved during the rebase: the IPC_EVENTS enum in src/shared/constants.ts gained GET_HOST_CONFIG, SET_HOST_CONFIG, SSO_LOGIN, SSO_LOGIN_RESULT and RECONNECT_PHONE_ISLAND. None of the five exist on main, so all were kept. Still a draft: not ready to merge. --- public/locales/en/translations.json | 6 +- public/locales/it/translations.json | 6 +- .../classes/controllers/AccountController.ts | 10 + src/main/lib/ipcEvents.ts | 98 +++++ src/main/main.ts | 2 +- .../public/locales/en/translations.json | 3 + .../public/locales/it/translations.json | 3 + .../pageComponents/login/LoginForm.tsx | 347 +++++++++++++----- src/renderer/src/pages/LoginPage.tsx | 70 +++- src/renderer/src/store.ts | 2 + src/shared/constants.ts | 5 + src/shared/types.ts | 22 +- src/shared/useLogin.ts | 20 +- src/shared/useNethVoiceAPI.ts | 37 +- 14 files changed, 515 insertions(+), 116 deletions(-) diff --git a/public/locales/en/translations.json b/public/locales/en/translations.json index 5caee65b..d8b17718 100644 --- a/public/locales/en/translations.json +++ b/public/locales/en/translations.json @@ -119,7 +119,11 @@ "OTP verification failed": "OTP verification failed", "Two-Factor Authentication": "Two-Factor Authentication", "Enter the 6-digit code (OTP code) from your authenticator app. If you cannot access the app, you can use one recovery OTP code.": "Enter the 6-digit code (OTP code) from your authenticator app. If you cannot access the app, you can use one recovery OTP code." - } + }, + "Continue": "Continue", + "Sign in with SSO": "Sign in with SSO", + "SSO login failed": "Single Sign-On failed, try again", + "SSO user not enabled": "You signed in on the identity provider, but this user is not enabled to use the CTI. Contact your administrator." }, "SplashScreen": { "Description": "Welcome to NethLink, a desktop solution for seamless communication. Make and receive calls, save contacts to you phonebook and much more.", diff --git a/public/locales/it/translations.json b/public/locales/it/translations.json index 6cd3bb1b..7e5bf0d9 100644 --- a/public/locales/it/translations.json +++ b/public/locales/it/translations.json @@ -119,7 +119,11 @@ "OTP verification failed": "Verifica OTP fallita", "Two-Factor Authentication": "Autenticazione a Due Fattori", "Enter the 6-digit code (OTP code) from your authenticator app. If you cannot access the app, you can use one recovery OTP code.": "Inserisci il codice a 6 cifre (codice OTP) dalla tua app di autenticazione. Se non puoi accedere all'app, puoi utilizzare un codice OTP di recupero." - } + }, + "Continue": "Continua", + "Sign in with SSO": "Accedi con SSO", + "SSO login failed": "Accesso Single Sign-On non riuscito, riprova", + "SSO user not enabled": "Ti sei autenticato sull'identity provider, ma questo utente non è abilitato all'uso del CTI. Contatta l'amministratore." }, "SplashScreen": { "Description": "Benvenuti in NethLink, la soluzione desktop per comunicazioni senza confini. Effettua e ricevi chiamate, salva i contatti nella tua rubrica e molto altro ancora.", diff --git a/src/main/classes/controllers/AccountController.ts b/src/main/classes/controllers/AccountController.ts index 5fb1604a..ff18a1cb 100644 --- a/src/main/classes/controllers/AccountController.ts +++ b/src/main/classes/controllers/AccountController.ts @@ -4,6 +4,7 @@ import { AvailableDevices, ConfigFile, PhoneIslandPosition, + isSsoMethod, } from '@shared/types' import { Log } from '@shared/utils/logger' import { safeStorage } from 'electron' @@ -200,6 +201,15 @@ export class AccountController { } } + // SSO accounts have no password: when the token expires the user + // must go through the interactive SSO flow again + if (isSsoMethod(lastLoggedAccount.authenticationMethod)) { + Log.info( + 'auto login failed: SSO account token expired, user interaction needed', + ) + return false + } + // Token is expired or doesn't exist, do a new login const tempLoggedAccount = await this.NethVoiceAPI.Authentication.login( diff --git a/src/main/lib/ipcEvents.ts b/src/main/lib/ipcEvents.ts index dffaeaa3..c8874946 100644 --- a/src/main/lib/ipcEvents.ts +++ b/src/main/lib/ipcEvents.ts @@ -8,6 +8,7 @@ import { BrowserWindow, app, ipcMain, + net, screen, shell, desktopCapturer, @@ -544,6 +545,103 @@ export function registerIpcEvents() { e.reply(IPC_EVENTS.SET_NETHVOICE_CONFIG, account) }) + ipcMain.on(IPC_EVENTS.GET_HOST_CONFIG, async (e, host: string) => { + // read the authentication capabilities of the host before asking credentials + const { parseHostConfig } = useLogin() + try { + const config: string = await NetworkController.instance.get( + `https://${host}/config/config.production.js`, + ) + e.reply(IPC_EVENTS.SET_HOST_CONFIG, { + hostConfig: parseHostConfig(config), + }) + } catch (error: any) { + e.reply(IPC_EVENTS.SET_HOST_CONFIG, { + error: error?.message || 'unreachable host', + }) + } + }) + + ipcMain.on( + IPC_EVENTS.SSO_LOGIN, + (event, payload: { host: string; url: string }) => { + // Single Sign-On: run the SAML dance in a dedicated browser window, then + // mint the JWT on the forwardAuth-guarded endpoint using the window session + // cookies. The persistent partition keeps the IdP session across logins. + const { host, url } = payload + const win = new BrowserWindow({ + width: 520, + height: 660, + autoHideMenuBar: true, + webPreferences: { + partition: 'persist:sso', + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + }, + }) + let settled = false + const settle = (token?: string, error?: string) => { + if (settled) return + settled = true + event.reply(IPC_EVENTS.SSO_LOGIN_RESULT, { token, error }) + if (!win.isDestroyed()) win.destroy() + } + const mint = () => { + const request = net.request({ + url: `https://${host}/api/sso-login`, + method: 'POST', + session: win.webContents.session, + useSessionCookies: true, + }) + request.setHeader('Content-Type', 'application/json') + request.on('response', (response) => { + let body = '' + response.on('data', (chunk) => (body += chunk)) + response.on('end', () => { + try { + const token = JSON.parse(body).token + if (response.statusCode === 200 && token) settle(token) + // authenticated on the IdP but the mint was rejected: a 401/403 + // means the user is not enabled on this CTI + else if ( + response.statusCode === 401 || + response.statusCode === 403 + ) + settle(undefined, 'SSO_USER_NOT_ENABLED') + else + settle( + undefined, + `SSO login failed with status ${response.statusCode}`, + ) + } catch { + settle(undefined, 'SSO login failed: invalid response') + } + }) + }) + request.on('error', (error) => settle(undefined, error.message)) + request.end('{}') + } + // the SSO flow ends with a redirect to https:///?ssologin=1: the + // session cookie is already set, mint the token instead of loading the app + const checkUrl = (e: Electron.Event, newUrl: string) => { + try { + const u = new URL(newUrl) + if (u.hostname === host && u.searchParams.has('ssologin')) { + e.preventDefault() + mint() + } + } catch (err) { + Log.warning('SSO login: unparsable navigation url', newUrl) + } + } + win.webContents.on('will-redirect', checkUrl) + win.webContents.on('will-navigate', checkUrl) + win.on('closed', () => settle(undefined, 'SSO window closed')) + win.loadURL(url) + }, + ) + ipcMain.on(IPC_EVENTS.EMIT_QUEUE_UPDATE, (_, queue) => { try { NethLinkController.instance.window.emit( diff --git a/src/main/main.ts b/src/main/main.ts index d8827f12..c2482344 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -104,7 +104,7 @@ function startup() { if ( LoginController.instance && LoginController.instance.window.isOpen() && - password && + password !== undefined && account ) { Log.info('LOGIN SUCCESS') diff --git a/src/renderer/public/locales/en/translations.json b/src/renderer/public/locales/en/translations.json index fbc96a82..16883fba 100644 --- a/src/renderer/public/locales/en/translations.json +++ b/src/renderer/public/locales/en/translations.json @@ -112,6 +112,9 @@ "Delete account": "Are you sure you want to delete {{username}}?", "Back": "Back", "User not authorized for NethLink": "User not authorized for NethLink", + "Sign in with SSO": "Sign in with SSO", + "SSO login failed": "Single Sign-On failed, try again", + "SSO user not enabled": "You signed in on the identity provider, but this user is not enabled to use the CTI. Contact your administrator.", "Generic error": "Generic error", "2FA": { "OTP code": "OTP code", diff --git a/src/renderer/public/locales/it/translations.json b/src/renderer/public/locales/it/translations.json index da90b8f8..468aa8de 100644 --- a/src/renderer/public/locales/it/translations.json +++ b/src/renderer/public/locales/it/translations.json @@ -112,6 +112,9 @@ "Delete account": "Sei sicuro di voler eliminare {{username}}?", "Back": "Indietro", "User not authorized for NethLink": "Utente non autorizzato per NethLink", + "Sign in with SSO": "Accedi con SSO", + "SSO login failed": "Single Sign-On non riuscito, riprova", + "SSO user not enabled": "Ti sei autenticato sull'identity provider, ma questo utente non è abilitato all'uso del CTI. Contatta l'amministratore.", "Generic error": "Errore generico", "2FA": { "OTP code": "Codice OTP", diff --git a/src/renderer/src/components/pageComponents/login/LoginForm.tsx b/src/renderer/src/components/pageComponents/login/LoginForm.tsx index fdade10f..769a072c 100644 --- a/src/renderer/src/components/pageComponents/login/LoginForm.tsx +++ b/src/renderer/src/components/pageComponents/login/LoginForm.tsx @@ -11,7 +11,7 @@ import { import { t } from 'i18next' import { useEffect, useRef, useState } from 'react' import { Button, TextInput } from '@renderer/components/Nethesis' -import { Account, LoginData } from '@shared/types' +import { Account, HostConfig, LoginData, isSsoMethod } from '@shared/types' import { DisplayedAccountLogin } from './DisplayedAccountLogin' import { OTPInput, OTPInputRef } from './OTPInput' import { useLoginPageData, useSharedState } from '@renderer/store' @@ -43,30 +43,32 @@ export const LoginForm = ({ onError, handleRefreshConnection }) => { const [showTwoFactor, setShowTwoFactor] = useLoginPageData('showTwoFactor') const [tempAccount, setTempAccount] = useState(undefined) const [otpDisabled, setOtpDisabled] = useState(false) + const [loginStep, setLoginStep] = useLoginPageData('loginStep') + const [hostConfig, setHostConfig] = useLoginPageData('hostConfig') const passwordRef = useRef() const otpInputRef = useRef() as React.MutableRefObject + const hostConfigHandlerRef = + useRef<(res: { hostConfig?: HostConfig; error?: string }) => void>() + const ssoResultHandlerRef = + useRef<(res: { token?: string; error?: string }) => void>() const schema: z.ZodType = z.object({ host: z .string() .trim() .min(1, `${t('Common.This field is required')}`), - username: z - .string() - .trim() - .min(1, `${t('Common.This field is required')}`), - password: z - .string() - .trim() - .min(1, `${t('Common.This field is required')}`), + username: z.string().trim().optional(), + password: z.string().trim().optional(), }) const { register, handleSubmit, setValue, + getValues, reset, setFocus, + setError: setFieldError, formState: { errors }, } = useForm({ defaultValues: {}, @@ -77,6 +79,15 @@ export const LoginForm = ({ onError, handleRefreshConnection }) => { onError(errors, error) }, [Object.keys(errors).length, error]) + useEffect(() => { + window.electron.receive(IPC_EVENTS.SET_HOST_CONFIG, (res) => + hostConfigHandlerRef.current?.(res), + ) + window.electron.receive(IPC_EVENTS.SSO_LOGIN_RESULT, (res) => + ssoResultHandlerRef.current?.(res), + ) + }, []) + useEffect(() => { setIsLoading(false) setTempAccount(undefined) @@ -86,17 +97,26 @@ export const LoginForm = ({ onError, handleRefreshConnection }) => { if (selectedAccount === NEW_ACCOUNT) { setShowTwoFactor(false) // Reset 2FA when switching to new account reset() + setLoginStep('host') + setHostConfig(undefined) focus('host') } else { setShowTwoFactor(false) // Reset 2FA when switching to existing account reset() setValue('host', selectedAccount.host) setValue('username', selectedAccount.username) - focus('password') + if (isSsoMethod(selectedAccount.authenticationMethod)) { + // the SSO entry point is read from the host at every login + fetchHostConfig(selectedAccount.host) + } else { + focus('password') + } } } else { setShowTwoFactor(false) // Reset 2FA when going back to account list setError(undefined) + setLoginStep('host') + setHostConfig(undefined) focus('host') } } @@ -115,6 +135,126 @@ export const LoginForm = ({ onError, handleRefreshConnection }) => { } }, [showTwoFactor]) + function cleanHost(rawHost: string): string | undefined { + const hostReg = + /^(?:(https?:\/\/)?([^:/$]{1,})(?::(\d{1,}))?(?:($|\/(?:[^?#]{0,}))?((?:\?(?:[^#]{1,}))?)?(?:(#(?:.*)?)?|$)))$/g + return hostReg.exec(rawHost.trim())?.[2] + } + + // shared login completion: enrich the account with the host configuration, + // then hand it over to the main process together with the password (empty + // for SSO accounts) + function completeLogin(loggedAccount: Account, password: string) { + window.electron.receive( + IPC_EVENTS.SET_NETHVOICE_CONFIG, + (account: Account) => { + passwordRef.current = password + Log.info('LOGIN received account server configuration', account) + const previousLoggedAccount = + auth?.availableAccounts[getAccountUID(account)] + account.theme = previousLoggedAccount + ? previousLoggedAccount.theme + : 'system' + Log.info('LOGIN send login event to the backend', account) + window.electron.send(IPC_EVENTS.LOGIN, { + password: passwordRef.current, + account, + }) + }, + ) + Log.info('LOGIN get account server configuration') + window.electron.send(IPC_EVENTS.GET_NETHVOICE_CONFIG, loggedAccount) + } + + function fetchHostConfig(host: string) { + setHostConfig(undefined) + hostConfigHandlerRef.current = (res) => { + if (res.hostConfig) { + setHostConfig(res.hostConfig) + } else { + Log.warning('LOGIN unable to read the host configuration', res.error) + setError(() => new Error(t('Login.Network connection is lost')!)) + } + } + window.electron.send(IPC_EVENTS.GET_HOST_CONFIG, host) + } + + // step 1: read the host authentication capabilities, then show the proper + // credentials step (username/password or SSO button) + function handleHostNext(data: LoginData) { + const host = cleanHost(data.host) + if (!host) { + setError(() => new Error(t('Login.Wrong host or username or password')!)) + return + } + setIsLoading(true) + setError(() => undefined) + hostConfigHandlerRef.current = (res) => { + setIsLoading(false) + if (res.hostConfig) { + setValue('host', host) + setHostConfig(res.hostConfig) + setLoginStep('credentials') + if (res.hostConfig.authenticationMethod === 'password') { + focus('username') + } + } else { + Log.warning('LOGIN unable to read the host configuration', res.error) + setError(() => new Error(t('Login.Network connection is lost')!)) + } + } + window.electron.send(IPC_EVENTS.GET_HOST_CONFIG, host) + } + + // Single Sign-On: the main process runs the SSO flow in a dedicated + // window and returns the minted JWT + function handleSsoLogin() { + if (isLoading || !hostConfig?.ssoLoginUrl) return + const host = + selectedAccount && selectedAccount !== NEW_ACCOUNT + ? selectedAccount.host + : getValues('host') + setIsLoading(true) + setError(() => undefined) + ssoResultHandlerRef.current = async (res) => { + if (!res.token) { + setIsLoading(false) + if (res.error !== 'SSO window closed') { + Log.warning('LOGIN SSO failed', res.error) + const msg = + res.error === 'SSO_USER_NOT_ENABLED' + ? t('Login.SSO user not enabled') + : t('Login.SSO login failed') + setError(() => new Error(msg!)) + } + return + } + try { + Log.info('LOGIN SSO token received, completing login') + const loggedAccount = await NethVoiceAPI.Authentication.ssoLogin( + host, + res.token, + hostConfig?.authenticationMethod, + ) + completeLogin(loggedAccount, '') + setError(() => undefined) + } catch (error: any) { + setIsLoading(false) + if (error.message === 'User not authorized for NethLink') { + setError( + () => new Error(t('Login.User not authorized for NethLink')!), + ) + } else { + setError(() => new Error(t('Login.SSO login failed')!)) + } + } + } + window.electron.send(IPC_EVENTS.SSO_LOGIN, { + host, + url: hostConfig.ssoLoginUrl, + }) + } + async function handleLogin(data: LoginData) { if (!isLoading) { const e: Error | undefined = undefined @@ -128,8 +268,8 @@ export const LoginForm = ({ onError, handleRefreshConnection }) => { Log.info('LOGIN try login with credential') const loggedAccount = await NethVoiceAPI.Authentication.login( res[2], - data.username, - data.password, + data.username!, + data.password!, ) Log.info('LOGIN successfully logged in with credential') @@ -144,25 +284,7 @@ export const LoginForm = ({ onError, handleRefreshConnection }) => { } // Complete login flow - window.electron.receive( - IPC_EVENTS.SET_NETHVOICE_CONFIG, - (account: Account) => { - passwordRef.current = data.password - Log.info('LOGIN received account server configuration', account) - const previousLoggedAccount = - auth?.availableAccounts[getAccountUID(account)] - account.theme = previousLoggedAccount - ? previousLoggedAccount.theme - : 'system' - Log.info('LOGIN send login event to the backend', account) - window.electron.send(IPC_EVENTS.LOGIN, { - password: passwordRef.current, - account, - }) - }, - ) - Log.info('LOGIN get account server configuration') - window.electron.send(IPC_EVENTS.GET_NETHVOICE_CONFIG, loggedAccount) + completeLogin(loggedAccount, data.password!) setFormValues({ host: '', @@ -223,27 +345,7 @@ export const LoginForm = ({ onError, handleRefreshConnection }) => { Log.info('LOGIN 2FA verification successful') // Complete login flow - window.electron.receive( - IPC_EVENTS.SET_NETHVOICE_CONFIG, - (account: Account) => { - Log.info( - 'LOGIN received account server configuration after 2FA', - account, - ) - const previousLoggedAccount = - auth?.availableAccounts[getAccountUID(account)] - account.theme = previousLoggedAccount - ? previousLoggedAccount.theme - : 'system' - Log.info('LOGIN send login event to the backend after 2FA', account) - window.electron.send(IPC_EVENTS.LOGIN, { - password: passwordRef.current, - account, - }) - }, - ) - Log.info('LOGIN get account server configuration after 2FA') - window.electron.send(IPC_EVENTS.GET_NETHVOICE_CONFIG, verifiedAccount) + completeLogin(verifiedAccount, passwordRef.current || '') setTempAccount(undefined) setError(() => undefined) @@ -277,13 +379,35 @@ export const LoginForm = ({ onError, handleRefreshConnection }) => { } const onSubmitForm: SubmitHandler = (data) => { - handleLogin(data) + const isSavedAccount = !!( + selectedAccount && selectedAccount !== NEW_ACCOUNT + ) + if (!isSavedAccount && loginStep === 'host') { + handleHostNext(data) + return + } + let valid = true + if (!isSavedAccount && !data.username?.trim()) { + setFieldError('username', { + message: `${t('Common.This field is required')}`, + }) + valid = false + } + if (!data.password?.trim()) { + setFieldError('password', { + message: `${t('Common.This field is required')}`, + }) + valid = false + } + if (valid) { + handleLogin(data) + } } function setFormValues(data: LoginData) { setValue('host', data.host) - setValue('username', data.username) - setValue('password', data.password) + setValue('username', data.username || '') + setValue('password', data.password || '') } const focus = (selector: keyof LoginData) => { @@ -292,6 +416,15 @@ export const LoginForm = ({ onError, handleRefreshConnection }) => { }, 100) } + const isSavedAccount = !!(selectedAccount && selectedAccount !== NEW_ACCOUNT) + const authMethod = isSavedAccount + ? (selectedAccount as Account).authenticationMethod || 'password' + : hostConfig?.authenticationMethod || 'password' + const showSsoButton = + isSsoMethod(authMethod) && (isSavedAccount || loginStep === 'credentials') + const ssoButtonLabel = + hostConfig?.ssoButtonLabel || (t('Login.Sign in with SSO') as string) + const RenderConnectionError = ({ handleRefreshConnection }) => { return (
@@ -417,14 +550,33 @@ export const LoginForm = ({ onError, handleRefreshConnection }) => { {connection ? (
- {!(selectedAccount && selectedAccount !== NEW_ACCOUNT) && ( - <> + {!isSavedAccount && loginStep === 'host' && ( + { + if (e.key === 'Enter') { + e.preventDefault() + submitButtonRef.current?.focus() + handleSubmit(onSubmitForm)(e) + } + }} + /> + )} + {!isSavedAccount && + loginStep === 'credentials' && + authMethod === 'password' && ( value?.toLowerCase() || '', + })} type='text' - label={t('Login.Host') as string} - helper={errors.host?.message || undefined} - error={!!errors.host?.message} + label={t('Login.Username') as string} + helper={errors.username?.message || undefined} + error={!!errors.username?.message} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault() @@ -433,14 +585,18 @@ export const LoginForm = ({ onError, handleRefreshConnection }) => { } }} /> + )} + {authMethod === 'password' && + (isSavedAccount || loginStep === 'credentials') && ( value?.toLowerCase() || '', - })} - type='text' - label={t('Login.Username') as string} - helper={errors.username?.message || undefined} - error={!!errors.username?.message} + {...register('password')} + label={t('Login.Password') as string} + type={pwdVisible ? 'text' : 'password'} + icon={pwdVisible ? EyeIcon : EyeSlashIcon} + onIconClick={() => setPwdVisible(!pwdVisible)} + trailingIcon={true} + helper={errors.password?.message || undefined} + error={!!errors.password?.message} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault() @@ -449,28 +605,41 @@ export const LoginForm = ({ onError, handleRefreshConnection }) => { } }} /> - + )} + {showSsoButton ? ( +
+ + {(hostConfig?.ssoIdpName || hostConfig?.ssoIdpLogo) && ( +
+ {hostConfig?.ssoIdpLogo && ( + + )} + {hostConfig?.ssoIdpName && ( + + {hostConfig.ssoIdpName} + + )} +
+ )} +
+ ) : ( + )} - setPwdVisible(!pwdVisible)} - trailingIcon={true} - helper={errors.password?.message || undefined} - error={!!errors.password?.message} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault() - submitButtonRef.current?.focus() - handleSubmit(onSubmitForm)(e) - } - }} - /> -
) : ( diff --git a/src/renderer/src/pages/LoginPage.tsx b/src/renderer/src/pages/LoginPage.tsx index 3930a61f..ffd2b23d 100644 --- a/src/renderer/src/pages/LoginPage.tsx +++ b/src/renderer/src/pages/LoginPage.tsx @@ -1,4 +1,4 @@ -import { Account, LoginData } from '@shared/types' +import { Account, LoginData, isSsoMethod } from '@shared/types' import classNames from 'classnames' import { MutableRefObject, useEffect, useRef, useState } from 'react' import spinner from '../assets/loginPageSpinner.svg' @@ -27,6 +27,8 @@ export interface LoginPageProps { enum LoginSizes { BASE = 550, + HOST_STEP = 400, + SSO_STEP = 400, ACCOUNT_FORM = 488, TWO_FACTOR_AUTH = 420, BACK_BUTTON = 60, @@ -55,6 +57,8 @@ export function LoginPage({ useLoginPageData('selectedAccount') const [windowHeight, setWindowHeight] = useLoginPageData('windowHeight') const [showTwoFactor, setShowTwoFactor] = useLoginPageData('showTwoFactor') + const [loginStep, setLoginStep] = useLoginPageData('loginStep') + const [hostConfig, setHostConfig] = useLoginPageData('hostConfig') const [connection] = useSharedState('connection') const [errorsData, setErrorsData] = useState() const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) @@ -64,7 +68,15 @@ export function LoginPage({ useEffect(() => { calculateHeight() - }, [selectedAccount, auth, errorsData, connection, showTwoFactor]) + }, [ + selectedAccount, + auth, + errorsData, + connection, + showTwoFactor, + loginStep, + hostConfig, + ]) useEffect(() => { if (windowHeight) { @@ -77,9 +89,16 @@ export function LoginPage({ // If we're in OTP verification, go back to login form setShowTwoFactor(false) // Keep selectedAccount, stay in the login form + } else if (selectedAccount === NEW_ACCOUNT && loginStep === 'credentials') { + // Back from the credentials/SSO step to the host step + setLoginStep('host') + setHostConfig(undefined) + setErrorsData({ formErrors: {}, generalError: undefined }) } else { // If we're in normal login form, go back to account selection setSelectedAccount(undefined) + setLoginStep('host') + setHostConfig(undefined) setErrorsData({ formErrors: {}, generalError: undefined }) } } @@ -126,9 +145,18 @@ export function LoginPage({ // Login form is shown else if (selectedAccount) { if (selectedAccount === NEW_ACCOUNT) { - loginWindowHeight = LoginSizes.BASE + if (loginStep === 'host') { + loginWindowHeight = LoginSizes.HOST_STEP + } else if (hostConfig && isSsoMethod(hostConfig.authenticationMethod)) { + loginWindowHeight = LoginSizes.SSO_STEP + if (hostConfig.ssoIdpName || hostConfig.ssoIdpLogo) { + loginWindowHeight += 40 + } + } else { + loginWindowHeight = LoginSizes.BASE + } if (!connection) loginWindowHeight = LoginSizes.CONNECTION_FAILURE_BASE - if (!auth?.isFirstStart) { + if (!auth?.isFirstStart || loginStep === 'credentials') { loginWindowHeight += LoginSizes.BACK_BUTTON - 24 } } else { @@ -186,22 +214,24 @@ export function LoginPage({
{auth && ( <> - {Object.keys(auth.availableAccounts).length > 0 && - selectedAccount && ( - - )} + {((Object.keys(auth.availableAccounts).length > 0 && + selectedAccount) || + (selectedAccount === NEW_ACCOUNT && + loginStep === 'credentials')) && ( + + )} {auth.isFirstStart || selectedAccount || showTwoFactor || diff --git a/src/renderer/src/store.ts b/src/renderer/src/store.ts index c1007344..e30b10eb 100644 --- a/src/renderer/src/store.ts +++ b/src/renderer/src/store.ts @@ -187,4 +187,6 @@ export const useLoginPageData = createGlobalStateHook({ selectedAccount: undefined, windowHeight: LoginPageSize.h, showTwoFactor: false, + loginStep: 'host', + hostConfig: undefined, } as LoginPageData).useGlobalState diff --git a/src/shared/constants.ts b/src/shared/constants.ts index d81643d9..fe56c7bf 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -65,6 +65,11 @@ export enum IPC_EVENTS { REQUEST_SHARED_STATE = 'REQUEST_SHARED_STATE', GET_NETHVOICE_CONFIG = 'GET_NETHVOICE_CONFIG', SET_NETHVOICE_CONFIG = 'SET_NETHVOICE_CONFIG', + GET_HOST_CONFIG = 'GET_HOST_CONFIG', + SET_HOST_CONFIG = 'SET_HOST_CONFIG', + SSO_LOGIN = 'SSO_LOGIN', + SSO_LOGIN_RESULT = 'SSO_LOGIN_RESULT', + RECONNECT_PHONE_ISLAND = 'RECONNECT_PHONE_ISLAND', RECONNECT_SOCKET = 'RECONNECT_SOCKET', CHECK_SERVER_CONFIG = 'CHECK_SERVER_CONFIG', LOGOUT_COMPLETED = 'LOGOUT_COMPLETED', diff --git a/src/shared/types.ts b/src/shared/types.ts index a216bedf..f9737481 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -47,6 +47,22 @@ export type Account = { commandBarShortcut?: string preferredDevices?: PreferredDevices apiBasePath?: string // Store which API path works for this account + authenticationMethod?: AuthenticationMethod +} + +// Pluggable authentication methods; 'saml2' and 'oidc' share the same SSO flow. +export type AuthenticationMethod = 'password' | 'saml2' | 'oidc' + +export const isSsoMethod = (method?: AuthenticationMethod | string): boolean => + method === 'saml2' || method === 'oidc' + +// authentication capabilities read from the host config/config.production.js +export type HostConfig = { + authenticationMethod: AuthenticationMethod + ssoLoginUrl: string + ssoButtonLabel: string + ssoIdpName: string + ssoIdpLogo: string } export type PreferredDevices = { @@ -57,8 +73,8 @@ export type PreferredDevices = { export type LoginData = { host: string - username: string - password: string + username?: string + password?: string } export type ConfigFile = { @@ -459,6 +475,8 @@ export type LoginPageData = { isLoading: boolean windowHeight?: number showTwoFactor: boolean + loginStep: 'host' | 'credentials' + hostConfig?: HostConfig } export type AuthAppData = { diff --git a/src/shared/useLogin.ts b/src/shared/useLogin.ts index 3e6edb7d..516305a8 100644 --- a/src/shared/useLogin.ts +++ b/src/shared/useLogin.ts @@ -1,4 +1,4 @@ -import { Account } from './types' +import { Account, AuthenticationMethod, HostConfig } from './types' export const useLogin = () => { const parseConfig = (account: Account, config): Account => { @@ -35,7 +35,25 @@ export const useLogin = () => { return account } + // Read the authentication capabilities; hosts without SSO support have no + // AUTHENTICATION_METHOD key and default to password. + const parseHostConfig = (config: string): HostConfig => { + const read = (key: string) => + config.match(new RegExp(`${key}: '([^']*)'`))?.[1] || '' + const method = read('AUTHENTICATION_METHOD') + const authenticationMethod: AuthenticationMethod = + method === 'saml2' || method === 'oidc' ? method : 'password' + return { + authenticationMethod, + ssoLoginUrl: read('SSO_LOGIN_URL'), + ssoButtonLabel: read('SSO_BUTTON_LABEL'), + ssoIdpName: read('SSO_IDP_NAME'), + ssoIdpLogo: read('SSO_IDP_LOGO'), + } + } + return { parseConfig, + parseHostConfig, } } diff --git a/src/shared/useNethVoiceAPI.ts b/src/shared/useNethVoiceAPI.ts index 17ad0a2d..dab3aabb 100644 --- a/src/shared/useNethVoiceAPI.ts +++ b/src/shared/useNethVoiceAPI.ts @@ -2,6 +2,7 @@ import moment from 'moment' import hmacSHA1 from 'crypto-js/hmac-sha1' import { Account, + AuthenticationMethod, NewContactType, OperatorData, ContactType, @@ -17,7 +18,7 @@ import { Log } from '@shared/utils/logger' import { normalizeSharedGroups, serializeSharedGroups } from './phonebook' import { useNetwork } from './useNetwork' import { SpeeddialTypes } from './constants' -import { requires2FA } from '@shared/utils/jwt' +import { decodeJWT, requires2FA } from '@shared/utils/jwt' // Base paths for API endpoints (fallback from /api to /webrest) const PRIMARY_API_BASE_PATH = '/api' @@ -399,6 +400,40 @@ export const useNethVoiceAPI = ( throw new Error('No authentication method available') }, + // Complete a Single Sign-On login: the JWT was already minted through the + // host SSO flow (see SSO_LOGIN in the main process), no password involved. + ssoLogin: async ( + host: string, + token: string, + method: AuthenticationMethod = 'saml2', + ): Promise => { + const payload = decodeJWT(token) + const username = (payload?.username || payload?.id || '') + .toString() + .toLowerCase() + if (!username) { + throw new Error('Unauthorized') + } + account = { + host, + username, + theme: 'system', + jwtToken: token, + lastAccess: moment().toISOString(), + apiBasePath: PRIMARY_API_BASE_PATH, + authenticationMethod: method, + } as Account + const me = await User.me() + account.data = me + const nethlinkExtension = account.data!.endpoints.extension.find( + (el) => el.type === 'nethlink', + ) + if (!nethlinkExtension) { + throw new Error('User not authorized for NethLink') + } + return account + }, + verify2FA: async ( otp: string, tempAccount: Account | undefined,