Skip to main content

Sign-in Identities and Resolvers

info

This documentation is written for the new backend system which is the default since Backstage version 1.24. If you are still on the old backend system, you may want to read its own article instead, and consider migrating!

By default, every Backstage auth provider is configured only for the use-case of access delegation. This enables Backstage to request resources and actions from external systems on behalf of the user, for example re-triggering a build in CI.

If you want to use an auth provider to sign in users, you need to explicitly configure it have sign-in enabled and also tell it how the external identities should be mapped to user identities within Backstage. You do this by either choosing a built-in sign in resolver, or supplying your own. Both methods are listed below.

Quick Start

Backstage projects created with npx @backstage/create-app come configured with a guest auth provider. This provider makes all users share a single "guest" identity. This is useful for testing purposes and quickly getting started locally, but is not safe for use in production and that particular provider will refuse to work there.

Because of this, one of the early things you want to do when standing up your Backstage instance is to choose a production ready auth provider. See the auth overview page for a full list of providers and how to install and configure them.

Backstage User Identity

A user identity within Backstage is built up from two main pieces of information: a user entity reference, and a set of ownership references. When a user signs in, a Backstage token is generated which is then used to identify the user within the Backstage ecosystem.

The user entity reference should uniquely identify the logged in user in Backstage. It is encouraged that a matching user entity also exists within the Software Catalog, but it is not required. If the user entity exists in the catalog it can be used to store additional data about the user. There may even be some plugins that require this for them to be able to function.

The ownership references are also entity references, and it is likewise encouraged that these entities exist within the catalog, but it is not a requirement. The ownership references are used to determine what the user owns, as a set of references that the user claims ownership though. For example, a user Jane (user:default/jane) might have the ownership references user:default/jane, group:default/team-a, and group:default/admins. Given these ownership claims, any entity that is marked as owned by either of user:jane, team-a, or admins would be considered owned by Jane.

The ownership claims often contain the user entity reference itself, but it is not required. It is also worth noting that the ownership claims can also be used to resolve other relations similar to ownership, such as a claim for a maintainer or operator status.

The Backstage token that encapsulates the user identity is a JWT. The user entity reference is stored in the sub claim of the payload, while the ownership references are stored in a custom ent claim in the old backend system but instead is made available through a user info API endpoint on the auth backend in the new system. Both the user and ownership references should always be full entity references, as opposed to shorthands like just jane or user:jane.

Sign-in Resolvers

Signing in a user into Backstage requires a mapping of the user identity from the third-party auth provider to a Backstage user identity. This mapping can vary quite a lot between different organizations and auth providers, and because of that there's no default way to resolve user identities. The auth provider that one wants to use for sign-in must instead be configured with a sign-in resolver, which is a function that is responsible for creating this user identity mapping.

The input to the sign-in resolver function is the result of a successful log in with the given auth provider, as well as a context object that contains various helpers for looking up users and issuing tokens. There are also a number of built-in sign-in resolvers that can be used, which are covered a bit further down.

Note that while it possible to configure multiple auth providers to be used for sign-in, you should take care when doing so. It is best to make sure that the different auth providers either do not have any user overlap, or that any users that are able to log in with multiple providers always end up with the same Backstage identity. For most organizations, it makes the most sense to provide only one sign-in method.

Using Builtin Resolvers

Most auth providers come with a set of builtin sign in providers that you can choose from. They target the most common use cases, and if they fit your needs, you can pick one or more of them without having to write any code at all. You still have to make a choice - as mentioned above, even if there are a set of builtins, none of them are selected by default.

You set up builtin sign in resolvers using your app-config, next to the respective provider's configuration. Here's an example for GitHub:

in e.g. app-config.yaml
auth:
environment: development
providers:
github:
development:
clientId: ${AUTH_GITHUB_CLIENT_ID}
clientSecret: ${AUTH_GITHUB_CLIENT_SECRET}
enterpriseInstanceUrl: ${AUTH_GITHUB_ENTERPRISE_INSTANCE_URL}
signIn:
resolvers:
- resolver: usernameMatchingUserEntityName
- resolver: emailMatchingUserEntityProfileEmail
- resolver: emailLocalPartMatchingUserEntityName

