From 59cc469a4467bdb23be9fa630911fa8619a69620 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 | 63 ++++++++++++++++------- src/ui/Navigation/LeftSidebar.tsx | 75 +++++++++++++++------------- src/ui/main-axios.ts | 11 ++-- 4 files changed, 139 insertions(+), 63 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 54cf46a0..8a7fe744 100644 --- a/src/ui/Admin/AdminSettings.tsx +++ b/src/ui/Admin/AdminSettings.tsx @@ -27,7 +27,6 @@ import { deleteUser } from "@/ui/main-axios.ts"; import {useTranslation} from "react-i18next"; -import {toast} from "sonner"; function getCookie(name: string) { return document.cookie.split('; ').reduce((r, v) => { @@ -58,6 +57,8 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React. scopes: 'openid email profile' }); const [oidcLoading, setOidcLoading] = React.useState(false); + const [oidcError, setOidcError] = React.useState(null); + const [oidcSuccess, setOidcSuccess] = React.useState(null); const [users, setUsers] = React.useState(null); + const [makeAdminSuccess, setMakeAdminSuccess] = React.useState(null); React.useEffect(() => { const jwt = getCookie("jwt"); @@ -118,11 +121,13 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React. const handleOIDCConfigSubmit = async (e: React.FormEvent) => { e.preventDefault(); setOidcLoading(true); + setOidcError(null); + setOidcSuccess(null); const required = ['client_id', 'client_secret', 'issuer_url', 'authorization_url', 'token_url']; const missing = required.filter(f => !oidcConfig[f as keyof typeof oidcConfig]); if (missing.length > 0) { - toast.error(`Missing required fields: ${missing.join(', ')}`); + setOidcError(`Missing required fields: ${missing.join(', ')}`); setOidcLoading(false); return; } @@ -130,9 +135,9 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React. const jwt = getCookie("jwt"); try { await updateOIDCConfig(oidcConfig); - toast.success("OIDC configuration updated successfully!"); + setOidcSuccess("OIDC configuration updated successfully!"); } catch (err: any) { - toast.error(err?.response?.data?.error || t('interface.failedToUpdateOidcConfig')); + setOidcError(err?.response?.data?.error || t('interface.failedToUpdateOidcConfig')); } finally { setOidcLoading(false); } @@ -142,44 +147,42 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React. setOidcConfig(prev => ({...prev, [field]: value})); }; - const handleMakeUserAdmin = async (e: React.FormEvent) => { + const makeUserAdmin = async (e: React.FormEvent) => { e.preventDefault(); if (!newAdminUsername.trim()) return; setMakeAdminLoading(true); + setMakeAdminError(null); + setMakeAdminSuccess(null); const jwt = getCookie("jwt"); try { await makeUserAdmin(newAdminUsername.trim()); - toast.success(`User ${newAdminUsername} is now an admin`); + setMakeAdminSuccess(`User ${newAdminUsername} is now an admin`); setNewAdminUsername(""); fetchUsers(); } catch (err: any) { - toast.error(err?.response?.data?.error || t('interface.failedToMakeUserAdmin')); + setMakeAdminError(err?.response?.data?.error || t('interface.failedToMakeUserAdmin')); } finally { setMakeAdminLoading(false); } }; - const handleRemoveAdminStatus = async (username: string) => { + const removeAdminStatus = async (username: string) => { if (!confirm(`Remove admin status from ${username}?`)) return; const jwt = getCookie("jwt"); try { await removeAdminStatus(username); - toast.success(`Admin status removed from ${username}`); fetchUsers(); - } catch (err: any) { - toast.error(err?.response?.data?.error || 'Failed to remove admin status'); + } catch { } }; - const handleDeleteUser = async (username: string) => { + const deleteUser = async (username: string) => { if (!confirm(`Delete user ${username}? This cannot be undone.`)) return; const jwt = getCookie("jwt"); try { await deleteUser(username); - toast.success(`User ${username} deleted successfully`); fetchUsers(); - } catch (err: any) { - toast.error(err?.response?.data?.error || 'Failed to delete user'); + } catch { } }; @@ -240,6 +243,12 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.

{t('admin.externalAuthentication')}

{t('admin.configureExternalProvider')}

+ {oidcError && ( + + {t('common.error')} + {oidcError} + + )}
@@ -306,6 +315,12 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React. })}>{t('admin.reset')}
+ {oidcSuccess && ( + + {t('admin.success')} + {oidcSuccess} + + )}
@@ -343,7 +358,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React. className="px-4">{user.is_oidc ? t('admin.external') : t('admin.local')} + {makeAdminError && ( + + {t('common.error')} + {makeAdminError} + + )} + {makeAdminSuccess && ( + + {t('admin.success')} + {makeAdminSuccess} + + )} @@ -400,7 +427,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React. className="px-4">{admin.is_oidc ? t('admin.external') : t('admin.local')} @@ -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; } }