feat: support Basic Auth for Bitbucket Server

Closes: #12586
Signed-off-by: Patrick Jungermann <Patrick.Jungermann@gmail.com>
This commit is contained in:
Patrick Jungermann
2022-07-19 01:18:19 +02:00
parent 5f36581151
commit 593dea6710
10 changed files with 218 additions and 26 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/integration': minor
'@backstage/plugin-scaffolder-backend': minor
---
Add support for Basic Auth for Bitbucket Server.
+14
View File
@@ -26,6 +26,16 @@ integrations:
token: ${BITBUCKET_SERVER_TOKEN}
```
or with Basic Auth
```yaml
integrations:
bitbucketServer:
- host: bitbucket.company.com
username: ${BITBUCKET_SERVER_USERNAME}
password: ${BITBUCKET_SERVER_PASSWORD}
```
Directly under the `bitbucketServer` key is a list of provider configurations, where
you can list the Bitbucket Server providers you want to fetch data from. Each entry is
a structure with the following elements:
@@ -34,5 +44,9 @@ a structure with the following elements:
- `token` (optional):
An [personal access token](https://confluence.atlassian.com/bitbucketserver/personal-access-tokens-939515499.html)
as expected by Bitbucket Server.
- `username` (optional):
use for [Basic Auth](https://developer.atlassian.com/server/bitbucket/how-tos/command-line-rest/#authentication) for Bitbucket Server.
- `password` (optional):
use for [Basic Auth](https://developer.atlassian.com/server/bitbucket/how-tos/command-line-rest/#authentication) for Bitbucket Server.
- `apiBaseUrl` (optional): The URL of the Bitbucket Server API. For self-hosted
installations, it is commonly at `https://<host>/rest/api/1.0`.
+2
View File
@@ -150,6 +150,8 @@ export type BitbucketServerIntegrationConfig = {
host: string;
apiBaseUrl: string;
token?: string;
username?: string;
password?: string;
};
// @public
+10
View File
@@ -92,6 +92,16 @@ export interface Config {
* @visibility secret
*/
token?: string;
/**
* Username used to authenticate requests with Basic Auth.
* @visibility secret
*/
username?: string;
/**
* Password (or token as password) used to authenticate requests with Basic Auth.
* @visibility secret
*/
password?: string;
/**
* The base url for the Bitbucket Server API, for example https://<host>/rest/api/1.0
* @visibility frontend
@@ -55,7 +55,7 @@ describe('readBitbucketServerIntegrationConfig', () => {
);
}
it('reads all values', () => {
it('reads all values, token', () => {
const output = readBitbucketServerIntegrationConfig(
buildConfig({
host: 'a.com',
@@ -70,6 +70,23 @@ describe('readBitbucketServerIntegrationConfig', () => {
});
});
it('reads all values, basic auth', () => {
const output = readBitbucketServerIntegrationConfig(
buildConfig({
host: 'a.com',
apiBaseUrl: 'https://a.com/api',
username: 'u',
password: 'p',
}),
);
expect(output).toEqual({
host: 'a.com',
apiBaseUrl: 'https://a.com/api',
username: 'u',
password: 'p',
});
});
it('rejects funky configs', () => {
const valid: any = {
host: 'a.com',
@@ -46,6 +46,24 @@ export type BitbucketServerIntegrationConfig = {
* If no token is specified, anonymous access is used.
*/
token?: string;
/**
* The credentials for Basic Authentication for requests to a Bitbucket Server provider.
*
* If `token` was provided, it will be preferred.
*
* See https://developer.atlassian.com/server/bitbucket/how-tos/command-line-rest/#authentication
*/
username?: string;
/**
* The credentials for Basic Authentication for requests to a Bitbucket Server provider.
*
* If `token` was provided, it will be preferred.
*
* See https://developer.atlassian.com/server/bitbucket/how-tos/command-line-rest/#authentication
*/
password?: string;
};
/**
@@ -60,6 +78,8 @@ export function readBitbucketServerIntegrationConfig(
const host = config.getString('host');
let apiBaseUrl = config.getOptionalString('apiBaseUrl');
const token = config.getOptionalString('token');
const username = config.getOptionalString('username');
const password = config.getOptionalString('password');
if (!isValidHost(host)) {
throw new Error(
@@ -77,6 +97,8 @@ export function readBitbucketServerIntegrationConfig(
host,
apiBaseUrl,
token,
username,
password,
};
}
@@ -36,7 +36,13 @@ describe('bitbucketServer core', () => {
apiBaseUrl: '',
token: 'A',
};
const withoutToken: BitbucketServerIntegrationConfig = {
const withBasicAuth: BitbucketServerIntegrationConfig = {
host: '',
apiBaseUrl: '',
username: 'u',
password: 'p',
};
const withoutCredentials: BitbucketServerIntegrationConfig = {
host: '',
apiBaseUrl: '',
};
@@ -45,7 +51,11 @@ describe('bitbucketServer core', () => {
.Authorization,
).toEqual('Bearer A');
expect(
(getBitbucketServerRequestOptions(withoutToken).headers as any)
(getBitbucketServerRequestOptions(withBasicAuth).headers as any)
.Authorization,
).toEqual('Basic dTpw');
expect(
(getBitbucketServerRequestOptions(withoutCredentials).headers as any)
.Authorization,
).toBeUndefined();
});
@@ -140,6 +140,10 @@ export function getBitbucketServerRequestOptions(
if (config.token) {
headers.Authorization = `Bearer ${config.token}`;
}
if (config.username && config.password) {
const buffer = Buffer.from(`${config.username}:${config.password}`, 'utf8');
headers.Authorization = `Basic ${buffer.toString('base64')}`;
}
return {
headers,
@@ -36,7 +36,13 @@ describe('publish:bitbucketServer', () => {
apiBaseUrl: 'https://hosted.bitbucket.com/rest/api/1.0',
},
{
host: 'notoken.bitbucket.com',
host: 'basic-auth.bitbucket.com',
username: 'test-user',
password: 'test-password',
apiBaseUrl: 'https://basic-auth.bitbucket.com/rest/api/1.0',
},
{
host: 'no-credentials.bitbucket.com',
},
],
},
@@ -96,21 +102,21 @@ describe('publish:bitbucketServer', () => {
).rejects.toThrow(/No matching integration configuration/);
});
it('should throw if there is no token in the integration config that is returned', async () => {
it('should throw if there no credentials in the integration config that is returned', async () => {
await expect(
action.handler({
...mockContext,
input: {
...mockContext.input,
repoUrl: 'notoken.bitbucket.com?project=project&repo=repo',
repoUrl: 'no-credentials.bitbucket.com?project=project&repo=repo',
},
}),
).rejects.toThrow(
/Authorization has not been provided for notoken.bitbucket.com/,
/Authorization has not been provided for no-credentials.bitbucket.com/,
);
});
it('should call the correct APIs', async () => {
it('should call the correct APIs with token', async () => {
expect.assertions(2);
server.use(
rest.post(
@@ -150,12 +156,54 @@ describe('publish:bitbucketServer', () => {
});
});
it('should call the correct APIs with basic auth', async () => {
expect.assertions(2);
server.use(
rest.post(
'https://basic-auth.bitbucket.com/rest/api/1.0/projects/project/repos',
(req, res, ctx) => {
expect(req.headers.get('Authorization')).toBe(
'Basic dGVzdC11c2VyOnRlc3QtcGFzc3dvcmQ=',
);
expect(req.body).toEqual({ public: false, name: 'repo' });
return res(
ctx.status(201),
ctx.set('Content-Type', 'application/json'),
ctx.json({
links: {
self: [
{
href: 'https://bitbucket.mycompany.com/projects/project/repos/repo',
},
],
clone: [
{
name: 'http',
href: 'https://bitbucket.mycompany.com/scm/project/repo',
},
],
},
}),
);
},
),
);
await action.handler({
...mockContext,
input: {
...mockContext.input,
repoUrl: 'basic-auth.bitbucket.com?project=project&repo=repo',
},
});
});
it('should work if the token is provided through ctx.input', async () => {
expect.assertions(2);
const token = 'user-token';
server.use(
rest.post(
'https://notoken.bitbucket.com/rest/api/1.0/projects/project/repos',
'https://no-credentials.bitbucket.com/rest/api/1.0/projects/project/repos',
(req, res, ctx) => {
expect(req.headers.get('Authorization')).toBe(`Bearer ${token}`);
expect(req.body).toEqual({ public: false, name: 'repo' });
@@ -185,7 +233,7 @@ describe('publish:bitbucketServer', () => {
...mockContext,
input: {
...mockContext.input,
repoUrl: 'notoken.bitbucket.com?project=project&repo=repo',
repoUrl: 'no-credentials.bitbucket.com?project=project&repo=repo',
token: token,
},
});
@@ -273,7 +321,7 @@ describe('publish:bitbucketServer', () => {
});
});
it('should call initAndPush with the correct values', async () => {
it('should call initAndPush with the correct values with token', async () => {
server.use(
rest.post(
'https://hosted.bitbucket.com/rest/api/1.0/projects/project/repos',
@@ -315,6 +363,56 @@ describe('publish:bitbucketServer', () => {
});
});
it('should call initAndPush with the correct values with basic auth', async () => {
server.use(
rest.post(
'https://basic-auth.bitbucket.com/rest/api/1.0/projects/project/repos',
(req, res, ctx) => {
expect(req.headers.get('Authorization')).toBe(
'Basic dGVzdC11c2VyOnRlc3QtcGFzc3dvcmQ=',
);
expect(req.body).toEqual({ public: false, name: 'repo' });
return res(
ctx.status(201),
ctx.set('Content-Type', 'application/json'),
ctx.json({
links: {
self: [
{
href: 'https://bitbucket.mycompany.com/projects/project/repos/repo',
},
],
clone: [
{
name: 'http',
href: 'https://bitbucket.mycompany.com/scm/project/repo',
},
],
},
}),
);
},
),
);
await action.handler({
...mockContext,
input: {
...mockContext.input,
repoUrl: 'basic-auth.bitbucket.com?project=project&repo=repo',
},
});
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: mockContext.workspacePath,
remoteUrl: 'https://bitbucket.mycompany.com/scm/project/repo',
defaultBranch: 'master',
auth: { username: 'test-user', password: 'test-password' },
logger: mockContext.logger,
gitAuthorInfo: {},
});
});
it('should call initAndPush with the correct default branch', async () => {
server.use(
rest.post(
@@ -373,7 +471,7 @@ describe('publish:bitbucketServer', () => {
apiBaseUrl: 'https://hosted.bitbucket.com/rest/api/1.0',
},
{
host: 'notoken.bitbucket.com',
host: 'no-credentials.bitbucket.com',
},
],
},
@@ -443,7 +541,7 @@ describe('publish:bitbucketServer', () => {
apiBaseUrl: 'https://hosted.bitbucket.com/rest/api/1.0',
},
{
host: 'notoken.bitbucket.com',
host: 'no-credentials.bitbucket.com',
},
],
},
@@ -15,7 +15,10 @@
*/
import { InputError } from '@backstage/errors';
import { ScmIntegrationRegistry } from '@backstage/integration';
import {
getBitbucketServerRequestOptions,
ScmIntegrationRegistry,
} from '@backstage/integration';
import fetch, { Response, RequestInit } from 'node-fetch';
import { initRepoAndPush } from '../helpers';
import { createTemplateAction } from '../../createTemplateAction';
@@ -79,10 +82,6 @@ const createRepository = async (opts: {
return { remoteUrl, repoContentsUrl };
};
const getAuthorizationHeader = (config: { token: string }) => {
return `Bearer ${config.token}`;
};
const performEnableLFS = async (opts: {
authorization: string;
host: string;
@@ -213,14 +212,19 @@ export function createPublishBitbucketServerAction(options: {
}
const token = ctx.input.token ?? integrationConfig.config.token;
if (!token) {
const authConfig = {
...integrationConfig.config,
...{ token },
};
const reqOpts = getBitbucketServerRequestOptions(authConfig);
const authorization = reqOpts.headers.Authorization;
if (!authorization) {
throw new Error(
`Authorization has not been provided for ${integrationConfig.config.host}. Please add either token to the Integrations config or a user login auth token`,
`Authorization has not been provided for ${integrationConfig.config.host}. Please add either (a) a user login auth token, (b) a token or (c) username + password to the integration config.`,
);
}
const authorization = getAuthorizationHeader({ token });
const apiBaseUrl = integrationConfig.config.apiBaseUrl;
const { remoteUrl, repoContentsUrl } = await createRepository({
@@ -237,10 +241,15 @@ export function createPublishBitbucketServerAction(options: {
email: config.getOptionalString('scaffolder.defaultAuthor.email'),
};
const auth = {
username: 'x-token-auth',
password: token,
};
const auth = authConfig.token
? {
username: 'x-token-auth',
password: token!,
}
: {
username: authConfig.username!,
password: authConfig.password!,
};
await initRepoAndPush({
dir: getRepoSourceDirectory(ctx.workspacePath, ctx.input.sourcePath),