feat(auth): add auth provider for Bitbucket Server

Signed-off-by: Katharina Sick <katharina.sick@dynatrace.com>
This commit is contained in:
Katharina Sick
2023-02-06 08:54:01 +01:00
parent 50d7039368
commit db10b6ef65
19 changed files with 983 additions and 1 deletions
+9
View File
@@ -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
+1 -1
View File
@@ -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
+52
View File
@@ -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.
+1
View File
@@ -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: {
+7
View File
@@ -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,
},
];
+7
View File
@@ -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" />`
@@ -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) });
});
});
@@ -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}
/>
)}
</>
);
};