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..ea61403cd5a37 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,18 @@ const PasswordLoginForm = () => { const SsoLoginLink = () => { const { colors } = useTheme(); - const auth = useAuth(); const [error, setError] = useState(null); + const auth = useAuth(); + + // 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; - if (!auth) return 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); @@ -166,9 +173,15 @@ const SsoLoginLink = () => { }); }; + if (!auth) { + return null; + } + return ( - {error && } + {oidcDisplayError && ( + + )} { }; 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 +209,19 @@ export const Login = () => { - {errorMessage && ( + {oidcError && ( + + )} + {oidcInitializationError && ( )} - {isOidc && } + {!!auth && }