update the sign-in resolver docs for the new backend system
Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
+270
-249
@@ -4,6 +4,14 @@ title: Sign-in Identities and Resolvers
|
||||
description: An introduction to Backstage user identities and sign-in resolvers
|
||||
---
|
||||
|
||||
:::info
|
||||
This documentation is written for [the new backend
|
||||
system](../backend-system/index.md) which is the default since Backstage
|
||||
[version 1.24](../releases/v1.24.0.md). If you are still on the old backend
|
||||
system, you may want to read [its own article](./identity-resolver--old.md)
|
||||
instead.
|
||||
:::
|
||||
|
||||
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.
|
||||
@@ -14,42 +22,24 @@ be mapped to user identities within Backstage.
|
||||
|
||||
## Quick Start
|
||||
|
||||
> See [providers](../reference/plugin-auth-backend.providers.md)
|
||||
> for a full list of auth providers and their built-in sign-in resolvers.
|
||||
Backstage projects created with `npx @backstage/create-app` come configured with
|
||||
a [guest auth provider](https://backstage.io/docs/auth/guest/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.
|
||||
|
||||
Backstage projects created with `npx @backstage/create-app` come configured with a
|
||||
sign-in resolver for GitHub guest access. This resolver makes all users share
|
||||
a single "guest" identity and is only intended as a minimum requirement to quickly
|
||||
get up and running. You can replace `github` for any of the other providers if you need.
|
||||
|
||||
This resolver should not be used in production, as it uses a single shared identity,
|
||||
and has no restrictions on who is able to sign-in. Be sure to read through the rest
|
||||
of this page to understand the Backstage identity system once you need to install
|
||||
a resolver for your production environment.
|
||||
|
||||
The guest resolver can be useful for testing purposes too, and it looks like this:
|
||||
|
||||
```ts
|
||||
signIn: {
|
||||
resolver(_, ctx) {
|
||||
const userRef = 'user:default/guest'
|
||||
return ctx.issueToken({
|
||||
claims: {
|
||||
sub: userRef,
|
||||
ent: [userRef],
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
```
|
||||
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](./index.md) 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 pieces of information, a
|
||||
user [entity reference](../features/software-catalog/references.md), and a
|
||||
set of ownership entity references.
|
||||
When a user signs in, a Backstage token is generated with these two pieces of information,
|
||||
which is then used to identify the user within the Backstage ecosystem.
|
||||
A user identity within Backstage is built up from two main pieces of
|
||||
information: a user [entity
|
||||
reference](../features/software-catalog/references.md), 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,
|
||||
@@ -71,10 +61,13 @@ 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. Both the user and ownership references should
|
||||
always be full entity references, as opposed to shorthands like just `jane` or `user:jane`.
|
||||
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
|
||||
|
||||
@@ -90,116 +83,156 @@ the given auth provider, as well as a context object that contains various helpe
|
||||
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.
|
||||
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.
|
||||
|
||||
### Custom Resolver Example
|
||||
### Using Builtin Resolvers
|
||||
|
||||
Let's look at an example of a custom sign-in resolver for the Google auth provider.
|
||||
This all typically happens within your `packages/backend/src/plugins/auth.ts` file,
|
||||
which is responsible for setting up and configuring the auth backend plugin.
|
||||
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 provide the resolver as part of the options you pass when creating a new auth
|
||||
provider factory. This means you need to replace the default Google provider with
|
||||
one that you create. Be sure to also include the existing `defaultAuthProviderFactories`
|
||||
if you want to keep all of the built-in auth providers installed.
|
||||
You set up builtin sign in resolvers using [your app-config](../conf/index.md),
|
||||
next to the respective provider's configuration. Here's an example for Microsoft
|
||||
Azure:
|
||||
|
||||
Now let's look at the example, with the rest of the commentary being made with in
|
||||
the code comments:
|
||||
```yaml title="in e.g. app-config.yaml"
|
||||
auth:
|
||||
environment: development
|
||||
providers:
|
||||
microsoft:
|
||||
development:
|
||||
clientId: ${AZURE_CLIENT_ID}
|
||||
clientSecret: ${AZURE_CLIENT_SECRET}
|
||||
tenantId: ${AZURE_TENANT_ID}
|
||||
signIn:
|
||||
resolvers:
|
||||
- resolver: emailMatchingUserEntityAnnotation
|
||||
- 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.
|
||||
|
||||
To find the list of available resolvers, consult the documentation of the
|
||||
respective provider.
|
||||
|
||||
### 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](./index.md), 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:
|
||||
|
||||
```ts title="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.
|
||||
|
||||
```ts title="in packages/backend/src/index.ts"
|
||||
/* highlight-add-start */
|
||||
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. *
|
||||
*********************************************************************/
|
||||
},
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
/* highlight-add-end */
|
||||
|
||||
backend.add(import('@backstage/plugin-auth-backend'));
|
||||
/* highlight-remove-next-line */
|
||||
backend.add(import('@backstage/plugin-auth-backend-module-github-provider'));
|
||||
/* highlight-add-next-line */
|
||||
backend.add(customAuth);
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
```ts
|
||||
// File: packages/backend/src/plugins/auth.ts
|
||||
import {
|
||||
createRouter,
|
||||
providers,
|
||||
defaultAuthProviderFactories,
|
||||
} from '@backstage/plugin-auth-backend';
|
||||
import { Router } from 'express';
|
||||
import { PluginEnvironment } from '../types';
|
||||
// ...
|
||||
async signInResolver(info, ctx) {
|
||||
const { profile: { email } } = info;
|
||||
|
||||
export default async function createPlugin(
|
||||
env: PluginEnvironment,
|
||||
): Promise<Router> {
|
||||
return await createRouter({
|
||||
...env,
|
||||
providerFactories: {
|
||||
...defaultAuthProviderFactories,
|
||||
google: providers.google.create({
|
||||
signIn: {
|
||||
resolver: async (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');
|
||||
}
|
||||
// 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);
|
||||
// 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 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.
|
||||
return ctx.signInWithCatalogUser({
|
||||
entityRef: { name },
|
||||
});
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
// 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 },
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Built-in Resolvers
|
||||
|
||||
You don't always have to write your own custom resolver. The auth backend plugin provides
|
||||
built-in resolvers for many of the common sign-in patterns. You access these via the `resolvers`
|
||||
property of each of the auth provider integrations. For example, the Google provider has
|
||||
a built in resolver that works just like the one we defined above:
|
||||
|
||||
```ts
|
||||
// File: packages/backend/src/plugins/auth.ts
|
||||
export default async function createPlugin(
|
||||
// ...
|
||||
return await createRouter({
|
||||
// ...
|
||||
providerFactories: {
|
||||
// ...
|
||||
google: providers.google.create({
|
||||
signIn: {
|
||||
resolver: providers.google.resolvers.emailLocalPartMatchingUserEntityName(),
|
||||
},
|
||||
});
|
||||
}
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
There are also other options, like the this one that looks up a user
|
||||
by matching the `google.com/email` annotation of user entities in the catalog:
|
||||
|
||||
```ts
|
||||
providers.google.create({
|
||||
signIn: {
|
||||
resolver: providers.google.resolvers.emailMatchingUserEntityAnnotation(),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Custom Ownership Resolution
|
||||
### 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
|
||||
@@ -209,55 +242,48 @@ of lower-level calls:
|
||||
// File: packages/backend/src/plugins/auth.ts
|
||||
import { getDefaultOwnershipEntityRefs } from '@backstage/plugin-auth-backend';
|
||||
|
||||
export default async function createPlugin(
|
||||
// ...
|
||||
return await createRouter({
|
||||
// ...
|
||||
providerFactories: {
|
||||
// ...
|
||||
google: async ({ profile: { email } }, ctx) => {
|
||||
if (!email) {
|
||||
throw new Error('User profile contained no email');
|
||||
}
|
||||
// ...
|
||||
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,
|
||||
},
|
||||
});
|
||||
// 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);
|
||||
// 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,
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
})
|
||||
)
|
||||
// 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
|
||||
### 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
|
||||
@@ -278,63 +304,44 @@ check like in the example below, or you might for example look up the GitHub org
|
||||
that the user belongs to using the user access token in the provided result object.
|
||||
|
||||
```ts
|
||||
// File: packages/backend/src/plugins/auth.ts
|
||||
import { createRouter, providers } from '@backstage/plugin-auth-backend';
|
||||
import { Router } from 'express';
|
||||
import { PluginEnvironment } from '../types';
|
||||
import {
|
||||
stringifyEntityRef,
|
||||
DEFAULT_NAMESPACE,
|
||||
} from '@backstage/catalog-model';
|
||||
import { stringifyEntityRef, DEFAULT_NAMESPACE } from '@backstage/catalog-model';
|
||||
|
||||
export default async function createPlugin(
|
||||
env: PluginEnvironment,
|
||||
): Promise<Router> {
|
||||
return await createRouter({
|
||||
...env,
|
||||
providerFactories: {
|
||||
google: providers.google.create({
|
||||
signIn: {
|
||||
resolver: async ({ 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('@');
|
||||
// ...
|
||||
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, this email ${profile.email} does not belong to the expected domain`,
|
||||
);
|
||||
}
|
||||
// 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],
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
}),
|
||||
// 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],
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## AuthHandler
|
||||
## Profile Transforms
|
||||
|
||||
Similar to a custom sign-in resolver, you can also write a custom auth handler
|
||||
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.
|
||||
@@ -343,29 +350,43 @@ 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.
|
||||
|
||||
```ts
|
||||
// File: packages/backend/src/plugins/auth.ts
|
||||
export default async function createPlugin(
|
||||
env: PluginEnvironment,
|
||||
): Promise<Router> {
|
||||
return await createRouter({
|
||||
...
|
||||
providerFactories: {
|
||||
google: providers.google.create({
|
||||
authHandler: async ({
|
||||
fullProfile // Type: passport.Profile,
|
||||
idToken // Type: (Optional) string,
|
||||
}) => {
|
||||
// Custom validation code goes here
|
||||
return {
|
||||
profile: {
|
||||
email,
|
||||
picture,
|
||||
displayName,
|
||||
}
|
||||
};
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user