diff --git a/.changeset/fresh-hounds-argue.md b/.changeset/fresh-hounds-argue.md new file mode 100644 index 0000000000..9291a99659 --- /dev/null +++ b/.changeset/fresh-hounds-argue.md @@ -0,0 +1,6 @@ +--- +'@backstage/integration': minor +'@backstage/plugin-scaffolder-backend': minor +--- + +Add support for Basic Auth for Bitbucket Server. diff --git a/docs/integrations/bitbucket/locations.md b/docs/integrations/bitbucket/locations.md index 878030a29c..4fbb96bb94 100644 --- a/docs/integrations/bitbucket/locations.md +++ b/docs/integrations/bitbucket/locations.md @@ -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:///rest/api/1.0`. diff --git a/packages/integration/api-report.md b/packages/integration/api-report.md index 8d8d017095..cf52bec65b 100644 --- a/packages/integration/api-report.md +++ b/packages/integration/api-report.md @@ -150,6 +150,8 @@ export type BitbucketServerIntegrationConfig = { host: string; apiBaseUrl: string; token?: string; + username?: string; + password?: string; }; // @public diff --git a/packages/integration/config.d.ts b/packages/integration/config.d.ts index 22acc5cd70..7c897d637a 100644 --- a/packages/integration/config.d.ts +++ b/packages/integration/config.d.ts @@ -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:///rest/api/1.0 * @visibility frontend diff --git a/packages/integration/src/bitbucketServer/config.test.ts b/packages/integration/src/bitbucketServer/config.test.ts index 83c28f5e9d..985165f23f 100644 --- a/packages/integration/src/bitbucketServer/config.test.ts +++ b/packages/integration/src/bitbucketServer/config.test.ts @@ -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', diff --git a/packages/integration/src/bitbucketServer/config.ts b/packages/integration/src/bitbucketServer/config.ts index ec7930616c..2f93bdeb07 100644 --- a/packages/integration/src/bitbucketServer/config.ts +++ b/packages/integration/src/bitbucketServer/config.ts @@ -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, }; } diff --git a/packages/integration/src/bitbucketServer/core.test.ts b/packages/integration/src/bitbucketServer/core.test.ts index 076a2c7a4f..de18dcd821 100644 --- a/packages/integration/src/bitbucketServer/core.test.ts +++ b/packages/integration/src/bitbucketServer/core.test.ts @@ -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(); }); diff --git a/packages/integration/src/bitbucketServer/core.ts b/packages/integration/src/bitbucketServer/core.ts index 0c57b0d3ed..5fca2aba68 100644 --- a/packages/integration/src/bitbucketServer/core.ts +++ b/packages/integration/src/bitbucketServer/core.ts @@ -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, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucketServer.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucketServer.test.ts index 338231ea55..7d4eb9b6c5 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucketServer.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucketServer.test.ts @@ -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', }, ], }, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucketServer.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucketServer.ts index 208403d6f4..10317766d4 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucketServer.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucketServer.ts @@ -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),