Backstage: OIDC Authentication using Keycloak

Backstage OIDC Custom Provider Setup using Keycloak

TJ. Podobnik, @dorkamotorka
Level Up Coding

--

Spotify’s Backstage, an open-source platform, helps developers streamline their software infrastructure and integrates well with various authentication methods, including OpenID Connect (OIDC). This blog post will focus on setting up Backstage to use Keycloak, a widely-used open-source Identity and Access Management solution, as its OIDC provider.

I’ll assume you know a bit about Backstage and Keycloak, so let’s dive into the integration details. In case you need it, here’s a quick refresher of why and when Backstage comes into play:

Integration

Backstage comes with various authentication providers pre-configured, including GitHub, Okta, OAuth2 Proxy, and more. While setting up these default solutions is pretty straightforward, integrating with an OIDC provider like Keycloak requires a bit more effort. Below, I’ll outline the steps based on the original documentation but tailored to Keycloak. Instead of lengthy explanations, I’ll include some in-code comments to guide you through the setup.

⚠️ Note: Code files provided here are complete file intentionally, though it includes more than just the changes applied. Please check the filename at the top of each file, as it corresponds to the code location inside the Backstage repository.

In simple steps here’s how you enable the provider:

  • Create an API reference to identify the provider and the API factory that will handle the authentication.
// packages/app/src/apis.ts
import {
ScmIntegrationsApi,
scmIntegrationsApiRef,
ScmAuth,
} from '@backstage/integration-react';
import {
AnyApiFactory,
configApiRef,
createApiFactory,
ApiRef,
createApiRef,
OpenIdConnectApi,
ProfileInfoApi,
BackstageIdentityApi,
SessionApi,
discoveryApiRef,
oauthRequestApiRef
} from '@backstage/core-plugin-api';
import { OAuth2 } from '@backstage/core-app-api';
// `ProfileInfoApi & BackstageIdentityApi & SessionApi` are required for sign-in
export const oidcAuthApiRef: ApiRef<
OpenIdConnectApi & // The OICD API that will handle authentication
ProfileInfoApi & // Profile API for requesting user profile info from the auth provider in question
BackstageIdentityApi & // Backstage identity API to handle and associate the user profile with backstage identity.
SessionApi // Session API, to handle the session the user will have while logged in.
> = createApiRef({
id: 'auth.example.oidc', // Can be anything as long as it doesn't conflict with other Api ref IDs
});
export const apis: AnyApiFactory[] = [
createApiFactory({
api: oidcAuthApiRef,
deps: {
discoveryApi: discoveryApiRef,
oauthRequestApi: oauthRequestApiRef,
configApi: configApiRef,
},
factory: ({ discoveryApi, oauthRequestApi, configApi }) =>
OAuth2.create({
discoveryApi,
oauthRequestApi,
provider: {
id: 'example',
title: 'Example Auth Provider',
icon: () => null,
},
environment: configApi.getOptionalString('auth.environment'),
defaultScopes: ['openid'],
popupOptions: {
size: {
fullscreen: true,
// or specify popup width and height
//width: 1000,
//height: 1000,
},
},
}),
}),
createApiFactory(
{
api: scmIntegrationsApiRef,
deps: { configApi: configApiRef },
factory: ({ configApi }) => ScmIntegrationsApi.fromConfig(configApi),
}),
ScmAuth.createDefaultApiFactory(),
];
  • Add the auth provider so you can authenticate and the resolver to handle the result from the authentication.
// packages/backend/src/plugins/auth.ts
import {
createRouter,
providers,
defaultAuthProviderFactories,
} from '@backstage/plugin-auth-backend';
import { Router } from 'express';
import { PluginEnvironment } from '../types';

import { DEFAULT_NAMESPACE, stringifyEntityRef } from '@backstage/catalog-model';

export default async function createPlugin(
env: PluginEnvironment,
): Promise<Router> {
return await createRouter({
logger: env.logger,
config: env.config,
database: env.database,
discovery: env.discovery,
tokenManager: env.tokenManager,
providerFactories: {
...defaultAuthProviderFactories,

// NOTE: Here you add the provider and resolver to unpack data from token
example: providers.oidc.create({
signIn: {
resolver(info, ctx) {
const userRef = stringifyEntityRef({
kind: 'User',
name: info.result.userinfo.sub, // NOTE: this is from the token
namespace: DEFAULT_NAMESPACE,
});
return ctx.issueToken({
claims: {
sub: userRef, // The user's own identity
ent: [userRef], // A list of identities that the user claims ownership through
},
});
},
},
}),
},
});
}
  • Configure the provider to access your 3rd party auth solution.

# app-config.yaml
...
auth:
### Providing an auth.session.secret will enable session support in the auth-backend
session:
secret: supersecretcookie
# see https://backstage.io/docs/auth/ to learn about auth providers
environment: development
providers:
example:
development:
# NOTE: Here you provide the URL to the Keycloak
metadataUrl: https://auth.example.com/auth/realms/master/.well-known/openid-configuration
clientId: backstage
clientSecret: supersecretsecret
..
  • Add the provider to sign in page so users can login with it.
