From ea792bf95fc70369131e2b623990840e54e976e3 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Tue, 2 Sep 2025 15:14:28 -0500 Subject: [PATCH 1/5] Fix OIDC errors for "Failed to get user information" --- src/backend/database/routes/users.ts | 131 +++++++++++++-------------- 1 file changed, 63 insertions(+), 68 deletions(-) diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 065ebc0b..3e776b7f 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -12,9 +12,19 @@ import type {Request, Response, NextFunction} from 'express'; async function verifyOIDCToken(idToken: string, issuerUrl: string, clientId: string): Promise { try { - let jwksUrl: string | null = null; - const normalizedIssuerUrl = issuerUrl.endsWith('/') ? issuerUrl.slice(0, -1) : issuerUrl; + const possibleIssuers = [ + issuerUrl, + normalizedIssuerUrl, + issuerUrl.replace(/\/application\/o\/[^\/]+$/, ''), + normalizedIssuerUrl.replace(/\/application\/o\/[^\/]+$/, '') + ]; + + const jwksUrls = [ + `${normalizedIssuerUrl}/.well-known/jwks.json`, + `${normalizedIssuerUrl}/jwks/`, + `${normalizedIssuerUrl.replace(/\/application\/o\/[^\/]+$/, '')}/.well-known/jwks.json` + ]; try { const discoveryUrl = `${normalizedIssuerUrl}/.well-known/openid-configuration`; @@ -22,53 +32,33 @@ async function verifyOIDCToken(idToken: string, issuerUrl: string, clientId: str if (discoveryResponse.ok) { const discovery = await discoveryResponse.json() as any; if (discovery.jwks_uri) { - jwksUrl = discovery.jwks_uri; - } else { - logger.warn('OIDC discovery document does not contain jwks_uri'); + jwksUrls.unshift(discovery.jwks_uri); } - } else { - logger.warn(`OIDC discovery failed with status: ${discoveryResponse.status}`); } } catch (discoveryError) { - logger.warn(`OIDC discovery failed: ${discoveryError}`); + logger.error(`OIDC discovery failed: ${discoveryError}`); } - if (!jwksUrl) { - jwksUrl = `${normalizedIssuerUrl}/.well-known/jwks.json`; - } + let jwks: any = null; + let jwksUrl: string | null = null; - if (!jwksUrl) { - const authentikJwksUrl = `${normalizedIssuerUrl}/jwks/`; + for (const url of jwksUrls) { try { - const jwksTestResponse = await fetch(authentikJwksUrl); - if (jwksTestResponse.ok) { - jwksUrl = authentikJwksUrl; + const response = await fetch(url); + if (response.ok) { + jwks = await response.json(); + jwksUrl = url; + break; } } catch (error) { - logger.warn(`Authentik JWKS URL also failed: ${error}`); + continue; } } - if (!jwksUrl) { - const baseUrl = normalizedIssuerUrl.replace(/\/application\/o\/[^\/]+$/, ''); - const rootJwksUrl = `${baseUrl}/.well-known/jwks.json`; - try { - const jwksTestResponse = await fetch(rootJwksUrl); - if (jwksTestResponse.ok) { - jwksUrl = rootJwksUrl; - } - } catch (error) { - logger.warn(`Authentik root JWKS URL also failed: ${error}`); - } + if (!jwks) { + throw new Error('Failed to fetch JWKS from any URL'); } - const jwksResponse = await fetch(jwksUrl); - if (!jwksResponse.ok) { - throw new Error(`Failed to fetch JWKS from ${jwksUrl}: ${jwksResponse.status}`); - } - - const jwks = await jwksResponse.json() as any; - const header = JSON.parse(Buffer.from(idToken.split('.')[0], 'base64').toString()); const keyId = header.kid; @@ -81,12 +71,7 @@ async function verifyOIDCToken(idToken: string, issuerUrl: string, clientId: str const key = await importJWK(publicKey); const {payload} = await jwtVerify(idToken, key, { - issuer: [ - issuerUrl, - normalizedIssuerUrl, - issuerUrl.replace(/\/application\/o\/[^\/]+$/, ''), - normalizedIssuerUrl.replace(/\/application\/o\/[^\/]+$/, '') - ], + issuer: possibleIssuers, audience: clientId, }); @@ -376,54 +361,64 @@ router.get('/oidc/callback', async (req, res) => { const tokenData = await tokenResponse.json() as any; - let userInfo; + let userInfo: any = null; + const userInfoUrls = []; + + 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`); + if (tokenData.id_token) { try { userInfo = await verifyOIDCToken(tokenData.id_token, config.issuer_url, config.client_id); } catch (error) { - logger.error('OIDC token verification failed, falling back to userinfo endpoint', error); - if (tokenData.access_token) { - const normalizedIssuerUrl = config.issuer_url.endsWith('/') ? config.issuer_url.slice(0, -1) : config.issuer_url; - const baseUrl = normalizedIssuerUrl.replace(/\/application\/o\/[^\/]+$/, ''); - const userInfoUrl = `${baseUrl}/userinfo/`; + logger.error('OIDC token verification failed, trying userinfo endpoints', error); + } + } + if (!userInfo && tokenData.access_token) { + for (const userInfoUrl of userInfoUrls) { + try { const userInfoResponse = await fetch(userInfoUrl, { headers: { 'Authorization': `Bearer ${tokenData.access_token}`, - }, + } }); if (userInfoResponse.ok) { userInfo = await userInfoResponse.json(); - } else { - logger.error(`Userinfo endpoint failed with status: ${userInfoResponse.status}`); + break; } + } catch (error) { + continue; } } - } else if (tokenData.access_token) { - const normalizedIssuerUrl = config.issuer_url.endsWith('/') ? config.issuer_url.slice(0, -1) : config.issuer_url; - const baseUrl = normalizedIssuerUrl.replace(/\/application\/o\/[^\/]+$/, ''); - const userInfoUrl = `${baseUrl}/userinfo/`; - - const userInfoResponse = await fetch(userInfoUrl, { - headers: { - 'Authorization': `Bearer ${tokenData.access_token}`, - }, - }); - - if (userInfoResponse.ok) { - userInfo = await userInfoResponse.json(); - } else { - logger.error(`Userinfo endpoint failed with status: ${userInfoResponse.status}`); - } } if (!userInfo) { + logger.error('Failed to get user information from all sources'); return res.status(400).json({error: 'Failed to get user information'}); } - const identifier = userInfo[config.identifier_path]; - const name = userInfo[config.name_path] || identifier; + const getNestedValue = (obj: any, path: string): any => { + if (!path || !obj) return null; + return path.split('.').reduce((current, key) => current?.[key], obj); + }; + + const identifier = getNestedValue(userInfo, config.identifier_path) || + userInfo[config.identifier_path] || + userInfo.sub || + userInfo.email || + userInfo.preferred_username; + + const name = getNestedValue(userInfo, config.name_path) || + userInfo[config.name_path] || + userInfo.name || + userInfo.given_name || + identifier; if (!identifier) { logger.error(`Identifier not found at path: ${config.identifier_path}`); From 52e9b16643dd699988a63c3ac54e3c64a3669109 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Tue, 2 Sep 2025 16:55:08 -0500 Subject: [PATCH 2/5] Fix OIDC errors for "Failed to get user information" --- src/backend/database/routes/users.ts | 53 +++++++++++++++++++++++++--- src/ui/Admin/AdminSettings.tsx | 32 +++++++++++------ src/ui/Navigation/LeftSidebar.tsx | 8 +++-- src/ui/main-axios.ts | 11 +++--- 4 files changed, 81 insertions(+), 23 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 bece9c0a..ee375929 100644 --- a/src/ui/Admin/AdminSettings.tsx +++ b/src/ui/Admin/AdminSettings.tsx @@ -52,7 +52,8 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React. token_url: '', identifier_path: 'sub', name_path: 'name', - scopes: 'openid email profile' + scopes: 'openid email profile', + userinfo_url: '' }); const [oidcLoading, setOidcLoading] = React.useState(false); const [oidcError, setOidcError] = React.useState(null); @@ -145,7 +146,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React. setOidcConfig(prev => ({...prev, [field]: value})); }; - const makeUserAdmin = async (e: React.FormEvent) => { + const handleMakeUserAdmin = async (e: React.FormEvent) => { e.preventDefault(); if (!newAdminUsername.trim()) return; setMakeAdminLoading(true); @@ -164,23 +165,25 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React. } }; - const removeAdminStatus = async (username: string) => { + const handleRemoveAdminStatus = async (username: string) => { if (!confirm(`Remove admin status from ${username}?`)) return; const jwt = getCookie("jwt"); try { await removeAdminStatus(username); fetchUsers(); - } catch { + } catch (err: any) { + console.error('Failed to remove admin status:', err); } }; - const deleteUser = async (username: string) => { + const handleDeleteUser = async (username: string) => { if (!confirm(`Delete user ${username}? This cannot be undone.`)) return; const jwt = getCookie("jwt"); try { await deleteUser(username); fetchUsers(); - } catch { + } catch (err: any) { + console.error('Failed to delete user:', err); } }; @@ -296,9 +299,15 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
handleOIDCConfigChange('scopes', (e.target as HTMLInputElement).value)} + onChange={(e) => handleOIDCConfigChange('scopes', e.target.value)} placeholder="openid email profile" required/>
+
+ + handleOIDCConfigChange('userinfo_url', e.target.value)} + placeholder="https://your-provider.com/application/o/userinfo/"/> +
@@ -310,7 +319,8 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React. token_url: '', identifier_path: 'sub', name_path: 'name', - scopes: 'openid email profile' + scopes: 'openid email profile', + userinfo_url: '' })}>Reset
@@ -357,7 +367,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React. className="px-4">{user.is_oidc ? "External" : "Local"} - - {oidcSuccess && ( - - Success - {oidcSuccess} - - )} @@ -404,12 +398,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React. {makeAdminError} )} - {makeAdminSuccess && ( - - Success - {makeAdminSuccess} - - )} + diff --git a/src/ui/Apps/Host Manager/HostManagerHostEditor.tsx b/src/ui/Apps/Host Manager/HostManagerHostEditor.tsx index 7dc84072..e4e4ccd1 100644 --- a/src/ui/Apps/Host Manager/HostManagerHostEditor.tsx +++ b/src/ui/Apps/Host Manager/HostManagerHostEditor.tsx @@ -19,6 +19,7 @@ import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx import React, {useEffect, useRef, useState} from "react"; import {Switch} from "@/components/ui/switch.tsx"; import {Alert, AlertDescription} from "@/components/ui/alert.tsx"; +import {toast} from "sonner"; import {createSSHHost, updateSSHHost, getSSHHosts} from '@/ui/main-axios.ts'; interface SSHHost { @@ -244,8 +245,10 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos if (editingHost) { await updateSSHHost(editingHost.id, formData); + toast.success(`Host "${formData.name}" updated successfully!`); } else { await createSSHHost(formData); + toast.success(`Host "${formData.name}" added successfully!`); } if (onFormSubmit) { @@ -254,7 +257,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos window.dispatchEvent(new CustomEvent('ssh-hosts:changed')); } catch (error) { - alert('Failed to save host. Please try again.'); + toast.error('Failed to save host. Please try again.'); } }; diff --git a/src/ui/Apps/Host Manager/HostManagerHostViewer.tsx b/src/ui/Apps/Host Manager/HostManagerHostViewer.tsx index 476eb895..61942489 100644 --- a/src/ui/Apps/Host Manager/HostManagerHostViewer.tsx +++ b/src/ui/Apps/Host Manager/HostManagerHostViewer.tsx @@ -7,6 +7,7 @@ import {Input} from "@/components/ui/input"; import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion"; import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip"; import {getSSHHosts, deleteSSHHost, bulkImportSSHHosts} from "@/ui/main-axios.ts"; +import {toast} from "sonner"; import { Edit, Trash2, @@ -74,10 +75,11 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) { if (window.confirm(`Are you sure you want to delete "${hostName}"?`)) { try { await deleteSSHHost(hostId); + toast.success(`Host "${hostName}" deleted successfully!`); await fetchHosts(); window.dispatchEvent(new CustomEvent('ssh-hosts:changed')); } catch (err) { - alert('Failed to delete host'); + toast.error('Failed to delete host'); } } }; @@ -114,16 +116,19 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) { const result = await bulkImportSSHHosts(hostsArray); if (result.success > 0) { - alert(`Import completed: ${result.success} successful, ${result.failed} failed${result.errors.length > 0 ? '\n\nErrors:\n' + result.errors.join('\n') : ''}`); + toast.success(`Import completed: ${result.success} hosts imported successfully${result.failed > 0 ? `, ${result.failed} failed` : ''}`); + if (result.errors.length > 0) { + toast.error(`Import errors: ${result.errors.join(', ')}`); + } await fetchHosts(); window.dispatchEvent(new CustomEvent('ssh-hosts:changed')); } else { - alert(`Import failed: ${result.errors.join('\n')}`); + toast.error(`Import failed: ${result.errors.join(', ')}`); } } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to import JSON file'; - alert(`Import error: ${errorMessage}`); + toast.error(`Import error: ${errorMessage}`); } finally { setImporting(false); event.target.value = ''; diff --git a/src/ui/Homepage/Homepage.tsx b/src/ui/Homepage/Homepage.tsx index b6fbe9ae..03854439 100644 --- a/src/ui/Homepage/Homepage.tsx +++ b/src/ui/Homepage/Homepage.tsx @@ -70,13 +70,20 @@ export function Homepage({ } }, [isAuthenticated]); + const topOffset = isTopbarOpen ? 66 : 0; + const topPadding = isTopbarOpen ? 66 : 0; + return (
+ className="w-full min-h-svh relative transition-[padding-top] duration-300 ease-in-out" + style={{ paddingTop: `${topPadding}px` }}> {!loggedIn ? ( -
+
) : ( -
-
+
+
diff --git a/src/ui/User/PasswordReset.tsx b/src/ui/User/PasswordReset.tsx index 19fb4724..df8b8db1 100644 --- a/src/ui/User/PasswordReset.tsx +++ b/src/ui/User/PasswordReset.tsx @@ -6,6 +6,7 @@ import {Label} from "@/components/ui/label.tsx"; import {Input} from "@/components/ui/input.tsx"; import {Button} from "@/components/ui/button.tsx"; import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx"; +import {toast} from "sonner"; interface PasswordResetProps { userInfo: { @@ -25,7 +26,6 @@ export function PasswordReset({userInfo}: PasswordResetProps) { const [confirmPassword, setConfirmPassword] = useState(""); const [tempToken, setTempToken] = useState(""); const [resetLoading, setResetLoading] = useState(false); - const [resetSuccess, setResetSuccess] = useState(false); async function handleInitiatePasswordReset() { setError(null); @@ -48,7 +48,6 @@ export function PasswordReset({userInfo}: PasswordResetProps) { setConfirmPassword(""); setTempToken(""); setError(null); - setResetSuccess(false); } async function handleVerifyResetCode() { @@ -85,14 +84,8 @@ export function PasswordReset({userInfo}: PasswordResetProps) { try { await completePasswordReset(userInfo.username, tempToken, newPassword); - setResetStep("initiate"); - setResetCode(""); - setNewPassword(""); - setConfirmPassword(""); - setTempToken(""); - setError(null); - - setResetSuccess(true); + toast.success("Password reset successfully! You can now log in with your new password."); + resetPasswordState(); } catch (err: any) { setError(err?.response?.data?.error || "Failed to complete password reset"); } finally { @@ -120,7 +113,7 @@ export function PasswordReset({userInfo}: PasswordResetProps) { <> - {resetStep === "initiate" && !resetSuccess && ( + {resetStep === "initiate" && ( <>