feat: support Basic Auth for Bitbucket Server
Closes: #12586 Signed-off-by: Patrick Jungermann <Patrick.Jungermann@gmail.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/integration': minor
|
||||
'@backstage/plugin-scaffolder-backend': minor
|
||||
---
|
||||
|
||||
Add support for Basic Auth for Bitbucket Server.
|
||||
@@ -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`.
|
||||
|
||||
@@ -150,6 +150,8 @@ export type BitbucketServerIntegrationConfig = {
|
||||
host: string;
|
||||
apiBaseUrl: string;
|
||||
token?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
};
|
||||
|
||||
// @public
|
||||
|
||||
Vendored
+10
@@ -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,
|
||||
|
||||
+108
-10
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
+22
-13
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user