diff --git a/.changeset/gold-wombats-shop.md b/.changeset/gold-wombats-shop.md new file mode 100644 index 0000000000..f29ed9cce1 --- /dev/null +++ b/.changeset/gold-wombats-shop.md @@ -0,0 +1,12 @@ +--- +'@backstage/core-app-api': patch +'@backstage/core-plugin-api': patch +'@backstage/plugin-auth-backend': patch +'@backstage/plugin-user-settings': patch +--- + +Bitbucket Cloud authentication - based on the existing GitHub authentication + changes around BB apis and updated scope. + +- BitbucketAuth added to core-app-api. +- Bitbucket provider added to plugin-auth-backend. +- Cosmetic entry for Bitbucket connection in user-settings Authentication Providers tab. diff --git a/app-config.yaml b/app-config.yaml index 65761bc07e..c24ac1701b 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -360,6 +360,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/docs/auth/bitbucket/provider.md b/docs/auth/bitbucket/provider.md new file mode 100644 index 0000000000..ae09d5dbee --- /dev/null +++ b/docs/auth/bitbucket/provider.md @@ -0,0 +1,52 @@ +--- +id: provider +title: Bitbucket Authentication Provider +sidebar_label: Bitbucket +description: Adding Bitbucket OAuth as an authentication provider in Backstage +--- + +The Backstage `core-plugin-api` package comes with a Bitbucket authentication +provider that can authenticate users using Bitbucket Cloud. This does **NOT** +work with Bitbucket Server. + +## Create an OAuth Consumer in Bitbucket + +To add Bitbucket Cloud authentication, you must create an OAuth Consumer. + +Go to `https://bitbucket.org//workspace/settings/api` . + +Click Add Consumer. + +Settings for local development: + +- Application name: Backstage (or your custom app name) +- Callback URL: `http://localhost:7000/api/auth/bitbucket` +- Other are optional +- (IMPORTANT) **Permissions: Account - Read, Workspace membership - Read** + +## Configuration + +The provider configuration can then be added to your `app-config.yaml` under the +root `auth` configuration: + +```yaml +auth: + environment: development + providers: + bitbucket: + development: + clientId: ${AUTH_BITBUCKET_CLIENT_ID} + clientSecret: ${AUTH_BITBUCKET_CLIENT_SECRET} +``` + +The Bitbucket provider is a structure with two configuration keys: + +- `clientId`: The Key that you generated in Bitbucket, e.g. + `b59241722e3c3b4816e2` +- `clientSecret`: The Secret tied to the generated Key. + +## Adding the provider to the Backstage frontend + +To add the provider to the frontend, add the `bitbucketAuthApi` reference and +`SignInPage` component as shown in +[Adding the provider to the sign-in page](../index.md#adding-the-provider-to-the-sign-in-page). diff --git a/mkdocs.yml b/mkdocs.yml index 88bf2d2bda..366411c7c4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -140,6 +140,7 @@ nav: - Google: 'auth/google/provider.md' - Okta: 'auth/okta/provider.md' - OneLogin: 'auth/onelogin/provider.md' + - Bitbucket: 'auth/bitbucket/provider.md' - Adding authentication providers: 'auth/add-auth-provider.md' - Using authentication and identity: 'auth/using-auth.md' - Sign in resolvers: 'auth/identity-resolver.md' diff --git a/packages/app/src/identityProviders.ts b/packages/app/src/identityProviders.ts index 4cc7bab91a..49204d2b5e 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', + apiRef: bitbucketAuthApiRef, + }, ]; diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index 2f057b329f..22d5092aa6 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -23,6 +23,7 @@ import { AuthRequestOptions } from '@backstage/core-plugin-api'; import { BackstageIdentity } from '@backstage/core-plugin-api'; import { BackstageIdentityApi } from '@backstage/core-plugin-api'; import { BackstagePlugin } from '@backstage/core-plugin-api'; +import { bitbucketAuthApiRef } from '@backstage/core-plugin-api'; import { ComponentType } from 'react'; import { ConfigReader } from '@backstage/config'; import { DiscoveryApi } from '@backstage/core-plugin-api'; @@ -271,6 +272,33 @@ export type BackstagePluginWithAnyOutput = Omit< output(): (PluginOutput | UnknownPluginOutput)[]; }; +// Warning: (ae-missing-release-tag) "BitbucketAuth" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export class BitbucketAuth { + // (undocumented) + static create({ + discoveryApi, + environment, + provider, + oauthRequestApi, + defaultScopes, + }: OAuthApiCreateOptions): typeof bitbucketAuthApiRef.T; +} + +// Warning: (ae-missing-release-tag) "BitbucketSession" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type BitbucketSession = { + providerInfo: { + accessToken: string; + scopes: Set; + expiresAt?: Date; + }; + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; + // Warning: (ae-missing-release-tag) "BootErrorPageProps" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) 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..f4bd7cbe85 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/bitbucket/BitbucketAuth.test.ts @@ -0,0 +1,47 @@ +/* + * 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 MockOAuthApi from '../../OAuthRequestApi/MockOAuthApi'; +import { UrlPatternDiscovery } from '../../DiscoveryApi'; +import BitbucketAuth from './BitbucketAuth'; + +const getSession = jest.fn(); + +jest.mock('../../../../lib/AuthSessionManager', () => ({ + ...(jest.requireActual('../../../../lib/AuthSessionManager') as any), + RefreshingAuthSessionManager: class { + getSession = getSession; + }, +})); + +describe('BitbucketAuth', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + it.each([ + ['team api write_repository', ['team', 'api', 'write_repository']], + ['read_repository sudo', ['read_repository', 'sudo']], + ])(`should normalize scopes correctly - %p`, (scope, scopes) => { + const gitlabAuth = BitbucketAuth.create({ + oauthRequestApi: new MockOAuthApi(), + discoveryApi: UrlPatternDiscovery.compile('http://example.com'), + }); + + gitlabAuth.getAccessToken(scope); + expect(getSession).toHaveBeenCalledWith({ scopes: new Set(scopes) }); + }); +}); 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..c88db2a2b0 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/bitbucket/BitbucketAuth.ts @@ -0,0 +1,61 @@ +/* + * 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 { + BackstageIdentity, + bitbucketAuthApiRef, + ProfileInfo, +} from '@backstage/core-plugin-api'; + +import { OAuthApiCreateOptions } from '../types'; +import { OAuth2 } from '../oauth2'; + +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 { + static create({ + discoveryApi, + environment = 'development', + provider = DEFAULT_PROVIDER, + oauthRequestApi, + defaultScopes = ['team'], + }: OAuthApiCreateOptions): typeof bitbucketAuthApiRef.T { + return OAuth2.create({ + discoveryApi, + oauthRequestApi, + provider, + environment, + defaultScopes, + }); + } +} + +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 d0f7786b4c..5e850f4cce 100644 --- a/packages/core-app-api/src/app/defaultApis.ts +++ b/packages/core-app-api/src/app/defaultApis.ts @@ -26,6 +26,7 @@ import { GitlabAuth, Auth0Auth, MicrosoftAuth, + BitbucketAuth, OAuthRequestManager, WebStorage, UrlPatternDiscovery, @@ -53,6 +54,7 @@ import { samlAuthApiRef, oneloginAuthApiRef, oidcAuthApiRef, + bitbucketAuthApiRef, } from '@backstage/core-plugin-api'; import OAuth2Icon from '@material-ui/icons/AcUnit'; @@ -227,4 +229,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/api-report.md b/packages/core-plugin-api/api-report.md index 48ac4f7156..4d1fe4fffd 100644 --- a/packages/core-plugin-api/api-report.md +++ b/packages/core-plugin-api/api-report.md @@ -294,6 +294,13 @@ export type BackstagePlugin< externalRoutes: ExternalRoutes; }; +// Warning: (ae-missing-release-tag) "bitbucketAuthApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export const bitbucketAuthApiRef: ApiRef< + OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi +>; + // Warning: (ae-missing-release-tag) "BootErrorPageProps" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) 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/api-report.md b/plugins/auth-backend/api-report.md index 5dfdd3df16..3834388db9 100644 --- a/plugins/auth-backend/api-report.md +++ b/plugins/auth-backend/api-report.md @@ -84,6 +84,64 @@ export type BackstageIdentity = { entity?: Entity; }; +// Warning: (ae-missing-release-tag) "BitbucketOAuthResult" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type BitbucketOAuthResult = { + fullProfile: BitbucketPassportProfile; + params: { + id_token?: string; + scope: string; + expires_in: number; + }; + accessToken: string; + refreshToken?: string; +}; + +// Warning: (ae-missing-release-tag) "BitbucketPassportProfile" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type BitbucketPassportProfile = Profile & { + id?: string; + displayName?: string; + username?: string; + avatarUrl?: string; + _json?: { + links?: { + avatar?: { + href?: string; + }; + }; + }; +}; + +// Warning: (ae-missing-release-tag) "BitbucketProviderOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type BitbucketProviderOptions = { + authHandler?: AuthHandler; + signIn: { + resolver: SignInResolver; + }; +}; + +// Warning: (ae-missing-release-tag) "bitbucketUserIdSignInResolver" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const bitbucketUserIdSignInResolver: SignInResolver; + +// Warning: (ae-missing-release-tag) "bitbucketUsernameSignInResolver" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const bitbucketUsernameSignInResolver: SignInResolver; + +// Warning: (ae-missing-release-tag) "createBitbucketProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const createBitbucketProvider: ( + options: BitbucketProviderOptions, +) => AuthProviderFactory; + // Warning: (ae-missing-release-tag) "createGithubProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -468,8 +526,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/github/provider.d.ts:50:5 - (ae-forgotten-export) The symbol "AuthHandler" needs to be exported by the entry point index.d.ts -// src/providers/github/provider.d.ts:58:9 - (ae-forgotten-export) The symbol "SignInResolver" needs to be exported by the entry point index.d.ts +// src/providers/bitbucket/provider.d.ts:61:5 - (ae-forgotten-export) The symbol "AuthHandler" needs to be exported by the entry point index.d.ts +// src/providers/bitbucket/provider.d.ts:69: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/package.json b/plugins/auth-backend/package.json index 1050ba46e8..10275c9095 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..55a5735655 --- /dev/null +++ b/plugins/auth-backend/src/providers/bitbucket/index.ts @@ -0,0 +1,26 @@ +/* + * 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, + bitbucketUsernameSignInResolver, + bitbucketUserIdSignInResolver, +} from './provider'; +export type { + BitbucketProviderOptions, + BitbucketPassportProfile, + BitbucketOAuthResult, +} 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..690729a5fb --- /dev/null +++ b/plugins/auth-backend/src/providers/bitbucket/provider.test.ts @@ -0,0 +1,98 @@ +/* + * 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, BitbucketOAuthResult } from './provider'; +import * as helpers from '../../lib/passport/PassportStrategyHelper'; +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: BitbucketOAuthResult; 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: { + _json: { + links: { + avatar: { + href: 'https://a1cf74336522e87f135f-2f21ace9a6cf0052456644b80fa06d4f.ssl.cf2.rackcdn.com/images/characters_opt/p-mystic-river-sean-penn.jpg', + }, + }, + }, + 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 new file mode 100644 index 0000000000..352e0fc116 --- /dev/null +++ b/plugins/auth-backend/src/providers/bitbucket/provider.ts @@ -0,0 +1,316 @@ +/* + * 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'; +import passport, { Profile as PassportProfile } from 'passport'; +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 { + AuthProviderFactory, + AuthHandler, + RedirectInfo, + SignInResolver, +} from '../types'; +import { Logger } from 'winston'; + +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 & { + id?: string; + displayName?: string; + username?: string; + 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: 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, + params: any, + fullProfile: passport.Profile, + done: PassportDoneCallback, + ) => { + 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, + ): Promise<{ response: OAuthResponse; refreshToken: string }> { + const { result, privateInfo } = await executeFrameHandlerStrategy< + OAuthResult, + PrivateInfo + >(req, this._strategy); + + return { + 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 const bitbucketUsernameSignInResolver: SignInResolver = + async (info, ctx) => { + const { result } = info; + + if (!result.fullProfile.username) { + throw new Error('Bitbucket profile contained no Username'); + } + + const entity = await ctx.catalogIdentityClient.findUser({ + annotations: { + 'bitbucket.org/username': result.fullProfile.username, + }, + }); + + const claims = getEntityClaims(entity); + const token = await ctx.tokenIssuer.issueToken({ claims }); + + return { id: entity.metadata.name, entity, token }; + }; + +export const bitbucketUserIdSignInResolver: SignInResolver = + async (info, ctx) => { + const { result } = info; + + if (!result.fullProfile.id) { + throw new Error('Bitbucket profile contained no User ID'); + } + + const entity = await ctx.catalogIdentityClient.findUser({ + annotations: { + 'bitbucket.org/user-id': result.fullProfile.id, + }, + }); + + const claims = getEntityClaims(entity); + const token = await ctx.tokenIssuer.issueToken({ claims }); + + return { id: entity.metadata.name, entity, 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 signInResolver: SignInResolver = info => + options.signIn.resolver(info, { + catalogIdentityClient, + tokenIssuer, + logger, + }); + + const provider = new BitbucketAuthProvider({ + clientId, + clientSecret, + callbackUrl, + signInResolver, + authHandler, + tokenIssuer, + catalogIdentityClient, + logger, + }); + + return OAuthAdapter.fromConfig(globalConfig, provider, { + disableRefresh: false, + providerId, + tokenIssuer, + }); + }); +}; diff --git a/plugins/auth-backend/src/providers/bitbucket/types.d.ts b/plugins/auth-backend/src/providers/bitbucket/types.d.ts new file mode 100644 index 0000000000..70c4c8cba9 --- /dev/null +++ b/plugins/auth-backend/src/providers/bitbucket/types.d.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. + */ +declare module 'passport-bitbucket-oauth2' { + import { StrategyCreated } from 'passport'; + import express = require('express'); + + export class Strategy { + name?: string | undefined; + authenticate( + this: StrategyCreated, + req: express.Request, + options?: any, + ): any; + constructor(options: any, verify: any); + } +} diff --git a/plugins/auth-backend/src/providers/factories.ts b/plugins/auth-backend/src/providers/factories.ts index 070cdb38f3..b8e4b37d0c 100644 --- a/plugins/auth-backend/src/providers/factories.ts +++ b/plugins/auth-backend/src/providers/factories.ts @@ -26,6 +26,10 @@ import { createMicrosoftProvider } from './microsoft'; import { createOneLoginProvider } from './onelogin'; import { AuthProviderFactory } from './types'; import { createAwsAlbProvider } from './aws-alb'; +import { + createBitbucketProvider, + bitbucketUsernameSignInResolver, +} from './bitbucket'; export const factories: { [providerId: string]: AuthProviderFactory } = { google: createGoogleProvider(), @@ -39,4 +43,7 @@ export const factories: { [providerId: string]: AuthProviderFactory } = { oidc: createOidcProvider(), onelogin: createOneLoginProvider(), awsalb: createAwsAlbProvider(), + bitbucket: createBitbucketProvider({ + signIn: { resolver: bitbucketUsernameSignInResolver }, + }), }; diff --git a/plugins/auth-backend/src/providers/index.ts b/plugins/auth-backend/src/providers/index.ts index 1f879b311b..90466a8094 100644 --- a/plugins/auth-backend/src/providers/index.ts +++ b/plugins/auth-backend/src/providers/index.ts @@ -20,6 +20,7 @@ export * from './google'; export * from './microsoft'; export * from './oauth2'; export * from './okta'; +export * from './bitbucket'; export { factories as defaultAuthProviderFactories } from './factories'; 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') && (