diff --git a/.changeset/pretty-jars-peel.md b/.changeset/pretty-jars-peel.md
new file mode 100644
index 0000000000..639d4565ed
--- /dev/null
+++ b/.changeset/pretty-jars-peel.md
@@ -0,0 +1,9 @@
+---
+'@backstage/core-plugin-api': minor
+'@backstage/app-defaults': minor
+'@backstage/core-app-api': minor
+'@backstage/plugin-user-settings': minor
+'@backstage/plugin-auth-backend': minor
+---
+
+Added a Bitbucket Server Auth Provider and added its API to the app defaults
diff --git a/docs/auth/bitbucket/provider.md b/docs/auth/bitbucket/provider.md
index 36859dab45..b03201893c 100644
--- a/docs/auth/bitbucket/provider.md
+++ b/docs/auth/bitbucket/provider.md
@@ -61,7 +61,7 @@ how this is done. Note that for the Bitbucket provider, you'll want to use
factory.
The `@backstage/plugin-auth-backend` plugin also comes with two built-in
-resolves that can be used if desired. The first one is the
+resolvers that can be used if desired. The first one is the
`bitbucketUsernameSignInResolver`, which identifies users by matching their
Bitbucket username to `bitbucket.org/username` annotations of `User` entities in
the catalog. Note that you must populate your catalog with matching entities or
diff --git a/docs/auth/bitbucketServer/provider.md b/docs/auth/bitbucketServer/provider.md
new file mode 100644
index 0000000000..96c7db263e
--- /dev/null
+++ b/docs/auth/bitbucketServer/provider.md
@@ -0,0 +1,52 @@
+---
+id: provider
+title: Bitbucket Server Authentication Provider
+sidebar_label: Bitbucket Server
+description: Adding Bitbucket Server OAuth as an authentication provider in Backstage
+---
+
+The Backstage `core-plugin-api` package comes with a Bitbucket Server authentication provider that can authenticate
+users using Bitbucket Server. This does **NOT** work with Bitbucket Cloud.
+
+## Create an Application Link in Bitbucket Server
+
+To add Bitbucket Server authentication, you must create an outgoing application link. Follow the steps described in
+the [Bitbucket Server documentation](https://confluence.atlassian.com/bitbucketserver/configure-an-outgoing-link-1108483656.html)
+to create one.
+
+## Configuration
+
+The provider configuration can then be added to your `app-config.yaml` under the root `auth` configuration:
+
+```yaml
+auth:
+ environment: development
+ providers:
+ bitbucketServer:
+ development:
+ host: bitbucket.org
+ clientId: ${AUTH_BITBUCKET_SERVER_CLIENT_ID}
+ clientSecret: ${AUTH_BITBUCKET_SERVER_CLIENT_SECRET}
+```
+
+The Bitbucket Server provider is a structure with two configuration keys:
+
+- `clientId`: The client ID that was generated by Bitbucket, e.g. `b0f868455c15dcdff5c5fb5d173ae684`.
+- `clientSecret`: The client secret tied to the generated client ID.
+
+## Adding the provider to the Backstage frontend
+
+To add the provider to the frontend, add the `bitbucketServerAuthApi` 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).
+
+## Using Bitbucket Server for sign-in
+
+In order to use the Bitbucket Server provider for sign-in, you must configure it with a `signIn.resolver`. See
+the [Sign-In Resolver documentation](../identity-resolver.md) for more details on how this is done. Note that for the
+Bitbucket Server provider, you'll want to use `bitbucketServer` as the provider ID,
+and `providers.bitbucketServer.create` for the provider factory.
+
+The `@backstage/plugin-auth-backend` plugin also comes with a built-in resolver that can be used if desired.
+The `emailMatchingUserEntityProfileEmail` identifies users by matching their Bitbucket Server email address to the email
+address of `User` entities in the catalog. Note that you must populate your catalog with matching entities or users will
+not be able to sign in with this resolver.
diff --git a/docs/auth/index.md b/docs/auth/index.md
index fa185c0c23..8e15830aa8 100644
--- a/docs/auth/index.md
+++ b/docs/auth/index.md
@@ -18,6 +18,7 @@ Backstage comes with many common authentication providers in the core library:
- [Auth0](auth0/provider.md)
- [Azure](microsoft/provider.md)
- [Bitbucket](bitbucket/provider.md)
+- [Bitbucket Server](bitbucketServer/provider.md)
- [Cloudflare Access](cloudflare/access.md)
- [GitHub](github/provider.md)
- [GitLab](gitlab/provider.md)
diff --git a/packages/app-defaults/src/defaults/apis.ts b/packages/app-defaults/src/defaults/apis.ts
index 3f5cfc1c58..d3ebddfb84 100644
--- a/packages/app-defaults/src/defaults/apis.ts
+++ b/packages/app-defaults/src/defaults/apis.ts
@@ -25,6 +25,7 @@ import {
GitlabAuth,
MicrosoftAuth,
BitbucketAuth,
+ BitbucketServerAuth,
OAuthRequestManager,
WebStorage,
UrlPatternDiscovery,
@@ -53,6 +54,7 @@ import {
configApiRef,
oneloginAuthApiRef,
bitbucketAuthApiRef,
+ bitbucketServerAuthApiRef,
atlassianAuthApiRef,
} from '@backstage/core-plugin-api';
import {
@@ -219,6 +221,19 @@ export const apis = [
environment: configApi.getOptionalString('auth.environment'),
}),
}),
+ createApiFactory({
+ api: bitbucketServerAuthApiRef,
+ deps: {
+ discoveryApi: discoveryApiRef,
+ oauthRequestApi: oauthRequestApiRef,
+ },
+ factory: ({ discoveryApi, oauthRequestApi }) =>
+ BitbucketServerAuth.create({
+ discoveryApi,
+ oauthRequestApi,
+ defaultScopes: ['REPO_READ'],
+ }),
+ }),
createApiFactory({
api: atlassianAuthApiRef,
deps: {
diff --git a/packages/app/src/identityProviders.ts b/packages/app/src/identityProviders.ts
index 5f8c0faf47..66f1460210 100644
--- a/packages/app/src/identityProviders.ts
+++ b/packages/app/src/identityProviders.ts
@@ -22,6 +22,7 @@ import {
microsoftAuthApiRef,
oneloginAuthApiRef,
bitbucketAuthApiRef,
+ bitbucketServerAuthApiRef,
} from '@backstage/core-plugin-api';
export const providers = [
@@ -67,4 +68,10 @@ export const providers = [
message: 'Sign In using Bitbucket',
apiRef: bitbucketAuthApiRef,
},
+ {
+ id: 'bitbucket-server-auth-provider',
+ title: 'Bitbucket Server',
+ message: 'Sign In using Bitbucket Server',
+ apiRef: bitbucketServerAuthApiRef,
+ },
];
diff --git a/packages/backend/src/plugins/auth.ts b/packages/backend/src/plugins/auth.ts
index 8beda6f3fb..0d92315f92 100644
--- a/packages/backend/src/plugins/auth.ts
+++ b/packages/backend/src/plugins/auth.ts
@@ -114,6 +114,13 @@ export default async function createPlugin(
},
}),
+ bitbucketServer: providers.bitbucketServer.create({
+ signIn: {
+ resolver:
+ providers.bitbucketServer.resolvers.emailMatchingUserEntityProfileEmail(),
+ },
+ }),
+
// This is an example of how to configure the OAuth2Proxy provider as well
// as how to sign a user in without a matching user entity in the catalog.
// You can try it out using ``
diff --git a/packages/core-app-api/src/apis/implementations/auth/bitbucketServer/BitbucketServerAuth.test.ts b/packages/core-app-api/src/apis/implementations/auth/bitbucketServer/BitbucketServerAuth.test.ts
new file mode 100644
index 0000000000..32e7755a00
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/auth/bitbucketServer/BitbucketServerAuth.test.ts
@@ -0,0 +1,51 @@
+/*
+ * 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 BitbucketServerAuth from './BitbucketServerAuth';
+
+const getSession = jest.fn();
+
+jest.mock('../../../../lib/AuthSessionManager', () => ({
+ ...(jest.requireActual('../../../../lib/AuthSessionManager') as any),
+ RefreshingAuthSessionManager: class {
+ getSession = getSession;
+ },
+}));
+
+describe('BitbucketServerAuth', () => {
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it.each([
+ ['PUBLIC_REPOS', ['PUBLIC_REPOS']],
+ ['PROJECT_ADMIN REPO_READ', ['PROJECT_ADMIN', 'REPO_READ']],
+ [
+ 'PROJECT_ADMIN REPO_READ ACCOUNT_WRITE',
+ ['PROJECT_ADMIN', 'REPO_READ', 'ACCOUNT_WRITE'],
+ ],
+ ])(`should normalize scopes correctly - %p`, (scope, scopes) => {
+ const bitbucketServerAuth = BitbucketServerAuth.create({
+ oauthRequestApi: new MockOAuthApi(),
+ discoveryApi: UrlPatternDiscovery.compile('http://example.com'),
+ });
+
+ bitbucketServerAuth.getAccessToken(scope);
+ expect(getSession).toHaveBeenCalledWith({ scopes: new Set(scopes) });
+ });
+});
diff --git a/packages/core-app-api/src/apis/implementations/auth/bitbucketServer/BitbucketServerAuth.ts b/packages/core-app-api/src/apis/implementations/auth/bitbucketServer/BitbucketServerAuth.ts
new file mode 100644
index 0000000000..dc50eff6ab
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/auth/bitbucketServer/BitbucketServerAuth.ts
@@ -0,0 +1,66 @@
+/*
+ * 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 {
+ BackstageIdentityResponse,
+ bitbucketServerAuthApiRef,
+ ProfileInfo,
+} from '@backstage/core-plugin-api';
+
+import { OAuthApiCreateOptions } from '../types';
+import { OAuth2 } from '../oauth2';
+
+export type BitbucketServerAuthResponse = {
+ providerInfo: {
+ accessToken: string;
+ scope: string;
+ expiresInSeconds: number;
+ };
+ profile: ProfileInfo;
+ backstageIdentity: BackstageIdentityResponse;
+};
+
+const DEFAULT_PROVIDER = {
+ id: 'bitbucketServer',
+ title: 'Bitbucket Server',
+ icon: () => null,
+};
+
+/**
+ * Implements the OAuth flow to Bitbucket Server.
+ * @public
+ */
+export default class BitbucketServerAuth {
+ static create(
+ options: OAuthApiCreateOptions,
+ ): typeof bitbucketServerAuthApiRef.T {
+ const {
+ discoveryApi,
+ environment = 'development',
+ provider = DEFAULT_PROVIDER,
+ oauthRequestApi,
+ defaultScopes = ['PROJECT_ADMIN'],
+ } = options;
+
+ return OAuth2.create({
+ discoveryApi,
+ oauthRequestApi,
+ provider,
+ environment,
+ defaultScopes,
+ });
+ }
+}
diff --git a/packages/core-app-api/src/apis/implementations/auth/bitbucketServer/index.ts b/packages/core-app-api/src/apis/implementations/auth/bitbucketServer/index.ts
new file mode 100644
index 0000000000..5fcb8f72f2
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/auth/bitbucketServer/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 BitbucketServerAuth } from './BitbucketServerAuth';
diff --git a/packages/core-app-api/src/apis/implementations/auth/bitbucketServer/types.ts b/packages/core-app-api/src/apis/implementations/auth/bitbucketServer/types.ts
new file mode 100644
index 0000000000..1041aa8110
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/auth/bitbucketServer/types.ts
@@ -0,0 +1,35 @@
+/*
+ * 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,
+ BackstageIdentityResponse,
+} from '@backstage/core-plugin-api';
+
+/**
+ * Session information for Bitbucket Server auth.
+ *
+ * @public
+ */
+export type BitbucketServerSession = {
+ providerInfo: {
+ accessToken: string;
+ scopes: Set;
+ expiresAt?: Date;
+ };
+ profile: ProfileInfo;
+ backstageIdentity: BackstageIdentityResponse;
+};
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 c4cf520db5..b54e0424da 100644
--- a/packages/core-app-api/src/apis/implementations/auth/index.ts
+++ b/packages/core-app-api/src/apis/implementations/auth/index.ts
@@ -23,5 +23,6 @@ export * from './saml';
export * from './microsoft';
export * from './onelogin';
export * from './bitbucket';
+export * from './bitbucketServer';
export * from './atlassian';
export type { OAuthApiCreateOptions, AuthApiCreateOptions } from './types';
diff --git a/packages/core-plugin-api/src/apis/definitions/auth.ts b/packages/core-plugin-api/src/apis/definitions/auth.ts
index 163ae74806..43597639b6 100644
--- a/packages/core-plugin-api/src/apis/definitions/auth.ts
+++ b/packages/core-plugin-api/src/apis/definitions/auth.ts
@@ -412,6 +412,21 @@ export const bitbucketAuthApiRef: ApiRef<
id: 'core.auth.bitbucket',
});
+/**
+ * Provides authentication towards Bitbucket Server APIs.
+ *
+ * @public
+ * @remarks
+ *
+ * See {@link https://confluence.atlassian.com/bitbucketserver/bitbucket-oauth-2-0-provider-api-1108483661.html#BitbucketOAuth2.0providerAPI-scopes}
+ * for a full list of supported scopes.
+ */
+export const bitbucketServerAuthApiRef: ApiRef<
+ OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi
+> = createApiRef({
+ id: 'core.auth.bitbucket-server',
+});
+
/**
* Provides authentication towards Atlassian APIs.
*
diff --git a/plugins/auth-backend/src/providers/bitbucketServer/index.ts b/plugins/auth-backend/src/providers/bitbucketServer/index.ts
new file mode 100644
index 0000000000..cef12cd50d
--- /dev/null
+++ b/plugins/auth-backend/src/providers/bitbucketServer/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 { bitbucketServer } from './provider';
+export type { BitbucketServerOAuthResult } from './provider';
diff --git a/plugins/auth-backend/src/providers/bitbucketServer/provider.test.ts b/plugins/auth-backend/src/providers/bitbucketServer/provider.test.ts
new file mode 100644
index 0000000000..127ca71a17
--- /dev/null
+++ b/plugins/auth-backend/src/providers/bitbucketServer/provider.test.ts
@@ -0,0 +1,377 @@
+/*
+ * 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 * as helpers from '../../lib/passport/PassportStrategyHelper';
+import { makeProfileInfo } from '../../lib/passport/PassportStrategyHelper';
+import { AuthResolverContext } from '../types';
+import {
+ BitbucketServerAuthProvider,
+ BitbucketServerOAuthResult,
+} from './provider';
+import { commonByEmailResolver } from '../resolvers';
+
+jest.mock('../../lib/passport/PassportStrategyHelper', () => {
+ return {
+ ...jest.requireActual('../../lib/passport/PassportStrategyHelper'),
+ executeFrameHandlerStrategy: jest.fn(),
+ executeRefreshTokenStrategy: jest.fn(),
+ executeFetchUserProfileStrategy: jest.fn(),
+ };
+});
+
+const mockFrameHandler = jest.spyOn(
+ helpers,
+ 'executeFrameHandlerStrategy',
+) as unknown as jest.MockedFunction<
+ () => Promise<{
+ result: BitbucketServerOAuthResult;
+ privateInfo: { refreshToken?: string };
+ }>
+>;
+
+const passportProfile = {
+ id: '123',
+ username: 'john.doe',
+ provider: 'bitubcketServer',
+ displayName: 'John Doe',
+ emails: [{ value: 'john@doe.com' }],
+ photos: [{ value: 'https://bitbucket.org/user/123/avatar' }],
+};
+
+const mockFetchUserRequests = (
+ failOnWhoAmI: boolean = false,
+ whoAmIValue: string = passportProfile.username,
+ failOnGetUser: boolean = false,
+ getUserOk: boolean = true,
+ avatarUrl: string = '/user/123/avatar',
+ setDisplayName: boolean = true,
+ setUserName: boolean = true,
+) => {
+ const fetchMock = global.fetch as jest.Mock;
+ if (failOnWhoAmI) {
+ fetchMock.mockRejectedValueOnce(() => {});
+ } else {
+ fetchMock.mockResolvedValueOnce({
+ headers: { get: jest.fn(() => whoAmIValue) },
+ });
+ }
+ if (failOnGetUser) {
+ fetchMock.mockRejectedValueOnce(() => {});
+ } else {
+ fetchMock.mockResolvedValueOnce({
+ ok: getUserOk,
+ json: () => ({
+ name: setUserName ? 'john.doe' : undefined,
+ emailAddress: 'john@doe.com',
+ id: 123,
+ displayName: setDisplayName ? 'John Doe' : undefined,
+ active: true,
+ slug: 'john.doe',
+ type: 'NORMAL',
+ links: {
+ self: [
+ {
+ href: 'https://bitbucket.org/users/john.doe',
+ },
+ ],
+ },
+ avatarUrl: avatarUrl,
+ }),
+ });
+ }
+};
+
+describe('BitbucketServerAuthProvider', () => {
+ const originalFetch = global.fetch;
+
+ beforeEach(() => {
+ global.fetch = jest.fn();
+ });
+
+ afterEach(() => {
+ global.fetch = originalFetch;
+ });
+
+ const provider = new BitbucketServerAuthProvider({
+ resolverContext: {
+ signInWithCatalogUser: jest.fn(info => {
+ return {
+ token: `token-for-user:${info.filter['spec.profile.email']}`,
+ };
+ }),
+ } as unknown as AuthResolverContext,
+ signInResolver: commonByEmailResolver,
+ authHandler: async ({ fullProfile }) => ({
+ profile: makeProfileInfo(fullProfile),
+ }),
+ callbackUrl: 'mock',
+ clientId: 'mock',
+ clientSecret: 'mock',
+ host: 'bitbucket.org',
+ authorizationUrl: 'mock',
+ tokenUrl: 'mock',
+ });
+
+ describe('when transforming to type OAuthResponse', () => {
+ it('should map to a valid response', async () => {
+ mockFetchUserRequests();
+ const accessToken = '19xasczxcm9n7gacn9jdgm19me';
+ const params = { scope: 'REPO_READ' };
+
+ const expected = {
+ backstageIdentity: {
+ token: 'token-for-user:john@doe.com',
+ },
+ providerInfo: {
+ accessToken: '19xasczxcm9n7gacn9jdgm19me',
+ scope: 'REPO_READ',
+ },
+ profile: {
+ email: 'john@doe.com',
+ displayName: 'John Doe',
+ picture: 'https://bitbucket.org/user/123/avatar',
+ },
+ };
+
+ mockFrameHandler.mockResolvedValueOnce({
+ result: { fullProfile: passportProfile, accessToken, params },
+ privateInfo: {},
+ });
+ const { response } = await provider.handler({} as any);
+ expect(response).toEqual(expected);
+ });
+ it('should throw if whoami fails', async () => {
+ mockFetchUserRequests(true);
+ const accessToken = '19xasczxcm9n7gacn9jdgm19me';
+ const params = { scope: 'REPO_READ' };
+ mockFrameHandler.mockResolvedValueOnce({
+ result: { fullProfile: passportProfile, accessToken, params },
+ privateInfo: {},
+ });
+
+ await expect(provider.handler({} as any)).rejects.toThrow(
+ `Failed to retrieve the username of the logged in user`,
+ );
+ });
+ it('should throw if whoami returns an invalid response', async () => {
+ mockFetchUserRequests(false, '');
+ const accessToken = '19xasczxcm9n7gacn9jdgm19me';
+ const params = { scope: 'REPO_READ' };
+ mockFrameHandler.mockResolvedValueOnce({
+ result: { fullProfile: passportProfile, accessToken, params },
+ privateInfo: {},
+ });
+
+ await expect(provider.handler({} as any)).rejects.toThrow(
+ `Failed to retrieve the username of the logged in user`,
+ );
+ });
+ it('should throw if get user fails', async () => {
+ mockFetchUserRequests(false, passportProfile.username, true);
+ const accessToken = '19xasczxcm9n7gacn9jdgm19me';
+ const params = { scope: 'REPO_READ' };
+ mockFrameHandler.mockResolvedValueOnce({
+ result: { fullProfile: passportProfile, accessToken, params },
+ privateInfo: {},
+ });
+
+ await expect(provider.handler({} as any)).rejects.toThrow(
+ `Failed to retrieve the user '${passportProfile.username}'`,
+ );
+ });
+ it('should throw if get user is not ok', async () => {
+ mockFetchUserRequests(false, passportProfile.username, false, false);
+ const accessToken = '19xasczxcm9n7gacn9jdgm19me';
+ const params = { scope: 'REPO_READ' };
+ mockFrameHandler.mockResolvedValueOnce({
+ result: { fullProfile: passportProfile, accessToken, params },
+ privateInfo: {},
+ });
+
+ await expect(provider.handler({} as any)).rejects.toThrow(
+ `Failed to retrieve the user '${passportProfile.username}'`,
+ );
+ });
+ it('should not set an avatar url if not given', async () => {
+ mockFetchUserRequests(false, passportProfile.username, false, true, '');
+ const accessToken = '19xasczxcm9n7gacn9jdgm19me';
+ const params = { scope: 'REPO_READ' };
+
+ const expected = {
+ backstageIdentity: {
+ token: 'token-for-user:john@doe.com',
+ },
+ providerInfo: {
+ accessToken: '19xasczxcm9n7gacn9jdgm19me',
+ scope: 'REPO_READ',
+ },
+ profile: {
+ email: 'john@doe.com',
+ displayName: 'John Doe',
+ },
+ };
+
+ mockFrameHandler.mockResolvedValueOnce({
+ result: { fullProfile: passportProfile, accessToken, params },
+ privateInfo: {},
+ });
+ const { response } = await provider.handler({} as any);
+ expect(response).toEqual(expected);
+ });
+ it('should fallback to the username if no displayName is given', async () => {
+ mockFetchUserRequests(
+ false,
+ passportProfile.username,
+ false,
+ true,
+ '/user/123/avatar',
+ false,
+ );
+ const accessToken = '19xasczxcm9n7gacn9jdgm19me';
+ const params = { scope: 'REPO_READ' };
+
+ const expected = {
+ backstageIdentity: {
+ token: 'token-for-user:john@doe.com',
+ },
+ providerInfo: {
+ accessToken: '19xasczxcm9n7gacn9jdgm19me',
+ scope: 'REPO_READ',
+ },
+ profile: {
+ email: 'john@doe.com',
+ displayName: 'john.doe',
+ picture: 'https://bitbucket.org/user/123/avatar',
+ },
+ };
+
+ mockFrameHandler.mockResolvedValueOnce({
+ result: { fullProfile: passportProfile, accessToken, params },
+ privateInfo: {},
+ });
+ const { response } = await provider.handler({} as any);
+ expect(response).toEqual(expected);
+ });
+ it('should fallback to the user id if no name is given', async () => {
+ mockFetchUserRequests(
+ false,
+ passportProfile.username,
+ false,
+ true,
+ '/user/123/avatar',
+ false,
+ false,
+ );
+ const accessToken = '19xasczxcm9n7gacn9jdgm19me';
+ const params = { scope: 'REPO_READ' };
+
+ const expected = {
+ backstageIdentity: {
+ token: 'token-for-user:john@doe.com',
+ },
+ providerInfo: {
+ accessToken: '19xasczxcm9n7gacn9jdgm19me',
+ scope: 'REPO_READ',
+ },
+ profile: {
+ email: 'john@doe.com',
+ displayName: '123',
+ picture: 'https://bitbucket.org/user/123/avatar',
+ },
+ };
+
+ mockFrameHandler.mockResolvedValueOnce({
+ result: { fullProfile: passportProfile, accessToken, params },
+ privateInfo: {},
+ });
+ const { response } = await provider.handler({} as any);
+ expect(response).toEqual(expected);
+ });
+ });
+
+ describe('when authenticating', () => {
+ it('should forward the refresh token', async () => {
+ mockFetchUserRequests();
+ const accessToken = '19xasczxcm9n7gacn9jdgm19me';
+ const params = { scope: 'REPO_READ' };
+ mockFrameHandler.mockResolvedValueOnce({
+ result: { fullProfile: passportProfile, accessToken, params },
+ privateInfo: { refreshToken: 'refresh-token' },
+ });
+
+ const response = await provider.handler({} as any);
+
+ const expected = {
+ response: {
+ backstageIdentity: {
+ token: 'token-for-user:john@doe.com',
+ },
+ providerInfo: {
+ accessToken: '19xasczxcm9n7gacn9jdgm19me',
+ scope: 'REPO_READ',
+ },
+ profile: {
+ email: 'john@doe.com',
+ displayName: 'John Doe',
+ picture: 'https://bitbucket.org/user/123/avatar',
+ },
+ },
+ refreshToken: 'refresh-token',
+ };
+
+ expect(response).toEqual(expected);
+ });
+ it('should forward a new refresh token on refresh', async () => {
+ mockFetchUserRequests();
+ const accessToken = '19xasczxcm9n7gacn9jdgm19me';
+ const params = { scope: 'REPO_READ' };
+ const mockRefreshToken = jest.spyOn(
+ helpers,
+ 'executeRefreshTokenStrategy',
+ ) as unknown as jest.MockedFunction<() => Promise<{}>>;
+ mockRefreshToken.mockResolvedValueOnce({
+ accessToken,
+ refreshToken: 'dont-forget-to-send-refresh',
+ params,
+ });
+ mockFrameHandler.mockResolvedValueOnce({
+ result: { fullProfile: passportProfile, accessToken, params },
+ privateInfo: { refreshToken: 'refresh-token' },
+ });
+
+ const expected = {
+ response: {
+ backstageIdentity: {
+ token: 'token-for-user:john@doe.com',
+ },
+ providerInfo: {
+ accessToken: '19xasczxcm9n7gacn9jdgm19me',
+ scope: 'REPO_READ',
+ },
+ profile: {
+ email: 'john@doe.com',
+ displayName: 'John Doe',
+ picture: 'https://bitbucket.org/user/123/avatar',
+ },
+ },
+ refreshToken: 'dont-forget-to-send-refresh',
+ };
+ const response = await provider.refresh({ scope: 'REPO_WRITE' } as any);
+
+ expect(response).toEqual(expected);
+ });
+ });
+});
diff --git a/plugins/auth-backend/src/providers/bitbucketServer/provider.ts b/plugins/auth-backend/src/providers/bitbucketServer/provider.ts
new file mode 100644
index 0000000000..397f63733f
--- /dev/null
+++ b/plugins/auth-backend/src/providers/bitbucketServer/provider.ts
@@ -0,0 +1,297 @@
+/*
+ * Copyright 2023 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 {
+ encodeState,
+ OAuthAdapter,
+ OAuthEnvironmentHandler,
+ OAuthHandlers,
+ OAuthProviderOptions,
+ OAuthRefreshRequest,
+ OAuthResponse,
+ OAuthStartRequest,
+} from '../../lib/oauth';
+import { Strategy as OAuth2Strategy, VerifyCallback } from 'passport-oauth2';
+import {
+ executeFetchUserProfileStrategy,
+ executeFrameHandlerStrategy,
+ executeRedirectStrategy,
+ executeRefreshTokenStrategy,
+ makeProfileInfo,
+} from '../../lib/passport';
+import {
+ AuthHandler,
+ AuthResolverContext,
+ OAuthStartResponse,
+ SignInResolver,
+} from '../types';
+import express from 'express';
+import { createAuthProviderIntegration } from '../createAuthProviderIntegration';
+import { PassportProfile } from '../../lib/passport/types';
+import { commonByEmailResolver } from '../resolvers';
+
+type PrivateInfo = {
+ refreshToken: string;
+};
+
+export type BitbucketServerOAuthResult = {
+ fullProfile: PassportProfile;
+ params: {
+ scope: string;
+ access_token?: string;
+ token_type?: string;
+ expires_in?: number;
+ };
+ accessToken: string;
+ refreshToken?: string;
+};
+
+export type BitbucketServerAuthProviderOptions = OAuthProviderOptions & {
+ host: string;
+ authorizationUrl: string;
+ tokenUrl: string;
+ authHandler: AuthHandler;
+ signInResolver?: SignInResolver;
+ resolverContext: AuthResolverContext;
+};
+
+export class BitbucketServerAuthProvider implements OAuthHandlers {
+ private readonly signInResolver?: SignInResolver;
+ private readonly authHandler: AuthHandler;
+ private readonly resolverContext: AuthResolverContext;
+ private readonly strategy: OAuth2Strategy;
+ private readonly host: string;
+
+ constructor(options: BitbucketServerAuthProviderOptions) {
+ this.signInResolver = options.signInResolver;
+ this.authHandler = options.authHandler;
+ this.resolverContext = options.resolverContext;
+ this.strategy = new OAuth2Strategy(
+ {
+ authorizationURL: options.authorizationUrl,
+ tokenURL: options.tokenUrl,
+ clientID: options.clientId,
+ clientSecret: options.clientSecret,
+ callbackURL: options.callbackUrl,
+ },
+ (
+ accessToken: string,
+ refreshToken: string,
+ params: any,
+ fullProfile: PassportProfile,
+ done: VerifyCallback,
+ ) => {
+ done(undefined, { fullProfile, params, accessToken }, { refreshToken });
+ },
+ );
+ this.host = options.host;
+ }
+
+ 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<
+ BitbucketServerOAuthResult,
+ PrivateInfo
+ >(req, this.strategy);
+
+ return {
+ response: await this.handleResult(result),
+ refreshToken: privateInfo.refreshToken,
+ };
+ }
+
+ async refresh(
+ req: OAuthRefreshRequest,
+ ): Promise<{ response: OAuthResponse; refreshToken?: string }> {
+ const { accessToken, refreshToken, params } =
+ await executeRefreshTokenStrategy(
+ this.strategy,
+ req.refreshToken,
+ req.scope,
+ );
+ const fullProfile = await executeFetchUserProfileStrategy(
+ this.strategy,
+ accessToken,
+ );
+ return {
+ response: await this.handleResult({
+ fullProfile,
+ params,
+ accessToken,
+ }),
+ refreshToken,
+ };
+ }
+
+ private async handleResult(
+ result: BitbucketServerOAuthResult,
+ ): Promise {
+ // The OAuth2 strategy does not return a user profile -> let's fetch it before calling the auth handler
+ result.fullProfile = await this.fetchProfile(result);
+ const { profile } = await this.authHandler(result, this.resolverContext);
+
+ let backstageIdentity = undefined;
+ if (this.signInResolver) {
+ backstageIdentity = await this.signInResolver(
+ { result, profile },
+ this.resolverContext,
+ );
+ }
+
+ return {
+ providerInfo: {
+ accessToken: result.accessToken,
+ scope: result.params.scope,
+ expiresInSeconds: result.params.expires_in,
+ },
+ profile,
+ backstageIdentity,
+ };
+ }
+
+ private async fetchProfile(
+ result: BitbucketServerOAuthResult,
+ ): Promise {
+ // Get current user name
+ let whoAmIResponse;
+ try {
+ whoAmIResponse = await fetch(
+ `https://${this.host}/plugins/servlet/applinks/whoami`,
+ {
+ headers: {
+ Authorization: `Bearer ${result.accessToken}`,
+ },
+ },
+ );
+ } catch (e) {
+ throw new Error(`Failed to retrieve the username of the logged in user`);
+ }
+
+ // A response.ok check here would be worthless as the Bitbucket API always returns 200 OK for this call
+ const username = whoAmIResponse.headers.get('X-Ausername');
+ if (!username) {
+ throw new Error(`Failed to retrieve the username of the logged in user`);
+ }
+
+ let userResponse;
+ try {
+ userResponse = await fetch(
+ `https://${this.host}/rest/api/latest/users/${username}?avatarSize=256`,
+ {
+ headers: {
+ Authorization: `Bearer ${result.accessToken}`,
+ },
+ },
+ );
+ } catch (e) {
+ throw new Error(`Failed to retrieve the user '${username}'`);
+ }
+
+ if (!userResponse.ok) {
+ throw new Error(`Failed to retrieve the user '${username}'`);
+ }
+
+ const user = await userResponse.json();
+
+ return {
+ provider: 'bitbucketServer',
+ id: user.id.toString(),
+ displayName: user.displayName,
+ username: user.name,
+ emails: [
+ {
+ value: user.emailAddress,
+ },
+ ],
+ avatarUrl: user.avatarUrl
+ ? `https://${this.host}${user.avatarUrl}`
+ : undefined,
+ };
+ }
+}
+
+export const bitbucketServer = createAuthProviderIntegration({
+ create(options?: {
+ /**
+ * 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;
+ };
+ }) {
+ return ({ providerId, globalConfig, config, resolverContext }) =>
+ OAuthEnvironmentHandler.mapConfig(config, envConfig => {
+ const clientId = envConfig.getString('clientId');
+ const clientSecret = envConfig.getString('clientSecret');
+ const host = envConfig.getString('host');
+ const customCallbackUrl = envConfig.getOptionalString('callbackUrl');
+ const callbackUrl =
+ customCallbackUrl ||
+ `${globalConfig.baseUrl}/${providerId}/handler/frame`;
+ const authorizationUrl = `https://${host}/rest/oauth2/latest/authorize`;
+ const tokenUrl = `https://${host}/rest/oauth2/latest/token`;
+
+ const authHandler: AuthHandler =
+ options?.authHandler
+ ? options.authHandler
+ : async ({ fullProfile }) => ({
+ profile: makeProfileInfo(fullProfile),
+ });
+
+ const provider = new BitbucketServerAuthProvider({
+ callbackUrl,
+ clientId,
+ clientSecret,
+ host,
+ authorizationUrl,
+ tokenUrl,
+ authHandler,
+ signInResolver: options?.signIn?.resolver,
+ resolverContext,
+ });
+
+ return OAuthAdapter.fromConfig(globalConfig, provider, {
+ providerId,
+ callbackUrl,
+ });
+ });
+ },
+ resolvers: {
+ /**
+ * Looks up the user by matching their email to the entity email.
+ */
+ emailMatchingUserEntityProfileEmail: () => commonByEmailResolver,
+ },
+});
diff --git a/plugins/auth-backend/src/providers/index.ts b/plugins/auth-backend/src/providers/index.ts
index 67d3a62990..a922065fd8 100644
--- a/plugins/auth-backend/src/providers/index.ts
+++ b/plugins/auth-backend/src/providers/index.ts
@@ -19,6 +19,7 @@ export type {
BitbucketOAuthResult,
BitbucketPassportProfile,
} from './bitbucket';
+export type { BitbucketServerOAuthResult } from './bitbucketServer';
export type {
CloudflareAccessClaims,
CloudflareAccessGroup,
diff --git a/plugins/auth-backend/src/providers/providers.ts b/plugins/auth-backend/src/providers/providers.ts
index b2795c25f0..aa630b7481 100644
--- a/plugins/auth-backend/src/providers/providers.ts
+++ b/plugins/auth-backend/src/providers/providers.ts
@@ -31,6 +31,7 @@ import { okta } from './okta';
import { onelogin } from './onelogin';
import { saml } from './saml';
import { AuthProviderFactory } from './types';
+import { bitbucketServer } from './bitbucketServer';
/**
* All built-in auth provider integrations.
@@ -42,6 +43,7 @@ export const providers = Object.freeze({
auth0,
awsAlb,
bitbucket,
+ bitbucketServer,
cfAccess,
gcpIap,
github,
@@ -76,5 +78,6 @@ export const defaultAuthProviderFactories: {
onelogin: onelogin.create(),
awsalb: awsAlb.create(),
bitbucket: bitbucket.create(),
+ bitbucketServer: bitbucketServer.create(),
atlassian: atlassian.create(),
};
diff --git a/plugins/user-settings/src/components/AuthProviders/DefaultProviderSettings.tsx b/plugins/user-settings/src/components/AuthProviders/DefaultProviderSettings.tsx
index f8e1fb190c..772ec80982 100644
--- a/plugins/user-settings/src/components/AuthProviders/DefaultProviderSettings.tsx
+++ b/plugins/user-settings/src/components/AuthProviders/DefaultProviderSettings.tsx
@@ -24,6 +24,7 @@ import {
oktaAuthApiRef,
microsoftAuthApiRef,
bitbucketAuthApiRef,
+ bitbucketServerAuthApiRef,
atlassianAuthApiRef,
oneloginAuthApiRef,
} from '@backstage/core-plugin-api';
@@ -99,6 +100,14 @@ export const DefaultProviderSettings = (props: {
icon={Star}
/>
)}
+ {configuredProviders.includes('bitbucketServer') && (
+
+ )}
>
);
};