feat(auth): add auth provider for Bitbucket Server
Signed-off-by: Katharina Sick <katharina.sick@dynatrace.com>
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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 `<ProxiedSignInPage {...props} provider="myproxy" />`
|
||||
|
||||
+51
@@ -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) });
|
||||
});
|
||||
});
|
||||
+66
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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<string>;
|
||||
expiresAt?: Date;
|
||||
};
|
||||
profile: ProfileInfo;
|
||||
backstageIdentity: BackstageIdentityResponse;
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<BitbucketServerOAuthResult>;
|
||||
signInResolver?: SignInResolver<BitbucketServerOAuthResult>;
|
||||
resolverContext: AuthResolverContext;
|
||||
};
|
||||
|
||||
export class BitbucketServerAuthProvider implements OAuthHandlers {
|
||||
private readonly signInResolver?: SignInResolver<BitbucketServerOAuthResult>;
|
||||
private readonly authHandler: AuthHandler<BitbucketServerOAuthResult>;
|
||||
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<OAuthStartResponse> {
|
||||
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<OAuthResponse> {
|
||||
// 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<PassportProfile> {
|
||||
// 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<BitbucketServerOAuthResult>;
|
||||
|
||||
/**
|
||||
* 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<BitbucketServerOAuthResult>;
|
||||
};
|
||||
}) {
|
||||
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<BitbucketServerOAuthResult> =
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -19,6 +19,7 @@ export type {
|
||||
BitbucketOAuthResult,
|
||||
BitbucketPassportProfile,
|
||||
} from './bitbucket';
|
||||
export type { BitbucketServerOAuthResult } from './bitbucketServer';
|
||||
export type {
|
||||
CloudflareAccessClaims,
|
||||
CloudflareAccessGroup,
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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') && (
|
||||
<ProviderSettingsItem
|
||||
title="Bitbucket Server"
|
||||
description="Provides authentication towards Bitbucket Server APIs"
|
||||
apiRef={bitbucketServerAuthApiRef}
|
||||
icon={Star}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user