Note that in this instance it lists several resolvers, which means that the framework will try them one by one until one succeeds. If none of them do, the sign in attempt is rejected.

The list of available resolvers is different for each provider, since they often depend on the information model returned from the upstream provider service. Consult the documentation of the respective provider to find the list.

Building Custom Resolvers

If the builtins don't work for you, you can also provide a completely custom sign-in resolver, through code. If you follow the installation instructions of one of the available providers, you will likely have added a dependency to your backend along with a line of code and some configuration.

Using GitHub as an example, this is the relevant parts of the backend code:

in packages/backend/src/index.ts
backend.add(import('@backstage/plugin-auth-backend'));
backend.add(import('@backstage/plugin-auth-backend-module-github-provider'));

When you want to supply a custom sign-in resolver, as a general pattern you remove that last import and instead construct your own provider using the facilities from the same package. You can leave the config unchanged from before.

in packages/backend/src/index.ts
import { createBackendModule } from '@backstage/backend-plugin-api';
import { githubAuthenticator } from '@backstage/plugin-auth-backend-module-github-provider';
import {
authProvidersExtensionPoint,
createOAuthProviderFactory,
} from '@backstage/plugin-auth-node';

const customAuth = createBackendModule({
// This ID must be exactly "auth" because that's the plugin it targets
pluginId: 'auth',
// This ID must be unique, but can be anything
moduleId: 'custom-auth-provider',
register(reg) {
reg.registerInit({
deps: { providers: authProvidersExtensionPoint },
async init({ providers }) {
providers.registerProvider({
// This ID must match the actual provider config, e.g. addressing
// auth.providers.github means that this must be "github".
providerId: 'github',
// Use createProxyAuthProviderFactory instead if it's one of the proxy
// based providers rather than an OAuth based one
factory: createOAuthProviderFactory({
authenticator: githubAuthenticator,
async signInResolver(info, ctx) {
/*********************************************************************
* Custom resolver code goes here, see farther down in this article! *
* "info" is the sign in result from the upstream (github here), and *
* "ctx" contains useful utilities for token issuance etc. *
*********************************************************************/
},
}),
});
},
});
},
});

backend.add(import('@backstage/plugin-auth-backend'));
backend.add(import('@backstage/plugin-auth-backend-module-github-provider'));
backend.add(customAuth);

Check out the naming patterns article for what rules apply regarding how to form valid IDs. In this example we also put the module declaration directly in packages/backend/src/index.ts but that's just for simplicity. You can place it anywhere you like, including in other packages, and import from there if you prefer.

The createOAuthProviderFactory / createProxyAuthProviderFactory functions have additional options for profile and state transforms - not covered here, but good to know about if you need them.

So what would the body of a typical sign in resolver callback look like? Here's an example:

// ...
async signInResolver(info, ctx) {
const { profile: { email } } = info;

// Profiles are not always guaranteed to to have an email address.
// You can also find more provider-specific information in `info.result`.
// It typically contains a `fullProfile` object as well as ID and/or access
// tokens that you can use for additional lookups.
if (!email) {
throw new Error('User profile contained no email');
}

// You can add your own custom validation logic here.
// Logins can be prevented by throwing an error like the one above.
myEmailValidator(email);

// This example resolver simply uses the local part of the email as the name.
const [name] = email.split('@');

// This helper function handles sign-in by looking up a user in the catalog.
// The lookup can be done either by reference, annotations, or custom filters.
//
// The helper also issues a token for the user, using the standard group
// membership logic to determine the ownership references of the user.
//
// There are a number of other methods on the ctx, feel free to explore them!
return ctx.signInWithCatalogUser({
entityRef: { name },
});
}

If you throw an error in the sign in resolver function, the sign in attempt is immediately rejected, and the error details are presented in the user interface.

The ctx context has several useful functions for issuing tokens in various ways.

Custom Ownership Resolution

If you want to have more control over the membership resolution and token generation that happens during sign-in you can replace ctx.signInWithCatalogUser with a set of lower-level calls:

// File: packages/backend/src/plugins/auth.ts
import { getDefaultOwnershipEntityRefs } from '@backstage/plugin-auth-backend';

