Skip to content

Commit 9c3c4ab

Browse files
authored
feat(app): dynamic authentication provider support (#2167)
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 <[email protected]>
1 parent 80ad1df commit 9c3c4ab

File tree

10 files changed

+281
-13
lines changed

10 files changed

+281
-13
lines changed

packages/app/config.d.ts

+30
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,36 @@ export interface Config {
143143
module?: string;
144144
importName?: string;
145145
}[];
146+
providerSettings?: {
147+
title: string;
148+
description: string;
149+
provider: string;
150+
}[];
151+
scaffolderFieldExtensions?: {
152+
module?: string;
153+
importName?: string;
154+
}[];
155+
signInPage?: {
156+
module?: string;
157+
importName: string;
158+
};
159+
techdocsAddons?: {
160+
module?: string;
161+
importName?: string;
162+
config?: {
163+
props?: {
164+
[key: string]: string;
165+
};
166+
};
167+
}[];
168+
themes?: {
169+
module?: string;
170+
id: string;
171+
title: string;
172+
variant: 'light' | 'dark';
173+
icon: string;
174+
importName?: string;
175+
}[];
146176
};
147177
};
148178
};

packages/app/src/components/AppBase/AppBase.tsx

+2-1
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ const AppBase = () => {
3737
AppRouter,
3838
dynamicRoutes,
3939
entityTabOverrides,
40+
providerSettings,
4041
scaffolderFieldExtensions,
4142
} = useContext(DynamicRootContext);
4243

