diff --git a/.changeset/strong-swans-wave.md b/.changeset/strong-swans-wave.md new file mode 100644 index 0000000000..2a7f409022 --- /dev/null +++ b/.changeset/strong-swans-wave.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Add Sign In and Handler resolver for GitLab provider diff --git a/ADOPTERS.md b/ADOPTERS.md index 6fb3f6ed50..020a7e3355 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -42,4 +42,4 @@ | [empathy.co](https://empathy.co/) | [@guillermotti](https://github.com/guillermotti) | Developer portal for tech docs, service catalog, plugin discovery and much more. | | [creditas.com](https://creditas.com/) | [@aureliosaraiva](https://github.com/aureliosaraiva) [@Creditas](https://github.com/creditas) | Centralization of all services, standards, documentation, etc. We started the deployment process. | | [Prisjakt](https://www.prisjakt.nu) / [PriceSpy](https://pricespy.co.uk) | [@kennylindahl](https://github.com/kennylindahl) | Internal developer portal - Documentation, scaffolding, software catalog, TechRadar, Gitlab org data integration | -| [Powerspike](https://powerspike.tv/) | [@trelore](https://github.com/trelore) | Developer portal for documentation of core libraries and repositories. | +| [Powerspike](https://powerspike.tv/) | [@trelore](https://github.com/trelore) | Developer portal for documentation of core libraries and repositories. | diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md index 11396cdc7c..6e65342828 100644 --- a/plugins/auth-backend/api-report.md +++ b/plugins/auth-backend/api-report.md @@ -84,6 +84,13 @@ export type BackstageIdentity = { entity?: Entity; }; +// Warning: (ae-missing-release-tag) "createGitlabProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const createGitlabProvider: ( + options?: GitlabProviderOptions | undefined, +) => AuthProviderFactory; + // Warning: (ae-missing-release-tag) "createGoogleProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -138,7 +145,16 @@ export const encodeState: (state: OAuthState) => string; // @public (undocumented) export const ensuresXRequestedWith: (req: express.Request) => boolean; -// Warning: (ae-forgotten-export) The symbol "SignInResolver" needs to be exported by the entry point index.d.ts +// Warning: (ae-missing-release-tag) "GitlabProviderOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type GitlabProviderOptions = { + authHandler?: AuthHandler; + signIn?: { + resolver?: SignInResolver; + }; +}; + // Warning: (ae-missing-release-tag) "googleEmailSignInResolver" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -404,7 +420,8 @@ export type WebMessageResponse = // // src/identity/types.d.ts:25:5 - (ae-forgotten-export) The symbol "TokenParams" needs to be exported by the entry point index.d.ts // src/identity/types.d.ts:31:9 - (ae-forgotten-export) The symbol "AnyJWK" needs to be exported by the entry point index.d.ts -// src/providers/google/provider.d.ts:36:5 - (ae-forgotten-export) The symbol "AuthHandler" needs to be exported by the entry point index.d.ts +// src/providers/gitlab/provider.d.ts:38:5 - (ae-forgotten-export) The symbol "AuthHandler" needs to be exported by the entry point index.d.ts +// src/providers/gitlab/provider.d.ts:49:9 - (ae-forgotten-export) The symbol "SignInResolver" needs to be exported by the entry point index.d.ts // src/providers/types.d.ts:109:5 - (ae-forgotten-export) The symbol "AuthProviderConfig" needs to be exported by the entry point index.d.ts // src/providers/types.d.ts:115:5 - (ae-forgotten-export) The symbol "ExperimentalIdentityResolver" needs to be exported by the entry point index.d.ts // src/providers/types.d.ts:132:8 - (tsdoc-missing-deprecation-message) The @deprecated block must include a deprecation message, e.g. describing the recommended alternative diff --git a/plugins/auth-backend/src/lib/passport/PassportStrategyHelper.ts b/plugins/auth-backend/src/lib/passport/PassportStrategyHelper.ts index 67c2678f8b..6117736e21 100644 --- a/plugins/auth-backend/src/lib/passport/PassportStrategyHelper.ts +++ b/plugins/auth-backend/src/lib/passport/PassportStrategyHelper.ts @@ -17,9 +17,11 @@ import express from 'express'; import passport from 'passport'; import jwtDecoder from 'jwt-decode'; -import { ProfileInfo, RedirectInfo } from '../../providers/types'; import { InternalOAuthError } from 'passport-oauth2'; +import { PassportProfile } from './types'; +import { ProfileInfo, RedirectInfo } from '../../providers/types'; + export type PassportDoneCallback = ( err?: Error, response?: Res, @@ -27,7 +29,7 @@ export type PassportDoneCallback = ( ) => void; export const makeProfileInfo = ( - profile: passport.Profile, + profile: PassportProfile, idToken?: string, ): ProfileInfo => { let email: string | undefined = undefined; @@ -37,7 +39,9 @@ export const makeProfileInfo = ( } let picture: string | undefined = undefined; - if (profile.photos && profile.photos.length > 0) { + if (profile.avatarUrl) { + picture = profile.avatarUrl; + } else if (profile.photos && profile.photos.length > 0) { const [firstPhoto] = profile.photos; picture = firstPhoto.value; } @@ -194,12 +198,12 @@ type ProviderStrategy = { export const executeFetchUserProfileStrategy = async ( providerStrategy: passport.Strategy, accessToken: string, -): Promise => { +): Promise => { return new Promise((resolve, reject) => { const anyStrategy = providerStrategy as unknown as ProviderStrategy; anyStrategy.userProfile( accessToken, - (error: Error, rawProfile: passport.Profile) => { + (error: Error, rawProfile: PassportProfile) => { if (error) { reject(error); } else { diff --git a/plugins/auth-backend/src/lib/passport/types.ts b/plugins/auth-backend/src/lib/passport/types.ts new file mode 100644 index 0000000000..55fe4543fc --- /dev/null +++ b/plugins/auth-backend/src/lib/passport/types.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 passport from 'passport'; + +export type PassportProfile = passport.Profile & { + avatarUrl?: string; +}; diff --git a/plugins/auth-backend/src/providers/gitlab/provider.test.ts b/plugins/auth-backend/src/providers/gitlab/provider.test.ts index 511a3dbc54..bb3297b043 100644 --- a/plugins/auth-backend/src/providers/gitlab/provider.test.ts +++ b/plugins/auth-backend/src/providers/gitlab/provider.test.ts @@ -14,9 +14,12 @@ * limitations under the License. */ -import { GitlabAuthProvider } from './provider'; +import { GitlabAuthProvider, gitlabDefaultSignInResolver } from './provider'; import * as helpers from '../../lib/passport/PassportStrategyHelper'; import { OAuthResult } from '../../lib/oauth'; +import { getVoidLogger } from '../../../../../packages/backend-common/src'; +import { TokenIssuer } from '../../identity'; +import { CatalogIdentityClient } from '../../lib/catalog'; const mockFrameHandler = jest.spyOn( helpers, @@ -60,12 +63,12 @@ describe('GitlabAuthProvider', () => { accessToken: '19xasczxcm9n7gacn9jdgm19me', expiresInSeconds: 100, scope: 'user_read write_repository', + idToken: undefined, }, profile: { email: 'jimmymarkum@gmail.com', displayName: 'Jimmy Markum', - picture: - 'https://a1cf74336522e87f135f-2f21ace9a6cf0052456644b80fa06d4f.ssl.cf2.rackcdn.com/images/characters_opt/p-mystic-river-sean-penn.jpg', + picture: 'http://gitlab.com/lols', }, }, }, @@ -102,21 +105,43 @@ describe('GitlabAuthProvider', () => { accessToken: 'ajakljsdoiahoawxbrouawucmbawe.awkxjemaneasdxwe.sodijxqeqwexeqwxe', expiresInSeconds: 200, + idToken: undefined, scope: 'read_repository', }, profile: { displayName: 'Dave Boyle', email: 'daveboyle@gitlab.org', + picture: 'http://gitlab.com/lols', }, }, }, ]; + const tokenIssuer = { + issueToken: jest.fn(), + listPublicKeys: jest.fn(), + }; + const catalogIdentityClient = { + findUser: jest.fn(), + }; + const provider = new GitlabAuthProvider({ clientId: 'mock', clientSecret: 'mock', callbackUrl: 'mock', baseUrl: 'mock', + 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://gitlab.com/lols', + }, + }), + signInResolver: gitlabDefaultSignInResolver, + logger: getVoidLogger(), }); for (const test of tests) { mockFrameHandler.mockResolvedValueOnce(test.input); diff --git a/plugins/auth-backend/src/providers/gitlab/provider.ts b/plugins/auth-backend/src/providers/gitlab/provider.ts index 606d2c76a6..419f00a346 100644 --- a/plugins/auth-backend/src/providers/gitlab/provider.ts +++ b/plugins/auth-backend/src/providers/gitlab/provider.ts @@ -16,6 +16,8 @@ import express from 'express'; import { Strategy as GitlabStrategy } from 'passport-gitlab2'; +import { Logger } from 'winston'; + import { executeRedirectStrategy, executeFrameHandlerStrategy, @@ -24,7 +26,12 @@ import { makeProfileInfo, PassportDoneCallback, } from '../../lib/passport'; -import { RedirectInfo, AuthProviderFactory } from '../types'; +import { + RedirectInfo, + AuthProviderFactory, + SignInResolver, + AuthHandler, +} from '../types'; import { OAuthAdapter, OAuthProviderOptions, @@ -36,10 +43,8 @@ import { encodeState, OAuthResult, } from '../../lib/oauth'; - -type FullProfile = OAuthResult['fullProfile'] & { - avatarUrl?: string; -}; +import { TokenIssuer } from '../../identity'; +import { CatalogIdentityClient } from '../../lib/catalog'; type PrivateInfo = { refreshToken: string; @@ -47,29 +52,54 @@ type PrivateInfo = { export type GitlabAuthProviderOptions = OAuthProviderOptions & { baseUrl: string; + signInResolver?: SignInResolver; + authHandler: AuthHandler; + tokenIssuer: TokenIssuer; + catalogIdentityClient: CatalogIdentityClient; + logger: Logger; }; -function transformProfile(fullProfile: FullProfile) { - const profile = makeProfileInfo({ - ...fullProfile, - photos: [ - ...(fullProfile.photos ?? []), - ...(fullProfile.avatarUrl ? [{ value: fullProfile.avatarUrl }] : []), - ], - }); +export const gitlabDefaultSignInResolver: SignInResolver = async ( + info, + ctx, +) => { + const { profile, result } = info; + + let id = result.fullProfile.id; - let id = fullProfile.id; if (profile.email) { id = profile.email.split('@')[0]; } - return { id, profile }; -} + const token = await ctx.tokenIssuer.issueToken({ + claims: { sub: id, ent: [`user:default/${id}`] }, + }); + + return { id, token }; +}; + +export const gitlabDefaultAuthHandler: AuthHandler = async ({ + fullProfile, + params, +}) => ({ + profile: makeProfileInfo(fullProfile, params.id_token), +}); export class GitlabAuthProvider implements OAuthHandlers { private readonly _strategy: GitlabStrategy; + private readonly signInResolver?: SignInResolver; + private readonly authHandler: AuthHandler; + private readonly tokenIssuer: TokenIssuer; + private readonly catalogIdentityClient: CatalogIdentityClient; + private readonly logger: Logger; constructor(options: GitlabAuthProviderOptions) { + this.catalogIdentityClient = options.catalogIdentityClient; + this.logger = options.logger; + this.tokenIssuer = options.tokenIssuer; + this.authHandler = options.authHandler; + this.signInResolver = options.signInResolver; + this._strategy = new GitlabStrategy( { clientID: options.clientId, @@ -109,23 +139,9 @@ export class GitlabAuthProvider implements OAuthHandlers { OAuthResult, PrivateInfo >(req, this._strategy); - const { accessToken, params } = result; - - const { id, profile } = transformProfile(result.fullProfile); return { - response: { - profile, - providerInfo: { - accessToken, - scope: params.scope, - expiresInSeconds: params.expires_in, - idToken: params.id_token, - }, - backstageIdentity: { - id, - }, - }, + response: await this.handleResult(result), refreshToken: privateInfo.refreshToken, }; } @@ -145,30 +161,78 @@ export class GitlabAuthProvider implements OAuthHandlers { this._strategy, accessToken, ); - const { id, profile } = transformProfile(fullProfile); - return { - profile, + return this.handleResult({ + fullProfile, + params, + accessToken, + refreshToken: newRefreshToken, + }); + } + + private async handleResult(result: OAuthResult): Promise { + const { profile } = await this.authHandler(result); + + const response: OAuthResponse = { providerInfo: { - accessToken, - refreshToken: newRefreshToken, // GitLab expires the old refresh token when used - idToken: params.id_token, - expiresInSeconds: params.expires_in, - scope: params.scope, - }, - backstageIdentity: { - id, + idToken: result.params.id_token, + accessToken: result.accessToken, + scope: result.params.scope, + expiresInSeconds: result.params.expires_in, }, + profile, }; + + if (this.signInResolver) { + response.backstageIdentity = await this.signInResolver( + { + result, + profile, + }, + { + tokenIssuer: this.tokenIssuer, + catalogIdentityClient: this.catalogIdentityClient, + logger: this.logger, + }, + ); + } + + return response; } } -export type GitlabProviderOptions = {}; +export type GitlabProviderOptions = { + /** + * 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 `microsoft.com/email` annotation. + */ + signIn?: { + resolver?: SignInResolver; + }; +}; export const createGitlabProvider = ( - _options?: GitlabProviderOptions, + options?: GitlabProviderOptions, ): AuthProviderFactory => { - return ({ providerId, globalConfig, config, tokenIssuer }) => + return ({ + providerId, + globalConfig, + config, + tokenIssuer, + catalogApi, + logger, + }) => OAuthEnvironmentHandler.mapConfig(config, envConfig => { const clientId = envConfig.getString('clientId'); const clientSecret = envConfig.getString('clientSecret'); @@ -176,11 +240,34 @@ export const createGitlabProvider = ( const baseUrl = audience || 'https://gitlab.com'; const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; + const catalogIdentityClient = new CatalogIdentityClient({ + catalogApi, + tokenIssuer, + }); + + const authHandler: AuthHandler = + options?.authHandler ?? gitlabDefaultAuthHandler; + + const signInResolverFn = + options?.signIn?.resolver ?? gitlabDefaultSignInResolver; + + const signInResolver: SignInResolver = info => + signInResolverFn(info, { + catalogIdentityClient, + tokenIssuer, + logger, + }); + const provider = new GitlabAuthProvider({ clientId, clientSecret, callbackUrl, baseUrl, + authHandler, + signInResolver, + catalogIdentityClient, + logger, + tokenIssuer, }); return OAuthAdapter.fromConfig(globalConfig, provider, { diff --git a/plugins/auth-backend/src/providers/index.ts b/plugins/auth-backend/src/providers/index.ts index 4c093df8ce..133498f342 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 './gitlab'; export * from './google'; export * from './microsoft'; export * from './okta';