From 6d8ac623a87f9fd9b82673e908599c35c3fd290b Mon Sep 17 00:00:00 2001 From: Stan Lewis Date: Thu, 13 Feb 2025 07:09:38 -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 }) ``` Signed-off-by: Stan Lewis --- .../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 +- 9 files changed, 251 insertions(+), 13 deletions(-) diff --git a/packages/app/src/components/AppBase/AppBase.tsx b/packages/app/src/components/AppBase/AppBase.tsx index 62ea3365b0..2715bf0fab 100644 --- a/packages/app/src/components/AppBase/AppBase.tsx +++ b/packages/app/src/components/AppBase/AppBase.tsx @@ -34,6 +34,7 @@ const AppBase = () => { AppRouter, dynamicRoutes, entityTabOverrides, + providerSettings, scaffolderFieldExtensions, } = useContext(DynamicRootContext); @@ -118,7 +119,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 55b3751221..0f3b2ed3f4 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'; @@ -61,7 +65,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(); @@ -76,10 +82,12 @@ export const DynamicRoot = ({ menuItems, entityTabs, mountPoints, + providerSettings, routeBindings, routeBindingTargets, scaffolderFieldExtensions, themes: pluginThemes, + signInPages, } = extractDynamicConfig(dynamicPlugins); const requiredModules = [ ...pluginModules.map(({ scope, module }) => ({ @@ -114,6 +122,10 @@ export const DynamicRoot = ({ scope, module, })), + ...signInPages.map(({ scope, module }) => ({ + scope, + module, + })), ]; const staticPlugins = Object.keys(staticPluginStore).reduce( @@ -387,6 +399,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 => @@ -408,7 +439,12 @@ export const DynamicRoot = ({ ...remoteBackstagePlugins, ], themes: [...filteredStaticThemes, ...dynamicThemeProviders], - components: defaultAppComponents, + components: { + ...defaultAppComponents, + ...(signInPage && { + SignInPage: signInPage, + }), + } as Partial, }); } @@ -424,16 +460,16 @@ export const DynamicRoot = ({ scaffolderFieldExtensionComponents; // 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, }); - afterInit().then(({ default: Component }) => { setChildComponent(() => Component); }); @@ -449,17 +485,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 a1e3ed5c64..3c5ed03a11 100644 --- a/packages/app/src/components/DynamicRoot/DynamicRootContext.tsx +++ b/packages/app/src/components/DynamicRoot/DynamicRootContext.tsx @@ -112,11 +112,18 @@ export type ScaffolderFieldExtension = { Component: React.ComponentType<{}>; }; +export type ProviderSetting = { + title: string; + description: string; + provider: string; +}; + export type DynamicRootConfig = { dynamicRoutes: ResolvedDynamicRoute[]; entityTabOverrides: EntityTabOverrides; mountPoints: MountPoints; menuItems: ResolvedMenuItem[]; + providerSettings: ProviderSetting[]; scaffolderFieldExtensions: ScaffolderFieldExtension[]; }; @@ -132,6 +139,7 @@ const DynamicRootContext = createContext({ entityTabOverrides: {}, mountPoints: {}, menuItems: [], + providerSettings: [], scaffolderFieldExtensions: [], }); diff --git a/packages/app/src/components/DynamicRoot/ScalprumRoot.tsx b/packages/app/src/components/DynamicRoot/ScalprumRoot.tsx index a44626a62e..b52244e740 100644 --- a/packages/app/src/components/DynamicRoot/ScalprumRoot.tsx +++ b/packages/app/src/components/DynamicRoot/ScalprumRoot.tsx @@ -77,6 +77,7 @@ const ScalprumRoot = ({ entityTabOverrides: {}, mountPoints: {}, scaffolderFieldExtensions: [], + providerSettings: [], } as DynamicRootConfig, }; return ( diff --git a/packages/app/src/components/UserSettings/SettingsPages.tsx b/packages/app/src/components/UserSettings/SettingsPages.tsx index 3b9b2963a4..94cac88001 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[]) => ( {generalPage} @@ -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 e24757b854..a40a529f9b 100644 --- a/packages/app/src/utils/dynamicUI/extractDynamicConfig.test.ts +++ b/packages/app/src/utils/dynamicUI/extractDynamicConfig.test.ts @@ -154,14 +154,29 @@ describe('extractDynamicConfig', () => { menuItems: [], mountPoints: [], appIcons: [], + providerSettings: [], routeBindingTargets: [], apiFactories: [], scaffolderFieldExtensions: [], + signInPages: [], themes: [], }); }); it.each([ + [ + 'a SignInPage', + { signInPage: { importName: 'blah' } }, + { + signInPages: [ + { + importName: 'blah', + module: 'PluginRoot', + scope: 'janus-idp.plugin-foo', + }, + ], + }, + ], [ 'a dynamicRoute', { dynamicRoutes: [{ path: '/foo' }] }, @@ -495,6 +510,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', + }, + ], + }, + ], ])('parses %s', (_, source: any, output) => { const config = extractDynamicConfig({ frontend: { 'janus-idp.plugin-foo': source }, @@ -515,7 +582,9 @@ describe('extractDynamicConfig', () => { appIcons: [], apiFactories: [], scaffolderFieldExtensions: [], + signInPages: [], themes: [], + providerSettings: [], ...output, }); }); diff --git a/packages/app/src/utils/dynamicUI/extractDynamicConfig.ts b/packages/app/src/utils/dynamicUI/extractDynamicConfig.ts index f89f8b22b9..4dc5891a6c 100644 --- a/packages/app/src/utils/dynamicUI/extractDynamicConfig.ts +++ b/packages/app/src/utils/dynamicUI/extractDynamicConfig.ts @@ -116,6 +116,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 & { @@ -133,7 +145,9 @@ type CustomProperties = { mountPoints?: MountPoint[]; appIcons?: AppIcon[]; apiFactories?: ApiFactory[]; + providerSettings?: ProviderSetting[]; scaffolderFieldExtensions?: ScaffolderFieldExtension[]; + signInPage: SignInPageEntry; themes?: ThemeEntry[]; }; @@ -153,9 +167,11 @@ type DynamicConfig = { menuItems: MenuItem[]; entityTabs: EntityTabEntry[]; mountPoints: MountPoint[]; + providerSettings: ProviderSetting[]; routeBindings: RouteBinding[]; routeBindingTargets: BindingTarget[]; scaffolderFieldExtensions: ScaffolderFieldExtension[]; + signInPages: SignInPageEntry[]; themes: ThemeEntry[]; }; @@ -177,9 +193,29 @@ function extractDynamicConfig( mountPoints: [], routeBindings: [], routeBindingTargets: [], + providerSettings: [], scaffolderFieldExtensions: [], + signInPages: [], 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: importName ?? 'default', + }); + return pluginSet; + }, + [], + ); config.pluginModules = Object.entries(frontend).reduce( (pluginSet, [scope, customProperties]) => { pluginSet.push({ @@ -304,6 +340,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 47c836b5a0..90e51bc53e 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: [], }), [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'));