@@ -123,7 +124,7 @@ const AppBase = () => {
123124
<SearchPage />
124125
</Route>
125126
<Route path="/settings" element={<UserSettingsPage />}>
126-
{settingsPage}
127+
{settingsPage(providerSettings)}
127128
</Route>
128129
<Route path="/catalog-graph" element={<CatalogGraphPage />} />
129130
<Route path="/learning-paths" element={<LearningPaths />} />

packages/app/src/components/DynamicRoot/DynamicRoot.tsx

+45-9
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
33

44
import { createApp } from '@backstage/app-defaults';
55
import { BackstageApp } from '@backstage/core-app-api';
6-
import { AnyApiFactory, BackstagePlugin } from '@backstage/core-plugin-api';
6+
import {
7+
AnyApiFactory,
8+
AppComponents,
9+
BackstagePlugin,
10+
} from '@backstage/core-plugin-api';
711

812
import { useThemes } from '@redhat-developer/red-hat-developer-hub-theme';
913
import { AppsConfig } from '@scalprum/core';
@@ -63,7 +67,9 @@ export const DynamicRoot = ({
6367
React.ComponentType | undefined
6468
>(undefined);
6569
// registry of remote components loaded at bootstrap
66-
const [components, setComponents] = useState<ComponentRegistry | undefined>();
70+
const [componentRegistry, setComponentRegistry] = useState<
71+
ComponentRegistry | undefined
72+
>();
6773
const { initialized, pluginStore, api: scalprumApi } = useScalprum();
6874

6975
const themes = useThemes();
@@ -78,11 +84,13 @@ export const DynamicRoot = ({
7884
menuItems,
7985
entityTabs,
8086
mountPoints,
87+
providerSettings,
8188
routeBindings,
8289
routeBindingTargets,
8390
scaffolderFieldExtensions,
8491
techdocsAddons,
8592
themes: pluginThemes,
93+
signInPages,
8694
} = extractDynamicConfig(dynamicPlugins);
8795
const requiredModules = [
8896
...pluginModules.map(({ scope, module }) => ({
@@ -117,6 +125,10 @@ export const DynamicRoot = ({
117125
scope,
118126
module,
119127
})),
128+
...signInPages.map(({ scope, module }) => ({
129+
scope,
130+
module,
131+
})),
120132
];
121133

122134
const staticPlugins = Object.keys(staticPluginStore).reduce(
@@ -416,6 +428,25 @@ export const DynamicRoot = ({
416428
[],
417429
);
418430

431+
// the config allows for multiple sign-in pages, discover and use the first
432+
// working instance but check all of them
433+
const signInPage = signInPages
434+
.map<React.ComponentType<{}> | undefined>(
435+
({ scope, module, importName }) => {
436+
const candidate = allPlugins[scope]?.[module]?.[
437+
importName
438+
] as React.ComponentType<{}>;
439+
if (!candidate) {
440+
// eslint-disable-next-line no-console
441+
console.warn(
442+
`Plugin ${scope} is not configured properly: ${module}.${importName} not found, ignoring SignInPage: ${importName}`,
443+
);
444+
}
445+
return candidate;
446+
},
447+
)
448+
.find(candidate => candidate !== undefined);
449+
419450
if (!app.current) {
420451
const filteredStaticThemes = themes.filter(
421452
theme =>
@@ -437,7 +468,12 @@ export const DynamicRoot = ({
437468
...remoteBackstagePlugins,
438469
],
439470
themes: [...filteredStaticThemes, ...dynamicThemeProviders],
440-
components: defaultAppComponents,
471+
components: {
472+
...defaultAppComponents,
473+
...(signInPage && {
474+
SignInPage: signInPage,
475+
}),
476+
} as Partial<AppComponents>,
441477
});
442478
}
443479

@@ -454,17 +490,17 @@ export const DynamicRoot = ({
454490
dynamicRootConfig.techdocsAddons = techdocsAddonComponents;
455491

456492
// make the dynamic UI configuration available to DynamicRootContext consumers
457-
setComponents({
493+
setComponentRegistry({
458494
AppProvider: app.current.getProvider(),
459495
AppRouter: app.current.getRouter(),
460496
dynamicRoutes: dynamicRoutesComponents,
461497
menuItems: dynamicRoutesMenuItems,
462498
entityTabOverrides,
463499
mountPoints: mountPointComponents,
500+
providerSettings,
464501
scaffolderFieldExtensions: scaffolderFieldExtensionComponents,
465502
techdocsAddons: techdocsAddonComponents,
466503
});
467-
468504
afterInit().then(({ default: Component }) => {
469505
setChildComponent(() => Component);
470506
});
@@ -480,17 +516,17 @@ export const DynamicRoot = ({
480516
]);
481517

482518
useEffect(() => {
483-
if (initialized && !components) {
519+
if (initialized && !componentRegistry) {
484520
initializeRemoteModules();
485521
}
486-
}, [initialized, components, initializeRemoteModules]);
522+
}, [initialized, componentRegistry, initializeRemoteModules]);
487523

488-
if (!initialized || !components) {
524+
if (!initialized || !componentRegistry) {
489525
return <Loader />;
490526
}
491527

492528
return (
493-
<DynamicRootContext.Provider value={components}>
529+
<DynamicRootContext.Provider value={componentRegistry}>
494530
{ChildComponent ? <ChildComponent /> : <Loader />}
495531
</DynamicRootContext.Provider>
496532
);

packages/app/src/components/DynamicRoot/DynamicRootContext.tsx

+8
Original file line numberDiff line numberDiff line change
@@ -128,11 +128,18 @@ export type TechdocsAddon = {
128128
};
129129
};
130130

131+
export type ProviderSetting = {
132+
title: string;
133+
description: string;
134+
provider: string;
135+
};
136+
131137
export type DynamicRootConfig = {
132138
dynamicRoutes: ResolvedDynamicRoute[];
133139
entityTabOverrides: EntityTabOverrides;
134140
mountPoints: MountPoints;
135141
menuItems: ResolvedMenuItem[];
142+
providerSettings: ProviderSetting[];
136143
scaffolderFieldExtensions: ScaffolderFieldExtension[];
137144
techdocsAddons: TechdocsAddon[];
138145
};
@@ -149,6 +156,7 @@ const DynamicRootContext = createContext<ComponentRegistry>({
149156
entityTabOverrides: {},
150157
mountPoints: {},
151158
menuItems: [],
159+
providerSettings: [],
152160
scaffolderFieldExtensions: [],
153161
techdocsAddons: [],
154162
});

packages/app/src/components/DynamicRoot/ScalprumRoot.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ const ScalprumRoot = ({
7878
mountPoints: {},
7979
scaffolderFieldExtensions: [],
8080
techdocsAddons: [],
81+
providerSettings: [],
8182
} as DynamicRootConfig,
8283
};
8384
return (
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,83 @@
1+
import { ErrorBoundary } from '@backstage/core-components';
12
import {
3+
AnyApiFactory,
4+
ApiRef,
5+
configApiRef,
6+
ProfileInfoApi,
7+
SessionApi,
8+
useApi,
9+
useApp,
10+
} from '@backstage/core-plugin-api';
11+
import {
12+
DefaultProviderSettings,
13+
ProviderSettingsItem,
214
SettingsLayout,
315
UserSettingsAuthProviders,
416
} from '@backstage/plugin-user-settings';
517

18+
import Star from '@mui/icons-material/Star';
19+
20+
import { ProviderSetting } from '../DynamicRoot/DynamicRootContext';
621
import { GeneralPage } from './GeneralPage';
722

8-
export const settingsPage = (
23+
const DynamicProviderSettingsItem = ({
24+
title,
25+
description,
26+
provider,
27+
}: {
28+
title: string;
29+
description: string;
30+
provider: string;
31+
}) => {
32+
const app = useApp();
33+
// The provider API needs to be registered with the app
34+
const apiRef = app
35+
.getPlugins()
36+
.flatMap(plugin => Array.from(plugin.getApis()))
37+
.filter((api: AnyApiFactory) => api.api.id === provider)
38+
.at(0)?.api;
39+
if (!apiRef) {
40+
// eslint-disable-next-line no-console
41+
console.warn(
42+
`No API factory found for provider ref "${provider}", hiding the related provider settings UI`,
43+
);
44+
return <></>;
45+
}
46+
return (
47+
<ProviderSettingsItem
48+
title={title}
49+
description={description}
50+
apiRef={apiRef as ApiRef<ProfileInfoApi & SessionApi>}
51+
icon={Star}
52+
/>
53+
);
54+
};
55+
56+
const DynamicProviderSettings = ({
57+
providerSettings,
58+
}: {
59+
providerSettings: ProviderSetting[];
60+
}) => {
61+
const configApi = useApi(configApiRef);
62+
const providersConfig = configApi.getOptionalConfig('auth.providers');
63+
const configuredProviders = providersConfig?.keys() || [];
64+
return (
65+
<>
66+
<DefaultProviderSettings configuredProviders={configuredProviders} />
67+
{providerSettings.map(({ title, description, provider }) => (
68+
<ErrorBoundary>
69+
<DynamicProviderSettingsItem
70+
title={title}
71+
description={description}
72+
provider={provider}
73+
/>
74+
</ErrorBoundary>
75+
))}
76+
</>
77+
);
78+
};
79+
80+
export const settingsPage = (providerSettings: ProviderSetting[]) => (
981
<SettingsLayout>
1082
<SettingsLayout.Route path="general" title="General">
1183
<GeneralPage />
@@ -14,7 +86,11 @@ export const settingsPage = (
1486
path="auth-providers"
1587
title="Authentication Providers"
1688
>
17-
<UserSettingsAuthProviders />
89+
<UserSettingsAuthProviders
90+
providerSettings={
91+
<DynamicProviderSettings providerSettings={providerSettings} />
92+
}
93+
/>
1894
</SettingsLayout.Route>
1995
</SettingsLayout>
2096
);

0 commit comments

Comments
 (0)