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