From 79d17d2f70efd473aad09e3a248302e4a3717810 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Tue, 2 Sep 2025 16:55:08 -0500 Subject: [PATCH] Fix OIDC errors for "Failed to get user information" --- src/backend/database/routes/users.ts | 53 ++++++++++++- src/ui/Admin/AdminSettings.tsx | 107 +++++++++++++-------------- src/ui/Navigation/LeftSidebar.tsx | 75 ++++++++++--------- src/ui/main-axios.ts | 11 +-- 4 files changed, 147 insertions(+), 99 deletions(-) diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 3e776b7f..3456aee3 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -222,6 +222,7 @@ router.post('/oidc-config', authenticateJWT, async (req, res) => { issuer_url, authorization_url, token_url, + userinfo_url, identifier_path, name_path, scopes @@ -240,6 +241,7 @@ router.post('/oidc-config', authenticateJWT, async (req, res) => { issuer_url, authorization_url, token_url, + userinfo_url: userinfo_url || '', identifier_path, name_path, scopes: scopes || 'openid email profile' @@ -362,14 +364,38 @@ router.get('/oidc/callback', async (req, res) => { const tokenData = await tokenResponse.json() as any; let userInfo: any = null; - const userInfoUrls = []; + let userInfoUrls: string[] = []; const normalizedIssuerUrl = config.issuer_url.endsWith('/') ? config.issuer_url.slice(0, -1) : config.issuer_url; const baseUrl = normalizedIssuerUrl.replace(/\/application\/o\/[^\/]+$/, ''); - userInfoUrls.push(`${baseUrl}/userinfo/`); - userInfoUrls.push(`${normalizedIssuerUrl}/userinfo/`); - userInfoUrls.push(`${normalizedIssuerUrl}/userinfo`); + try { + const discoveryUrl = `${normalizedIssuerUrl}/.well-known/openid-configuration`; + const discoveryResponse = await fetch(discoveryUrl); + if (discoveryResponse.ok) { + const discovery = await discoveryResponse.json() as any; + if (discovery.userinfo_endpoint) { + userInfoUrls.push(discovery.userinfo_endpoint); + } + } + } catch (discoveryError) { + logger.error(`OIDC discovery failed: ${discoveryError}`); + } + + if (config.userinfo_url) { + userInfoUrls.unshift(config.userinfo_url); + } + + userInfoUrls.push( + `${baseUrl}/userinfo/`, + `${baseUrl}/userinfo`, + `${normalizedIssuerUrl}/userinfo/`, + `${normalizedIssuerUrl}/userinfo`, + `${baseUrl}/oauth2/userinfo/`, + `${baseUrl}/oauth2/userinfo`, + `${normalizedIssuerUrl}/oauth2/userinfo/`, + `${normalizedIssuerUrl}/oauth2/userinfo` + ); if (tokenData.id_token) { try { @@ -391,15 +417,34 @@ router.get('/oidc/callback', async (req, res) => { if (userInfoResponse.ok) { userInfo = await userInfoResponse.json(); break; + } else { + logger.error(`Userinfo endpoint ${userInfoUrl} failed with status: ${userInfoResponse.status}`); } } catch (error) { + logger.error(`Userinfo endpoint ${userInfoUrl} failed:`, error); continue; } } } + if (!userInfo && tokenData.id_token) { + try { + const parts = tokenData.id_token.split('.'); + if (parts.length === 3) { + const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString()); + userInfo = payload; + } + } catch (error) { + logger.error('Failed to decode ID token payload:', error); + } + } + if (!userInfo) { logger.error('Failed to get user information from all sources'); + logger.error(`Tried userinfo URLs: ${userInfoUrls.join(', ')}`); + logger.error(`Token data keys: ${Object.keys(tokenData).join(', ')}`); + logger.error(`Has id_token: ${!!tokenData.id_token}`); + logger.error(`Has access_token: ${!!tokenData.access_token}`); return res.status(400).json({error: 'Failed to get user information'}); } diff --git a/src/ui/Admin/AdminSettings.tsx b/src/ui/Admin/AdminSettings.tsx index 8a7fe744..bece9c0a 100644 --- a/src/ui/Admin/AdminSettings.tsx +++ b/src/ui/Admin/AdminSettings.tsx @@ -26,7 +26,6 @@ import { removeAdminStatus, deleteUser } from "@/ui/main-axios.ts"; -import {useTranslation} from "react-i18next"; function getCookie(name: string) { return document.cookie.split('; ').reduce((r, v) => { @@ -41,7 +40,6 @@ interface AdminSettingsProps { export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.ReactElement { const {state: sidebarState} = useSidebar(); - const {t} = useTranslation(); const [allowRegistration, setAllowRegistration] = React.useState(true); const [regLoading, setRegLoading] = React.useState(false); @@ -137,7 +135,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React. await updateOIDCConfig(oidcConfig); setOidcSuccess("OIDC configuration updated successfully!"); } catch (err: any) { - setOidcError(err?.response?.data?.error || t('interface.failedToUpdateOidcConfig')); + setOidcError(err?.response?.data?.error || "Failed to update OIDC configuration"); } finally { setOidcLoading(false); } @@ -160,7 +158,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React. setNewAdminUsername(""); fetchUsers(); } catch (err: any) { - setMakeAdminError(err?.response?.data?.error || t('interface.failedToMakeUserAdmin')); + setMakeAdminError(err?.response?.data?.error || "Failed to make user admin"); } finally { setMakeAdminLoading(false); } @@ -202,7 +200,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React. className="bg-[#18181b] text-white rounded-lg border-2 border-[#303032] overflow-hidden">
-

{t('admin.title')}

+

Admin Settings

@@ -211,98 +209,99 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React. - {t('common.settings')} + General - {t('admin.oidcSettings')} + OIDC - {t('admin.users')} + Users - {t('nav.admin')} + Admins
-

{t('admin.userManagement')}

+

User Registration

-

{t('admin.externalAuthentication')}

-

{t('admin.configureExternalProvider')}

+

External Authentication (OIDC)

+

Configure external identity provider for + OIDC/OAuth2 authentication.

{oidcError && ( - {t('common.error')} + Error {oidcError} )}
- + handleOIDCConfigChange('client_id', e.target.value)} - placeholder={t('placeholders.clientId')} required/> + placeholder="your-client-id" required/>
- + handleOIDCConfigChange('client_secret', e.target.value)} - placeholder={t('placeholders.clientSecret')} required/> + placeholder="your-client-secret" required/>
- + handleOIDCConfigChange('authorization_url', e.target.value)} - placeholder={t('placeholders.authUrl')} + placeholder="https://your-provider.com/application/o/authorize/" required/>
- + handleOIDCConfigChange('issuer_url', e.target.value)} - placeholder={t('placeholders.redirectUrl')} required/> + placeholder="https://your-provider.com/application/o/termix/" required/>
- + handleOIDCConfigChange('token_url', e.target.value)} - placeholder={t('placeholders.tokenUrl')} required/> + placeholder="https://your-provider.com/application/o/token/" required/>
- + handleOIDCConfigChange('identifier_path', e.target.value)} - placeholder={t('placeholders.userIdField')} required/> + placeholder="sub" required/>
- + handleOIDCConfigChange('name_path', e.target.value)} - placeholder={t('placeholders.usernameField')} required/> + placeholder="name" required/>
- + handleOIDCConfigChange('scopes', (e.target as HTMLInputElement).value)} - placeholder={t('placeholders.scopes')} required/> + placeholder="openid email profile" required/>
+ disabled={oidcLoading}>{oidcLoading ? "Saving..." : "Save Configuration"} + })}>Reset
{oidcSuccess && ( - {t('admin.success')} + Success {oidcSuccess} )} @@ -328,20 +327,20 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
-

