Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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