Skip to content

Commit 0a51a97

Browse files
committed
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 <[email protected]>
1 parent dfe4cd8 commit 0a51a97

File tree

9 files changed

+251
-13
lines changed

9 files changed

+251
-13
lines changed

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

+2-1
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const AppBase = () => {
3434
AppRouter,
3535
dynamicRoutes,
3636
entityTabOverrides,
37+
providerSettings,
3738
scaffolderFieldExtensions,
3839
} = useContext(DynamicRootContext);
3940

@@ -118,7 +119,7 @@ const AppBase = () => {
118119
<SearchPage />
119120
</Route>
120121
<Route path="/settings" element={<UserSettingsPage />}>
121-
{settingsPage}
122+
{settingsPage(providerSettings)}
122123
</Route>
123124
<Route path="/catalog-graph" element={<CatalogGraphPage />} />
124125
<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';
@@ -61,7 +65,9 @@ export const DynamicRoot = ({
6165
React.ComponentType | undefined
6266
>(undefined);
6367
// registry of remote components loaded at bootstrap
64-
const [components, setComponents] = useState<ComponentRegistry | undefined>();
68+
const [componentRegistry, setComponentRegistry] = useState<
69+
ComponentRegistry | undefined
70+
>();
6571
const { initialized, pluginStore, api: scalprumApi } = useScalprum();
6672

6773
const themes = useThemes();
@@ -76,10 +82,12 @@ export const DynamicRoot = ({
7682
menuItems,
7783
entityTabs,
7884
mountPoints,
85+
providerSettings,
7986
routeBindings,
8087
routeBindingTargets,
8188
scaffolderFieldExtensions,
8289
themes: pluginThemes,
90+
signInPages,
8391
} = extractDynamicConfig(dynamicPlugins);
8492
const requiredModules = [
8593
...pluginModules.map(({ scope, module }) => ({
@@ -114,6 +122,10 @@ export const DynamicRoot = ({
114122
scope,
115123
module,
116124
})),
125+
...signInPages.map(({ scope, module }) => ({
126+
scope,
127+
module,
128+
})),
117129
];
118130

119131
const staticPlugins = Object.keys(staticPluginStore).reduce(
@@ -387,6 +399,25 @@ export const DynamicRoot = ({
387399
[],
388400
);
389401

402+
// the config allows for multiple sign-in pages, discover and use the first
403+
// working instance but check all of them
404+
const signInPage = signInPages
405+
.map<React.ComponentType<{}> | undefined>(
406+
({ scope, module, importName }) => {
407+
const candidate = allPlugins[scope]?.[module]?.[
408+
importName
409+
] as React.ComponentType<{}>;
410+
if (!candidate) {
411+
// eslint-disable-next-line no-console
412+
console.warn(
413+
`Plugin ${scope} is not configured properly: ${module}.${importName} not found, ignoring SignInPage: ${importName}`,
414+
);
415+
}
416+
return candidate;
417+
},
418+
)
419+
.find(candidate => candidate !== undefined);
420+
390421
if (!app.current) {
391422
const filteredStaticThemes = themes.filter(
392423
theme =>
@@ -408,7 +439,12 @@ export const DynamicRoot = ({
408439
...remoteBackstagePlugins,
409440
],
410441
themes: [...filteredStaticThemes, ...dynamicThemeProviders],
411-
components: defaultAppComponents,
442+
components: {
443+
...defaultAppComponents,
444+
...(signInPage && {
445+
SignInPage: signInPage,
446+
}),
447+
} as Partial<AppComponents>,
412448
});
413449
}
414450

@@ -424,16 +460,16 @@ export const DynamicRoot = ({
424460
scaffolderFieldExtensionComponents;
425461

426462
// make the dynamic UI configuration available to DynamicRootContext consumers
427-
setComponents({
463+
setComponentRegistry({
428464
AppProvider: app.current.getProvider(),
429465
AppRouter: app.current.getRouter(),
430466
dynamicRoutes: dynamicRoutesComponents,
431467
menuItems: dynamicRoutesMenuItems,
432468
entityTabOverrides,
433469
mountPoints: mountPointComponents,
470+
providerSettings,
434471
scaffolderFieldExtensions: scaffolderFieldExtensionComponents,
435472
});
436-
437473
afterInit().then(({ default: Component }) => {
438474
setChildComponent(() => Component);
439475
});
@@ -449,17 +485,17 @@ export const DynamicRoot = ({
449485
]);
450486

451487
useEffect(() => {
452-
if (initialized && !components) {
488+
if (initialized && !componentRegistry) {
453489
initializeRemoteModules();
454490
}
455-
}, [initialized, components, initializeRemoteModules]);
491+
}, [initialized, componentRegistry, initializeRemoteModules]);
456492

457-
if (!initialized || !components) {
493+
if (!initialized || !componentRegistry) {
458494
return <Loader />;
459495
}
460496

461497
return (
462-
<DynamicRootContext.Provider value={components}>
498+
<DynamicRootContext.Provider value={componentRegistry}>
463499
{ChildComponent ? <ChildComponent /> : <Loader />}
464500
</DynamicRootContext.Provider>
465501
);

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

+8
Original file line numberDiff line numberDiff line change
@@ -112,11 +112,18 @@ export type ScaffolderFieldExtension = {
112112
Component: React.ComponentType<{}>;
113113
};
114114

115+
export type ProviderSetting = {
116+
title: string;
117+
description: string;
118+
provider: string;
119+
};
120+
115121
export type DynamicRootConfig = {
116122
dynamicRoutes: ResolvedDynamicRoute[];
117123
entityTabOverrides: EntityTabOverrides;
118124
mountPoints: MountPoints;
119125
menuItems: ResolvedMenuItem[];
126+
providerSettings: ProviderSetting[];
120127
scaffolderFieldExtensions: ScaffolderFieldExtension[];
121128
};
122129

@@ -132,6 +139,7 @@ const DynamicRootContext = createContext<ComponentRegistry>({
132139
entityTabOverrides: {},
133140
mountPoints: {},
134141
menuItems: [],
142+
providerSettings: [],
135143
scaffolderFieldExtensions: [],
136144
});
137145

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

+1
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ const ScalprumRoot = ({
7777
entityTabOverrides: {},
7878
mountPoints: {},
7979
scaffolderFieldExtensions: [],
80+
providerSettings: [],
8081
} as DynamicRootConfig,
8182
};
8283
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

618
import { generalPage } from './GeneralPage';
19+
import Star from '@mui/icons-material/Star';
20+
21+
import { ProviderSetting } from '../DynamicRoot/DynamicRootContext';
22+
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+
};
779

8-
export const settingsPage = (
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
);

packages/app/src/utils/dynamicUI/extractDynamicConfig.test.ts

+69
Original file line numberDiff line numberDiff line change
@@ -154,14 +154,29 @@ describe('extractDynamicConfig', () => {
154154
menuItems: [],
155155
mountPoints: [],
156156
appIcons: [],
157+
providerSettings: [],
157158
routeBindingTargets: [],
158159
apiFactories: [],
159160
scaffolderFieldExtensions: [],
161+
signInPages: [],
160162
themes: [],
161163
});
162164
});
163165

164166
it.each([
167+
[
168+
'a SignInPage',
169+
{ signInPage: { importName: 'blah' } },
170+
{
171+
signInPages: [
172+
{
173+
importName: 'blah',
174+
module: 'PluginRoot',
175+
scope: 'janus-idp.plugin-foo',
176+
},
177+
],
178+
},
179+
],
165180
[
166181
'a dynamicRoute',
167182
{ dynamicRoutes: [{ path: '/foo' }] },
@@ -495,6 +510,58 @@ describe('extractDynamicConfig', () => {
495510
],
496511
},
497512
],
513+
[
514+
'a providerSettings',
515+
{
516+
providerSettings: [
517+
{
518+
title: 'foo',
519+
description: 'bar',
520+
provider: 'foo.bar',
521+
},
522+
],
523+
},
524+
{
525+
providerSettings: [
526+
{
527+
title: 'foo',
528+
description: 'bar',
529+
provider: 'foo.bar',
530+
},
531+
],
532+
},
533+
],
534+
[
535+
'multiple providerSettings',
536+
{
537+
providerSettings: [
538+
{
539+
title: 'foo1',
540+
description: 'bar1',
541+
provider: 'foo.bar1',
542+
},
543+
{
544+
title: 'foo2',
545+
description: 'bar2',
546+
provider: 'foo.bar2',
547+
},
548+
],
549+
},
550+
{
551+
providerSettings: [
552+
{
553+
title: 'foo1',
554+
description: 'bar1',
555+
provider: 'foo.bar1',
556+
},
557+
{
558+
title: 'foo2',
559+
description: 'bar2',
560+
provider: 'foo.bar2',
561+
},
562+
],
563+
},
564+
],
498565
])('parses %s', (_, source: any, output) => {
499566
const config = extractDynamicConfig({
500567
frontend: { 'janus-idp.plugin-foo': source },
@@ -515,7 +582,9 @@ describe('extractDynamicConfig', () => {
515582
appIcons: [],
516583
apiFactories: [],
517584
scaffolderFieldExtensions: [],
585+
signInPages: [],
518586
themes: [],
587+
providerSettings: [],
519588
...output,
520589
});
521590
});

0 commit comments

Comments
 (0)