From fa255f530a00a4f1e4216d274c86a53960459b1d Mon Sep 17 00:00:00 2001 From: Andre Wanlin Date: Mon, 17 Nov 2025 12:49:43 -0600 Subject: [PATCH] Bitbucket Cloud - API Token Support Signed-off-by: Andre Wanlin --- .changeset/every-breads-act.md | 18 +++ app-config.yaml | 2 +- docs/integrations/bitbucketCloud/locations.md | 18 ++- .../urlReader/lib/BitbucketCloudUrlReader.ts | 12 +- packages/integration/config.d.ts | 10 +- .../src/bitbucketCloud/config.test.ts | 141 +++++++++++++----- .../integration/src/bitbucketCloud/config.ts | 14 +- .../src/bitbucketCloud/core.test.ts | 32 ++-- .../integration/src/bitbucketCloud/core.ts | 16 +- .../src/BitbucketCloudClient.ts | 11 +- .../src/actions/helpers.ts | 27 ++-- .../src/actions/bitbucket.ts | 6 +- 12 files changed, 223 insertions(+), 84 deletions(-) create mode 100644 .changeset/every-breads-act.md diff --git a/.changeset/every-breads-act.md b/.changeset/every-breads-act.md new file mode 100644 index 0000000000..851dd13940 --- /dev/null +++ b/.changeset/every-breads-act.md @@ -0,0 +1,18 @@ +--- +'@backstage/plugin-scaffolder-backend-module-bitbucket-cloud': patch +'@backstage/plugin-scaffolder-backend-module-bitbucket': patch +'@backstage/plugin-bitbucket-cloud-common': patch +'@backstage/backend-defaults': patch +'@backstage/integration': patch +--- + +Support for Bitbucket Cloud's API token was added as `appPassword` is deprecated (no new creation from September 9, 2025) and will be removed on June 9, 2026. + +API token usage example: + +```yaml +integrations: + bitbucketCloud: + - username: user@domain.com + token: my-token +``` diff --git a/app-config.yaml b/app-config.yaml index b45255d26c..1489a39105 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -110,7 +110,7 @@ integrations: ### Example for how to add a bitbucket cloud integration # bitbucketCloud: # - username: ${BITBUCKET_USERNAME} - # appPassword: ${BITBUCKET_APP_PASSWORD} + # token: ${BITBUCKET_API_TOKEN} ### Example for how to add your bitbucket server instance using the API: # - host: server.bitbucket.com # apiBaseUrl: server.bitbucket.com diff --git a/docs/integrations/bitbucketCloud/locations.md b/docs/integrations/bitbucketCloud/locations.md index f0a032353c..6b735171a4 100644 --- a/docs/integrations/bitbucketCloud/locations.md +++ b/docs/integrations/bitbucketCloud/locations.md @@ -14,11 +14,22 @@ plugin. ## Configuration +API token usage example (recommended): + ```yaml integrations: bitbucketCloud: - - username: ${BITBUCKET_CLOUD_USERNAME} - appPassword: ${BITBUCKET_CLOUD_PASSWORD} + - username: user@domain.com # username -> user email + token: my-token +``` + +Legacy: + +```yaml +integrations: + bitbucketCloud: + - username: username + appPassword: my-password ``` :::note Note @@ -30,7 +41,7 @@ convenience, so you only need to list it if you want to supply credentials. :::note Note -The credential used for this is type [App Password](https://support.atlassian.com/bitbucket-cloud/docs/app-passwords/). An Atlassian Account API key will not work. +The credential required for this type is either an [Api token](https://support.atlassian.com/bitbucket-cloud/docs/using-api-tokens/) or an [App Password](https://support.atlassian.com/bitbucket-cloud/docs/app-passwords/). An Atlassian Account API key will not work. ::: @@ -42,4 +53,5 @@ This one entry will have the following elements: - `username`: The Bitbucket Cloud username to use in API requests. If neither a username nor token are supplied, anonymous access will be used. +- `token`: The token used to authenticate requests. - `appPassword`: The app password for the Bitbucket Cloud user. diff --git a/packages/backend-defaults/src/entrypoints/urlReader/lib/BitbucketCloudUrlReader.ts b/packages/backend-defaults/src/entrypoints/urlReader/lib/BitbucketCloudUrlReader.ts index 3a228f1e9c..f078b5c71e 100644 --- a/packages/backend-defaults/src/entrypoints/urlReader/lib/BitbucketCloudUrlReader.ts +++ b/packages/backend-defaults/src/entrypoints/urlReader/lib/BitbucketCloudUrlReader.ts @@ -68,11 +68,11 @@ export class BitbucketCloudUrlReader implements UrlReaderService { ) { this.integration = integration; this.deps = deps; - const { host, username, appPassword } = integration.config; + const { host, username, appPassword, token } = integration.config; - if (username && !appPassword) { + if (username && (!token || !appPassword)) { throw new Error( - `Bitbucket Cloud integration for '${host}' has configured a username but is missing a required appPassword.`, + `Bitbucket Cloud integration for '${host}' has configured a username but is missing a required token or appPassword.`, ); } } @@ -228,8 +228,10 @@ export class BitbucketCloudUrlReader implements UrlReaderService { } toString() { - const { host, username, appPassword } = this.integration.config; - const authed = Boolean(username && appPassword); + // TODO: appPassword can be removed once fully + // deprecated by BitBucket on 9th June 2026. + const { host, username, appPassword, token } = this.integration.config; + const authed = Boolean(username && (token ?? appPassword)); return `bitbucketCloud{host=${host},authed=${authed}}`; } diff --git a/packages/integration/config.d.ts b/packages/integration/config.d.ts index 298d765697..a601d63472 100644 --- a/packages/integration/config.d.ts +++ b/packages/integration/config.d.ts @@ -176,10 +176,16 @@ export interface Config { */ username: string; /** - * Bitbucket Cloud app password used to authenticate requests. + * Token used to authenticate requests. * @visibility secret */ - appPassword: string; + token?: string; + /** + * Bitbucket Cloud app password used to authenticate requests. + * @visibility secret + * @deprecated Use `token` instead. + */ + appPassword?: string; /** * PGP signing key for signing commits. * @visibility secret diff --git a/packages/integration/src/bitbucketCloud/config.test.ts b/packages/integration/src/bitbucketCloud/config.test.ts index 2c68059196..28322909a6 100644 --- a/packages/integration/src/bitbucketCloud/config.test.ts +++ b/packages/integration/src/bitbucketCloud/config.test.ts @@ -22,54 +22,58 @@ import { readBitbucketCloudIntegrationConfigs, } from './config'; +// Mock constants +const BITBUCKET_CLOUD_HOST = 'bitbucket.org'; +const BITBUCKET_CLOUD_API_BASE_URL = 'https://api.bitbucket.org/2.0'; + +async function buildFrontendConfig( + data: Partial, +): Promise { + const fullSchema = await loadConfigSchema({ + dependencies: ['@backstage/integration'], + }); + const serializedSchema = fullSchema.serialize() as { + schemas: { value: { properties?: { integrations?: object } } }[]; + }; + const schema = await loadConfigSchema({ + serialized: { + ...serializedSchema, // only include schemas that apply to integrations + schemas: serializedSchema.schemas.filter( + s => s.value?.properties?.integrations, + ), + }, + }); + const processed = schema.process( + [{ data: { integrations: { bitbucketCloud: [data] } }, context: 'app' }], + { visibility: ['frontend'] }, + ); + return new ConfigReader(processed[0].data as any); +} + describe('readBitbucketCloudIntegrationConfig', () => { function buildConfig(data: Partial): Config { return new ConfigReader(data); } - async function buildFrontendConfig( - data: Partial, - ): Promise { - const fullSchema = await loadConfigSchema({ - dependencies: ['@backstage/integration'], - }); - const serializedSchema = fullSchema.serialize() as { - schemas: { value: { properties?: { integrations?: object } } }[]; - }; - const schema = await loadConfigSchema({ - serialized: { - ...serializedSchema, // only include schemas that apply to integrations - schemas: serializedSchema.schemas.filter( - s => s.value?.properties?.integrations, - ), - }, - }); - const processed = schema.process( - [{ data: { integrations: { bitbucketCloud: [data] } }, context: 'app' }], - { visibility: ['frontend'] }, - ); - return new ConfigReader(processed[0].data as any); - } - it('reads all values', () => { const output = readBitbucketCloudIntegrationConfig( buildConfig({ username: 'u', - appPassword: '\n\n\np', + token: 't', }), ); expect(output).toEqual({ - apiBaseUrl: 'https://api.bitbucket.org/2.0', - appPassword: 'p', - host: 'bitbucket.org', + host: BITBUCKET_CLOUD_HOST, + apiBaseUrl: BITBUCKET_CLOUD_API_BASE_URL, username: 'u', + token: 't', }); }); it('rejects funky configs', () => { const valid: any = { username: 'u', - appPassword: 'p', + token: 't', }; expect(() => readBitbucketCloudIntegrationConfig( @@ -77,15 +81,13 @@ describe('readBitbucketCloudIntegrationConfig', () => { ), ).toThrow(/username/); expect(() => - readBitbucketCloudIntegrationConfig( - buildConfig({ ...valid, appPassword: 7 }), - ), - ).toThrow(/appPassword/); + readBitbucketCloudIntegrationConfig(buildConfig({ ...valid, token: 7 })), + ).toThrow(/token/); }); it('credentials hidden on the frontend', async () => { const frontendConfig = await buildFrontendConfig({ - appPassword: 'p', + token: 't', username: 'u', }); expect( @@ -95,11 +97,74 @@ describe('readBitbucketCloudIntegrationConfig', () => { ), ).toEqual([ { - apiBaseUrl: 'https://api.bitbucket.org/2.0', - host: 'bitbucket.org', + host: BITBUCKET_CLOUD_HOST, + apiBaseUrl: BITBUCKET_CLOUD_API_BASE_URL, }, ]); }); + + // TODO: appPassword can be removed once fully + // deprecated by BitBucket on 9th June 2026. + describe('handles deprecated appPassword', () => { + it('reads all values', () => { + const output = readBitbucketCloudIntegrationConfig( + buildConfig({ + appPassword: '\n\np', + username: 'u', + }), + ); + expect(output).toEqual({ + host: BITBUCKET_CLOUD_HOST, + apiBaseUrl: BITBUCKET_CLOUD_API_BASE_URL, + appPassword: 'p', + username: 'u', + }); + }); + + it('rejects funky configs', () => { + const valid: any = { + appPassword: 'p', + username: 'u', + }; + expect(() => + readBitbucketCloudIntegrationConfig( + buildConfig({ ...valid, appPassword: 7 }), + ), + ).toThrow(/appPassword/); + }); + + it('rejects if misconfigured', () => { + const valid: any = { + appPassword: 'p', + token: 't', + username: 'u', + }; + expect(() => + readBitbucketCloudIntegrationConfig( + buildConfig({ ...valid, appPassword: undefined, token: undefined }), + ), + ).toThrow(/must configure either a token or appPassword/); + }); + + it('credentials hidden on the frontend', async () => { + const frontendConfig = await buildFrontendConfig({ + appPassword: 'p', + username: 'u', + }); + expect( + readBitbucketCloudIntegrationConfigs( + frontendConfig.getOptionalConfigArray( + 'integrations.bitbucketCloud', + ) ?? [], + ), + ).toEqual([ + { + host: BITBUCKET_CLOUD_HOST, + apiBaseUrl: BITBUCKET_CLOUD_API_BASE_URL, + }, + ]); + }); + }); }); describe('readBitbucketCloudIntegrationConfigs', () => { @@ -114,15 +179,15 @@ describe('readBitbucketCloudIntegrationConfigs', () => { buildConfig([ { username: 'u', - appPassword: 'p', + token: 't', }, ]), ); expect(output).toContainEqual({ apiBaseUrl: 'https://api.bitbucket.org/2.0', - appPassword: 'p', host: 'bitbucket.org', username: 'u', + token: 't', }); }); diff --git a/packages/integration/src/bitbucketCloud/config.ts b/packages/integration/src/bitbucketCloud/config.ts index eb4b5c4904..b45a6ebb05 100644 --- a/packages/integration/src/bitbucketCloud/config.ts +++ b/packages/integration/src/bitbucketCloud/config.ts @@ -49,6 +49,8 @@ export type BitbucketCloudIntegrationConfig = { /** * The access token to use for requests to Bitbucket Cloud (bitbucket.org). + * + * See https://support.atlassian.com/bitbucket-cloud/docs/api-tokens/ */ token?: string; @@ -70,13 +72,23 @@ export function readBitbucketCloudIntegrationConfig( // If config is provided, we assume authenticated access is desired // (as the anonymous one is provided by default). const username = config.getString('username'); - const appPassword = config.getString('appPassword')?.trim(); + // TODO: appPassword can be removed once fully + // deprecated by BitBucket on 9th June 2026. + const appPassword = config.getOptionalString('appPassword')?.trim(); + const token = config.getOptionalString('token'); + + if (!token || !appPassword) { + throw new Error( + `Bitbucket Cloud integration must be configured with as username and either a token or an appPassword.`, + ); + } return { host, apiBaseUrl, username, appPassword, + token, commitSigningKey: config.getOptionalString('commitSigningKey'), }; } diff --git a/packages/integration/src/bitbucketCloud/core.test.ts b/packages/integration/src/bitbucketCloud/core.test.ts index 56a53b924d..fa8fc2ae43 100644 --- a/packages/integration/src/bitbucketCloud/core.test.ts +++ b/packages/integration/src/bitbucketCloud/core.test.ts @@ -25,22 +25,38 @@ import { getBitbucketCloudRequestOptions, } from './core'; +// Mock constants +const BITBUCKET_CLOUD_HOST = 'bitbucket.org'; +const BITBUCKET_CLOUD_API_BASE_URL = 'https://api.bitbucket.org/2.0'; + describe('bitbucketCloud core', () => { const worker = setupServer(); registerMswTestHooks(worker); describe('getBitbucketCloudRequestOptions', () => { it('insert basic auth when needed', () => { + const withUsernameAndToken: BitbucketCloudIntegrationConfig = { + host: BITBUCKET_CLOUD_HOST, + apiBaseUrl: BITBUCKET_CLOUD_API_BASE_URL, + username: 'some-user@domain.com', + token: 'my-token', + }; + // TODO: appPassword can be removed once fully + // deprecated by BitBucket on 9th June 2026. const withUsernameAndPassword: BitbucketCloudIntegrationConfig = { - host: 'bitbucket.org', - apiBaseUrl: 'https://api.bitbucket.org/2.0', + host: BITBUCKET_CLOUD_HOST, + apiBaseUrl: BITBUCKET_CLOUD_API_BASE_URL, username: 'some-user', appPassword: 'my-secret', }; - const withoutUsernameAndPassword: BitbucketCloudIntegrationConfig = { - host: 'bitbucket.org', - apiBaseUrl: 'https://api.bitbucket.org/2.0', + const withoutUsername: BitbucketCloudIntegrationConfig = { + host: BITBUCKET_CLOUD_HOST, + apiBaseUrl: BITBUCKET_CLOUD_API_BASE_URL, }; + expect( + (getBitbucketCloudRequestOptions(withUsernameAndToken).headers as any) + .Authorization, + ).toEqual('Basic c29tZS11c2VyQGRvbWFpbi5jb206bXktdG9rZW4='); expect( ( getBitbucketCloudRequestOptions(withUsernameAndPassword) @@ -48,10 +64,8 @@ describe('bitbucketCloud core', () => { ).Authorization, ).toEqual('Basic c29tZS11c2VyOm15LXNlY3JldA=='); expect( - ( - getBitbucketCloudRequestOptions(withoutUsernameAndPassword) - .headers as any - ).Authorization, + (getBitbucketCloudRequestOptions(withoutUsername).headers as any) + .Authorization, ).toBeUndefined(); }); }); diff --git a/packages/integration/src/bitbucketCloud/core.ts b/packages/integration/src/bitbucketCloud/core.ts index 37feb5dad6..a97de872de 100644 --- a/packages/integration/src/bitbucketCloud/core.ts +++ b/packages/integration/src/bitbucketCloud/core.ts @@ -117,24 +117,28 @@ export function getBitbucketCloudFileFetchUrl( /** * Gets the request options necessary to make requests to a given provider. + * Returns headers for authenticating with Bitbucket Cloud. + * Supports both username/token and username/appPassword auth. * * @param config - The relevant provider config * @public */ export function getBitbucketCloudRequestOptions( config: BitbucketCloudIntegrationConfig, -): { headers: Record } { +): { + headers: Record; +} { const headers: Record = {}; - if (config.username && config.appPassword) { + // TODO: appPassword can be removed once fully + // deprecated by BitBucket on 9th June 2026. + if (config.username && (config.token ?? config.appPassword)) { const buffer = Buffer.from( - `${config.username}:${config.appPassword}`, + `${config.username}:${config.token ?? config.appPassword}`, 'utf8', ); headers.Authorization = `Basic ${buffer.toString('base64')}`; } - return { - headers, - }; + return { headers }; } diff --git a/plugins/bitbucket-cloud-common/src/BitbucketCloudClient.ts b/plugins/bitbucket-cloud-common/src/BitbucketCloudClient.ts index d1d4a35561..adbf08cf40 100644 --- a/plugins/bitbucket-cloud-common/src/BitbucketCloudClient.ts +++ b/plugins/bitbucket-cloud-common/src/BitbucketCloudClient.ts @@ -155,14 +155,17 @@ export class BitbucketCloudClient { private getAuthHeaders(): Record { const headers: Record = {}; - if (this.config.username) { + if ( + this.config.username && + (this.config.token ?? this.config.appPassword) + ) { const buffer = Buffer.from( - `${this.config.username}:${this.config.appPassword}`, + `${this.config.username}:${ + this.config.token ?? this.config.appPassword + }`, 'utf8', ); headers.Authorization = `Basic ${buffer.toString('base64')}`; - } else if (this.config.token) { - headers.Authorization = `Bearer ${this.config.token}`; } return headers; diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/helpers.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/helpers.ts index 1abffea393..da70d9252a 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/helpers.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/helpers.ts @@ -21,22 +21,24 @@ export const getBitbucketClient = (config: { username?: string; appPassword?: string; }) => { - if (config.username && config.appPassword) { + if (config.token) { + return new Bitbucket({ + auth: { + token: config.token, + }, + }); + } else if (config.username && config.appPassword) { + // TODO: appPassword can be removed once fully + // deprecated by BitBucket on 9th June 2026. return new Bitbucket({ auth: { username: config.username, password: config.appPassword, }, }); - } else if (config.token) { - return new Bitbucket({ - auth: { - token: config.token, - }, - }); } throw new Error( - `Authorization has not been provided for Bitbucket Cloud. Please add either username + appPassword to the Integrations config or a user login auth token`, + `Authorization has not been provided for Bitbucket Cloud. Please add either provide a username and token or username and appPassword to the Integrations config`, ); }; @@ -45,12 +47,13 @@ export const getAuthorizationHeader = (config: { appPassword?: string; token?: string; }) => { - if (config.username && config.appPassword) { + // TODO: appPassword can be removed once fully + // deprecated by BitBucket on 9th June 2026. + if (config.username && (config.token ?? config.appPassword)) { const buffer = Buffer.from( - `${config.username}:${config.appPassword}`, + `${config.username}:${config.token ?? config.appPassword}`, 'utf8', ); - return `Basic ${buffer.toString('base64')}`; } @@ -59,6 +62,6 @@ export const getAuthorizationHeader = (config: { } throw new Error( - `Authorization has not been provided for Bitbucket Cloud. Please add either username + appPassword to the Integrations config or a user login auth token`, + `Authorization has not been provided for Bitbucket Cloud. Please add either provide a username and token or username and appPassword to the Integrations config`, ); }; diff --git a/plugins/scaffolder-backend-module-bitbucket/src/actions/bitbucket.ts b/plugins/scaffolder-backend-module-bitbucket/src/actions/bitbucket.ts index f25bc0a7f1..113e642ee8 100644 --- a/plugins/scaffolder-backend-module-bitbucket/src/actions/bitbucket.ts +++ b/plugins/scaffolder-backend-module-bitbucket/src/actions/bitbucket.ts @@ -153,9 +153,9 @@ const createBitbucketServerRepository = async (opts: { }; const getAuthorizationHeader = (config: BitbucketIntegrationConfig) => { - if (config.username && config.appPassword) { + if (config.username && (config.token ?? config.appPassword)) { const buffer = Buffer.from( - `${config.username}:${config.appPassword}`, + `${config.username}:${config.token ?? config.appPassword}`, 'utf8', ); @@ -167,7 +167,7 @@ const getAuthorizationHeader = (config: BitbucketIntegrationConfig) => { } throw new Error( - `Authorization has not been provided for Bitbucket. Please add either username + appPassword or token to the Integrations config`, + `Authorization has not been provided for Bitbucket. Please add either provide a username and token or username and appPassword to the Integrations config`, ); };