【Backstage.io】OIDC Authentication with Duende IdentityServer
Creating custom OIDC Providers to Connect with IdentityServer for Authentication
Introduction:
In this article, I’ll assume you’re already familiar with Backstage and have consulted the provided documentation.
- For secure authentication, the IdentityServer client requires use of an SSL certificate. Please consult the following documentation to set up a self-signed certificate in Backstage
- By following this guide, you can configure the necessary settings within Backstage. Although this example is based on old backend system, it may be possible to migrate to new backend system.
- For further details on OIDC authentication in Backstage, refer to the official documentation:
Backstage Example:
Here are the five steps from the Backstage documentation, illustrated with code from a successful implementation I’ve completed
- Create an API reference to identify the provider.
- Create the API factory that will handle the authentication.
- Add or reuse an auth provider so you can authenticate.
- Add or reuse a resolver to handle the result from the authentication.
- Configure the provider to access your 3rd party auth solution.
- Add the provider to sign in page so users can login with it.
1. Create an API reference to identify the provider.
2. Create the API factory that will handle the authentication.
In my case, I implemented the provider within the same file to keep the setup consolidated. The ID of the provider can be any unique identifier you choose. For instance, I used ‘sso-auth-provider’ as my auth provider ID, which is referenced in the createApiFactory
and configured in app-config.yaml
.
app/src/api.ts
import {
ScmIntegrationsApi,
scmIntegrationsApiRef,
ScmAuth,
} from '@backstage/integration-react';
import {
AnyApiFactory,
configApiRef,
createApiFactory,
ApiRef, OpenIdConnectApi, ProfileInfoApi, BackstageIdentityApi, SessionApi, createApiRef,
discoveryApiRef, oauthRequestApiRef
} from '@backstage/core-plugin-api';
import { OAuth2 } from '@backstage/core-app-api';
export const identityServerSSO: ApiRef<
OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & SessionApi
> = createApiRef({
id: 'auth.sso-auth-provider',
});
export const apis: AnyApiFactory[] = [
createApiFactory({
api: scmIntegrationsApiRef,
deps: { configApi: configApiRef },
factory: ({ configApi }) => ScmIntegrationsApi.fromConfig(configApi),
}),
ScmAuth.createDefaultApiFactory(),
createApiFactory({
api: identityServerSSO,
deps: {
discoveryApi: discoveryApiRef,
oauthRequestApi: oauthRequestApiRef,
configApi: configApiRef,
},
factory: ({ discoveryApi, oauthRequestApi, configApi }) => OAuth2.create({
configApi,
discoveryApi,
oauthRequestApi,
provider: {
id: 'sso-auth-provider',
title: 'SSO auth provider',
icon: () => null,
},
environment: configApi.getOptionalString('auth.environment'),
defaultScopes: ['openid', 'profile'],
popupOptions: {
// optional, used to customize login in popup size
size: {
fullscreen: true,
},
/**
* or specify popup width and height
* size: {
width: 1000,
height: 1000,
}
*/
},
}),
}),
];
3. Add or reuse an auth provider so you can authenticate.
4. Add or reuse a resolver to handle the result from the authentication.
The OIDC provider doesn’t provide any build-in resolvers, so we’ll need to define our own.
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,
'sso-auth-provider': providers.oidc.create({
signIn: {
resolver(info, ctx) {
const userRef = stringifyEntityRef({
kind: 'User',
name: info.result.userinfo.sub,
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
},
});
},
},
}),
},
});
}
To integrate the authentication provider functionality into the Backstage index.ts
file, here my example code.
backend/src/index.ts
import {
createServiceBuilder,
loadBackendConfig,
getRootLogger,
useHotMemoize,
notFoundHandler,
CacheManager,
DatabaseManager,
HostDiscovery,
UrlReaders,
ServerTokenManager,
} from '@backstage/backend-common';
import { TaskScheduler } from '@backstage/backend-tasks';
import { Config } from '@backstage/config';
import app from './plugins/app';
import catalog from './plugins/catalog';
import scaffolder from './plugins/scaffolder';
import proxy from './plugins/proxy';
import techdocs from './plugins/techdocs';
import search from './plugins/search';
import { PluginEnvironment } from './types';
import { ServerPermissionClient } from '@backstage/plugin-permission-node';
import { DefaultIdentityClient } from '@backstage/plugin-auth-node';
import express from 'express';
import auth from './plugins/auth'; // here
function makeCreateEnv(config: Config) {
const root = getRootLogger();
const reader = UrlReaders.default({ logger: root, config });
const discovery = HostDiscovery.fromConfig(config);
const cacheManager = CacheManager.fromConfig(config);
const databaseManager = DatabaseManager.fromConfig(config, { logger: root });
const tokenManager = ServerTokenManager.noop();
const taskScheduler = TaskScheduler.fromConfig(config, { databaseManager });
const identity = DefaultIdentityClient.create({
discovery,
});
const permissions = ServerPermissionClient.fromConfig(config, {
discovery,
tokenManager,
});
root.info(`Created UrlReader ${reader}`);
return (plugin: string): PluginEnvironment => {
const logger = root.child({ type: 'plugin', plugin });
const database = databaseManager.forPlugin(plugin);
const cache = cacheManager.forPlugin(plugin);
const scheduler = taskScheduler.forPlugin(plugin);
return {
logger,
database,
cache,
config,
reader,
discovery,
tokenManager,
scheduler,
permissions,
identity,
};
};
}
async function main() {
const config = await loadBackendConfig({
argv: process.argv,
logger: getRootLogger(),
});
const createEnv = makeCreateEnv(config);
const catalogEnv = useHotMemoize(module, () => createEnv('catalog'));
const scaffolderEnv = useHotMemoize(module, () => createEnv('scaffolder'));
const proxyEnv = useHotMemoize(module, () => createEnv('proxy'));
const techdocsEnv = useHotMemoize(module, () => createEnv('techdocs'));
const searchEnv = useHotMemoize(module, () => createEnv('search'));
const appEnv = useHotMemoize(module, () => createEnv('app'));
const authEnv = useHotMemoize(module, () => createEnv('auth'));
const apiRouter = express();
apiRouter.use(express.json());
apiRouter.use('/catalog', await catalog(catalogEnv));
apiRouter.use('/scaffolder', await scaffolder(scaffolderEnv));
apiRouter.use('/techdocs', await techdocs(techdocsEnv));
apiRouter.use('/proxy', await proxy(proxyEnv));
apiRouter.use('/search', await search(searchEnv));
apiRouter.use('/auth', await auth(authEnv));
// Add backends ABOVE this line; this 404 handler is the catch-all fallback
apiRouter.use(notFoundHandler());
const service = createServiceBuilder(module)
.loadConfig(config)
.addRouter('/api', apiRouter)
.addRouter('', await app(appEnv));
await service.start().catch(err => {
console.log(err);
process.exit(1);
});
}
module.hot?.accept();
main().catch(error => {
console.error('Backend failed to start up', error);
process.exit(1);
});
5. Configure the provider to access your 3rd party auth solution.
app-config.yaml
Set the prompt
attribute to enable IdentityServer to trigger a login popup window. Using login
will always display the login interface, even if the user already has a logged-in session. Alternatively, using auto
allows the system to determine whether to prompt for login based on the user's session status.
auth:
environment: development
session:
secret: secret
providers:
sso-auth-provider:
development:
metadataUrl: https://localhost:5001/.well-known/openid-configuration
clientId: backstage
clientSecret: backstage
prompt: login #or 'auto'
signIn:
resolvers:
# one of the following resolvers
- resolver: emailMatchingUserEntityAnnotation
- resolver: emailMatchingUserEntityProfileEmail
- resolver: emailLocalPartMatchingUserEntityName
And add the authentication module to index.ts
to handle user authentication.
6. Add the provider to sign in page so users can login with it.
app/src/App.tsx
import { apis, identityServerSSO } from './apis';
...
components: {
SignInPage: props => (
<SignInPage
{...props}
auto
providers={[
{
id: 'sso-auth-provider',
title: 'SSO OIDC identity server',
message: 'Sign in using SSO',
apiRef: identityServerSSO ,
}]
}
title="Select a sign-in method"
align="center"
/>
),
},
We have successfully set up OIDC authentication in Backstage. Now, let’s proceed to configure the connection with IdentityServer.
IdentityServer Example:
By using this quickstart project to host a local IdentityServer. It’s easy to set up client information for testing connections with Backstage.
With this template, you only need to modify Config.cs
. Make sure the RedirectUris
is set to https://localhost:7007/api/auth/sso-auth-provider/handler/frame
, the default setting for Backstage OIDC.
Test results:
In the end, we successfully integrated OIDC login functionality into Backstage.
Error Notes:
In my experience, I ran into a few errors, but I didn’t cover the details in this article. Check out these references; hopefully, maybe they’ll help you out!