{t('admin.userManagement')}

+

User Management

+ size="sm">{usersLoading ? "Loading..." : "Refresh"}
{usersLoading ? ( -
{t('admin.loadingUsers')}
+
Loading users...
) : (
- {t('admin.username')} - {t('admin.type')} - {t('admin.actions')} + Username + Type + Actions @@ -351,11 +350,11 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React. {user.username} {user.is_admin && ( {t('admin.adminBadge')} + className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">Admin )} {user.is_oidc ? t('admin.external') : t('admin.local')} + className="px-4">{user.is_oidc ? "External" : "Local"} + disabled={makeAdminLoading || !newAdminUsername.trim()}>{makeAdminLoading ? "Adding..." : "Make Admin"} {makeAdminError && ( - {t('common.error')} + Error {makeAdminError} )} {makeAdminSuccess && ( - {t('admin.success')} + Success {makeAdminSuccess} )} @@ -405,14 +404,14 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
-

{t('admin.currentAdmins')}

+

Current Admins

- {t('admin.username')} - {t('admin.type')} - {t('admin.actions')} + Username + Type + Actions @@ -424,13 +423,13 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React. className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">Admin {admin.is_oidc ? t('admin.external') : t('admin.local')} + className="px-4">{admin.is_oidc ? "External" : "Local"} diff --git a/src/ui/Navigation/LeftSidebar.tsx b/src/ui/Navigation/LeftSidebar.tsx index 06491099..9ddcec72 100644 --- a/src/ui/Navigation/LeftSidebar.tsx +++ b/src/ui/Navigation/LeftSidebar.tsx @@ -1,5 +1,4 @@ import React, {useState} from 'react'; -import {useTranslation} from 'react-i18next'; import { Computer, Server, @@ -113,7 +112,6 @@ export function LeftSidebar({ username, children, }: SidebarProps): React.ReactElement { - const {t} = useTranslation(); const [adminSheetOpen, setAdminSheetOpen] = React.useState(false); const [deleteAccountOpen, setDeleteAccountOpen] = React.useState(false); @@ -142,7 +140,7 @@ export function LeftSidebar({ const sshManagerTab = tabList.find((t) => t.type === 'ssh_manager'); const openSshManagerTab = () => { if (sshManagerTab || isSplitScreenActive) return; - const id = addTab({type: 'ssh_manager', title: t('hosts.title')} as any); + const id = addTab({type: 'ssh_manager', title: 'SSH Manager'} as any); setCurrentTab(id); }; const adminTab = tabList.find((t) => t.type === 'admin'); @@ -152,7 +150,7 @@ export function LeftSidebar({ setCurrentTab(adminTab.id); return; } - const id = addTab({type: 'admin', title: t('nav.admin')} as any); + const id = addTab({type: 'admin', title: 'Admin'} as any); setCurrentTab(id); }; @@ -234,7 +232,7 @@ export function LeftSidebar({ }, 50); } } catch (err: any) { - setHostsError(t('leftSidebar.failedToLoadHosts')); + setHostsError('Failed to load hosts'); } }, []); @@ -277,7 +275,7 @@ export function LeftSidebar({ const hostsByFolder = React.useMemo(() => { const map: Record = {}; filteredHosts.forEach(h => { - const folder = h.folder && h.folder.trim() ? h.folder : t('leftSidebar.noFolder'); + const folder = h.folder && h.folder.trim() ? h.folder : 'No Folder'; if (!map[folder]) map[folder] = []; map[folder].push(h); }); @@ -287,8 +285,8 @@ export function LeftSidebar({ const sortedFolders = React.useMemo(() => { const folders = Object.keys(hostsByFolder); folders.sort((a, b) => { - if (a === t('leftSidebar.noFolder')) return -1; - if (b === t('leftSidebar.noFolder')) return 1; + if (a === 'No Folder') return -1; + if (b === 'No Folder') return 1; return a.localeCompare(b); }); return folders; @@ -306,7 +304,7 @@ export function LeftSidebar({ setDeleteError(null); if (!deletePassword.trim()) { - setDeleteError(t('leftSidebar.passwordRequired')); + setDeleteError("Password is required"); setDeleteLoading(false); return; } @@ -317,7 +315,7 @@ export function LeftSidebar({ handleLogout(); } catch (err: any) { - setDeleteError(err?.response?.data?.error || t('leftSidebar.failedToDeleteAccount')); + setDeleteError(err?.response?.data?.error || "Failed to delete account"); setDeleteLoading(false); } }; @@ -372,18 +370,18 @@ export function LeftSidebar({ const jwt = getCookie("jwt"); try { await makeUserAdmin(newAdminUsername.trim()); - setMakeAdminSuccess(t('leftSidebar.userIsNowAdmin', {username: newAdminUsername})); + setMakeAdminSuccess(`User ${newAdminUsername} is now an admin`); setNewAdminUsername(""); fetchUsers(); } catch (err: any) { - setMakeAdminError(err?.response?.data?.error || t('leftSidebar.failedToMakeUserAdmin')); + setMakeAdminError(err?.response?.data?.error || "Failed to make user admin"); } finally { setMakeAdminLoading(false); } }; const removeAdminStatus = async (username: string) => { - if (!confirm(t('leftSidebar.removeAdminConfirm', {username}))) return; + if (!confirm(`Are you sure you want to remove admin status from ${username}?`)) return; if (!isAdmin) { return; @@ -398,7 +396,7 @@ export function LeftSidebar({ }; const deleteUser = async (username: string) => { - if (!confirm(t('leftSidebar.deleteUserConfirm', {username}))) return; + if (!confirm(`Are you sure you want to delete user ${username}? This action cannot be undone.`)) return; if (!isAdmin) { return; @@ -433,9 +431,9 @@ export function LeftSidebar({ @@ -444,7 +442,7 @@ export function LeftSidebar({ setSearch(e.target.value)} - placeholder={t('placeholders.searchHostsAny')} + placeholder="Search hosts by any info..." className="w-full h-8 text-sm border-2 !bg-[#222225] border-[#303032] rounded-md" autoComplete="off" /> @@ -462,7 +460,7 @@ export function LeftSidebar({ {hostsLoading && (
- {t('common.loading')} + Loading hosts...
)} @@ -489,7 +487,7 @@ export function LeftSidebar({ style={{width: '100%'}} disabled={disabled} > - {username ? username : t('common.logout')} + {username ? username : 'Signed out'} @@ -508,10 +506,10 @@ export function LeftSidebar({ setCurrentTab(profileTab.id); return; } - const id = addTab({type: 'profile', title: t('common.profile')} as any); + const id = addTab({type: 'profile', title: 'Profile'} as any); setCurrentTab(id); }}> - {t('common.profile')} + Profile & Security {isAdmin && ( { if (isAdmin) openAdminTab(); }}> - {t('admin.title')} + Admin Settings )} - {t('common.logout')} + Sign out - {t('admin.deleteUser')} + Delete Account {isAdmin && adminCount <= 1 && " (Last Admin)"} @@ -588,7 +586,7 @@ export function LeftSidebar({ onClick={(e) => e.stopPropagation()} >
-

{t('leftSidebar.deleteAccount')}

+

Delete Account

@@ -607,19 +605,22 @@ export function LeftSidebar({
- {t('leftSidebar.deleteAccountWarning')} + This action cannot be undone. This will permanently delete your account and all + associated data.
- {t('common.warning')} + Warning - {t('leftSidebar.deleteAccountWarningDetails')} + Deleting your account will remove all your data including SSH hosts, + configurations, and settings. + This action is irreversible. {deleteError && ( - {t('common.error')} + Error {deleteError} )} @@ -627,21 +628,23 @@ export function LeftSidebar({ {isAdmin && adminCount <= 1 && ( - {t('leftSidebar.cannotDeleteAccount')} + Cannot Delete Account - {t('leftSidebar.lastAdminWarning')} + You are the last admin user. You cannot delete your account as this + would leave the system without any administrators. + Please make another user an admin first, or contact system support. )}
- + setDeletePassword(e.target.value)} - placeholder={t('placeholders.confirmPassword')} + placeholder="Enter your password to confirm" required disabled={isAdmin && adminCount <= 1} /> @@ -654,7 +657,7 @@ export function LeftSidebar({ className="flex-1" disabled={deleteLoading || !deletePassword.trim() || (isAdmin && adminCount <= 1)} > - {deleteLoading ? t('leftSidebar.deleting') : t('leftSidebar.deleteAccount')} + {deleteLoading ? "Deleting..." : "Delete Account"}
diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 99627071..6fa29322 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -143,6 +143,7 @@ interface UserInfo { id: string; username: string; is_admin: boolean; + is_oidc: boolean; } interface UserCount { @@ -897,7 +898,7 @@ export async function setupTOTP(): Promise<{ secret: string; qr_code: string }> const response = await authApi.post('/users/totp/setup'); return response.data; } catch (error) { - handleApiError(error as AxiosError); + handleApiError(error as AxiosError, 'setup TOTP'); throw error; } } @@ -907,7 +908,7 @@ export async function enableTOTP(totp_code: string): Promise<{ message: string; const response = await authApi.post('/users/totp/enable', { totp_code }); return response.data; } catch (error) { - handleApiError(error as AxiosError); + handleApiError(error as AxiosError, 'enable TOTP'); throw error; } } @@ -917,7 +918,7 @@ export async function disableTOTP(password?: string, totp_code?: string): Promis const response = await authApi.post('/users/totp/disable', { password, totp_code }); return response.data; } catch (error) { - handleApiError(error as AxiosError); + handleApiError(error as AxiosError, 'disable TOTP'); throw error; } } @@ -927,7 +928,7 @@ export async function verifyTOTPLogin(temp_token: string, totp_code: string): Pr const response = await authApi.post('/users/totp/verify-login', { temp_token, totp_code }); return response.data; } catch (error) { - handleApiError(error as AxiosError); + handleApiError(error as AxiosError, 'verify TOTP login'); throw error; } } @@ -937,7 +938,7 @@ export async function generateBackupCodes(password?: string, totp_code?: string) const response = await authApi.post('/users/totp/backup-codes', { password, totp_code }); return response.data; } catch (error) { - handleApiError(error as AxiosError); + handleApiError(error as AxiosError, 'generate backup codes'); throw error; } }