diff --git a/app-config.yaml b/app-config.yaml index 2fb75f718e..05e0440ac2 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -1,6 +1,6 @@ app: title: Backstage Example App - baseUrl: http://localhost:3000 + baseUrl: http://localhost:7000 googleAnalyticsTrackingId: # UA-000000-0 #datadogRum: # clientToken: '123456789' @@ -32,7 +32,7 @@ backend: cache: store: memory cors: - origin: http://localhost:3000 + origin: http://localhost:7000 methods: [GET, POST, PUT, DELETE] credentials: true csp: @@ -358,6 +358,10 @@ auth: clientId: ${AUTH_ONELOGIN_CLIENT_ID} clientSecret: ${AUTH_ONELOGIN_CLIENT_SECRET} issuer: ${AUTH_ONELOGIN_ISSUER} + bitbucket: + development: + clientId: ${AUTH_BITBUCKET_CLIENT_ID} + clientSecret: ${AUTH_BITBUCKET_CLIENT_SECRET} costInsights: engineerCost: 200000 products: diff --git a/packages/app/src/identityProviders.ts b/packages/app/src/identityProviders.ts index 4cc7bab91a..24e02f61b0 100644 --- a/packages/app/src/identityProviders.ts +++ b/packages/app/src/identityProviders.ts @@ -24,6 +24,7 @@ import { oneloginAuthApiRef, oauth2ApiRef, oidcAuthApiRef, + bitbucketAuthApiRef, } from '@backstage/core-plugin-api'; export const providers = [ @@ -81,4 +82,10 @@ export const providers = [ message: 'Sign In using OneLogin', apiRef: oneloginAuthApiRef, }, + { + id: 'bitbucket-auth-provider', + title: 'Bitbucket', + message: 'Sign In using Bitbucket Cloud', + apiRef: bitbucketAuthApiRef, + }, ]; diff --git a/packages/core-app-api/src/apis/implementations/auth/bitbucket/BitbucketAuth.test.ts b/packages/core-app-api/src/apis/implementations/auth/bitbucket/BitbucketAuth.test.ts new file mode 100644 index 0000000000..97de78f400 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/bitbucket/BitbucketAuth.test.ts @@ -0,0 +1,29 @@ +/* + * 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 BitbucketAuth from './BitbucketAuth'; + +describe('BitbucketAuth', () => { + it('should get access token', async () => { + const getSession = jest + .fn() + .mockResolvedValue({ providerInfo: { accessToken: 'access-token' } }); + const githubAuth = new BitbucketAuth({ getSession } as any); + + expect(await githubAuth.getAccessToken()).toBe('access-token'); + expect(getSession).toBeCalledTimes(1); + }); +}); diff --git a/packages/core-app-api/src/apis/implementations/auth/bitbucket/BitbucketAuth.ts b/packages/core-app-api/src/apis/implementations/auth/bitbucket/BitbucketAuth.ts new file mode 100644 index 0000000000..882f2cbeec --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/bitbucket/BitbucketAuth.ts @@ -0,0 +1,142 @@ +/* + * 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 BitbucketIcon from '@material-ui/icons/FormatBold'; +import { DefaultAuthConnector } from '../../../../lib/AuthConnector'; +import { BitbucketSession } from './types'; +import { + OAuthApi, + SessionApi, + SessionState, + ProfileInfo, + BackstageIdentity, + AuthRequestOptions, + Observable, +} from '@backstage/core-plugin-api'; +import { SessionManager } from '../../../../lib/AuthSessionManager/types'; +import { + AuthSessionStore, + StaticAuthSessionManager, +} from '../../../../lib/AuthSessionManager'; +import { OAuthApiCreateOptions } from '../types'; + +export type BitbucketAuthResponse = { + providerInfo: { + accessToken: string; + scope: string; + expiresInSeconds: number; + }; + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; + +const DEFAULT_PROVIDER = { + id: 'bitbucket', + title: 'Bitbucket', + icon: BitbucketIcon, +}; + +class BitbucketAuth implements OAuthApi, SessionApi { + static create({ + discoveryApi, + environment = 'development', + provider = DEFAULT_PROVIDER, + oauthRequestApi, + defaultScopes = ['team'], + }: OAuthApiCreateOptions) { + const connector = new DefaultAuthConnector({ + discoveryApi, + environment, + provider, + oauthRequestApi: oauthRequestApi, + sessionTransform(res: BitbucketAuthResponse): BitbucketSession { + return { + ...res, + providerInfo: { + accessToken: res.providerInfo.accessToken, + scopes: BitbucketAuth.normalizeScope(res.providerInfo.scope), + expiresAt: new Date( + Date.now() + res.providerInfo.expiresInSeconds * 1000, + ), + }, + }; + }, + }); + + const sessionManager = new StaticAuthSessionManager({ + connector, + defaultScopes: new Set(defaultScopes), + sessionScopes: (session: BitbucketSession) => session.providerInfo.scopes, + }); + + const authSessionStore = new AuthSessionStore({ + manager: sessionManager, + storageKey: `${provider.id}Session`, + sessionScopes: (session: BitbucketSession) => session.providerInfo.scopes, + }); + + return new BitbucketAuth(authSessionStore); + } + + constructor( + private readonly sessionManager: SessionManager, + ) {} + + async signIn() { + await this.getAccessToken(); + } + + async signOut() { + await this.sessionManager.removeSession(); + } + + sessionState$(): Observable { + return this.sessionManager.sessionState$(); + } + + async getAccessToken(scope?: string, options?: AuthRequestOptions) { + const session = await this.sessionManager.getSession({ + ...options, + scopes: BitbucketAuth.normalizeScope(scope), + }); + return session?.providerInfo.accessToken ?? ''; + } + + async getBackstageIdentity( + options: AuthRequestOptions = {}, + ): Promise { + const session = await this.sessionManager.getSession(options); + return session?.backstageIdentity; + } + + async getProfile(options: AuthRequestOptions = {}) { + const session = await this.sessionManager.getSession(options); + return session?.profile; + } + + static normalizeScope(scope?: string): Set { + if (!scope) { + return new Set(); + } + + const scopeList = Array.isArray(scope) + ? scope + : scope.split(/[\s|,]/).filter(Boolean); + + return new Set(scopeList); + } +} +export default BitbucketAuth; diff --git a/packages/core-app-api/src/apis/implementations/auth/bitbucket/index.ts b/packages/core-app-api/src/apis/implementations/auth/bitbucket/index.ts new file mode 100644 index 0000000000..33c6e75b3b --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/bitbucket/index.ts @@ -0,0 +1,18 @@ +/* + * 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. + */ + +export * from './types'; +export { default as BitbucketAuth } from './BitbucketAuth'; diff --git a/packages/core-app-api/src/apis/implementations/auth/bitbucket/types.ts b/packages/core-app-api/src/apis/implementations/auth/bitbucket/types.ts new file mode 100644 index 0000000000..7babfdf686 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/bitbucket/types.ts @@ -0,0 +1,27 @@ +/* + * 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 { ProfileInfo, BackstageIdentity } from '@backstage/core-plugin-api'; + +export type BitbucketSession = { + providerInfo: { + accessToken: string; + scopes: Set; + expiresAt?: Date; + }; + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; diff --git a/packages/core-app-api/src/apis/implementations/auth/index.ts b/packages/core-app-api/src/apis/implementations/auth/index.ts index 9889a33878..b622b90b8c 100644 --- a/packages/core-app-api/src/apis/implementations/auth/index.ts +++ b/packages/core-app-api/src/apis/implementations/auth/index.ts @@ -23,3 +23,4 @@ export * from './saml'; export * from './auth0'; export * from './microsoft'; export * from './onelogin'; +export * from './bitbucket'; diff --git a/packages/core-app-api/src/app/defaultApis.ts b/packages/core-app-api/src/app/defaultApis.ts index 2322f72e8d..6ed9a71265 100644 --- a/packages/core-app-api/src/app/defaultApis.ts +++ b/packages/core-app-api/src/app/defaultApis.ts @@ -25,6 +25,7 @@ import { GitlabAuth, Auth0Auth, MicrosoftAuth, + BitbucketAuth, OAuthRequestManager, WebStorage, UrlPatternDiscovery, @@ -51,6 +52,7 @@ import { samlAuthApiRef, oneloginAuthApiRef, oidcAuthApiRef, + bitbucketAuthApiRef, } from '@backstage/core-plugin-api'; import OAuth2Icon from '@material-ui/icons/AcUnit'; @@ -224,4 +226,19 @@ export const defaultApis = [ environment: configApi.getOptionalString('auth.environment'), }), }), + createApiFactory({ + api: bitbucketAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + BitbucketAuth.create({ + discoveryApi, + oauthRequestApi, + defaultScopes: ['team'], + environment: configApi.getOptionalString('auth.environment'), + }), + }), ]; diff --git a/packages/core-plugin-api/src/apis/definitions/auth.ts b/packages/core-plugin-api/src/apis/definitions/auth.ts index c585619ba5..a78414048c 100644 --- a/packages/core-plugin-api/src/apis/definitions/auth.ts +++ b/packages/core-plugin-api/src/apis/definitions/auth.ts @@ -340,3 +340,15 @@ export const oneloginAuthApiRef: ApiRef< > = createApiRef({ id: 'core.auth.onelogin', }); + +/** + * Provides authentication towards Bitbucket APIs. + * + * See https://support.atlassian.com/bitbucket-cloud/docs/use-oauth-on-bitbucket-cloud/ + * for a full list of supported scopes. + */ +export const bitbucketAuthApiRef: ApiRef< + OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi +> = createApiRef({ + id: 'core.auth.bitbucket', +}); diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 9293aec596..65804f242d 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -58,6 +58,7 @@ "node-cache": "^5.1.2", "openid-client": "^4.2.1", "passport": "^0.4.1", + "passport-bitbucket-oauth2": "^0.1.2", "passport-github2": "^0.1.12", "passport-gitlab2": "^5.0.0", "passport-google-oauth20": "^2.0.0", diff --git a/plugins/auth-backend/src/providers/bitbucket/index.ts b/plugins/auth-backend/src/providers/bitbucket/index.ts new file mode 100644 index 0000000000..a863c452d9 --- /dev/null +++ b/plugins/auth-backend/src/providers/bitbucket/index.ts @@ -0,0 +1,18 @@ +/* + * 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. + */ + +export { createBitbucketProvider } from './provider'; +export type { BitbucketProviderOptions } from './provider'; diff --git a/plugins/auth-backend/src/providers/bitbucket/provider.ts b/plugins/auth-backend/src/providers/bitbucket/provider.ts new file mode 100644 index 0000000000..d902595402 --- /dev/null +++ b/plugins/auth-backend/src/providers/bitbucket/provider.ts @@ -0,0 +1,124 @@ +/* + * 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 express from 'express'; +// @ts-ignore passport-bitbucket-oauth2 does not have type definitions +import { Strategy as BitbucketStrategy } from 'passport-bitbucket-oauth2'; +import { + executeFrameHandlerStrategy, + executeRedirectStrategy, + makeProfileInfo, + PassportDoneCallback, +} from '../../lib/passport'; +import { RedirectInfo, AuthProviderFactory } from '../types'; +import { + OAuthAdapter, + OAuthProviderOptions, + OAuthHandlers, + OAuthEnvironmentHandler, + OAuthStartRequest, + encodeState, + OAuthResult, +} from '../../lib/oauth'; + +export type BitbucketAuthProviderOptions = OAuthProviderOptions & { + // extra options +}; + +export class BitbucketAuthProvider implements OAuthHandlers { + private readonly _strategy: BitbucketStrategy; + + constructor(options: BitbucketAuthProviderOptions) { + this._strategy = new BitbucketStrategy( + { + clientID: options.clientId, + clientSecret: options.clientSecret, + callbackURL: options.callbackUrl, + }, + ( + accessToken: any, + _refreshToken: any, + params: any, + fullProfile: any, + done: PassportDoneCallback, + ) => { + done(undefined, { fullProfile, params, accessToken }); + }, + ); + } + + async start(req: OAuthStartRequest): Promise { + return await executeRedirectStrategy(req, this._strategy, { + 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, + ); + + return { + response: { + profile, + providerInfo: { + accessToken, + scope: params.scope, + expiresInSeconds: params.expires_in, + }, + backstageIdentity: { + id: fullProfile.username || fullProfile.id, + }, + }, + }; + } +} + +export type BitbucketProviderOptions = {}; + +export const createBitbucketProvider = (): AuthProviderFactory => { + return ({ providerId, globalConfig, config, tokenIssuer }) => + OAuthEnvironmentHandler.mapConfig(config, envConfig => { + const clientId = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + + const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; + + const provider = new BitbucketAuthProvider({ + clientId, + clientSecret, + callbackUrl, + }); + + return OAuthAdapter.fromConfig(globalConfig, provider, { + disableRefresh: true, + persistScopes: true, + providerId, + tokenIssuer, + }); + }); +}; diff --git a/plugins/auth-backend/src/providers/factories.ts b/plugins/auth-backend/src/providers/factories.ts index 070cdb38f3..c4894f2757 100644 --- a/plugins/auth-backend/src/providers/factories.ts +++ b/plugins/auth-backend/src/providers/factories.ts @@ -26,6 +26,7 @@ import { createMicrosoftProvider } from './microsoft'; import { createOneLoginProvider } from './onelogin'; import { AuthProviderFactory } from './types'; import { createAwsAlbProvider } from './aws-alb'; +import { createBitbucketProvider } from './bitbucket'; export const factories: { [providerId: string]: AuthProviderFactory } = { google: createGoogleProvider(), @@ -39,4 +40,5 @@ export const factories: { [providerId: string]: AuthProviderFactory } = { oidc: createOidcProvider(), onelogin: createOneLoginProvider(), awsalb: createAwsAlbProvider(), + bitbucket: createBitbucketProvider(), }; diff --git a/plugins/user-settings/src/components/AuthProviders/DefaultProviderSettings.tsx b/plugins/user-settings/src/components/AuthProviders/DefaultProviderSettings.tsx index 91438e5a5d..4b9cf9c783 100644 --- a/plugins/user-settings/src/components/AuthProviders/DefaultProviderSettings.tsx +++ b/plugins/user-settings/src/components/AuthProviders/DefaultProviderSettings.tsx @@ -24,6 +24,7 @@ import { oauth2ApiRef, oktaAuthApiRef, microsoftAuthApiRef, + bitbucketAuthApiRef, } from '@backstage/core-plugin-api'; type Props = { @@ -80,6 +81,14 @@ export const DefaultProviderSettings = ({ configuredProviders }: Props) => ( icon={Star} /> )} + {configuredProviders.includes('bitbucket') && ( + + )} {configuredProviders.includes('oauth2') && (