Merge pull request #12711 from Bonial-International-GmbH/pjungermann/bitbucketServer/auth

feat: support Basic Auth and token-only at Git commands for Bitbucket Server
This commit is contained in:
Ben Lambert
2022-07-25 17:06:08 +02:00
committed by GitHub
17 changed files with 371 additions and 56 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/integration': minor
'@backstage/plugin-scaffolder-backend': minor
---
Add support for Basic Auth for Bitbucket Server.
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-scaffolder-backend': minor
'@backstage/backend-common': patch
---
Add support for Bearer Authorization header / token-based auth at Git commands.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend': minor
---
User Bearer Authorization header at Git commands with token-based auth at 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`.
+1
View File
@@ -373,6 +373,7 @@ export class Git {
static fromAuth: (options: {
username?: string;
password?: string;
token?: string;
logger?: Logger;
}) => Git;
// (undocumented)
+86 -3
View File
@@ -26,6 +26,7 @@ describe('Git', () => {
beforeEach(() => {
jest.resetAllMocks();
});
describe('add', () => {
it('should call isomorphic-git add with the correct arguments', async () => {
const git = Git.fromAuth({});
@@ -146,6 +147,33 @@ describe('Git', () => {
onAuth: expect.any(Function),
});
});
it('should call isomorphic-git with the correct arguments (Bearer)', async () => {
const url = 'http://github.com/some/repo';
const dir = '/some/mock/dir';
const auth = {
token: 'test',
};
const git = Git.fromAuth(auth);
await git.clone({ url, dir });
expect(isomorphic.clone).toHaveBeenCalledWith({
fs,
http,
url,
dir,
singleBranch: true,
depth: 1,
onProgress: expect.any(Function),
headers: {
Authorization: 'Bearer test',
'user-agent': 'git/@isomorphic-git',
},
onAuth: expect.any(Function),
});
});
it('should pass a function that returns the authorization as the onAuth handler', async () => {
const url = 'http://github.com/some/repo';
const dir = '/some/mock/dir';
@@ -164,7 +192,7 @@ describe('Git', () => {
expect(onAuth()).toEqual(auth);
});
it('should propogate the data from the error handler', async () => {
it('should propagate the data from the error handler', async () => {
const url = 'http://github.com/some/repo';
const dir = '/some/mock/dir';
const auth = {
@@ -234,6 +262,31 @@ describe('Git', () => {
onAuth: expect.any(Function),
});
});
it('should call isomorphic-git with the correct arguments (Bearer)', async () => {
const remote = 'http://github.com/some/repo';
const dir = '/some/mock/dir';
const auth = {
token: 'test',
};
const git = Git.fromAuth(auth);
await git.fetch({ remote, dir });
expect(isomorphic.fetch).toHaveBeenCalledWith({
fs,
http,
remote,
dir,
onProgress: expect.any(Function),
headers: {
Authorization: 'Bearer test',
'user-agent': 'git/@isomorphic-git',
},
onAuth: expect.any(Function),
});
});
it('should pass a function that returns the authorization as the onAuth handler', async () => {
const remote = 'http://github.com/some/repo';
const dir = '/some/mock/dir';
@@ -252,7 +305,7 @@ describe('Git', () => {
expect(onAuth()).toEqual(auth);
});
it('should propogate the data from the error handler', async () => {
it('should propagate the data from the error handler', async () => {
const remote = 'http://github.com/some/repo';
const dir = '/some/mock/dir';
const auth = {
@@ -348,6 +401,35 @@ describe('Git', () => {
onAuth: expect.any(Function),
});
});
it('should call isomorphic-git with the correct arguments (Bearer)', async () => {
const remote = 'origin';
const dir = '/some/mock/dir';
const auth = {
token: 'test',
};
const git = Git.fromAuth(auth);
const remoteRef = 'master';
const force = true;
await git.push({ dir, remote, remoteRef, force });
expect(isomorphic.push).toHaveBeenCalledWith({
fs,
http,
remote,
dir,
remoteRef,
force,
onProgress: expect.any(Function),
headers: {
Authorization: 'Bearer test',
'user-agent': 'git/@isomorphic-git',
},
onAuth: expect.any(Function),
});
});
it('should call isomorphic-git with remoteRef parameter', async () => {
const remote = 'origin';
const remoteRef = 'refs/for/master';
@@ -373,6 +455,7 @@ describe('Git', () => {
onAuth: expect.any(Function),
});
});
it('should pass a function that returns the authorization as the onAuth handler', async () => {
const remote = 'origin';
const dir = '/some/mock/dir';
@@ -393,7 +476,7 @@ describe('Git', () => {
expect(onAuth()).toEqual(auth);
});
it('should propogate the data from the error handler', async () => {
it('should propagate the data from the error handler', async () => {
const remote = 'origin';
const dir = '/some/mock/dir';
const auth = {
+26 -15
View File
@@ -24,13 +24,17 @@ import fs from 'fs-extra';
import { Logger } from 'winston';
/*
provider username password
GitHub 'x-access-token' token
BitBucket 'x-token-auth' token
GitLab 'oauth2' token
provider username password
Azure 'notempty' token
Bitbucket Cloud 'x-token-auth' token
Bitbucket Server username password or token
GitHub 'x-access-token' token
GitLab 'oauth2' token
From : https://isomorphic-git.org/docs/en/onAuth with fix for GitHub
Azure 'notempty' token
Or token provided as `token` for Bearer auth header
instead of Basic Auth (e.g., Bitbucket Server).
*/
/**
@@ -39,13 +43,23 @@ Azure 'notempty' token
* @public
*/
export class Git {
private readonly headers: {
[x: string]: string;
};
private constructor(
private readonly config: {
username?: string;
password?: string;
token?: string;
logger?: Logger;
},
) {}
) {
this.headers = {
'user-agent': 'git/@isomorphic-git',
...(config.token ? { Authorization: `Bearer ${config.token}` } : {}),
};
}
async add(options: { dir: string; filepath: string }): Promise<void> {
const { dir, filepath } = options;
@@ -116,9 +130,7 @@ export class Git {
depth: depth ?? 1,
noCheckout,
onProgress: this.onProgressHandler(),
headers: {
'user-agent': 'git/@isomorphic-git',
},
headers: this.headers,
onAuth: this.onAuth,
});
} catch (ex) {
@@ -155,7 +167,7 @@ export class Git {
dir,
remote,
onProgress: this.onProgressHandler(),
headers: { 'user-agent': 'git/@isomorphic-git' },
headers: this.headers,
onAuth: this.onAuth,
});
} catch (ex) {
@@ -222,9 +234,7 @@ export class Git {
onProgress: this.onProgressHandler(),
remoteRef,
force,
headers: {
'user-agent': 'git/@isomorphic-git',
},
headers: this.headers,
remote,
onAuth: this.onAuth,
});
@@ -290,9 +300,10 @@ export class Git {
static fromAuth = (options: {
username?: string;
password?: string;
token?: string;
logger?: Logger;
}) => {
const { username, password, logger } = options;
return new Git({ username, password, logger });
const { username, password, token, logger } = options;
return new Git({ username, password, token, logger });
};
}
+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,
@@ -33,8 +33,6 @@ jest.mock('@backstage/backend-common', () => ({
}));
const mockedGit = Git.fromAuth({
username: 'test-user',
password: 'test-password',
logger: getVoidLogger(),
});
@@ -101,6 +99,22 @@ describe('initRepoAndPush', () => {
});
});
it('with token', async () => {
await initRepoAndPush({
dir: '/test/repo/dir/',
remoteUrl: 'git@github.com:test/repo.git',
auth: {
token: 'test-token',
},
logger: getVoidLogger(),
});
expect(mockedGit.init).toHaveBeenCalledWith({
dir: '/test/repo/dir/',
defaultBranch: 'master',
});
});
it('allows overriding the default branch', async () => {
await initRepoAndPush({
dir: '/test/repo/dir/',
@@ -83,15 +83,17 @@ export async function initRepoAndPush({
}: {
dir: string;
remoteUrl: string;
auth: { username: string; password: string };
// For use cases where token has to be used with Basic Auth
// it has to be provided as password together with a username
// which may be a fixed value defined by the provider.
auth: { username: string; password: string } | { token: string };
logger: Logger;
defaultBranch?: string;
commitMessage?: string;
gitAuthorInfo?: { name?: string; email?: string };
}): Promise<void> {
const git = Git.fromAuth({
username: auth.username,
password: auth.password,
...auth,
logger,
});
@@ -137,7 +139,10 @@ export async function commitAndPushRepo({
remoteRef,
}: {
dir: string;
auth: { username: string; password: string };
// For use cases where token has to be used with Basic Auth
// it has to be provided as password together with a username
// which may be a fixed value defined by the provider.
auth: { username: string; password: string } | { token: string };
logger: Logger;
commitMessage: string;
gitAuthorInfo?: { name?: string; email?: string };
@@ -145,8 +150,7 @@ export async function commitAndPushRepo({
remoteRef?: string;
}): Promise<void> {
const git = Git.fromAuth({
username: auth.username,
password: auth.password,
...auth,
logger,
});
@@ -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',
@@ -309,7 +357,57 @@ describe('publish:bitbucketServer', () => {
dir: mockContext.workspacePath,
remoteUrl: 'https://bitbucket.mycompany.com/scm/project/repo',
defaultBranch: 'master',
auth: { username: 'x-token-auth', password: 'thing' },
auth: { token: 'thing' },
logger: mockContext.logger,
gitAuthorInfo: {},
});
});
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: {},
});
@@ -357,7 +455,7 @@ describe('publish:bitbucketServer', () => {
dir: mockContext.workspacePath,
remoteUrl: 'https://bitbucket.mycompany.com/scm/project/repo',
defaultBranch: 'main',
auth: { username: 'x-token-auth', password: 'thing' },
auth: { token: 'thing' },
logger: mockContext.logger,
gitAuthorInfo: {},
});
@@ -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',
},
],
},
@@ -426,7 +524,7 @@ describe('publish:bitbucketServer', () => {
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: mockContext.workspacePath,
remoteUrl: 'https://bitbucket.mycompany.com/scm/project/repo',
auth: { username: 'x-token-auth', password: 'thing' },
auth: { token: 'thing' },
logger: mockContext.logger,
defaultBranch: 'master',
gitAuthorInfo: { name: 'Test', email: 'example@example.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',
},
],
},
@@ -493,7 +591,7 @@ describe('publish:bitbucketServer', () => {
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: mockContext.workspacePath,
remoteUrl: 'https://bitbucket.mycompany.com/scm/project/repo',
auth: { username: 'x-token-auth', password: 'thing' },
auth: { token: 'thing' },
logger: mockContext.logger,
defaultBranch: 'master',
commitMessage: 'Test commit message',
@@ -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, or (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,14 @@ export function createPublishBitbucketServerAction(options: {
email: config.getOptionalString('scaffolder.defaultAuthor.email'),
};
const auth = {
username: 'x-token-auth',
password: token,
};
const auth = authConfig.token
? {
token: token!,
}
: {
username: authConfig.username!,
password: authConfig.password!,
};
await initRepoAndPush({
dir: getRepoSourceDirectory(ctx.workspacePath, ctx.input.sourcePath),