// packages/app/src/App.tsx
import React from 'react';
import { Navigate, Route } from 'react-router-dom';
import { apiDocsPlugin, ApiExplorerPage } from '@backstage/plugin-api-docs';
import {
CatalogEntityPage,
CatalogIndexPage,
catalogPlugin,
} from '@backstage/plugin-catalog';
import {
CatalogImportPage,
catalogImportPlugin,
} from '@backstage/plugin-catalog-import';
import { ScaffolderPage, scaffolderPlugin } from '@backstage/plugin-scaffolder';
import { orgPlugin } from '@backstage/plugin-org';
import { SearchPage } from '@backstage/plugin-search';
import { TechRadarPage } from '@backstage/plugin-tech-radar';
import {
TechDocsIndexPage,
techdocsPlugin,
TechDocsReaderPage,
} from '@backstage/plugin-techdocs';
import { TechDocsAddons } from '@backstage/plugin-techdocs-react';
import { ReportIssue } from '@backstage/plugin-techdocs-module-addons-contrib';
import { UserSettingsPage } from '@backstage/plugin-user-settings';
import { apis } from './apis';
import { entityPage } from './components/catalog/EntityPage';
import { searchPage } from './components/search/SearchPage';
import { Root } from './components/Root';
import { AlertDisplay, OAuthRequestDialog } from '@backstage/core-components';
import { createApp } from '@backstage/app-defaults';
import { AppRouter, FlatRoutes } from '@backstage/core-app-api';
import { CatalogGraphPage } from '@backstage/plugin-catalog-graph';
import { RequirePermission } from '@backstage/plugin-permission-react';
import { catalogEntityCreatePermission } from '@backstage/plugin-catalog-common/alpha';
import { oidcAuthApiRef } from './apis';
import { SignInProviderConfig, SignInPage } from '@backstage/core-components';

// NOTE: Here you add the example provider to display Sign-in page
const keycloakProvider: SignInProviderConfig = {
id: 'oidc-auth-provider',
title: 'Keycloak SSO',
message: 'Sign in with Keycloak SSO',
apiRef: oidcAuthApiRef,
};
const app = createApp({
components: {
SignInPage: props => (
<SignInPage
{...props}
auto
provider={keycloakProvider}
/>
),
},
apis,
bindRoutes({ bind }) {
bind(catalogPlugin.externalRoutes, {
createComponent: scaffolderPlugin.routes.root,
viewTechDoc: techdocsPlugin.routes.docRoot,
createFromTemplate: scaffolderPlugin.routes.selectedTemplate,
});
bind(apiDocsPlugin.externalRoutes, {
registerApi: catalogImportPlugin.routes.importPage,
});
bind(scaffolderPlugin.externalRoutes, {
registerComponent: catalogImportPlugin.routes.importPage,
viewTechDoc: techdocsPlugin.routes.docRoot,
});
bind(orgPlugin.externalRoutes, {
catalogIndex: catalogPlugin.routes.catalogIndex,
});
},
});
const routes = (
<FlatRoutes>
<Route path="/" element={<Navigate to="catalog" />} />
<Route path="/catalog" element={<CatalogIndexPage />} />
<Route
path="/catalog/:namespace/:kind/:name"
element={<CatalogEntityPage />}
>
{entityPage}
</Route>
<Route path="/docs" element={<TechDocsIndexPage />} />
<Route
path="/docs/:namespace/:kind/:name/*"
element={<TechDocsReaderPage />}
>
<TechDocsAddons>
<ReportIssue />
</TechDocsAddons>
</Route>
<Route path="/create" element={<ScaffolderPage />} />
<Route path="/api-docs" element={<ApiExplorerPage />} />
<Route
path="/tech-radar"
element={<TechRadarPage width={1500} height={800} />}
/>
<Route
path="/catalog-import"
element={
<RequirePermission permission={catalogEntityCreatePermission}>
<CatalogImportPage />
</RequirePermission>
}
/>
<Route path="/search" element={<SearchPage />}>
{searchPage}
</Route>
<Route path="/settings" element={<UserSettingsPage />} />
<Route path="/catalog-graph" element={<CatalogGraphPage />} />
</FlatRoutes>
);
export default app.createRoot(
<>
<AlertDisplay />
<OAuthRequestDialog />
<AppRouter>
<Root>{routes}</Root>
</AppRouter>
</>,
);

Conclusion

In wrapping up, bringing Keycloak into the mix as an OIDC provider alongside Spotify’s Backstage equips developers with a solid framework for overseeing user authentication and authorization within their software ecosystem. Although Backstage readily integrates with diverse authentication methods right out of the gate, getting it to sync smoothly with Keycloak entails a few extra steps. This post has walked through the necessary procedures, enabling developers to tap into Keycloak’s robust identity and access management tools while tapping into the streamlined development workflow offered by Backstage.

To stay current with the latest cloud technologies, make sure to subscribe to my weekly newsletter, Cloud Chirp. 🚀

--

--