diff --git a/plugins/auth-backend/src/providers/bitbucket/index.ts b/plugins/auth-backend/src/providers/bitbucket/index.ts index a863c452d9..d9fcdf3a68 100644 --- a/plugins/auth-backend/src/providers/bitbucket/index.ts +++ b/plugins/auth-backend/src/providers/bitbucket/index.ts @@ -14,5 +14,8 @@ * limitations under the License. */ -export { createBitbucketProvider } from './provider'; +export { + createBitbucketProvider, + bitbucketEmailSignInResolver, +} from './provider'; export type { BitbucketProviderOptions } from './provider'; diff --git a/plugins/auth-backend/src/providers/bitbucket/provider.test.ts b/plugins/auth-backend/src/providers/bitbucket/provider.test.ts new file mode 100644 index 0000000000..7ab803243a --- /dev/null +++ b/plugins/auth-backend/src/providers/bitbucket/provider.test.ts @@ -0,0 +1,92 @@ +/* + * 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 { BitbucketAuthProvider } 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('createBitbucketProvider', () => { + it('should auth', async () => { + const tokenIssuer = { + issueToken: jest.fn(), + listPublicKeys: jest.fn(), + }; + const catalogIdentityClient = { + findUser: jest.fn(), + }; + + const provider = new BitbucketAuthProvider({ + 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/bitbucket/provider.ts b/plugins/auth-backend/src/providers/bitbucket/provider.ts index d902595402..81dddba5d0 100644 --- a/plugins/auth-backend/src/providers/bitbucket/provider.ts +++ b/plugins/auth-backend/src/providers/bitbucket/provider.ts @@ -15,108 +15,297 @@ */ import express from 'express'; +import passport, { Profile as PassportProfile } from 'passport'; // @ts-ignore passport-bitbucket-oauth2 does not have type definitions import { Strategy as BitbucketStrategy } from 'passport-bitbucket-oauth2'; +import { TokenIssuer } from '../../identity/types'; +import { CatalogIdentityClient, getEntityClaims } from '../../lib/catalog'; import { + encodeState, + OAuthAdapter, + OAuthEnvironmentHandler, + OAuthHandlers, + OAuthProviderOptions, + OAuthRefreshRequest, + OAuthResponse, + OAuthResult, + OAuthStartRequest, +} from '../../lib/oauth'; +import { + executeFetchUserProfileStrategy, executeFrameHandlerStrategy, executeRedirectStrategy, + executeRefreshTokenStrategy, makeProfileInfo, PassportDoneCallback, } from '../../lib/passport'; -import { RedirectInfo, AuthProviderFactory } from '../types'; import { - OAuthAdapter, - OAuthProviderOptions, - OAuthHandlers, - OAuthEnvironmentHandler, - OAuthStartRequest, - encodeState, - OAuthResult, -} from '../../lib/oauth'; + AuthProviderFactory, + AuthHandler, + RedirectInfo, + SignInResolver, +} from '../types'; +import { Logger } from 'winston'; -export type BitbucketAuthProviderOptions = OAuthProviderOptions & { - // extra options +type PrivateInfo = { + refreshToken: string; +}; + +type Options = OAuthProviderOptions & { + signInResolver?: SignInResolver; + authHandler: AuthHandler; + tokenIssuer: TokenIssuer; + catalogIdentityClient: CatalogIdentityClient; + logger: Logger; +}; + +export type BitbucketOAuthResult = { + fullProfile: BitbucketPassportProfile; + params: { + id_token?: string; + scope: string; + expires_in: number; + }; + accessToken: string; + refreshToken?: string; +}; + +export type BitbucketPassportProfile = PassportProfile & { + avatarUrl?: string; + _json?: { + links?: { + avatar?: { + href?: string; + }; + }; + }; }; export class BitbucketAuthProvider implements OAuthHandlers { private readonly _strategy: BitbucketStrategy; + private readonly signInResolver?: SignInResolver; + private readonly authHandler: AuthHandler; + private readonly tokenIssuer: TokenIssuer; + private readonly catalogIdentityClient: CatalogIdentityClient; + private readonly logger: Logger; - constructor(options: BitbucketAuthProviderOptions) { + constructor(options: Options) { + this.signInResolver = options.signInResolver; + this.authHandler = options.authHandler; + this.tokenIssuer = options.tokenIssuer; + this.catalogIdentityClient = options.catalogIdentityClient; + this.logger = options.logger; this._strategy = new BitbucketStrategy( { clientID: options.clientId, clientSecret: options.clientSecret, callbackURL: options.callbackUrl, + // We need passReqToCallback set to false to get params, but there's + // no matching type signature for that, so instead behold this beauty + passReqToCallback: false as true, }, ( accessToken: any, - _refreshToken: any, + refreshToken: any, params: any, - fullProfile: any, - done: PassportDoneCallback, + fullProfile: passport.Profile, + done: PassportDoneCallback, ) => { - done(undefined, { fullProfile, params, accessToken }); + done( + undefined, + { + fullProfile, + params, + accessToken, + refreshToken, + }, + { + refreshToken, + }, + ); }, ); } async start(req: OAuthStartRequest): Promise { return await executeRedirectStrategy(req, this._strategy, { + accessType: 'offline', + prompt: 'consent', scope: req.scope, state: encodeState(req.state), }); } - async handler(req: express.Request) { - const { - result: { fullProfile, accessToken, params }, - } = await executeFrameHandlerStrategy(req, this._strategy); - - const profile = makeProfileInfo( - { - ...fullProfile, - id: fullProfile.username || fullProfile.id, - displayName: - fullProfile.displayName || fullProfile.username || fullProfile.id, - }, - params.id_token, - ); + async handler( + req: express.Request, + ): Promise<{ response: OAuthResponse; refreshToken: string }> { + const { result, privateInfo } = await executeFrameHandlerStrategy< + OAuthResult, + PrivateInfo + >(req, this._strategy); return { - response: { - profile, - providerInfo: { - accessToken, - scope: params.scope, - expiresInSeconds: params.expires_in, - }, - backstageIdentity: { - id: fullProfile.username || fullProfile.id, - }, - }, + response: await this.handleResult(result), + refreshToken: privateInfo.refreshToken, }; } + + async refresh(req: OAuthRefreshRequest): Promise { + const { accessToken, params } = await executeRefreshTokenStrategy( + this._strategy, + req.refreshToken, + req.scope, + ); + const fullProfile = await executeFetchUserProfileStrategy( + this._strategy, + accessToken, + ); + return this.handleResult({ + fullProfile, + params, + accessToken, + refreshToken: req.refreshToken, + }); + } + + private async handleResult(result: BitbucketOAuthResult) { + result.fullProfile.avatarUrl = + result.fullProfile._json!.links!.avatar!.href; + const { profile } = await this.authHandler(result); + + const response: OAuthResponse = { + providerInfo: { + 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 BitbucketProviderOptions = {}; +export const bitbucketEmailSignInResolver: SignInResolver = async ( + info, + ctx, +) => { + const { profile } = info; -export const createBitbucketProvider = (): AuthProviderFactory => { - return ({ providerId, globalConfig, config, tokenIssuer }) => + if (!profile.email) { + throw new Error('Bitbucket profile contained no email'); + } + + const entity = await ctx.catalogIdentityClient.findUser({ + annotations: { + 'bitbucket/email': profile.email, + }, + }); + + const claims = getEntityClaims(entity); + const token = await ctx.tokenIssuer.issueToken({ claims }); + + return { id: entity.metadata.name, entity, token }; +}; + +const bitbucketDefaultSignInResolver: SignInResolver = async ( + info, + ctx, +) => { + const { fullProfile } = info.result; + + const userId = fullProfile.username || fullProfile.id; + + const token = await ctx.tokenIssuer.issueToken({ + claims: { sub: userId, ent: [`user:default/${userId}`] }, + }); + + return { id: userId, token }; +}; + +export type BitbucketProviderOptions = { + /** + * 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. + */ + signIn?: { + /** + * Maps an auth result to a Backstage identity for the user. + */ + resolver?: SignInResolver; + }; +}; + +export const createBitbucketProvider = ( + options?: BitbucketProviderOptions, +): AuthProviderFactory => { + return ({ + providerId, + globalConfig, + config, + 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 ?? bitbucketDefaultSignInResolver; + + const signInResolver: SignInResolver = info => + signInResolverFn(info, { + catalogIdentityClient, + tokenIssuer, + logger, + }); + const provider = new BitbucketAuthProvider({ clientId, clientSecret, callbackUrl, + signInResolver, + authHandler, + tokenIssuer, + catalogIdentityClient, + logger, }); return OAuthAdapter.fromConfig(globalConfig, provider, { - disableRefresh: true, - persistScopes: true, + disableRefresh: false, providerId, tokenIssuer, });