From f56b50b356ac9e608614eefb55a1fca4a5a7cbf5 Mon Sep 17 00:00:00 2001 From: Stan Lewis Date: Wed, 8 Jan 2025 14:26:01 -0500 Subject: [PATCH] feat(app): dynamic authentication provider support This change adds support for loading authentication providers or modules from dynamic plugins via 3 main changes to the code. First, an environment variable `ENABLE_AUTH_PROVIDER_MODULE_OVERRIDE` controls whether or not the backend installs the default authentication provider module. When this override is enabled dynamic plugins can be used to supply custom authentication providers. Secondly this change also adds a `signInPage` configuration for frontend dynamic plugins which is required for dynamic plugins to be able to provide a custom SignInPage component, for example: ```yaml dynamicPlugins: frontend: my-plugin-package: signInPage: importName: CustomSignInPage ``` Where the named export `CustomSignInPage` will be mapped to `components.SignInPage` when the frontend is initialized. Finally, to ensure authentication providers can be managed by the user a new `providerSettings` configuration field is available for frontend dynamic plugins, which can be used to inform the user settings page of the new provider, for example: ```yaml dynamicPlugins: frontend: my-plugin-package: providerSettings: - title: Github Two description: Sign in with GitHub Org Two provider: core.auth.github-two ``` Each `providerSettings` item will be turned into a new row under the "Authentication Providers" tab on the user settings page. The `provider` field is used to look up and connect the API ref for the external authentication provider and should be the same string used when calling `createApiRef`, for example: ```javascript export const ghTwoAuthApiRef: ApiRef< OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi > = createApiRef({ id: 'core.auth.github-two', // <--- this string }) ``` This commit also updates the app config.d.ts with some missing definitions as well as adds definitions for the above. Signed-off-by: Stan Lewis --- packages/app/config.d.ts | 30 +++++++ .../app/src/components/AppBase/AppBase.tsx | 3 +- .../components/DynamicRoot/DynamicRoot.tsx | 54 ++++++++++--- .../DynamicRoot/DynamicRootContext.tsx | 8 ++ .../components/DynamicRoot/ScalprumRoot.tsx | 1 + .../components/UserSettings/SettingsPages.tsx | 80 ++++++++++++++++++- .../dynamicUI/extractDynamicConfig.test.ts | 69 ++++++++++++++++ .../utils/dynamicUI/extractDynamicConfig.ts | 42 ++++++++++ packages/app/src/utils/test/TestRoot.tsx | 1 + packages/backend/src/index.ts | 6 +- 10 files changed, 281 insertions(+), 13 deletions(-) diff --git a/packages/app/config.d.ts b/packages/app/config.d.ts index 468e075846..56b41d945f 100644 --- a/packages/app/config.d.ts +++ b/packages/app/config.d.ts @@ -143,6 +143,36 @@ export interface Config { module?: string; importName?: string; }[]; + providerSettings?: { + title: string; + description: string; + provider: string; + }[]; + scaffolderFieldExtensions?: { + module?: string; + importName?: string; + }[]; + signInPage?: { + module?: string; + importName: string; + }; + techdocsAddons?: { + module?: string; + importName?: string; + config?: { + props?: { + [key: string]: string; + }; + }; + }[]; + themes?: { + module?: string; + id: string; + title: string; + variant: 'light' | 'dark'; + icon: string; + importName?: string; + }[]; }; }; }; diff --git a/packages/app/src/components/AppBase/AppBase.tsx b/packages/app/src/components/AppBase/AppBase.tsx index bc3d8f2a71..94b51293c7 100644 --- a/packages/app/src/components/AppBase/AppBase.tsx +++ b/packages/app/src/components/AppBase/AppBase.tsx @@ -37,6 +37,7 @@ const AppBase = () => { AppRouter, dynamicRoutes, entityTabOverrides, + providerSettings, scaffolderFieldExtensions, } = useContext(DynamicRootContext); @@ -123,7 +124,7 @@ const AppBase = () => { }> - {settingsPage} + {settingsPage(providerSettings)} } /> } /> diff --git a/packages/app/src/components/DynamicRoot/DynamicRoot.tsx b/packages/app/src/components/DynamicRoot/DynamicRoot.tsx index 1b603f769e..e26b34a3b5 100644 --- a/packages/app/src/components/DynamicRoot/DynamicRoot.tsx +++ b/packages/app/src/components/DynamicRoot/DynamicRoot.tsx @@ -3,7 +3,11 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { createApp } from '@backstage/app-defaults'; import { BackstageApp } from '@backstage/core-app-api'; -import { AnyApiFactory, BackstagePlugin } from '@backstage/core-plugin-api'; +import { + AnyApiFactory, + AppComponents, + BackstagePlugin, +} from '@backstage/core-plugin-api'; import { useThemes } from '@redhat-developer/red-hat-developer-hub-theme'; import { AppsConfig } from '@scalprum/core'; @@ -63,7 +67,9 @@ export const DynamicRoot = ({ React.ComponentType | undefined >(undefined); // registry of remote components loaded at bootstrap - const [components, setComponents] = useState(); + const [componentRegistry, setComponentRegistry] = useState< + ComponentRegistry | undefined + >(); const { initialized, pluginStore, api: scalprumApi } = useScalprum(); const themes = useThemes(); @@ -78,11 +84,13 @@ export const DynamicRoot = ({ menuItems, entityTabs, mountPoints, + providerSettings, routeBindings, routeBindingTargets, scaffolderFieldExtensions, techdocsAddons, themes: pluginThemes, + signInPages, } = extractDynamicConfig(dynamicPlugins); const requiredModules = [ ...pluginModules.map(({ scope, module }) => ({ @@ -117,6 +125,10 @@ export const DynamicRoot = ({ scope, module, })), + ...signInPages.map(({ scope, module }) => ({ + scope, + module, + })), ]; const staticPlugins = Object.keys(staticPluginStore).reduce( @@ -416,6 +428,25 @@ export const DynamicRoot = ({ [], ); + // the config allows for multiple sign-in pages, discover and use the first + // working instance but check all of them + const signInPage = signInPages + .map | undefined>( + ({ scope, module, importName }) => { + const candidate = allPlugins[scope]?.[module]?.[ + importName + ] as React.ComponentType<{}>; + if (!candidate) { + // eslint-disable-next-line no-console + console.warn( + `Plugin ${scope} is not configured properly: ${module}.${importName} not found, ignoring SignInPage: ${importName}`, + ); + } + return candidate; + }, + ) + .find(candidate => candidate !== undefined); + if (!app.current) { const filteredStaticThemes = themes.filter( theme => @@ -437,7 +468,12 @@ export const DynamicRoot = ({ ...remoteBackstagePlugins, ], themes: [...filteredStaticThemes, ...dynamicThemeProviders], - components: defaultAppComponents, + components: { + ...defaultAppComponents, + ...(signInPage && { + SignInPage: signInPage, + }), + } as Partial, }); } @@ -454,17 +490,17 @@ export const DynamicRoot = ({ dynamicRootConfig.techdocsAddons = techdocsAddonComponents; // make the dynamic UI configuration available to DynamicRootContext consumers - setComponents({ + setComponentRegistry({ AppProvider: app.current.getProvider(), AppRouter: app.current.getRouter(), dynamicRoutes: dynamicRoutesComponents, menuItems: dynamicRoutesMenuItems, entityTabOverrides, mountPoints: mountPointComponents, + providerSettings, scaffolderFieldExtensions: scaffolderFieldExtensionComponents, techdocsAddons: techdocsAddonComponents, }); - afterInit().then(({ default: Component }) => { setChildComponent(() => Component); }); @@ -480,17 +516,17 @@ export const DynamicRoot = ({ ]); useEffect(() => { - if (initialized && !components) { + if (initialized && !componentRegistry) { initializeRemoteModules(); } - }, [initialized, components, initializeRemoteModules]); + }, [initialized, componentRegistry, initializeRemoteModules]); - if (!initialized || !components) { + if (!initialized || !componentRegistry) { return ; } return ( - + {ChildComponent ? : } ); diff --git a/packages/app/src/components/DynamicRoot/DynamicRootContext.tsx b/packages/app/src/components/DynamicRoot/DynamicRootContext.tsx index 5a1d6157f3..b8d04adc15 100644 --- a/packages/app/src/components/DynamicRoot/DynamicRootContext.tsx +++ b/packages/app/src/components/DynamicRoot/DynamicRootContext.tsx @@ -128,11 +128,18 @@ export type TechdocsAddon = { }; }; +export type ProviderSetting = { + title: string; + description: string; + provider: string; +}; + export type DynamicRootConfig = { dynamicRoutes: ResolvedDynamicRoute[]; entityTabOverrides: EntityTabOverrides; mountPoints: MountPoints; menuItems: ResolvedMenuItem[]; + providerSettings: ProviderSetting[]; scaffolderFieldExtensions: ScaffolderFieldExtension[]; techdocsAddons: TechdocsAddon[]; }; @@ -149,6 +156,7 @@ const DynamicRootContext = createContext({ entityTabOverrides: {}, mountPoints: {}, menuItems: [], + providerSettings: [], scaffolderFieldExtensions: [], techdocsAddons: [], }); diff --git a/packages/app/src/components/DynamicRoot/ScalprumRoot.tsx b/packages/app/src/components/DynamicRoot/ScalprumRoot.tsx index 7bc7888f84..d3c8ec95d8 100644 --- a/packages/app/src/components/DynamicRoot/ScalprumRoot.tsx +++ b/packages/app/src/components/DynamicRoot/ScalprumRoot.tsx @@ -78,6 +78,7 @@ const ScalprumRoot = ({ mountPoints: {}, scaffolderFieldExtensions: [], techdocsAddons: [], + providerSettings: [], } as DynamicRootConfig, }; return ( diff --git a/packages/app/src/components/UserSettings/SettingsPages.tsx b/packages/app/src/components/UserSettings/SettingsPages.tsx index 2d04ae111e..dc63a09873 100644 --- a/packages/app/src/components/UserSettings/SettingsPages.tsx +++ b/packages/app/src/components/UserSettings/SettingsPages.tsx @@ -1,11 +1,83 @@ +import { ErrorBoundary } from '@backstage/core-components'; import { + AnyApiFactory, + ApiRef, + configApiRef, + ProfileInfoApi, + SessionApi, + useApi, + useApp, +} from '@backstage/core-plugin-api'; +import { + DefaultProviderSettings, + ProviderSettingsItem, SettingsLayout, UserSettingsAuthProviders, } from '@backstage/plugin-user-settings'; +import Star from '@mui/icons-material/Star'; + +import { ProviderSetting } from '../DynamicRoot/DynamicRootContext'; import { GeneralPage } from './GeneralPage'; -export const settingsPage = ( +const DynamicProviderSettingsItem = ({ + title, + description, + provider, +}: { + title: string; + description: string; + provider: string; +}) => { + const app = useApp(); + // The provider API needs to be registered with the app + const apiRef = app + .getPlugins() + .flatMap(plugin => Array.from(plugin.getApis())) + .filter((api: AnyApiFactory) => api.api.id === provider) + .at(0)?.api; + if (!apiRef) { + // eslint-disable-next-line no-console + console.warn( + `No API factory found for provider ref "${provider}", hiding the related provider settings UI`, + ); + return <>; + } + return ( + } + icon={Star} + /> + ); +}; + +const DynamicProviderSettings = ({ + providerSettings, +}: { + providerSettings: ProviderSetting[]; +}) => { + const configApi = useApi(configApiRef); + const providersConfig = configApi.getOptionalConfig('auth.providers'); + const configuredProviders = providersConfig?.keys() || []; + return ( + <> + + {providerSettings.map(({ title, description, provider }) => ( + + + + ))} + + ); +}; + +export const settingsPage = (providerSettings: ProviderSetting[]) => ( @@ -14,7 +86,11 @@ export const settingsPage = ( path="auth-providers" title="Authentication Providers" > - + + } + /> ); diff --git a/packages/app/src/utils/dynamicUI/extractDynamicConfig.test.ts b/packages/app/src/utils/dynamicUI/extractDynamicConfig.test.ts index 31f89d4251..688f84ba25 100644 --- a/packages/app/src/utils/dynamicUI/extractDynamicConfig.test.ts +++ b/packages/app/src/utils/dynamicUI/extractDynamicConfig.test.ts @@ -154,15 +154,30 @@ describe('extractDynamicConfig', () => { menuItems: [], mountPoints: [], appIcons: [], + providerSettings: [], routeBindingTargets: [], apiFactories: [], scaffolderFieldExtensions: [], + signInPages: [], techdocsAddons: [], themes: [], }); }); it.each([ + [ + 'a SignInPage', + { signInPage: { importName: 'blah' } }, + { + signInPages: [ + { + importName: 'blah', + module: 'PluginRoot', + scope: 'janus-idp.plugin-foo', + }, + ], + }, + ], [ 'a dynamicRoute', { dynamicRoutes: [{ path: '/foo' }] }, @@ -496,6 +511,58 @@ describe('extractDynamicConfig', () => { ], }, ], + [ + 'a providerSettings', + { + providerSettings: [ + { + title: 'foo', + description: 'bar', + provider: 'foo.bar', + }, + ], + }, + { + providerSettings: [ + { + title: 'foo', + description: 'bar', + provider: 'foo.bar', + }, + ], + }, + ], + [ + 'multiple providerSettings', + { + providerSettings: [ + { + title: 'foo1', + description: 'bar1', + provider: 'foo.bar1', + }, + { + title: 'foo2', + description: 'bar2', + provider: 'foo.bar2', + }, + ], + }, + { + providerSettings: [ + { + title: 'foo1', + description: 'bar1', + provider: 'foo.bar1', + }, + { + title: 'foo2', + description: 'bar2', + provider: 'foo.bar2', + }, + ], + }, + ], [ 'a techdocs field extension', { @@ -569,8 +636,10 @@ describe('extractDynamicConfig', () => { appIcons: [], apiFactories: [], scaffolderFieldExtensions: [], + signInPages: [], techdocsAddons: [], themes: [], + providerSettings: [], ...output, }); }); diff --git a/packages/app/src/utils/dynamicUI/extractDynamicConfig.ts b/packages/app/src/utils/dynamicUI/extractDynamicConfig.ts index 370aa803c9..7569c8a5e4 100644 --- a/packages/app/src/utils/dynamicUI/extractDynamicConfig.ts +++ b/packages/app/src/utils/dynamicUI/extractDynamicConfig.ts @@ -125,6 +125,18 @@ type ThemeEntry = { importName: string; }; +type SignInPageEntry = { + scope: string; + module: string; + importName: string; +}; + +type ProviderSetting = { + title: string; + description: string; + provider: string; +}; + type CustomProperties = { pluginModule?: string; dynamicRoutes?: (DynamicModuleEntry & { @@ -142,7 +154,9 @@ type CustomProperties = { mountPoints?: MountPoint[]; appIcons?: AppIcon[]; apiFactories?: ApiFactory[]; + providerSettings?: ProviderSetting[]; scaffolderFieldExtensions?: ScaffolderFieldExtension[]; + signInPage: SignInPageEntry; techdocsAddons?: TechdocsAddon[]; themes?: ThemeEntry[]; }; @@ -163,9 +177,11 @@ type DynamicConfig = { menuItems: MenuItem[]; entityTabs: EntityTabEntry[]; mountPoints: MountPoint[]; + providerSettings: ProviderSetting[]; routeBindings: RouteBinding[]; routeBindingTargets: BindingTarget[]; scaffolderFieldExtensions: ScaffolderFieldExtension[]; + signInPages: SignInPageEntry[]; techdocsAddons: TechdocsAddon[]; themes: ThemeEntry[]; }; @@ -188,10 +204,30 @@ function extractDynamicConfig( mountPoints: [], routeBindings: [], routeBindingTargets: [], + providerSettings: [], scaffolderFieldExtensions: [], + signInPages: [], techdocsAddons: [], themes: [], }; + config.signInPages = Object.entries(frontend).reduce( + (pluginSet, [scope, { signInPage }]) => { + if (!signInPage) { + return pluginSet; + } + const { importName, module } = signInPage; + if (!importName) { + return pluginSet; + } + pluginSet.push({ + scope, + module: module ?? 'PluginRoot', + importName, + }); + return pluginSet; + }, + [], + ); config.pluginModules = Object.entries(frontend).reduce( (pluginSet, [scope, customProperties]) => { pluginSet.push({ @@ -330,6 +366,12 @@ function extractDynamicConfig( }, [], ); + config.providerSettings = Object.entries(frontend).reduce( + (accProviderSettings, [_, { providerSettings = [] }]) => { + return [...accProviderSettings, ...providerSettings]; + }, + [], + ); return config; } diff --git a/packages/app/src/utils/test/TestRoot.tsx b/packages/app/src/utils/test/TestRoot.tsx index 3d1709af7a..495d2d2146 100644 --- a/packages/app/src/utils/test/TestRoot.tsx +++ b/packages/app/src/utils/test/TestRoot.tsx @@ -42,6 +42,7 @@ const TestRoot = ({ children }: PropsWithChildren<{}>) => { mountPoints: {}, entityTabOverrides: {}, scaffolderFieldExtensions: [], + providerSettings: [], techdocsAddons: [], }), [current], diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 812796b107..31f0aa6414 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -91,7 +91,11 @@ backend.add(rbacDynamicPluginsProvider); backend.add(import('@backstage/plugin-auth-backend')); backend.add(import('@backstage/plugin-auth-backend-module-guest-provider')); -backend.add(import('./modules/authProvidersModule')); +if (process.env.ENABLE_AUTH_PROVIDER_MODULE_OVERRIDE !== 'true') { + backend.add(import('./modules/authProvidersModule')); +} else { + staticLogger.info(`Default authentication provider module disabled`); +} backend.add(import('@internal/plugin-dynamic-plugins-info-backend')); backend.add(import('@internal/plugin-scalprum-backend'));