Merge pull request #4532 from backstage/rugvip/authin

auth-backend: implement sign-in resolver and profile transform for google provider
This commit is contained in:
Ben Lambert
2021-06-17 15:33:13 +02:00
committed by GitHub
16 changed files with 551 additions and 84 deletions
+25
View File
@@ -0,0 +1,25 @@
---
'@backstage/plugin-auth-backend': patch
---
Adds support for custom sign-in resolvers and profile transformations for the
Google auth provider.
Adds an `ent` claim in Backstage tokens, with a list of
[entity references](https://backstage.io/docs/features/software-catalog/references)
related to your signed-in user's identities and groups across multiple systems.
Adds an optional `providerFactories` argument to the `createRouter` exported by
the `auth-backend` plugin.
Updates `BackstageIdentity` so that
- `idToken` is deprecated in favor of `token`
- An optional `entity` field is added which represents the entity that the user is represented by within Backstage.
More information:
- [The identity resolver documentation](https://backstage.io/docs/auth/identity-resolver)
explains the concepts and shows how to implement your own.
- The [From Identity to Ownership](https://github.com/backstage/backstage/issues/4089)
RFC contains details about how this affects ownership in the catalog
+150
View File
@@ -0,0 +1,150 @@
---
id: identity-resolver
title: Identity resolver
description: Identity resolvers of Backstage users after they sign-in
---
This guide explains how the identity of a Backstage user is stored inside their
Backstage Identity Token and how you can customize the Sign-In resolvers to
include identity and group membership information of the user from other
external systems. This ultimately helps with determining the ownership of a
Backstage entity by a user. The ideas here were originally proposed in the RFC
[#4089](https://github.com/backstage/backstage/issues/4089).
When a user signs in to Backstage, inside the `claims` field of their Backstage
Token (which are standard JWT tokens) a special `ent` claim is set. `ent`
contains a list of
[entity references](../features/software-catalog/references.md), each of which
denotes an identity or a membership that is relevant to the user. There is no
guarantee that these correspond to actual existing catalog entities.
Let's take an example sign-in resolver for the Google auth provider and explore
how the `ent` field inside `claims` can be set.
Inside your `packages/backend/src/plugins/auth.ts` file, you can provide custom
sign-in resolvers and set them for any of the Authentication providers inside
`providerFactories` of the `createRouter` imported from the
`@backstage/plugin-auth-backend` plugin.
```ts
export default async function createPlugin({
...
}: PluginEnvironment): Promise<Router> {
return await createRouter({
...
providerFactories: {
google: createGoogleProvider({
signIn: {
resolver: async ({ profile: { email } }, ctx) => {
// Call a custom validator function that checks that the email is
// valid and on our own company's domain, and throws an Error if it
// isn't
validateEmail(email);
// List of entity references that denote the identity and
// membership of the user
const ent = [];
// Let's use the username in the email ID as the user's default
// unique identifier inside Backstage.
const [id] = email.split('@');
ent.push(`User:default/${id}`)
// Let's call the internal LDAP provider to get a list of groups
// that the user belongs to, and add those to the list as well
const ldapGroups = await getLdapGroups(email);
ldapGroups.forEach(group => ent.push(`Group:default/${group}`))
// Issue the token containing the entity claims
const token = await ctx.tokenIssuer.issueToken({
claims: { sub: id, ent },
});
return { id, token };
},
},
}),
},
});
}
```
As you can see, the generated Backstage Token now contains all the claims about
the identity and membership of the user. Once the sign-in process is complete,
and we need to find out if a user owns an Entity in the Software Catalog, these
`ent` claims can be used to determine the ownership.
According to the RFC, the definition of the ownership of an entity E, for a user
U, is as follows:
- Get all the `ownedBy` relations of E, and call them O
- Get all the claims of the user U and call them C
- If any C matches any O, return `true`
- Get all Group entities that U is a member of, using the regular
`memberOf`/`hasMember` relation mechanism, and call them G
- If any G matches any O, return `true`
- Otherwise, return `false`
## Default sign-in resolvers
Of course you don't have to customize the sign-in resolver if you don't need to.
The Auth backend plugin comes with a set of default sign-in resolvers which you
can use. For example - the Google provider has a default email-based sign-in
resolver, which will search the catalog for a single user entity that has a
matching `google.com/email` annotation.
It can be enabled like this
```tsx
// File: packages/backend/src/plugins/auth.ts
import { googleEmailSignInResolver } from '@backstage/plugin-auth-backend';
export default async function createPlugin({
...
}: PluginEnvironment): Promise<Router> {
return await createRouter({
...
providerFactories: {
google: createGoogleProvider({
signIn: {
resolver: googleEmailSignInResolver
}
...
```
## AuthHandler
Similar to a custom sign-in resolver, you can also write a custom auth handler
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.
```tsx
// File: packages/backend/src/plugins/auth.ts
export default async function createPlugin({
...
}: PluginEnvironment): Promise<Router> {
return await createRouter({
...
providerFactories: {
google: createGoogleProvider({
authHandler: async ({
fullProfile // Type: passport.Profile,
idToken // Type: (Optional) string,
}) => {
// Custom validation code goes here
return {
profile: {
email,
picture,
displayName,
}
};
}
})
}
})
}
```
+1
View File
@@ -205,6 +205,7 @@
},
"auth/add-auth-provider",
"auth/using-auth",
"auth/identity-resolver",
"auth/auth-backend",
"auth/oauth",
"auth/auth-backend-classes",
+1
View File
@@ -132,6 +132,7 @@ nav:
- OneLogin: 'auth/onelogin/provider.md'
- Adding authentication providers: 'auth/add-auth-provider.md'
- Using authentication and identity: 'auth/using-auth.md'
- Sign in resolvers: 'auth/identity-resolver.md'
- Auth backend: 'auth/auth-backend.md'
- OAuth and OpenID Connect: 'auth/oauth.md'
- Auth backend classes: 'auth/auth-backend-classes.md'
@@ -67,13 +67,14 @@ export class TokenFactory implements TokenIssuer {
const iss = this.issuer;
const sub = params.claims.sub;
const ent = params.claims.ent;
const aud = 'backstage';
const iat = Math.floor(Date.now() / MS_IN_S);
const exp = iat + this.keyDurationSeconds;
this.logger.info(`Issuing token for ${sub}`);
this.logger.info(`Issuing token for ${sub}, with entities ${ent ?? []}`);
return JWS.sign({ iss, sub, aud, iat, exp }, key, {
return JWS.sign({ iss, sub, aud, iat, exp, ent }, key, {
alg: key.alg,
kid: key.kid,
});
@@ -28,6 +28,8 @@ export type TokenParams = {
claims: {
/** The token subject, i.e. User ID */
sub: string;
/** A list of entity references that the user claims ownership through */
ent?: string[];
};
};
@@ -16,6 +16,7 @@
import { CatalogApi } from '@backstage/catalog-client';
import { UserEntity } from '@backstage/catalog-model';
import { TokenIssuer } from '../../identity';
import { CatalogIdentityClient } from './CatalogIdentityClient';
describe('CatalogIdentityClient', () => {
@@ -29,25 +30,36 @@ describe('CatalogIdentityClient', () => {
getLocationByEntity: jest.fn(),
removeEntityByUid: jest.fn(),
};
const tokenIssuer: jest.Mocked<TokenIssuer> = {
issueToken: jest.fn(),
listPublicKeys: jest.fn(),
};
afterEach(() => jest.resetAllMocks());
it('passes through the correct search params', async () => {
catalogApi.getEntities.mockResolvedValueOnce({ items: [{} as UserEntity] });
tokenIssuer.issueToken.mockResolvedValue('my-token');
const client = new CatalogIdentityClient({
catalogApi: catalogApi as CatalogApi,
catalogApi: catalogApi,
tokenIssuer: tokenIssuer,
});
client.findUser({ annotations: { key: 'value' } });
await client.findUser({ annotations: { key: 'value' } });
expect(catalogApi.getEntities).toBeCalledWith(
expect(catalogApi.getEntities).toHaveBeenCalledWith(
{
filter: {
kind: 'user',
'metadata.annotations.key': 'value',
},
},
undefined,
{ token: 'my-token' },
);
expect(tokenIssuer.issueToken).toHaveBeenCalledWith({
claims: {
sub: 'backstage.io/auth-backend',
},
});
});
});
@@ -17,6 +17,7 @@
import { ConflictError, NotFoundError } from '@backstage/errors';
import { CatalogApi } from '@backstage/catalog-client';
import { UserEntity } from '@backstage/catalog-model';
import { TokenIssuer } from '../../identity';
type UserQuery = {
annotations: Record<string, string>;
@@ -27,9 +28,11 @@ type UserQuery = {
*/
export class CatalogIdentityClient {
private readonly catalogApi: CatalogApi;
private readonly tokenIssuer: TokenIssuer;
constructor(options: { catalogApi: CatalogApi }) {
constructor(options: { catalogApi: CatalogApi; tokenIssuer: TokenIssuer }) {
this.catalogApi = options.catalogApi;
this.tokenIssuer = options.tokenIssuer;
}
/**
@@ -37,10 +40,7 @@ export class CatalogIdentityClient {
*
* Throws a NotFoundError or ConflictError if 0 or multiple users are found.
*/
async findUser(
query: UserQuery,
options?: { token?: string },
): Promise<UserEntity> {
async findUser(query: UserQuery): Promise<UserEntity> {
const filter: Record<string, string> = {
kind: 'user',
};
@@ -48,7 +48,11 @@ export class CatalogIdentityClient {
filter[`metadata.annotations.${key}`] = value;
}
const { items } = await this.catalogApi.getEntities({ filter }, options);
// TODO(Rugvip): cache the token
const token = await this.tokenIssuer.issueToken({
claims: { sub: 'backstage.io/auth-backend' },
});
const { items } = await this.catalogApi.getEntities({ filter }, { token });
if (items.length !== 1) {
if (items.length > 1) {
@@ -0,0 +1,40 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
RELATION_MEMBER_OF,
stringifyEntityRef,
UserEntity,
} from '@backstage/catalog-model';
import { TokenParams } from '../../identity';
export function getEntityClaims(entity: UserEntity): TokenParams['claims'] {
const userRef = stringifyEntityRef(entity);
const membershipRefs =
entity.relations
?.filter(
r =>
r.type === RELATION_MEMBER_OF &&
r.target.kind.toLocaleLowerCase('en-US') === 'group',
)
.map(r => stringifyEntityRef(r.target)) ?? [];
return {
sub: userRef,
ent: [userRef, ...membershipRefs],
};
}
@@ -15,3 +15,4 @@
*/
export { CatalogIdentityClient } from './CatalogIdentityClient';
export { getEntityClaims } from './helpers';
@@ -14,5 +14,9 @@
* limitations under the License.
*/
export { createGoogleProvider } from './provider';
export {
createGoogleProvider,
googleDefaultSignInResolver,
googleEmailSignInResolver,
} from './provider';
export type { GoogleProviderOptions } from './provider';
@@ -0,0 +1,91 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { GoogleAuthProvider } from './provider';
import * as helpers from '../../lib/passport/PassportStrategyHelper';
import { OAuthResult } from '../../lib/oauth';
import { getVoidLogger } from '@backstage/backend-common';
import { TokenIssuer } from '../../identity/types';
import { CatalogIdentityClient } from '../../lib/catalog';
const mockFrameHandler = (jest.spyOn(
helpers,
'executeFrameHandlerStrategy',
) as unknown) as jest.MockedFunction<
() => Promise<{ result: OAuthResult; privateInfo: any }>
>;
describe('createGoogleProvider', () => {
it('should auth', async () => {
const tokenIssuer = {
issueToken: jest.fn(),
listPublicKeys: jest.fn(),
};
const catalogIdentityClient = {
findUser: jest.fn(),
};
const provider = new GoogleAuthProvider({
logger: getVoidLogger(),
catalogIdentityClient: (catalogIdentityClient as unknown) as CatalogIdentityClient,
tokenIssuer: (tokenIssuer as unknown) as TokenIssuer,
authHandler: async ({ fullProfile }) => ({
profile: {
email: fullProfile.emails![0]!.value,
displayName: fullProfile.displayName,
picture: 'http://google.com/lols',
},
}),
clientId: 'mock',
clientSecret: 'mock',
callbackUrl: 'mock',
});
mockFrameHandler.mockResolvedValueOnce({
result: {
fullProfile: {
emails: [{ value: 'conrad@example.com' }],
displayName: 'Conrad',
id: 'conrad',
provider: 'google',
},
params: {
id_token: 'idToken',
scope: 'scope',
expires_in: 123,
},
accessToken: 'accessToken',
},
privateInfo: {
refreshToken: 'wacka',
},
});
const { response } = await provider.handler({} as any);
expect(response).toEqual({
providerInfo: {
accessToken: 'accessToken',
expiresInSeconds: 123,
idToken: 'idToken',
scope: 'scope',
},
profile: {
email: 'conrad@example.com',
displayName: 'Conrad',
picture: 'http://google.com/lols',
},
});
});
});
@@ -17,8 +17,8 @@
import express from 'express';
import passport from 'passport';
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
import { Logger } from 'winston';
import { CatalogIdentityClient } from '../../lib/catalog';
import { TokenIssuer } from '../../identity/types';
import { CatalogIdentityClient, getEntityClaims } from '../../lib/catalog';
import {
encodeState,
OAuthAdapter,
@@ -27,8 +27,8 @@ import {
OAuthProviderOptions,
OAuthRefreshRequest,
OAuthResponse,
OAuthStartRequest,
OAuthResult,
OAuthStartRequest,
} from '../../lib/oauth';
import {
executeFetchUserProfileStrategy,
@@ -38,30 +38,40 @@ import {
makeProfileInfo,
PassportDoneCallback,
} from '../../lib/passport';
import { AuthProviderFactory, RedirectInfo } from '../types';
import { TokenIssuer } from '../../identity/types';
import {
AuthProviderFactory,
AuthHandler,
RedirectInfo,
SignInResolver,
} from '../types';
import { Logger } from 'winston';
type PrivateInfo = {
refreshToken: string;
};
type Options = OAuthProviderOptions & {
logger: Logger;
identityClient: CatalogIdentityClient;
signInResolver?: SignInResolver<OAuthResult>;
authHandler: AuthHandler<OAuthResult>;
tokenIssuer: TokenIssuer;
catalogIdentityClient: CatalogIdentityClient;
logger: Logger;
};
export class GoogleAuthProvider implements OAuthHandlers {
private readonly _strategy: GoogleStrategy;
private readonly logger: Logger;
private readonly identityClient: CatalogIdentityClient;
private readonly signInResolver?: SignInResolver<OAuthResult>;
private readonly authHandler: AuthHandler<OAuthResult>;
private readonly tokenIssuer: TokenIssuer;
private readonly catalogIdentityClient: CatalogIdentityClient;
private readonly logger: Logger;
constructor(options: Options) {
this.logger = options.logger;
this.identityClient = options.identityClient;
this.signInResolver = options.signInResolver;
this.authHandler = options.authHandler;
this.tokenIssuer = options.tokenIssuer;
// TODO: throw error if env variables not set?
this.catalogIdentityClient = options.catalogIdentityClient;
this.logger = options.logger;
this._strategy = new GoogleStrategy(
{
clientID: options.clientId,
@@ -111,18 +121,8 @@ export class GoogleAuthProvider implements OAuthHandlers {
PrivateInfo
>(req, this._strategy);
const profile = makeProfileInfo(result.fullProfile, result.params.id_token);
return {
response: await this.populateIdentity({
providerInfo: {
idToken: result.params.id_token,
accessToken: result.accessToken,
scope: result.params.scope,
expiresInSeconds: result.params.expires_in,
},
profile,
}),
response: await this.handleResult(result),
refreshToken: privateInfo.refreshToken,
};
}
@@ -133,89 +133,170 @@ export class GoogleAuthProvider implements OAuthHandlers {
req.refreshToken,
req.scope,
);
const fullProfile = await executeFetchUserProfileStrategy(
this._strategy,
accessToken,
);
const profile = makeProfileInfo(fullProfile, params.id_token);
return this.populateIdentity({
providerInfo: {
accessToken,
idToken: params.id_token,
expiresInSeconds: params.expires_in,
scope: params.scope,
},
profile,
return this.handleResult({
fullProfile,
params,
accessToken,
refreshToken: req.refreshToken,
});
}
private async populateIdentity(
response: OAuthResponse,
): Promise<OAuthResponse> {
const { profile } = response;
private async handleResult(result: OAuthResult) {
const { profile } = await this.authHandler(result);
if (!profile.email) {
throw new Error('Google profile contained no email');
}
const response: OAuthResponse = {
providerInfo: {
idToken: result.params.id_token,
accessToken: result.accessToken,
scope: result.params.scope,
expiresInSeconds: result.params.expires_in,
},
profile,
};
try {
const token = await this.tokenIssuer.issueToken({
claims: { sub: 'backstage.io/auth-backend' },
});
const user = await this.identityClient.findUser(
if (this.signInResolver) {
response.backstageIdentity = await this.signInResolver(
{
annotations: {
'google.com/email': profile.email,
},
result,
profile,
},
{ token },
);
return {
...response,
backstageIdentity: {
id: user.metadata.name,
{
tokenIssuer: this.tokenIssuer,
catalogIdentityClient: this.catalogIdentityClient,
logger: this.logger,
},
};
} catch (error) {
this.logger.warn(
`Failed to look up user, ${error}, falling back to allowing login based on email pattern, this will probably break in the future`,
);
return {
...response,
backstageIdentity: { id: profile.email.split('@')[0] },
};
}
return response;
}
}
export type GoogleProviderOptions = {};
export const googleEmailSignInResolver: SignInResolver<OAuthResult> = async (
info,
ctx,
) => {
const { profile } = info;
if (!profile.email) {
throw new Error('Google profile contained no email');
}
const entity = await ctx.catalogIdentityClient.findUser({
annotations: {
'google.com/email': profile.email,
},
});
const claims = getEntityClaims(entity);
const token = await ctx.tokenIssuer.issueToken({ claims });
return { id: entity.metadata.name, entity, token };
};
export const googleDefaultSignInResolver: SignInResolver<OAuthResult> = async (
info,
ctx,
) => {
const { profile } = info;
if (!profile.email) {
throw new Error('Google profile contained no email');
}
let userId: string;
try {
const entity = await ctx.catalogIdentityClient.findUser({
annotations: {
'google.com/email': profile.email,
},
});
userId = entity.metadata.name;
} catch (error) {
ctx.logger.warn(
`Failed to look up user, ${error}, falling back to allowing login based on email pattern, this will probably break in the future`,
);
userId = profile.email.split('@')[0];
}
const token = await ctx.tokenIssuer.issueToken({
claims: { sub: userId, ent: [`user:default/${userId}`] },
});
return { id: userId, token };
};
export type GoogleProviderOptions = {
/**
* The profile transformation function used to verify and convert the auth response
* into the profile that will be presented to the user.
*/
authHandler?: AuthHandler<OAuthResult>;
/**
* Configure sign-in for this provider, without it the provider can not be used to sign users in.
*/
/**
* Maps an auth result to a Backstage identity for the user.
*
* Set to `'email'` to use the default email-based sign in resolver, which will search
* the catalog for a single user entity that has a matching `google.com/email` annotation.
*/
signIn?: {
resolver?: SignInResolver<OAuthResult>;
};
};
export const createGoogleProvider = (
_options?: GoogleProviderOptions,
options?: GoogleProviderOptions,
): AuthProviderFactory => {
return ({
providerId,
globalConfig,
config,
logger,
tokenIssuer,
catalogApi,
logger,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
const catalogIdentityClient = new CatalogIdentityClient({
catalogApi,
tokenIssuer,
});
const authHandler: AuthHandler<OAuthResult> = options?.authHandler
? options.authHandler
: async ({ fullProfile, params }) => ({
profile: makeProfileInfo(fullProfile, params.id_token),
});
const signInResolverFn =
options?.signIn?.resolver ?? googleDefaultSignInResolver;
const signInResolver: SignInResolver<OAuthResult> = info =>
signInResolverFn(info, {
catalogIdentityClient,
tokenIssuer,
logger,
});
const provider = new GoogleAuthProvider({
clientId,
clientSecret,
callbackUrl,
logger,
signInResolver,
authHandler,
tokenIssuer,
identityClient: new CatalogIdentityClient({ catalogApi }),
catalogIdentityClient,
logger,
});
return OAuthAdapter.fromConfig(globalConfig, provider, {
@@ -14,6 +14,7 @@
* limitations under the License.
*/
export * from './google';
export { factories as defaultAuthProviderFactories } from './factories';
// Export the minimal interface required for implementing a
+55 -2
View File
@@ -16,10 +16,12 @@
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { CatalogApi } from '@backstage/catalog-client';
import { Entity } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import express from 'express';
import { Logger } from 'winston';
import { TokenIssuer } from '../identity/types';
import { CatalogIdentityClient } from '../lib/catalog';
export type AuthProviderConfig = {
/**
@@ -148,14 +150,30 @@ export type AuthResponse<ProviderInfo> = {
export type BackstageIdentity = {
/**
* The backstage user ID.
* An opaque ID that uniquely identifies the user within Backstage.
*
* This is typically the same as the user entity `metadata.name`.
*/
id: string;
/**
* An ID token that can be used to authenticate the user within Backstage.
* This is deprecated, use `token` instead.
* @deprecated
*/
idToken?: string;
/**
* The token used to authenticate the user within Backstage.
*/
token?: string;
/**
* The entity that the user is represented by within Backstage.
*
* This entity may or may not exist within the Catalog, and it can be used
* to read and store additional metadata about the user.
*/
entity?: Entity;
};
/**
@@ -179,3 +197,38 @@ export type ProfileInfo = {
*/
picture?: string;
};
export type SignInInfo<AuthResult> = {
/**
* The simple profile passed down for use in the frontend.
*/
profile: ProfileInfo;
/**
* The authentication result that was received from the authentication provider.
*/
result: AuthResult;
};
export type SignInResolver<AuthResult> = (
info: SignInInfo<AuthResult>,
context: {
tokenIssuer: TokenIssuer;
catalogIdentityClient: CatalogIdentityClient;
logger: Logger;
},
) => Promise<BackstageIdentity>;
export type AuthHandlerResult = { profile: ProfileInfo };
/**
* The AuthHandler function is called every time the user authenticates using the provider.
*
* The handler should return a profile that represents the session for the user in the frontend.
*
* Throwing an error in the function will cause the authentication to fail, making it
* possible to use this function as a way to limit access to a certain group of users.
*/
export type AuthHandler<AuthResult> = (
input: AuthResult,
) => Promise<AuthHandlerResult>;
View File