From de81880419e5fa372472e4f8753fd646dba87d74 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 14 Feb 2021 19:47:38 +0100 Subject: [PATCH 01/21] auth-backend: refactor CatalogIdentityClient to depend on TokenIssuer directly Signed-off-by: Patrik Oldsberg --- .../lib/catalog/CatalogIdentityClient.test.ts | 20 +++++++++++++++---- .../src/lib/catalog/CatalogIdentityClient.ts | 16 +++++++++------ 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts index 0ec65b7a0b..57c2b3345b 100644 --- a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts +++ b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts @@ -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 = { + 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', + }, + }); }); }); diff --git a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts index abd3624df2..947b8dac1f 100644 --- a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts +++ b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts @@ -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; @@ -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 { + async findUser(query: UserQuery): Promise { const filter: Record = { 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) { From 3430f0d1ff8898f52d7094f276f53cbaea76777c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 14 Feb 2021 19:49:36 +0100 Subject: [PATCH 02/21] auth-backend: add ent to possible claims in TokenParams Signed-off-by: Patrik Oldsberg --- plugins/auth-backend/src/identity/TokenFactory.ts | 5 +++-- plugins/auth-backend/src/identity/types.ts | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/plugins/auth-backend/src/identity/TokenFactory.ts b/plugins/auth-backend/src/identity/TokenFactory.ts index 1c6192d5c5..ac8f974cdb 100644 --- a/plugins/auth-backend/src/identity/TokenFactory.ts +++ b/plugins/auth-backend/src/identity/TokenFactory.ts @@ -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, }); diff --git a/plugins/auth-backend/src/identity/types.ts b/plugins/auth-backend/src/identity/types.ts index 6e984d41cc..2080b08cb6 100644 --- a/plugins/auth-backend/src/identity/types.ts +++ b/plugins/auth-backend/src/identity/types.ts @@ -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[]; }; }; From 02d2b8681f24b489d7ff68543170600c6c218c3a Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 14 Feb 2021 19:50:41 +0100 Subject: [PATCH 03/21] auth-backend: add getEntityClaims helper that figures out the claims needed to represent an entity Signed-off-by: Patrik Oldsberg --- .../auth-backend/src/lib/catalog/helpers.ts | 49 +++++++++++++++++++ plugins/auth-backend/src/lib/catalog/index.ts | 1 + 2 files changed, 50 insertions(+) create mode 100644 plugins/auth-backend/src/lib/catalog/helpers.ts diff --git a/plugins/auth-backend/src/lib/catalog/helpers.ts b/plugins/auth-backend/src/lib/catalog/helpers.ts new file mode 100644 index 0000000000..a73b797368 --- /dev/null +++ b/plugins/auth-backend/src/lib/catalog/helpers.ts @@ -0,0 +1,49 @@ +/* + * 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 { + UserEntity, + serializeEntityRef, + RELATION_MEMBER_OF, +} from '@backstage/catalog-model'; +import { TokenParams } from '../../identity'; + +export function getEntityClaims(entity: UserEntity): TokenParams['claims'] { + const userRef = serializeEntityRef(entity); + if (typeof userRef !== 'string') { + throw new Error( + `Failed to serialize user entity ref, ${JSON.stringify(userRef)}`, + ); + } + + const membershipRefs = + entity.relations + ?.filter(r => r.type === RELATION_MEMBER_OF) + .map(r => { + const ref = serializeEntityRef(r.target); + if (typeof ref !== 'string') { + throw new Error( + `Failed to serialize relation entity ref, ${JSON.stringify(ref)}`, + ); + } + return ref; + }) ?? []; + + return { + sub: userRef, + ent: [userRef, ...membershipRefs], + }; +} diff --git a/plugins/auth-backend/src/lib/catalog/index.ts b/plugins/auth-backend/src/lib/catalog/index.ts index f15469668f..fbd58081e3 100644 --- a/plugins/auth-backend/src/lib/catalog/index.ts +++ b/plugins/auth-backend/src/lib/catalog/index.ts @@ -15,3 +15,4 @@ */ export { CatalogIdentityClient } from './CatalogIdentityClient'; +export { getEntityClaims } from './helpers'; From 48a5dcf9d5caf9d91e4a9439bc2060e6ff042427 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 15 Feb 2021 12:35:02 +0100 Subject: [PATCH 04/21] auth-backend: added SignInResolver and ProfileTransform types Signed-off-by: Patrik Oldsberg --- plugins/auth-backend/src/providers/types.ts | 54 ++++++++++++++++++++- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index 700af3ebef..1f71bfe59c 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -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 = { 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,35 @@ export type ProfileInfo = { */ picture?: string; }; + +export type SignInInfo = { + /** + * 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 = ( + info: SignInInfo, + context: { + tokenIssuer: TokenIssuer; + catalogIdentityClient: CatalogIdentityClient; + }, +) => Promise; + +/** + * A transformation function called every time the user authenticates using the provider. + * + * The transform 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 ProfileTransform = ( + input: AuthResult, +) => Promise; From 1111c0753c152962b8bd02663b5ff8b1f446a3e7 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 15 Feb 2021 12:35:25 +0100 Subject: [PATCH 05/21] auth-backend: implement sign-in and profile options for google provider Signed-off-by: Patrik Oldsberg --- .../src/providers/google/provider.ts | 189 +++++++++++------- plugins/auth-backend/src/providers/index.ts | 1 + 2 files changed, 114 insertions(+), 76 deletions(-) diff --git a/plugins/auth-backend/src/providers/google/provider.ts b/plugins/auth-backend/src/providers/google/provider.ts index a714674447..56295640be 100644 --- a/plugins/auth-backend/src/providers/google/provider.ts +++ b/plugins/auth-backend/src/providers/google/provider.ts @@ -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,36 @@ import { makeProfileInfo, PassportDoneCallback, } from '../../lib/passport'; -import { AuthProviderFactory, RedirectInfo } from '../types'; -import { TokenIssuer } from '../../identity/types'; +import { + AuthProviderFactory, + ProfileTransform, + RedirectInfo, + SignInResolver, +} from '../types'; type PrivateInfo = { refreshToken: string; }; type Options = OAuthProviderOptions & { - logger: Logger; - identityClient: CatalogIdentityClient; + signInResolver?: SignInResolver; + profileTransform: ProfileTransform; tokenIssuer: TokenIssuer; + catalogIdentityClient: CatalogIdentityClient; }; export class GoogleAuthProvider implements OAuthHandlers { private readonly _strategy: GoogleStrategy; - private readonly logger: Logger; - private readonly identityClient: CatalogIdentityClient; + private readonly signInResolver?: SignInResolver; + private readonly profileTransform: ProfileTransform; private readonly tokenIssuer: TokenIssuer; + private readonly catalogIdentityClient: CatalogIdentityClient; constructor(options: Options) { - this.logger = options.logger; - this.identityClient = options.identityClient; + this.signInResolver = options.signInResolver; + this.profileTransform = options.profileTransform; this.tokenIssuer = options.tokenIssuer; - // TODO: throw error if env variables not set? + this.catalogIdentityClient = options.catalogIdentityClient; this._strategy = new GoogleStrategy( { clientID: options.clientId, @@ -111,18 +117,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 +129,130 @@ 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 { - const { profile } = response; + private async handleResult(result: OAuthResult) { + const profile = await this.profileTransform(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, }, - }; - } 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 = {}; +const emailSignInResolver: SignInResolver = 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 type GoogleProviderOptions = { + /** + * The profile transformation function used to verify and convert the auth response + * into the profile that will be presented to the user. + */ + profileTransform?: ProfileTransform; + + /** + * Configure sign-in for this provider, without it the provider can not be used to sign users in. + */ + signIn?: { + /** + * 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 + * for the catalog for a single user entity that has a matching `google.com/email` annotation. + */ + resolver?: 'email' | SignInResolver; + }; +}; export const createGoogleProvider = ( - _options?: GoogleProviderOptions, + options?: GoogleProviderOptions, ): AuthProviderFactory => { - return ({ - providerId, - globalConfig, - config, - logger, - tokenIssuer, - catalogApi, - }) => + return ({ providerId, globalConfig, config, tokenIssuer, catalogApi }) => 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, + }); + + let profileTransform: ProfileTransform = async ({ + fullProfile, + params, + }) => makeProfileInfo(fullProfile, params.id_token); + if (options?.profileTransform) { + profileTransform = options.profileTransform; + } + + let signInResolver: SignInResolver | undefined = undefined; + const resolver = options?.signIn?.resolver; + if (resolver === 'email') { + signInResolver = emailSignInResolver; + } else if (typeof resolver === 'function') { + signInResolver = info => + resolver(info, { + catalogIdentityClient, + tokenIssuer, + }); + } + const provider = new GoogleAuthProvider({ clientId, clientSecret, callbackUrl, - logger, + signInResolver, + profileTransform, tokenIssuer, - identityClient: new CatalogIdentityClient({ catalogApi }), + catalogIdentityClient, }); return OAuthAdapter.fromConfig(globalConfig, provider, { diff --git a/plugins/auth-backend/src/providers/index.ts b/plugins/auth-backend/src/providers/index.ts index 3f3d16f28f..eedc79cf09 100644 --- a/plugins/auth-backend/src/providers/index.ts +++ b/plugins/auth-backend/src/providers/index.ts @@ -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 From 11e3e0fc24a17cc91cdf58feb635a8df1d0cc2a6 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 15 Feb 2021 12:43:41 +0100 Subject: [PATCH 06/21] backend: mock custom google auth provider Signed-off-by: Patrik Oldsberg --- packages/backend/src/plugins/auth.ts | 31 ++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/plugins/auth.ts b/packages/backend/src/plugins/auth.ts index 2b1c85f052..43bc8393bb 100644 --- a/packages/backend/src/plugins/auth.ts +++ b/packages/backend/src/plugins/auth.ts @@ -14,7 +14,11 @@ * limitations under the License. */ -import { createRouter } from '@backstage/plugin-auth-backend'; +import { + createGoogleProvider, + createRouter, + defaultAuthProviderFactories, +} from '@backstage/plugin-auth-backend'; import { Router } from 'express'; import { PluginEnvironment } from '../types'; @@ -24,5 +28,28 @@ export default async function createPlugin({ config, discovery, }: PluginEnvironment): Promise { - return await createRouter({ logger, config, database, discovery }); + return await createRouter({ + logger, + config, + database, + discovery, + providerFactories: { + ...defaultAuthProviderFactories, + google: createGoogleProvider({ + signIn: { + // resolver: 'email', + resolver: async ({ profile: { email } }, ctx) => { + if (!email) { + throw new Error('No email associated with user account'); + } + const id = email.split('@')[0]; + const token = await ctx.tokenIssuer.issueToken({ + claims: { sub: id, ent: [id] }, + }); + return { id, token }; + }, + }, + }), + }, + }); } From 17a26e7ed36e4fd2530618e6e560fdd6de99f7b3 Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Mon, 19 Apr 2021 14:49:53 +0530 Subject: [PATCH 07/21] auth-backend: Use stringifyEntityRef helper Signed-off-by: Himanshu Mishra --- .../auth-backend/src/lib/catalog/helpers.ts | 21 ++++--------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/plugins/auth-backend/src/lib/catalog/helpers.ts b/plugins/auth-backend/src/lib/catalog/helpers.ts index a73b797368..c2dfe12455 100644 --- a/plugins/auth-backend/src/lib/catalog/helpers.ts +++ b/plugins/auth-backend/src/lib/catalog/helpers.ts @@ -15,32 +15,19 @@ */ import { - UserEntity, - serializeEntityRef, RELATION_MEMBER_OF, + stringifyEntityRef, + UserEntity, } from '@backstage/catalog-model'; import { TokenParams } from '../../identity'; export function getEntityClaims(entity: UserEntity): TokenParams['claims'] { - const userRef = serializeEntityRef(entity); - if (typeof userRef !== 'string') { - throw new Error( - `Failed to serialize user entity ref, ${JSON.stringify(userRef)}`, - ); - } + const userRef = stringifyEntityRef(entity); const membershipRefs = entity.relations ?.filter(r => r.type === RELATION_MEMBER_OF) - .map(r => { - const ref = serializeEntityRef(r.target); - if (typeof ref !== 'string') { - throw new Error( - `Failed to serialize relation entity ref, ${JSON.stringify(ref)}`, - ); - } - return ref; - }) ?? []; + .map(r => stringifyEntityRef(r.target)) ?? []; return { sub: userRef, From a848395e2bff6ec5799823c609b4e596e0d3e6f5 Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Thu, 29 Apr 2021 13:09:29 +0200 Subject: [PATCH 08/21] auth-backend: getEntityClaims make sure target is group kind We are interested in the User isMemberOf Group relation. So this is just an additional check that the target is of Group kind. Signed-off-by: Himanshu Mishra --- plugins/auth-backend/src/lib/catalog/helpers.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/plugins/auth-backend/src/lib/catalog/helpers.ts b/plugins/auth-backend/src/lib/catalog/helpers.ts index c2dfe12455..e155c7defe 100644 --- a/plugins/auth-backend/src/lib/catalog/helpers.ts +++ b/plugins/auth-backend/src/lib/catalog/helpers.ts @@ -26,7 +26,11 @@ export function getEntityClaims(entity: UserEntity): TokenParams['claims'] { const membershipRefs = entity.relations - ?.filter(r => r.type === RELATION_MEMBER_OF) + ?.filter( + r => + r.type === RELATION_MEMBER_OF && + r.target.kind.toLocaleLowerCase() === 'group', + ) .map(r => stringifyEntityRef(r.target)) ?? []; return { From 7303b6520094295bdff9941cbd192664378e5eeb Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Thu, 6 May 2021 13:24:57 +0200 Subject: [PATCH 09/21] auth: remove duplicated use of defaultAuthProviderFactories Signed-off-by: Himanshu Mishra --- packages/backend/src/plugins/auth.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/backend/src/plugins/auth.ts b/packages/backend/src/plugins/auth.ts index 43bc8393bb..1d8bbdc892 100644 --- a/packages/backend/src/plugins/auth.ts +++ b/packages/backend/src/plugins/auth.ts @@ -17,7 +17,6 @@ import { createGoogleProvider, createRouter, - defaultAuthProviderFactories, } from '@backstage/plugin-auth-backend'; import { Router } from 'express'; import { PluginEnvironment } from '../types'; @@ -34,7 +33,6 @@ export default async function createPlugin({ database, discovery, providerFactories: { - ...defaultAuthProviderFactories, google: createGoogleProvider({ signIn: { // resolver: 'email', From a0949d58928f62e4ae0184b2821d6319ff9cebe5 Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Thu, 6 May 2021 21:34:21 +0200 Subject: [PATCH 10/21] docs: Add docs explaining sign-in resolvers and profile transform Signed-off-by: Himanshu Mishra --- docs/auth/identity-resolver.md | 161 ++++++++++++++++++ microsite/sidebars.json | 1 + mkdocs.yml | 1 + .../src/providers/google/provider.ts | 2 +- 4 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 docs/auth/identity-resolver.md diff --git a/docs/auth/identity-resolver.md b/docs/auth/identity-resolver.md new file mode 100644 index 0000000000..1e5c8dcdba --- /dev/null +++ b/docs/auth/identity-resolver.md @@ -0,0 +1,161 @@ +--- +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 +ID Token (which are standard JWT tokens) a special `ent` field is set. `ent` +contains a list of [entity references](../features/software-catalog/references), +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 { + return await createRouter({ + ... + providerFactories: { + google: createGoogleProvider({ + signIn: { + resolver: async ({ profile: { email } }, ctx) => { + if (!email) { + throw new Error('No email associated with user account'); + } + + // Ignore email addresses which do not belong to company's domain name + if (email.split('@')[1] != 'mycompany.com') { + throw new Error('Unrecognized domain name of the email ID used to sign in.') + } + + // 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('@')[0]; + // Let's add the unique ID in the list + ent.push(id) + // Note: While the complete entity references look like `kind:namespace/name`, it should be safe to assume + // that standalone strings without any : or / can be interpresed as user kind in the default namespace. So, + // a 'freben' inside the `ent` list should be translated to `User:default/freben` when making any assertions. + + // Let's call the GitHub Enterprise API inside the company and get the teams that the user belongs to + const gheUsername = getGheUsername(email); + const gheTeams = getGheTeams(gheUsername); + + // Let's add the GHE identities to ent claims inside a new ghe namespace to keep things separate from the + // default namespace. + ent.push(`User:ghe/${gheUsername}`) + gheTeams.forEach(team => ent.push(`Group:ghe/${team}`)) + + // Let's call the internal LDAP provider to get a list of groups the user belongs to + const ldapGroups = getLdapGroups(email); + ldapGroups.forEach(ldapGroup => ent.push(`Group:myldap/${ldapGroup}`)) + + // 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 ID 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. A full +algorithm as proposed in the RFC is as follows + +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 +... +export default async function createPlugin({ + ... +}: PluginEnvironment): Promise { + return await createRouter({ + ... + providerFactories: { + google: createGoogleProvider({ + signIn: { + resolver: 'email' + } +... +``` + +## Profile transform + +Similar to a custom sign-in resolver, you can also write custom profile +transformation 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. + +```tsx +# File: packages/backend/src/plugins/auth.ts +... +export default async function createPlugin({ + ... +}: PluginEnvironment): Promise { + return await createRouter({ + ... + providerFactories: { + google: createGoogleProvider({ + signIn: { + resolver: 'email' + }, + profileTransform: async ({ + fullProfile // Type: passport.Profile, + idToken // Type: (Optional) string, + }): ProfileInfo => { + // Do stuff + return { + email, + picture, + displayName, + }; + } + }) + } + }) +} +``` diff --git a/microsite/sidebars.json b/microsite/sidebars.json index f2ce425307..a9e1b07d62 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -206,6 +206,7 @@ }, "auth/add-auth-provider", "auth/using-auth", + "auth/identity-resolver", "auth/auth-backend", "auth/oauth", "auth/auth-backend-classes", diff --git a/mkdocs.yml b/mkdocs.yml index 67b97b6ffb..90e84c3533 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -133,6 +133,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' diff --git a/plugins/auth-backend/src/providers/google/provider.ts b/plugins/auth-backend/src/providers/google/provider.ts index 56295640be..b97e625699 100644 --- a/plugins/auth-backend/src/providers/google/provider.ts +++ b/plugins/auth-backend/src/providers/google/provider.ts @@ -205,7 +205,7 @@ export type GoogleProviderOptions = { * 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 - * for the catalog for a single user entity that has a matching `google.com/email` annotation. + * the catalog for a single user entity that has a matching `google.com/email` annotation. */ resolver?: 'email' | SignInResolver; }; From 46c52d0aca5724c4721108faed4d5215dc0f3b88 Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Thu, 6 May 2021 21:41:18 +0200 Subject: [PATCH 11/21] docs: fix doc reference links Signed-off-by: Himanshu Mishra --- docs/auth/identity-resolver.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/auth/identity-resolver.md b/docs/auth/identity-resolver.md index 1e5c8dcdba..f1f99bde71 100644 --- a/docs/auth/identity-resolver.md +++ b/docs/auth/identity-resolver.md @@ -13,9 +13,10 @@ Backstage entity by a user. The ideas here were originally proposed in the RFC When a user signs in to Backstage, inside the `claims` field of their Backstage ID Token (which are standard JWT tokens) a special `ent` field is set. `ent` -contains a list of [entity references](../features/software-catalog/references), -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. +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. From c467cc4b912119e27443c0ccb96bc3c069ffdd2d Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Thu, 6 May 2021 21:53:14 +0200 Subject: [PATCH 12/21] auth-backend: Add changeset for the sign-in resolver work Signed-off-by: Himanshu Mishra --- .changeset/seven-adults-act.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .changeset/seven-adults-act.md diff --git a/.changeset/seven-adults-act.md b/.changeset/seven-adults-act.md new file mode 100644 index 0000000000..93541e06a6 --- /dev/null +++ b/.changeset/seven-adults-act.md @@ -0,0 +1,15 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Adds custom sign-in resolvers and profile transformation for Google auth provider. Read more about what this means for Backstage user identity and determining ownership of entities https://backstage.io/docs/auth/identity-resolver +Related the [RFC] From Identity to Ownership, v2 https://github.com/backstage/backstage/issues/4089 + +Adds `ent` field in the claims of Backstage ID Token with a list of entity references containing identity and membership info about the user across multiple systems. + +Adds an optional `providerFactories` 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. From 7dd84f37f0575f721f7c59d89a034e03d511d352 Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Thu, 20 May 2021 11:53:47 +0200 Subject: [PATCH 13/21] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Himanshu Mishra Co-authored-by: Fredrik Adelöw Co-authored-by: Patrik Oldsberg --- docs/auth/identity-resolver.md | 16 ++++++++-------- plugins/auth-backend/src/lib/catalog/helpers.ts | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/auth/identity-resolver.md b/docs/auth/identity-resolver.md index f1f99bde71..fa344afcef 100644 --- a/docs/auth/identity-resolver.md +++ b/docs/auth/identity-resolver.md @@ -12,7 +12,7 @@ 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 -ID Token (which are standard JWT tokens) a special `ent` field is set. `ent` +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 @@ -41,7 +41,7 @@ export default async function createPlugin({ } // Ignore email addresses which do not belong to company's domain name - if (email.split('@')[1] != 'mycompany.com') { + if (email.split('@')[1] !== 'mycompany.com') { throw new Error('Unrecognized domain name of the email ID used to sign in.') } @@ -82,11 +82,11 @@ export default async function createPlugin({ } ``` -As you can see, the generated Backstage ID 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. A full -algorithm as proposed in the RFC is as follows +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. A full algorithm as +proposed in the RFC is as follows The definition of the ownership of an entity E, for a user U, is as follows: @@ -126,7 +126,7 @@ export default async function createPlugin({ ## Profile transform -Similar to a custom sign-in resolver, you can also write custom profile +Similar to a custom sign-in resolver, you can also write a custom profile transformation 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. diff --git a/plugins/auth-backend/src/lib/catalog/helpers.ts b/plugins/auth-backend/src/lib/catalog/helpers.ts index e155c7defe..c60198dc98 100644 --- a/plugins/auth-backend/src/lib/catalog/helpers.ts +++ b/plugins/auth-backend/src/lib/catalog/helpers.ts @@ -29,7 +29,7 @@ export function getEntityClaims(entity: UserEntity): TokenParams['claims'] { ?.filter( r => r.type === RELATION_MEMBER_OF && - r.target.kind.toLocaleLowerCase() === 'group', + r.target.kind.toLocaleLowerCase('en-US') === 'group', ) .map(r => stringifyEntityRef(r.target)) ?? []; From db1d558411e7c4964537f702fd76f8d7d8aac5d7 Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Thu, 20 May 2021 15:47:01 +0200 Subject: [PATCH 14/21] auth-backend: always use full user/group entity refs Signed-off-by: Himanshu Mishra --- docs/auth/identity-resolver.md | 5 +---- packages/backend/src/plugins/auth.ts | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/docs/auth/identity-resolver.md b/docs/auth/identity-resolver.md index fa344afcef..9414c9aa14 100644 --- a/docs/auth/identity-resolver.md +++ b/docs/auth/identity-resolver.md @@ -51,10 +51,7 @@ export default async function createPlugin({ // Let's use the username in the email ID as the user's default unique identifier inside Backstage const id = email.split('@')[0]; // Let's add the unique ID in the list - ent.push(id) - // Note: While the complete entity references look like `kind:namespace/name`, it should be safe to assume - // that standalone strings without any : or / can be interpresed as user kind in the default namespace. So, - // a 'freben' inside the `ent` list should be translated to `User:default/freben` when making any assertions. + ent.push(`User:default/${id}`) // Let's call the GitHub Enterprise API inside the company and get the teams that the user belongs to const gheUsername = getGheUsername(email); diff --git a/packages/backend/src/plugins/auth.ts b/packages/backend/src/plugins/auth.ts index 1d8bbdc892..3157284df7 100644 --- a/packages/backend/src/plugins/auth.ts +++ b/packages/backend/src/plugins/auth.ts @@ -42,7 +42,7 @@ export default async function createPlugin({ } const id = email.split('@')[0]; const token = await ctx.tokenIssuer.issueToken({ - claims: { sub: id, ent: [id] }, + claims: { sub: id, ent: [`User:default/${id}`] }, }); return { id, token }; }, From 8adb6f6bcd0d20a0aa421af8a5b3b9d66f9f6e4d Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 16 Jun 2021 10:34:48 +0200 Subject: [PATCH 15/21] feat: enable backwards compatability and write a simple test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: Johan Haals Co-authored-by: Patrik Oldsberg Signed-off-by: blam --- .../src/providers/google/provider.test.ts | 89 +++++++++++++++++++ .../src/providers/google/provider.ts | 86 +++++++++++++----- plugins/auth-backend/src/providers/types.ts | 1 + 3 files changed, 156 insertions(+), 20 deletions(-) create mode 100644 plugins/auth-backend/src/providers/google/provider.test.ts diff --git a/plugins/auth-backend/src/providers/google/provider.test.ts b/plugins/auth-backend/src/providers/google/provider.test.ts new file mode 100644 index 0000000000..a8b5b92b5e --- /dev/null +++ b/plugins/auth-backend/src/providers/google/provider.test.ts @@ -0,0 +1,89 @@ +/* + * 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, + profileTransform: async ({ fullProfile }) => ({ + 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', + }, + }); + }); +}); diff --git a/plugins/auth-backend/src/providers/google/provider.ts b/plugins/auth-backend/src/providers/google/provider.ts index b97e625699..f2feb7155b 100644 --- a/plugins/auth-backend/src/providers/google/provider.ts +++ b/plugins/auth-backend/src/providers/google/provider.ts @@ -44,6 +44,7 @@ import { RedirectInfo, SignInResolver, } from '../types'; +import { Logger } from 'winston'; type PrivateInfo = { refreshToken: string; @@ -54,6 +55,7 @@ type Options = OAuthProviderOptions & { profileTransform: ProfileTransform; tokenIssuer: TokenIssuer; catalogIdentityClient: CatalogIdentityClient; + logger: Logger; }; export class GoogleAuthProvider implements OAuthHandlers { @@ -62,12 +64,14 @@ export class GoogleAuthProvider implements OAuthHandlers { private readonly profileTransform: ProfileTransform; private readonly tokenIssuer: TokenIssuer; private readonly catalogIdentityClient: CatalogIdentityClient; + private readonly logger: Logger; constructor(options: Options) { this.signInResolver = options.signInResolver; this.profileTransform = options.profileTransform; this.tokenIssuer = options.tokenIssuer; this.catalogIdentityClient = options.catalogIdentityClient; + this.logger = options.logger; this._strategy = new GoogleStrategy( { clientID: options.clientId, @@ -163,6 +167,7 @@ export class GoogleAuthProvider implements OAuthHandlers { { tokenIssuer: this.tokenIssuer, catalogIdentityClient: this.catalogIdentityClient, + logger: this.logger, }, ); } @@ -171,7 +176,10 @@ export class GoogleAuthProvider implements OAuthHandlers { } } -const emailSignInResolver: SignInResolver = async (info, ctx) => { +export const googleEmailSignInResolver: SignInResolver = async ( + info, + ctx, +) => { const { profile } = info; if (!profile.email) { @@ -190,6 +198,38 @@ const emailSignInResolver: SignInResolver = async (info, ctx) => { return { id: entity.metadata.name, entity, token }; }; +export const googleDefaultSignInResolver: SignInResolver = 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 @@ -200,21 +240,28 @@ export type GoogleProviderOptions = { /** * 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?: { - /** - * 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. - */ - resolver?: 'email' | SignInResolver; + resolver?: SignInResolver; }; }; export const createGoogleProvider = ( options?: GoogleProviderOptions, ): AuthProviderFactory => { - return ({ providerId, globalConfig, config, tokenIssuer, catalogApi }) => + return ({ + providerId, + globalConfig, + config, + tokenIssuer, + catalogApi, + logger, + }) => OAuthEnvironmentHandler.mapConfig(config, envConfig => { const clientId = envConfig.getString('clientId'); const clientSecret = envConfig.getString('clientSecret'); @@ -233,17 +280,15 @@ export const createGoogleProvider = ( profileTransform = options.profileTransform; } - let signInResolver: SignInResolver | undefined = undefined; - const resolver = options?.signIn?.resolver; - if (resolver === 'email') { - signInResolver = emailSignInResolver; - } else if (typeof resolver === 'function') { - signInResolver = info => - resolver(info, { - catalogIdentityClient, - tokenIssuer, - }); - } + const signInResolverFn = + options?.signIn?.resolver ?? googleDefaultSignInResolver; + + const signInResolver: SignInResolver = info => + signInResolverFn(info, { + catalogIdentityClient, + tokenIssuer, + logger, + }); const provider = new GoogleAuthProvider({ clientId, @@ -253,6 +298,7 @@ export const createGoogleProvider = ( profileTransform, tokenIssuer, catalogIdentityClient, + logger, }); return OAuthAdapter.fromConfig(globalConfig, provider, { diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index 1f71bfe59c..305ff7e776 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -215,6 +215,7 @@ export type SignInResolver = ( context: { tokenIssuer: TokenIssuer; catalogIdentityClient: CatalogIdentityClient; + logger: Logger; }, ) => Promise; From 2fbded87e5dca60c96b46157270b9e490b56eaa5 Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 16 Jun 2021 10:38:26 +0200 Subject: [PATCH 16/21] chore: revert the changes in app/backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: Johan Haals Co-authored-by: Patrik Oldsberg Signed-off-by: blam --- .changeset/seven-adults-act.md | 18 +++++++++++++---- packages/backend/src/plugins/auth.ts | 29 ++-------------------------- test.yaml | 0 3 files changed, 16 insertions(+), 31 deletions(-) create mode 100644 test.yaml diff --git a/.changeset/seven-adults-act.md b/.changeset/seven-adults-act.md index 93541e06a6..fa2839aad2 100644 --- a/.changeset/seven-adults-act.md +++ b/.changeset/seven-adults-act.md @@ -2,14 +2,24 @@ '@backstage/plugin-auth-backend': patch --- -Adds custom sign-in resolvers and profile transformation for Google auth provider. Read more about what this means for Backstage user identity and determining ownership of entities https://backstage.io/docs/auth/identity-resolver -Related the [RFC] From Identity to Ownership, v2 https://github.com/backstage/backstage/issues/4089 +Adds support for custom sign-in resolvers and profile transformations for the +Google auth provider. -Adds `ent` field in the claims of Backstage ID Token with a list of entity references containing identity and membership info about the user across multiple systems. +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` to the `createRouter` exported by the auth-backend plugin. +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 diff --git a/packages/backend/src/plugins/auth.ts b/packages/backend/src/plugins/auth.ts index 3157284df7..2b1c85f052 100644 --- a/packages/backend/src/plugins/auth.ts +++ b/packages/backend/src/plugins/auth.ts @@ -14,10 +14,7 @@ * limitations under the License. */ -import { - createGoogleProvider, - createRouter, -} from '@backstage/plugin-auth-backend'; +import { createRouter } from '@backstage/plugin-auth-backend'; import { Router } from 'express'; import { PluginEnvironment } from '../types'; @@ -27,27 +24,5 @@ export default async function createPlugin({ config, discovery, }: PluginEnvironment): Promise { - return await createRouter({ - logger, - config, - database, - discovery, - providerFactories: { - google: createGoogleProvider({ - signIn: { - // resolver: 'email', - resolver: async ({ profile: { email } }, ctx) => { - if (!email) { - throw new Error('No email associated with user account'); - } - const id = email.split('@')[0]; - const token = await ctx.tokenIssuer.issueToken({ - claims: { sub: id, ent: [`User:default/${id}`] }, - }); - return { id, token }; - }, - }, - }), - }, - }); + return await createRouter({ logger, config, database, discovery }); } diff --git a/test.yaml b/test.yaml new file mode 100644 index 0000000000..e69de29bb2 From 551c050e2310c259eb8173a0f87ca9e2ce980f8a Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 16 Jun 2021 10:42:48 +0200 Subject: [PATCH 17/21] feat: actually export the googleSignInResovlers for use in apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: Johan Haals Co-authored-by: Patrik Oldsberg Signed-off-by: blam --- plugins/auth-backend/src/providers/google/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/plugins/auth-backend/src/providers/google/index.ts b/plugins/auth-backend/src/providers/google/index.ts index 2615a2d8e5..8bff8b250b 100644 --- a/plugins/auth-backend/src/providers/google/index.ts +++ b/plugins/auth-backend/src/providers/google/index.ts @@ -14,5 +14,9 @@ * limitations under the License. */ -export { createGoogleProvider } from './provider'; +export { + createGoogleProvider, + googleDefaultSignInResolver, + googleEmailSignInResolver, +} from './provider'; export type { GoogleProviderOptions } from './provider'; From f5f290f1c26ae9d152517a427c9a85020ec9229c Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 16 Jun 2021 10:56:42 +0200 Subject: [PATCH 18/21] feat: update the public api instead of profileTransform it is AuthHandler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: Johan Haals Co-authored-by: Patrik Oldsberg Signed-off-by: blam --- .../src/providers/google/provider.test.ts | 10 ++++--- .../src/providers/google/provider.ts | 26 +++++++++---------- plugins/auth-backend/src/providers/types.ts | 10 ++++--- 3 files changed, 24 insertions(+), 22 deletions(-) diff --git a/plugins/auth-backend/src/providers/google/provider.test.ts b/plugins/auth-backend/src/providers/google/provider.test.ts index a8b5b92b5e..45df5bd319 100644 --- a/plugins/auth-backend/src/providers/google/provider.test.ts +++ b/plugins/auth-backend/src/providers/google/provider.test.ts @@ -42,10 +42,12 @@ describe('createGoogleProvider', () => { logger: getVoidLogger(), catalogIdentityClient: (catalogIdentityClient as unknown) as CatalogIdentityClient, tokenIssuer: (tokenIssuer as unknown) as TokenIssuer, - profileTransform: async ({ fullProfile }) => ({ - email: fullProfile.emails![0]!.value, - displayName: fullProfile.displayName, - picture: 'http://google.com/lols', + authHandler: async ({ fullProfile }) => ({ + profile: { + email: fullProfile.emails![0]!.value, + displayName: fullProfile.displayName, + picture: 'http://google.com/lols', + }, }), clientId: 'mock', clientSecret: 'mock', diff --git a/plugins/auth-backend/src/providers/google/provider.ts b/plugins/auth-backend/src/providers/google/provider.ts index f2feb7155b..ace0c8e132 100644 --- a/plugins/auth-backend/src/providers/google/provider.ts +++ b/plugins/auth-backend/src/providers/google/provider.ts @@ -40,7 +40,7 @@ import { } from '../../lib/passport'; import { AuthProviderFactory, - ProfileTransform, + AuthHandler, RedirectInfo, SignInResolver, } from '../types'; @@ -52,7 +52,7 @@ type PrivateInfo = { type Options = OAuthProviderOptions & { signInResolver?: SignInResolver; - profileTransform: ProfileTransform; + authHandler: AuthHandler; tokenIssuer: TokenIssuer; catalogIdentityClient: CatalogIdentityClient; logger: Logger; @@ -61,14 +61,14 @@ type Options = OAuthProviderOptions & { export class GoogleAuthProvider implements OAuthHandlers { private readonly _strategy: GoogleStrategy; private readonly signInResolver?: SignInResolver; - private readonly profileTransform: ProfileTransform; + private readonly authHandler: AuthHandler; private readonly tokenIssuer: TokenIssuer; private readonly catalogIdentityClient: CatalogIdentityClient; private readonly logger: Logger; constructor(options: Options) { this.signInResolver = options.signInResolver; - this.profileTransform = options.profileTransform; + this.authHandler = options.authHandler; this.tokenIssuer = options.tokenIssuer; this.catalogIdentityClient = options.catalogIdentityClient; this.logger = options.logger; @@ -146,7 +146,7 @@ export class GoogleAuthProvider implements OAuthHandlers { } private async handleResult(result: OAuthResult) { - const profile = await this.profileTransform(result); + const { profile } = await this.authHandler(result); const response: OAuthResponse = { providerInfo: { @@ -235,7 +235,7 @@ 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. */ - profileTransform?: ProfileTransform; + authHandler?: AuthHandler; /** * Configure sign-in for this provider, without it the provider can not be used to sign users in. @@ -272,13 +272,11 @@ export const createGoogleProvider = ( tokenIssuer, }); - let profileTransform: ProfileTransform = async ({ - fullProfile, - params, - }) => makeProfileInfo(fullProfile, params.id_token); - if (options?.profileTransform) { - profileTransform = options.profileTransform; - } + const authHandler: AuthHandler = options?.authHandler + ? options.authHandler + : async ({ fullProfile, params }) => ({ + profile: makeProfileInfo(fullProfile, params.id_token), + }); const signInResolverFn = options?.signIn?.resolver ?? googleDefaultSignInResolver; @@ -295,7 +293,7 @@ export const createGoogleProvider = ( clientSecret, callbackUrl, signInResolver, - profileTransform, + authHandler, tokenIssuer, catalogIdentityClient, logger, diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index 305ff7e776..e4bbaa091c 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -219,14 +219,16 @@ export type SignInResolver = ( }, ) => Promise; +export type AuthHandlerResult = { profile: ProfileInfo }; + /** - * A transformation function called every time the user authenticates using the provider. + * The AuthHandler function is called every time the user authenticates using the provider. * - * The transform should return a profile that represents the session for the user in the frontend. + * 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 ProfileTransform = ( +export type AuthHandler = ( input: AuthResult, -) => Promise; +) => Promise; From ca60400ebeba1604c88affe5f290836ae4cb0c7a Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 16 Jun 2021 11:03:57 +0200 Subject: [PATCH 19/21] chore: update documentation a little bit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: blam Co-authored-by: Fredrik Adelöw Co-authored-by: Johan Haals Co-authored-by: Patrik Oldsberg --- docs/auth/identity-resolver.md | 35 +++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/docs/auth/identity-resolver.md b/docs/auth/identity-resolver.md index 9414c9aa14..8c1aefb4d3 100644 --- a/docs/auth/identity-resolver.md +++ b/docs/auth/identity-resolver.md @@ -108,6 +108,8 @@ 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 { @@ -116,21 +118,26 @@ export default async function createPlugin({ providerFactories: { google: createGoogleProvider({ signIn: { - resolver: 'email' + resolver: googleEmailSignInResolver } ... ``` -## Profile transform +## AuthHandler -Similar to a custom sign-in resolver, you can also write a custom profile -transformation 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. +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 ... +import { googleEmailSignInResolver } from '@backstage/plugin-auth-backend'; + export default async function createPlugin({ ... }: PluginEnvironment): Promise { @@ -139,17 +146,19 @@ export default async function createPlugin({ providerFactories: { google: createGoogleProvider({ signIn: { - resolver: 'email' + resolver: googleEmailSignInResolver }, - profileTransform: async ({ + authHandler: async ({ fullProfile // Type: passport.Profile, idToken // Type: (Optional) string, - }): ProfileInfo => { - // Do stuff + }) => { + // Custom validation return { - email, - picture, - displayName, + profile: { + email, + picture, + displayName, + } }; } }) From 8061d1eb9e0885b01aa743c9bb98fdcf3461d2f0 Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 16 Jun 2021 11:40:24 +0200 Subject: [PATCH 20/21] docs: rework the documentation to make the example a little simpler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: blam Co-authored-by: Fredrik Adelöw Co-authored-by: Johan Haals Co-authored-by: Patrik Oldsberg --- docs/auth/identity-resolver.md | 38 +++++++++++++--------------------- 1 file changed, 14 insertions(+), 24 deletions(-) diff --git a/docs/auth/identity-resolver.md b/docs/auth/identity-resolver.md index 8c1aefb4d3..28bcd7c211 100644 --- a/docs/auth/identity-resolver.md +++ b/docs/auth/identity-resolver.md @@ -36,35 +36,25 @@ export default async function createPlugin({ google: createGoogleProvider({ signIn: { resolver: async ({ profile: { email } }, ctx) => { - if (!email) { - throw new Error('No email associated with user account'); - } + // 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); - // Ignore email addresses which do not belong to company's domain name - if (email.split('@')[1] !== 'mycompany.com') { - throw new Error('Unrecognized domain name of the email ID used to sign in.') - } - - // List of entity references that denote the identity and membership of the user + // 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('@')[0]; - // Let's add the unique ID in the list + // Let's use the username in the email ID as the user's default + // unique identifier inside Backstage + const [id] = email.split('@'); + + // Add the unique ID in the list ent.push(`User:default/${id}`) - // Let's call the GitHub Enterprise API inside the company and get the teams that the user belongs to - const gheUsername = getGheUsername(email); - const gheTeams = getGheTeams(gheUsername); - - // Let's add the GHE identities to ent claims inside a new ghe namespace to keep things separate from the - // default namespace. - ent.push(`User:ghe/${gheUsername}`) - gheTeams.forEach(team => ent.push(`Group:ghe/${team}`)) - // Let's call the internal LDAP provider to get a list of groups the user belongs to - const ldapGroups = getLdapGroups(email); - ldapGroups.forEach(ldapGroup => ent.push(`Group:myldap/${ldapGroup}`)) + const ldapGroups = await getLdapGroups(email); + ldapGroups.forEach(ldapGroup => ent.push(`Group:default/${ldapGroup}`)) // Issue the token containing the entity claims const token = await ctx.tokenIssuer.issueToken({ @@ -152,7 +142,7 @@ export default async function createPlugin({ fullProfile // Type: passport.Profile, idToken // Type: (Optional) string, }) => { - // Custom validation + // Custom validation code goes here return { profile: { email, From c41bb50f94532ee2da5ce87a0df82ff05f7c36ab Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 16 Jun 2021 22:03:51 +0200 Subject: [PATCH 21/21] chore: tidy up docs again Signed-off-by: blam --- docs/auth/identity-resolver.md | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/docs/auth/identity-resolver.md b/docs/auth/identity-resolver.md index 28bcd7c211..814ff63729 100644 --- a/docs/auth/identity-resolver.md +++ b/docs/auth/identity-resolver.md @@ -5,7 +5,7 @@ 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 +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 @@ -46,15 +46,14 @@ export default async function createPlugin({ const ent = []; // Let's use the username in the email ID as the user's default - // unique identifier inside Backstage + // unique identifier inside Backstage. const [id] = email.split('@'); - - // Add the unique ID in the list ent.push(`User:default/${id}`) - // Let's call the internal LDAP provider to get a list of groups the user belongs to + // 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(ldapGroup => ent.push(`Group:default/${ldapGroup}`)) + ldapGroups.forEach(group => ent.push(`Group:default/${group}`)) // Issue the token containing the entity claims const token = await ctx.tokenIssuer.issueToken({ @@ -72,10 +71,10 @@ export default async function createPlugin({ 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. A full algorithm as -proposed in the RFC is as follows +`ent` claims can be used to determine the ownership. -The definition of the ownership of an entity E, for a user U, is as follows: +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 @@ -89,15 +88,14 @@ The definition of the ownership of an entity E, for a user U, is as follows: 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 +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 -... +// File: packages/backend/src/plugins/auth.ts import { googleEmailSignInResolver } from '@backstage/plugin-auth-backend'; export default async function createPlugin({ @@ -124,10 +122,7 @@ 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 -... -import { googleEmailSignInResolver } from '@backstage/plugin-auth-backend'; - +// File: packages/backend/src/plugins/auth.ts export default async function createPlugin({ ... }: PluginEnvironment): Promise { @@ -135,9 +130,6 @@ export default async function createPlugin({ ... providerFactories: { google: createGoogleProvider({ - signIn: { - resolver: googleEmailSignInResolver - }, authHandler: async ({ fullProfile // Type: passport.Profile, idToken // Type: (Optional) string,