Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions console/src/components/OidcConnectModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -36,12 +36,13 @@ const OIDC_USERNAME_PLACEHOLDER = "<your_oidc_username>";
const OidcConnectModal = ({
onClose,
isOpen,
auth,
}: {
onClose: () => void;
isOpen: boolean;
auth: AuthContextProps;
}) => {
const { colors } = useTheme<MaterializeTheme>();
const auth = useAuth();
const idToken = auth.user?.id_token;

const obfuscated = idToken ? obfuscateSecret(idToken) : "";
Expand Down
29 changes: 5 additions & 24 deletions console/src/components/OidcProviderWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 });
Expand Down
66 changes: 65 additions & 1 deletion console/src/config/AppConfigSwitch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -36,13 +40,27 @@ export type CloudRuntimeConfig =
| CloudFronteggRuntimeConfig
| CloudImpersonationRuntimeConfig;

type SelfManagedOidcAvailableRuntimeConfig = {
isOidcAvailable: true;
auth: NonNullable<ReturnType<typeof useOidcAuth>>;
};

type SelfManagedOidcUnavailableRuntimeConfig = {
isOidcAvailable: false;
};

export type SelfManagedRuntimeConfig =
| SelfManagedOidcAvailableRuntimeConfig
| SelfManagedOidcUnavailableRuntimeConfig;

type CloudConfigElementRenderProps = {
appConfig: Readonly<CloudAppConfig>;
runtimeConfig: CloudRuntimeConfig;
};

type SelfManagedConfigElementRenderProps = {
appConfig: Readonly<SelfManagedAppConfig>;
runtimeConfig: SelfManagedRuntimeConfig;
};

type CloudConfigElementFunction = (
Expand Down Expand Up @@ -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<SelfManagedAppConfig>;
selfManagedConfigElement: SelfManagedConfigElementFunction;
}) => {
const auth = useOidcAuth();

return selfManagedConfigElement({
appConfig: selfManagedAppConfig,
runtimeConfig: { isOidcAvailable: true, auth },
});
};

const SelfManagedConfigElementWrapper = ({
selfManagedAppConfig,
selfManagedConfigElement,
}: {
selfManagedAppConfig: Readonly<SelfManagedAppConfig>;
selfManagedConfigElement: SelfManagedConfigElementFunction;
}) => {
const { data: oidcManager } = useOidcManagerQuery();

if (!oidcManager) {
return selfManagedConfigElement({
appConfig: selfManagedAppConfig,
runtimeConfig: { isOidcAvailable: false },
});
}

return (
<SelfManagedOidcAvailableConfigElementWrapper
selfManagedAppConfig={selfManagedAppConfig}
selfManagedConfigElement={selfManagedConfigElement}
/>
);
};

// 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.
//
Expand Down Expand Up @@ -136,7 +195,12 @@ export const AppConfigSwitch = ({
}

if (typeof selfManagedConfigElement === "function") {
return selfManagedConfigElement({ appConfig });
return (
<SelfManagedConfigElementWrapper
selfManagedAppConfig={appConfig}
selfManagedConfigElement={selfManagedConfigElement}
/>
);
}

return selfManagedConfigElement;
Expand Down
47 changes: 43 additions & 4 deletions console/src/external-library-wrappers/oidc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -36,15 +47,15 @@ async function fetchOidcConfig(): Promise<OidcConfig> {

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 (
!data.console_oidc_scopes ||
!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/",
);
}

Expand Down Expand Up @@ -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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is cool! Didn't know retryOnMount was a thing

});
};
7 changes: 4 additions & 3 deletions console/src/hooks/useSelfManagedProfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();

Expand Down
5 changes: 3 additions & 2 deletions console/src/layouts/NavBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -279,18 +279,19 @@ export const NavBar = ({ isCollapsed }: NavBarProps) => {
</HideIfEnvironmentDisabled>
)
}
selfManagedConfigElement={({ appConfig }) =>
selfManagedConfigElement={({ appConfig, runtimeConfig }) =>
appConfig.authMode === "None" ? null : (
<HideIfEnvironmentDisabled>
<ConnectMenuItem
isCollapsed={isCollapsed}
width="100%"
onClick={onOpenConnectModal}
/>
{appConfig.authMode === "Oidc" ? (
{runtimeConfig.isOidcAvailable ? (
<OidcConnectModal
onClose={onCloseConnectModal}
isOpen={isConnectModalOpen}
auth={runtimeConfig.auth}
/>
) : (
<PasswordConnectModal
Expand Down
5 changes: 3 additions & 2 deletions console/src/layouts/NavBar/NavMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -315,18 +315,19 @@ const NavMenuMobile = (props: {
</HideIfEnvironmentDisabled>
)
}
selfManagedConfigElement={({ appConfig }) =>
selfManagedConfigElement={({ appConfig, runtimeConfig }) =>
appConfig.authMode === "None" ? null : (
<HideIfEnvironmentDisabled>
<ConnectMenuItem
width="100%"
onClick={onOpenConnectModal}
mb={{ base: 0, lg: 6 }}
/>
{appConfig.authMode === "Oidc" ? (
{runtimeConfig.isOidcAvailable ? (
<OidcConnectModal
onClose={onCloseConnectModal}
isOpen={isConnectModalOpen}
auth={runtimeConfig.auth}
/>
) : (
<PasswordConnectModal
Expand Down
Loading
Loading