diff --git a/plugins/auth-backend/src/lib/passport/PassportStrategyHelper.ts b/plugins/auth-backend/src/lib/passport/PassportStrategyHelper.ts index 313b4a79bd..e6ef47c2c0 100644 --- a/plugins/auth-backend/src/lib/passport/PassportStrategyHelper.ts +++ b/plugins/auth-backend/src/lib/passport/PassportStrategyHelper.ts @@ -30,7 +30,7 @@ export const makeProfileInfo = ( profile: passport.Profile, idToken?: string, ): ProfileInfo => { - let { displayName } = profile; + let { displayName, username } = profile; let email: string | undefined = undefined; if (profile.emails && profile.emails.length > 0) { @@ -56,6 +56,10 @@ export const makeProfileInfo = ( if (!displayName && decoded.name) { displayName = decoded.name; } + + if (!username && decoded.username) { + username = decoded.username; + } } catch (e) { throw new Error(`Failed to parse id token and get profile info, ${e}`); } @@ -65,6 +69,7 @@ export const makeProfileInfo = ( email, picture, displayName, + username, }; }; diff --git a/plugins/auth-backend/src/providers/gitlab/provider.ts b/plugins/auth-backend/src/providers/gitlab/provider.ts index 606d2c76a6..0fa99252b7 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,6 +43,8 @@ import { encodeState, OAuthResult, } from '../../lib/oauth'; +import { TokenIssuer } from '../../identity'; +import { CatalogIdentityClient } from '../../lib/catalog'; type FullProfile = OAuthResult['fullProfile'] & { avatarUrl?: string; @@ -47,29 +56,72 @@ type PrivateInfo = { export type GitlabAuthProviderOptions = OAuthProviderOptions & { baseUrl: string; + signInResolver?: SignInResolver; + authHandler: AuthHandler; + tokenIssuer: TokenIssuer; + catalogIdentityClient: CatalogIdentityClient; + logger: Logger; }; -function transformProfile(fullProfile: FullProfile) { +function transformResult(result: OAuthResult): OAuthResult { + const { fullProfile, ...authResult } = result; + const profile = makeProfileInfo({ ...fullProfile, photos: [ ...(fullProfile.photos ?? []), - ...(fullProfile.avatarUrl ? [{ value: fullProfile.avatarUrl }] : []), + ...((fullProfile as FullProfile).avatarUrl + ? [{ value: (fullProfile as FullProfile).avatarUrl as string }] + : []), ], }); let id = fullProfile.id; + if (profile.email) { id = profile.email.split('@')[0]; } - return { id, profile }; + return { + ...authResult, + fullProfile, + }; } +export const gitlabDefaultSignInResolver: SignInResolver = async ( + info, + ctx, +) => { + const { profile } = info; + + if (!profile.email) { + throw new Error('Profile contained no email'); + } + + const userId = profile.email.split('@')[0]; + + const token = await ctx.tokenIssuer.issueToken({ + claims: { sub: userId, ent: [`user:default/${userId}`] }, + }); + + return { id: userId, 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.signInResolver = options.signInResolver; + this.authHandler = options.authHandler; + this.tokenIssuer = options.tokenIssuer; + this.logger = options.logger; + this.catalogIdentityClient = options.catalogIdentityClient; + this._strategy = new GitlabStrategy( { clientID: options.clientId, @@ -109,23 +161,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 +183,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(transformResult(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 +262,37 @@ 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 + ? options.authHandler + : async ({ fullProfile, params }) => ({ + profile: makeProfileInfo(fullProfile, params.id_token), + }); + + 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 6b262dcf68..bf5615a72e 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 { factories as defaultAuthProviderFactories } from './factories'; diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index c838005c00..a8b087156d 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -196,6 +196,10 @@ export type ProfileInfo = { * signed in user. */ picture?: string; + /** + * Username of the signed in user. + */ + username?: string; }; export type SignInInfo = {