diff --git a/.changeset/seven-adults-act.md b/.changeset/seven-adults-act.md new file mode 100644 index 0000000000..fa2839aad2 --- /dev/null +++ b/.changeset/seven-adults-act.md @@ -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 diff --git a/docs/auth/identity-resolver.md b/docs/auth/identity-resolver.md new file mode 100644 index 0000000000..814ff63729 --- /dev/null +++ b/docs/auth/identity-resolver.md @@ -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 { + 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 { + 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 { + 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, + } + }; + } + }) + } + }) +} +``` diff --git a/microsite/sidebars.json b/microsite/sidebars.json index 6caf14e854..e0add7a007 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -205,6 +205,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 c62824d39e..4b2b29a997 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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' 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[]; }; }; 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) { 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..c60198dc98 --- /dev/null +++ b/plugins/auth-backend/src/lib/catalog/helpers.ts @@ -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], + }; +} 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'; 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'; 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..45df5bd319 --- /dev/null +++ b/plugins/auth-backend/src/providers/google/provider.test.ts @@ -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', + }, + }); + }); +}); diff --git a/plugins/auth-backend/src/providers/google/provider.ts b/plugins/auth-backend/src/providers/google/provider.ts index a714674447..ace0c8e132 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,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; + authHandler: AuthHandler; 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; + private readonly authHandler: AuthHandler; 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 { - 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 = 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 = 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; + + /** + * 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; + }; +}; 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 = options?.authHandler + ? options.authHandler + : async ({ fullProfile, params }) => ({ + profile: makeProfileInfo(fullProfile, params.id_token), + }); + + const signInResolverFn = + options?.signIn?.resolver ?? googleDefaultSignInResolver; + + const signInResolver: SignInResolver = 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, { 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 diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index 700af3ebef..e4bbaa091c 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,38 @@ 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; + logger: Logger; + }, +) => Promise; + +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 = ( + input: AuthResult, +) => Promise; diff --git a/test.yaml b/test.yaml new file mode 100644 index 0000000000..e69de29bb2