From ef864c175a949e6731f8a21a19a7b60329f5d169 Mon Sep 17 00:00:00 2001 From: Sang Jun Bak Date: Wed, 20 May 2026 14:49:22 -0400 Subject: [PATCH 1/2] console: Encapsulate useAuth hook into AppConfigSwitch Before we were getting throws on password login when Oidc was never initialized. This allows us to not have to check if the oidcManager is available everytime we want Oidc auth details. --- console/src/components/OidcConnectModal.tsx | 5 +- .../src/components/OidcProviderWrapper.tsx | 29 ++------ console/src/config/AppConfigSwitch.tsx | 66 ++++++++++++++++++- console/src/external-library-wrappers/oidc.ts | 47 +++++++++++-- console/src/hooks/useSelfManagedProfile.ts | 7 +- console/src/layouts/NavBar.tsx | 5 +- console/src/layouts/NavBar/NavMenu.tsx | 5 +- console/src/layouts/ProfileDropdown.tsx | 56 +++++++++++----- .../src/platform/UnauthenticatedRoutes.tsx | 9 ++- console/src/platform/auth/Login.tsx | 26 ++++---- 10 files changed, 184 insertions(+), 71 deletions(-) diff --git a/console/src/components/OidcConnectModal.tsx b/console/src/components/OidcConnectModal.tsx index a07dea8c69dfb..0ddc31c522154 100644 --- a/console/src/components/OidcConnectModal.tsx +++ b/console/src/components/OidcConnectModal.tsx @@ -26,7 +26,7 @@ import { } from "~/components/copyableComponents"; import McpConnectInstructions from "~/components/McpConnectInstructions"; import { Modal } from "~/components/Modal"; -import { useAuth } from "~/external-library-wrappers/oidc"; +import { type AuthContextProps } from "~/external-library-wrappers/oidc"; import ConnectionIcon from "~/svg/ConnectionIcon"; import { MaterializeTheme } from "~/theme"; import { obfuscateSecret } from "~/utils/format"; @@ -36,12 +36,13 @@ const OIDC_USERNAME_PLACEHOLDER = ""; const OidcConnectModal = ({ onClose, isOpen, + auth, }: { onClose: () => void; isOpen: boolean; + auth: AuthContextProps; }) => { const { colors } = useTheme(); - const auth = useAuth(); const idToken = auth.user?.id_token; const obfuscated = idToken ? obfuscateSecret(idToken) : ""; diff --git a/console/src/components/OidcProviderWrapper.tsx b/console/src/components/OidcProviderWrapper.tsx index e16ec425ee9ae..e0cf6ff5f1d56 100644 --- a/console/src/components/OidcProviderWrapper.tsx +++ b/console/src/components/OidcProviderWrapper.tsx @@ -7,14 +7,15 @@ // the Business Source License, use of this software will be governed // by the Apache License, Version 2.0. -import { useQuery } from "@tanstack/react-query"; import React, { useCallback } from "react"; import { useNavigate } from "react-router-dom"; -import { apiClient } from "~/api/apiClient"; import LoadingScreen from "~/components/LoadingScreen"; import { useAppConfig } from "~/config/useAppConfig"; -import { AuthProvider } from "~/external-library-wrappers/oidc"; +import { + AuthProvider, + useOidcManagerQuery, +} from "~/external-library-wrappers/oidc"; export const OidcProviderWrapper = ({ children }: React.PropsWithChildren) => { const navigate = useNavigate(); @@ -23,27 +24,7 @@ export const OidcProviderWrapper = ({ children }: React.PropsWithChildren) => { const isOidc = appConfig.mode === "self-managed" && appConfig.authMode === "Oidc"; - // Not a typical data fetch — using React Query to get loading/error - // state without wiring up useState + useEffect manually. - const { - data: oidcManager, - isLoading, - error, - } = useQuery({ - queryKey: ["oidc-manager"], - queryFn: () => { - if ( - apiClient.type !== "self-managed" || - !apiClient.oidcManagerInitializationPromise - ) { - return null; - } - return apiClient.oidcManagerInitializationPromise; - }, - enabled: isOidc, - staleTime: Infinity, - retry: false, - }); + const { data: oidcManager, isLoading, error } = useOidcManagerQuery(); const onSigninCallback = useCallback(() => { navigate("/", { replace: true }); diff --git a/console/src/config/AppConfigSwitch.tsx b/console/src/config/AppConfigSwitch.tsx index d4250f5aa7c92..8b1be4c2ac848 100644 --- a/console/src/config/AppConfigSwitch.tsx +++ b/console/src/config/AppConfigSwitch.tsx @@ -17,6 +17,10 @@ import { useAuthUser, type User, } from "~/external-library-wrappers/frontegg"; +import { + useAuth as useOidcAuth, + useOidcManagerQuery, +} from "~/external-library-wrappers/oidc"; import { CloudAppConfig, SelfManagedAppConfig } from "./AppConfig"; import { useAppConfig } from "./useAppConfig"; @@ -36,6 +40,19 @@ export type CloudRuntimeConfig = | CloudFronteggRuntimeConfig | CloudImpersonationRuntimeConfig; +type SelfManagedOidcAvailableRuntimeConfig = { + isOidcAvailable: true; + auth: NonNullable>; +}; + +type SelfManagedOidcUnavailableRuntimeConfig = { + isOidcAvailable: false; +}; + +export type SelfManagedRuntimeConfig = + | SelfManagedOidcAvailableRuntimeConfig + | SelfManagedOidcUnavailableRuntimeConfig; + type CloudConfigElementRenderProps = { appConfig: Readonly; runtimeConfig: CloudRuntimeConfig; @@ -43,6 +60,7 @@ type CloudConfigElementRenderProps = { type SelfManagedConfigElementRenderProps = { appConfig: Readonly; + runtimeConfig: SelfManagedRuntimeConfig; }; type CloudConfigElementFunction = ( @@ -97,6 +115,47 @@ const CloudImpersonationConfigElementWrapper = ({ }); }; +// Only mounted when the OIDC manager has initialized, which implies +// OidcProviderWrapper has mounted an AuthProvider in scope. +const SelfManagedOidcAvailableConfigElementWrapper = ({ + selfManagedAppConfig, + selfManagedConfigElement, +}: { + selfManagedAppConfig: Readonly; + selfManagedConfigElement: SelfManagedConfigElementFunction; +}) => { + const auth = useOidcAuth(); + + return selfManagedConfigElement({ + appConfig: selfManagedAppConfig, + runtimeConfig: { isOidcAvailable: true, auth }, + }); +}; + +const SelfManagedConfigElementWrapper = ({ + selfManagedAppConfig, + selfManagedConfigElement, +}: { + selfManagedAppConfig: Readonly; + selfManagedConfigElement: SelfManagedConfigElementFunction; +}) => { + const { data: oidcManager } = useOidcManagerQuery(); + + if (!oidcManager) { + return selfManagedConfigElement({ + appConfig: selfManagedAppConfig, + runtimeConfig: { isOidcAvailable: false }, + }); + } + + return ( + + ); +}; + // A component that controls which component to render based on the deployment mode. // This is used to avoid having to do a discriminant check throughout the application. // @@ -136,7 +195,12 @@ export const AppConfigSwitch = ({ } if (typeof selfManagedConfigElement === "function") { - return selfManagedConfigElement({ appConfig }); + return ( + + ); } return selfManagedConfigElement; diff --git a/console/src/external-library-wrappers/oidc.ts b/console/src/external-library-wrappers/oidc.ts index 54d374a702da5..b1fa8e7cbdf2f 100644 --- a/console/src/external-library-wrappers/oidc.ts +++ b/console/src/external-library-wrappers/oidc.ts @@ -11,10 +11,21 @@ /** * This file is a facade for the react-oidc-context / oidc-client-ts libraries. */ -export { AuthProvider, hasAuthParams, useAuth } from "react-oidc-context"; - +export { + type AuthContextProps, + AuthProvider, + hasAuthParams, + // We should not use this hook directly because it requires AuthProvider to be mounted, + // which it only is when OIDC is available. Instead, use AppConfigSwitch. + useAuth, +} from "react-oidc-context"; + +import { useQuery } from "@tanstack/react-query"; import { UserManager, WebStorageStateStore } from "oidc-client-ts"; +import { apiClient } from "~/api/apiClient"; +import { useAppConfig } from "~/config/useAppConfig"; + export interface OidcConfig { issuer: string; clientId: string; @@ -36,7 +47,7 @@ async function fetchOidcConfig(): Promise { if (!data.console_oidc_client_id) { throw new Error( - "OIDC client ID is required but was empty. Configure the console_oidc_client_id system parameter: https://materialize.com/docs/self-managed-deployments/configuration-system-parameters/", + "To use SSO, OIDC client ID must be set. Configure the console_oidc_client_id system parameter: https://materialize.com/docs/self-managed-deployments/configuration-system-parameters/", ); } if ( @@ -44,7 +55,7 @@ async function fetchOidcConfig(): Promise { !data.console_oidc_scopes.includes("openid") ) { throw new Error( - "OIDC scopes must include at least 'openid'. Configure the console_oidc_scopes system parameter: https://materialize.com/docs/self-managed-deployments/configuration-system-parameters/", + "To use SSO, OIDC scopes must include at least 'openid'. Configure the console_oidc_scopes system parameter: https://materialize.com/docs/self-managed-deployments/configuration-system-parameters/", ); } @@ -119,3 +130,31 @@ export class MzOidcUserManager { return new MzOidcUserManager(config); } } + +/** + * Resolves the OIDC manager once initialization completes. Returns `null` + * when not in OIDC mode or when init fails — callers should treat the + * absence of a manager as "OIDC unavailable, fall back to password sign-in" + */ +export const useOidcManagerQuery = () => { + const appConfig = useAppConfig(); + const isOidc = + appConfig.mode === "self-managed" && appConfig.authMode === "Oidc"; + + return useQuery({ + queryKey: ["oidc-manager"], + queryFn: () => { + if ( + apiClient.type !== "self-managed" || + !apiClient.oidcManagerInitializationPromise + ) { + return null; + } + return apiClient.oidcManagerInitializationPromise; + }, + enabled: isOidc, + staleTime: Infinity, + retry: false, + retryOnMount: false, // Do not retry on mount, otherwise we will retry indefinitely + }); +}; diff --git a/console/src/hooks/useSelfManagedProfile.ts b/console/src/hooks/useSelfManagedProfile.ts index 75a4816b3656b..3bc9812deca91 100644 --- a/console/src/hooks/useSelfManagedProfile.ts +++ b/console/src/hooks/useSelfManagedProfile.ts @@ -8,7 +8,7 @@ // by the Apache License, Version 2.0. import useCurrentUser from "~/api/materialize/useCurrentUser"; -import { useAuth } from "~/external-library-wrappers/oidc"; +import { type AuthContextProps } from "~/external-library-wrappers/oidc"; export interface SelfManagedProfile { name: string | undefined; @@ -21,8 +21,9 @@ export interface SelfManagedProfile { /** Unified identity for self-managed UI: OIDC claims when present, with the * SQL role as the authoritative fallback. */ -export const useSelfManagedProfile = (): SelfManagedProfile => { - const auth = useAuth(); +export const useSelfManagedProfile = ( + auth: AuthContextProps | undefined, +): SelfManagedProfile => { const profile = auth?.user?.profile; const { results: sqlRole, isLoading } = useCurrentUser(); diff --git a/console/src/layouts/NavBar.tsx b/console/src/layouts/NavBar.tsx index b1450cb5c7395..2fd17f015cdf6 100644 --- a/console/src/layouts/NavBar.tsx +++ b/console/src/layouts/NavBar.tsx @@ -279,7 +279,7 @@ export const NavBar = ({ isCollapsed }: NavBarProps) => { ) } - selfManagedConfigElement={({ appConfig }) => + selfManagedConfigElement={({ appConfig, runtimeConfig }) => appConfig.authMode === "None" ? null : ( { width="100%" onClick={onOpenConnectModal} /> - {appConfig.authMode === "Oidc" ? ( + {runtimeConfig.isOidcAvailable ? ( ) : ( ) } - selfManagedConfigElement={({ appConfig }) => + selfManagedConfigElement={({ appConfig, runtimeConfig }) => appConfig.authMode === "None" ? null : ( - {appConfig.authMode === "Oidc" ? ( + {runtimeConfig.isOidcAvailable ? ( ) : ( { - const { name, email, sqlRole, isLoading } = useSelfManagedProfile(); +const SelfManagedUserInfoMenuItem = ({ auth }: { auth?: AuthContextProps }) => { + const { name, email, sqlRole, isLoading } = useSelfManagedProfile(auth); if (isLoading && !name && !email && !sqlRole) { return ( <> @@ -114,9 +114,15 @@ const UserInfoMenuItem = () => { ); }} - selfManagedConfigElement={({ appConfig }) => { + selfManagedConfigElement={({ appConfig, runtimeConfig }) => { if (appConfig.authMode === "None") return null; - return ; + return ( + + ); }} /> ); @@ -134,9 +140,9 @@ const PricingMenuItem = () => ( ); -const SelfManagedMenuButtonLabel = () => { +const SelfManagedMenuButtonLabel = ({ auth }: { auth?: AuthContextProps }) => { const { colors } = useTheme(); - const { name } = useSelfManagedProfile(); + const { name } = useSelfManagedProfile(auth); return ( {name ?? "Settings"} @@ -144,9 +150,7 @@ const SelfManagedMenuButtonLabel = () => { ); }; -const OidcSignOutMenuItem = () => { - const auth = useAuth(); - +const OidcSignOutMenuItem = ({ auth }: { auth: AuthContextProps }) => { const handleLogout = async () => { // Clear both the session cookie and OIDC state so that // logout works regardless of which method the user used. @@ -188,9 +192,11 @@ const SignOutMenuItem = () => { ); }} - selfManagedConfigElement={({ appConfig }) => { + selfManagedConfigElement={({ appConfig, runtimeConfig }) => { if (appConfig.authMode === "None") return null; - if (appConfig.authMode === "Oidc") return ; + if (runtimeConfig.isOidcAvailable) { + return ; + } return ( <> @@ -208,8 +214,8 @@ const DefaultAvatar = () => { return ; }; -const SelfManagedAvatar = () => { - const { name, picture } = useSelfManagedProfile(); +const SelfManagedAvatar = ({ auth }: { auth?: AuthContextProps }) => { + const { name, picture } = useSelfManagedProfile(auth); return ; }; @@ -229,9 +235,15 @@ const Avatar = () => { /> ); }} - selfManagedConfigElement={({ appConfig }) => { + selfManagedConfigElement={({ appConfig, runtimeConfig }) => { if (appConfig.authMode === "None") return ; - return ; + return ( + + ); }} /> ); @@ -312,7 +324,7 @@ const ProfileDropdown = ({ ); }} - selfManagedConfigElement={({ appConfig }) => { + selfManagedConfigElement={({ appConfig, runtimeConfig }) => { if (appConfig.authMode === "None") { return ( ); } - return ; + return ( + + ); }} /> diff --git a/console/src/platform/UnauthenticatedRoutes.tsx b/console/src/platform/UnauthenticatedRoutes.tsx index a0666d0b2700b..d4680f0ab1b14 100644 --- a/console/src/platform/UnauthenticatedRoutes.tsx +++ b/console/src/platform/UnauthenticatedRoutes.tsx @@ -16,7 +16,10 @@ import LoadingScreen from "~/components/LoadingScreen"; import { type SelfManagedAppConfig } from "~/config/AppConfig"; import { useAppConfig } from "~/config/useAppConfig"; import { useIsAuthenticated } from "~/external-library-wrappers/frontegg"; -import { hasAuthParams, useAuth } from "~/external-library-wrappers/oidc"; +import { + hasAuthParams, + useOidcManagerQuery, +} from "~/external-library-wrappers/oidc"; import { AUTH_ROUTES } from "~/fronteggRoutes"; import { AuthenticatedRoutes } from "~/platform/AuthenticatedRoutes"; import { SentryRoutes } from "~/sentry"; @@ -25,14 +28,14 @@ import { Login } from "./auth/Login"; import { OidcCallback } from "./auth/OidcCallback"; const OidcAuthGuard = ({ children }: React.PropsWithChildren) => { - const auth = useAuth(); + const { isLoading, data: auth } = useOidcManagerQuery(); // OIDC initialization failed — `OidcProviderWrapper` rendered us without // an `AuthProvider` so password sign-in still works. Skip the OIDC checks // and let the user reach the app via their password session cookie. if (!auth) return <>{children}; - if (auth.isLoading || hasAuthParams()) { + if (isLoading || hasAuthParams()) { return ; } diff --git a/console/src/platform/auth/Login.tsx b/console/src/platform/auth/Login.tsx index c62482a4cd180..e89a47290955b 100644 --- a/console/src/platform/auth/Login.tsx +++ b/console/src/platform/auth/Login.tsx @@ -29,8 +29,7 @@ import { LOGIN_ERROR_PARAM, loginOrThrow } from "~/api/materialize/auth"; import Alert from "~/components/Alert"; import { LabeledInput } from "~/components/formComponentsV2"; import { MaterializeLogo } from "~/components/MaterializeLogo"; -import { useAppConfig } from "~/config/useAppConfig"; -import { useAuth } from "~/external-library-wrappers/oidc"; +import { useAuth, useOidcManagerQuery } from "~/external-library-wrappers/oidc"; import { AuthContentContainer, AuthLayout } from "~/layouts/AuthLayout"; import EyeClosedIcon from "~/svg/EyeClosedIcon"; import EyeOpenIcon from "~/svg/EyeOpenIcon"; @@ -152,10 +151,12 @@ const PasswordLoginForm = () => { const SsoLoginLink = () => { const { colors } = useTheme(); - const auth = useAuth(); const [error, setError] = useState(null); + const auth = useAuth(); - if (!auth) return null; + if (!auth) { + return null; + } const handleSsoLogin = () => { setError(null); @@ -184,12 +185,10 @@ const SsoLoginLink = () => { }; export const Login = () => { - const appConfig = useAppConfig(); const [searchParams] = useSearchParams(); - const isOidc = - appConfig.mode === "self-managed" && appConfig.authMode === "Oidc"; + const { data: auth, error: oidcInitializationError } = useOidcManagerQuery(); - const errorMessage = searchParams.get(LOGIN_ERROR_PARAM); + const oidcError = searchParams.get(LOGIN_ERROR_PARAM); return ( @@ -198,16 +197,19 @@ export const Login = () => { - {errorMessage && ( + {oidcError && ( + + )} + {oidcInitializationError && ( )} - {isOidc && } + {!!auth && } From 26065b98e658859a6b6c2678df2d882e63f06315 Mon Sep 17 00:00:00 2001 From: Sang Jun Bak Date: Thu, 21 May 2026 12:45:04 -0400 Subject: [PATCH 2/2] console: Add better errors for OIDC failures --- console/src/platform/auth/Login.tsx | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/console/src/platform/auth/Login.tsx b/console/src/platform/auth/Login.tsx index e89a47290955b..ea61403cd5a37 100644 --- a/console/src/platform/auth/Login.tsx +++ b/console/src/platform/auth/Login.tsx @@ -154,9 +154,15 @@ const SsoLoginLink = () => { const [error, setError] = useState(null); const auth = useAuth(); - if (!auth) { - return null; - } + // For internal errors, react-oidc-context won't throw an error in auth.signinRedirect, + // but will save it in its error object. So we need to check the error state + const oidcError = auth.error?.message ?? null; + + const oidcDisplayError = + error || + (oidcError && + `${oidcError}. It looks like there may be an issue with the sign-in configuration. Please review your OIDC settings or check the console logs for more information.`) || + null; const handleSsoLogin = () => { setError(null); @@ -167,9 +173,15 @@ const SsoLoginLink = () => { }); }; + if (!auth) { + return null; + } + return ( - {error && } + {oidcDisplayError && ( + + )}