// ...
async signInResolver({ profile: { email} }, ctx) {
if (!email) {
throw new Error('User profile contained no email');
}

// This step calls the catalog to look up a user entity. You could for example
// replace it with a call to a different external system.
const { entity } = await ctx.findCatalogUser({
annotations: {
'acme.org/email': email,
},
});

// In this step we extract the ownership references from the user entity using
// the standard logic. It uses a reference to the entity itself, as well as the
// target of each `memberOf` relation where the target is of the kind `Group`.
//
// If you replace the catalog lookup with something that does not return
// an entity you will need to replace this step as well.
//
// You might also replace it if you for example want to filter out certain groups.
//
// Note that `getDefaultOwnershipEntityRefs` only includes groups to which the
// user has a direct MEMBER_OF relationship. It's perfectly fine to include
// groups that the user is transitively part of in the claims array, but the
// catalog doesn't currently provide a direct way of accessing this list of
// groups.
const ownershipRefs = getDefaultOwnershipEntityRefs(entity);

// The last step is to issue the token, where we might provide more options in the
// future.
return ctx.issueToken({
claims: {
sub: stringifyEntityRef(entity),
ent: ownershipRefs,
},
});
}

Sign-In without Users in the Catalog

While populating the catalog with organizational data unlocks more powerful ways to browse your software ecosystem, it might not always be a viable or prioritized option. However, even if you do not have user entities populated in your catalog, you can still sign in users. As there are currently no built-in sign-in resolvers for this scenario you will need to implement your own.

Signing in a user that doesn't exist in the catalog is as simple as skipping the catalog lookup step from the above example. Rather than looking up the user, we instead immediately issue a token using whatever information is available. One caveat is that it can be tricky to determine the ownership references, although it can be achieved for example through a lookup to an external service. You typically want to at least use the user itself as a lone ownership reference.

Because we no longer use the catalog as an allow-list of users, it is often important that you limit what users are allowed to sign in. This could be a simple email domain check like in the example below, or you might for example look up the GitHub organizations that the user belongs to using the user access token in the provided result object.

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

// ...
async signInResolver({ profile }, ctx) {
if (!profile.email) {
throw new Error(
'Login failed, user profile does not contain an email',
);
}
// Split the email into the local part and the domain.
const [localPart, domain] = profile.email.split('@');

// Next we verify the email domain. It is recommended to include this
// kind of check if you don't look up the user in an external service.
if (domain !== 'acme.org') {
throw new Error(
`Login failed, '${profile.email}' does not belong to the expected domain`,
);
}

// By using `stringifyEntityRef` we ensure that the reference is formatted correctly
const userEntity = stringifyEntityRef({
kind: 'User',
name: localPart,
namespace: DEFAULT_NAMESPACE,
});
return ctx.issueToken({
claims: {
sub: userEntity,
ent: [userEntity],
},
});
}

Profile Transforms

Similar to a custom sign-in resolver, you can also write a custom profile transform function which is used to verify and convert the auth response into the profile that will be presented to the user. This is where you can customize things like display name and profile picture.

This is also the place where you can do authorization and validation of the user and throw errors if the user should not be allowed access in Backstage.

const customAuth = createBackendModule({
// This ID must be exactly "auth" because that's the plugin it targets
pluginId: 'auth',
// This ID must be unique, but can be anything
moduleId: 'custom-auth-provider',
register(reg) {
reg.registerInit({
deps: { providers: authProvidersExtensionPoint },
async init({ providers }) {
providers.registerProvider({
// This ID must match the actual provider config, e.g. addressing
// auth.providers.github means that this must be "github".
providerId: 'github',
// Use createProxyAuthProviderFactory instead if it's one of the proxy
// based providers rather than an OAuth based one
factory: createOAuthProviderFactory({
authenticator: githubAuthenticator,
async profileTransform(result, ctx) {
/**********************************************************************
* Custom transform code goes here! *
* "info" is the sign in result from the upstream (github here), and *
* "ctx" contains useful utilities. *
**********************************************************************/
return {
profile: {
email,
picture,
displayName,
},
};
},
}),
});
},
});
},
});

Remember to backend.add the created module just like above.