From 4b21b25c24035d6dc35459fef20a8bbb93661748 Mon Sep 17 00:00:00 2001 From: Joseph Roberto Date: Fri, 16 Jan 2026 09:11:29 -0600 Subject: [PATCH 1/5] fix: Bitbucket Cloud api token auth on git actions Signed-off-by: Joseph Roberto --- .../src/actions/bitbucketCloud.ts | 40 ++--------- .../src/actions/bitbucketCloudPullRequest.ts | 42 ++--------- .../src/actions/helpers.ts | 72 +++++++++++++++++-- 3 files changed, 78 insertions(+), 76 deletions(-) diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloud.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloud.ts index a278410639..d34235f633 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloud.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloud.ts @@ -24,8 +24,7 @@ import { } from '@backstage/plugin-scaffolder-node'; import { Config } from '@backstage/config'; -import { getAuthorizationHeader } from './helpers'; -import { getBitbucketCloudOAuthToken } from '@backstage/integration'; +import { getAuthorizationHeader, getGitAuth } from './helpers'; import { examples } from './bitbucketCloud.examples'; const createRepository = async (opts: { @@ -243,40 +242,9 @@ export function createPublishBitbucketCloudAction(options: { email: config.getOptionalString('scaffolder.defaultAuthor.email'), }; - let auth; - - if (ctx.input.token) { - auth = { - username: 'x-token-auth', - password: ctx.input.token, - }; - } else if ( - integrationConfig.config.clientId && - integrationConfig.config.clientSecret - ) { - const token = await getBitbucketCloudOAuthToken( - integrationConfig.config.clientId, - integrationConfig.config.clientSecret, - ); - auth = { - username: 'x-token-auth', - password: token, - }; - } else { - if ( - !integrationConfig.config.username || - !integrationConfig.config.appPassword - ) { - throw new Error( - 'Credentials for Bitbucket Cloud integration required for this action.', - ); - } - - auth = { - username: integrationConfig.config.username, - password: integrationConfig.config.appPassword, - }; - } + const auth = await getGitAuth( + ctx.input.token ? { token: ctx.input.token } : integrationConfig.config, + ); const signingKey = integrationConfig.config.commitSigningKey ?? diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPullRequest.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPullRequest.ts index 40dd09d386..01b556e6c2 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPullRequest.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPullRequest.ts @@ -27,8 +27,7 @@ import { } from '@backstage/plugin-scaffolder-node'; import { Config } from '@backstage/config'; import fs from 'fs-extra'; -import { getAuthorizationHeader } from './helpers'; -import { getBitbucketCloudOAuthToken } from '@backstage/integration'; +import { getAuthorizationHeader, getGitAuth } from './helpers'; import { examples } from './bitbucketCloudPullRequest.examples'; const createPullRequest = async (opts: { @@ -359,40 +358,11 @@ export function createPublishBitbucketCloudPullRequestAction(options: { const remoteUrl = `https://${host}/${workspace}/${repo}.git`; - let auth; - - if (ctx.input.token) { - auth = { - username: 'x-token-auth', - password: ctx.input.token, - }; - } else if ( - integrationConfig.config.clientId && - integrationConfig.config.clientSecret - ) { - const token = await getBitbucketCloudOAuthToken( - integrationConfig.config.clientId, - integrationConfig.config.clientSecret, - ); - auth = { - username: 'x-token-auth', - password: token, - }; - } else { - if ( - !integrationConfig.config.username || - !integrationConfig.config.appPassword - ) { - throw new Error( - 'Credentials for Bitbucket Cloud integration required for this action.', - ); - } - - auth = { - username: integrationConfig.config.username, - password: integrationConfig.config.appPassword, - }; - } + const auth = await getGitAuth( + ctx.input.token + ? { token: ctx.input.token } + : integrationConfig.config, + ); const gitAuthorInfo = { name: 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 47a212fcab..a2954570d8 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/helpers.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/helpers.ts @@ -37,15 +37,28 @@ export const getBitbucketClient = async (config: { }); } - if (config.token) { + // Standalone token (Bearer) + if (config.token && !config.username) { 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. + } + + // Username + API token (new method) + if (config.username && config.token) { + return new Bitbucket({ + auth: { + username: config.username, + password: config.token, + }, + }); + } + + // TODO: appPassword can be removed once fully + // deprecated by BitBucket on 9th June 2026. + if (config.username && config.appPassword) { return new Bitbucket({ auth: { username: config.username, @@ -53,6 +66,7 @@ export const getBitbucketClient = async (config: { }, }); } + throw new Error( `Authorization has not been provided for Bitbucket Cloud. Please provide either OAuth credentials (clientId/clientSecret), username and token, or username and appPassword in the Integrations config`, ); @@ -92,3 +106,53 @@ export const getAuthorizationHeader = async (config: { `Authorization has not been provided for Bitbucket Cloud. Please provide either OAuth credentials (clientId/clientSecret), username and token, or username and appPassword in the Integrations config`, ); }; + +export const getGitAuth = async (config: { + username?: string; + appPassword?: string; + token?: string; + clientId?: string; + clientSecret?: string; +}): Promise<{ username: string; password: string }> => { + // OAuth authentication + if (config.clientId && config.clientSecret) { + const token = await getBitbucketCloudOAuthToken( + config.clientId, + config.clientSecret, + ); + return { + username: 'x-token-auth', + password: token, + }; + } + + // Standalone token (Bearer) + if (config.token && !config.username) { + return { + username: 'x-token-auth', + password: config.token, + }; + } + + // Username + API token (new method) + // For git operations, use the static username 'x-bitbucket-api-token-auth' + if (config.username && config.token) { + return { + username: 'x-bitbucket-api-token-auth', + password: config.token, + }; + } + + // TODO: appPassword can be removed once fully + // deprecated by BitBucket on 9th June 2026. + if (config.username && config.appPassword) { + return { + username: config.username, + password: config.appPassword, + }; + } + + throw new Error( + `Authorization has not been provided for Bitbucket Cloud. Please provide either OAuth credentials (clientId/clientSecret), username and token, or username and appPassword in the Integrations config`, + ); +}; From a7aaa3d044a73934f2effdbb49ad49421649b097 Mon Sep 17 00:00:00 2001 From: Joseph Roberto Date: Fri, 16 Jan 2026 09:12:57 -0600 Subject: [PATCH 2/5] test: Add unit tests for updated Bitbucket Cloud authentication Signed-off-by: Joseph Roberto --- .../src/actions/bitbucketCloud.test.ts | 100 ++++++++ .../actions/bitbucketCloudPullRequest.test.ts | 46 ++++ .../src/actions/helpers.test.ts | 240 ++++++++++++++++++ 3 files changed, 386 insertions(+) create mode 100644 plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/helpers.test.ts diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloud.test.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloud.test.ts index 6cac8a0e1c..9e55be473a 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloud.test.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloud.test.ts @@ -481,4 +481,104 @@ describe('publish:bitbucketCloud', () => { 'https://bitbucket.org/workspace/repo/src/main', ); }); + + it('should call initAndPush with username and token credentials', async () => { + const tokenConfig = new ConfigReader({ + integrations: { + bitbucketCloud: [ + { + username: 'test-user', + token: 'api-token-123', + }, + ], + }, + }); + + const tokenIntegrations = ScmIntegrations.fromConfig(tokenConfig); + const tokenAction = createPublishBitbucketCloudAction({ + integrations: tokenIntegrations, + config: tokenConfig, + }); + + server.use( + rest.post( + 'https://api.bitbucket.org/2.0/repositories/workspace/repo', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json({ + links: { + html: { + href: 'https://bitbucket.org/workspace/repo', + }, + clone: [ + { + name: 'https', + href: 'https://bitbucket.org/workspace/cloneurl', + }, + ], + }, + }), + ), + ), + ); + + await tokenAction.handler(mockContext); + + expect(initRepoAndPush).toHaveBeenCalledWith({ + dir: mockContext.workspacePath, + remoteUrl: 'https://bitbucket.org/workspace/cloneurl', + defaultBranch: 'master', + auth: { + username: 'x-bitbucket-api-token-auth', + password: 'api-token-123', + }, + logger: mockContext.logger, + gitAuthorInfo: {}, + }); + }); + + it('should call initAndPush with token passed through ctx.input', async () => { + server.use( + rest.post( + 'https://api.bitbucket.org/2.0/repositories/workspace/repo', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json({ + links: { + html: { + href: 'https://bitbucket.org/workspace/repo', + }, + clone: [ + { + name: 'https', + href: 'https://bitbucket.org/workspace/cloneurl', + }, + ], + }, + }), + ), + ), + ); + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + token: 'input-token-override', + }, + }); + + expect(initRepoAndPush).toHaveBeenCalledWith({ + dir: mockContext.workspacePath, + remoteUrl: 'https://bitbucket.org/workspace/cloneurl', + defaultBranch: 'master', + auth: { username: 'x-token-auth', password: 'input-token-override' }, + logger: mockContext.logger, + gitAuthorInfo: {}, + }); + }); }); diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPullRequest.test.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPullRequest.test.ts index b4db3f78c6..c0947a616f 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPullRequest.test.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPullRequest.test.ts @@ -415,4 +415,50 @@ describe('publish:bitbucketCloud:pull-request', () => { 'https://bitbucket.org/workspace/repo/pull-requests/1', ); }); + + it('should use username and token credentials from integration config', async () => { + const tokenConfig = new ConfigReader({ + integrations: { + bitbucketCloud: [ + { + username: 'test-user', + token: 'api-token-123', + }, + ], + }, + }); + + const tokenIntegrations = ScmIntegrations.fromConfig(tokenConfig); + const tokenAction = createPublishBitbucketCloudPullRequestAction({ + integrations: tokenIntegrations, + config: tokenConfig, + }); + + server.use( + ...handlers, + rest.post( + 'https://api.bitbucket.org/2.0/repositories/workspace/repo/refs/branches', + (_, res, ctx) => { + return res( + ctx.status(201), + ctx.set('Content-Type', 'application/json'), + ctx.json({}), + ); + }, + ), + ); + + await tokenAction.handler({ + ...mockContext, + input: { + ...mockContext.input, + sourceBranch: 'new-branch', + }, + }); + + expect(mockContext.output).toHaveBeenCalledWith( + 'pullRequestUrl', + 'https://bitbucket.org/workspace/repo/pull-requests/1', + ); + }); }); diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/helpers.test.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/helpers.test.ts new file mode 100644 index 0000000000..c236bb941a --- /dev/null +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/helpers.test.ts @@ -0,0 +1,240 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + getBitbucketClient, + getAuthorizationHeader, + getGitAuth, +} from './helpers'; +import { getBitbucketCloudOAuthToken } from '@backstage/integration'; + +jest.mock('@backstage/integration', () => ({ + getBitbucketCloudOAuthToken: jest.fn(), +})); + +describe('helpers', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getBitbucketClient', () => { + it('should create client with OAuth token', async () => { + (getBitbucketCloudOAuthToken as jest.Mock).mockResolvedValue( + 'oauth-token-123', + ); + + const client = await getBitbucketClient({ + clientId: 'client-id', + clientSecret: 'client-secret', + }); + + expect(client).toBeDefined(); + expect(getBitbucketCloudOAuthToken).toHaveBeenCalledWith( + 'client-id', + 'client-secret', + ); + }); + + it('should create client with standalone token', async () => { + const client = await getBitbucketClient({ + token: 'standalone-token', + }); + + expect(client).toBeDefined(); + }); + + it('should create client with username and API token', async () => { + const client = await getBitbucketClient({ + username: 'test-user', + token: 'api-token', + }); + + expect(client).toBeDefined(); + }); + + it('should create client with username and appPassword', async () => { + const client = await getBitbucketClient({ + username: 'test-user', + appPassword: 'app-password', + }); + + expect(client).toBeDefined(); + }); + + it('should throw error when no credentials provided', async () => { + await expect(getBitbucketClient({})).rejects.toThrow( + /Authorization has not been provided for Bitbucket Cloud/, + ); + }); + }); + + describe('getAuthorizationHeader', () => { + it('should return Bearer token for OAuth credentials', async () => { + (getBitbucketCloudOAuthToken as jest.Mock).mockResolvedValue( + 'oauth-token-123', + ); + + const result = await getAuthorizationHeader({ + clientId: 'client-id', + clientSecret: 'client-secret', + }); + + expect(result).toBe('Bearer oauth-token-123'); + expect(getBitbucketCloudOAuthToken).toHaveBeenCalledWith( + 'client-id', + 'client-secret', + ); + }); + + it('should return Basic auth for username and token', async () => { + const result = await getAuthorizationHeader({ + username: 'test-user', + token: 'api-token', + }); + + const expectedAuth = Buffer.from('test-user:api-token', 'utf8').toString( + 'base64', + ); + expect(result).toBe(`Basic ${expectedAuth}`); + }); + + it('should return Basic auth for username and appPassword', async () => { + const result = await getAuthorizationHeader({ + username: 'test-user', + appPassword: 'app-password', + }); + + const expectedAuth = Buffer.from( + 'test-user:app-password', + 'utf8', + ).toString('base64'); + expect(result).toBe(`Basic ${expectedAuth}`); + }); + + it('should prefer token over appPassword when both are provided', async () => { + const result = await getAuthorizationHeader({ + username: 'test-user', + token: 'api-token', + appPassword: 'app-password', + }); + + const expectedAuth = Buffer.from('test-user:api-token', 'utf8').toString( + 'base64', + ); + expect(result).toBe(`Basic ${expectedAuth}`); + }); + + it('should return Bearer token for standalone token', async () => { + const result = await getAuthorizationHeader({ + token: 'standalone-token', + }); + + expect(result).toBe('Bearer standalone-token'); + }); + + it('should throw error when no credentials provided', async () => { + await expect(getAuthorizationHeader({})).rejects.toThrow( + /Authorization has not been provided for Bitbucket Cloud/, + ); + }); + }); + + describe('getGitAuth', () => { + it('should return x-token-auth for OAuth credentials', async () => { + (getBitbucketCloudOAuthToken as jest.Mock).mockResolvedValue( + 'oauth-token-123', + ); + + const result = await getGitAuth({ + clientId: 'client-id', + clientSecret: 'client-secret', + }); + + expect(result).toEqual({ + username: 'x-token-auth', + password: 'oauth-token-123', + }); + expect(getBitbucketCloudOAuthToken).toHaveBeenCalledWith( + 'client-id', + 'client-secret', + ); + }); + + it('should return x-token-auth for standalone token', async () => { + const result = await getGitAuth({ + token: 'standalone-token', + }); + + expect(result).toEqual({ + username: 'x-token-auth', + password: 'standalone-token', + }); + }); + + it('should return x-bitbucket-api-token-auth for username + API token', async () => { + const result = await getGitAuth({ + username: 'test-user', + token: 'api-token', + }); + + expect(result).toEqual({ + username: 'x-bitbucket-api-token-auth', + password: 'api-token', + }); + }); + + it('should return username and appPassword for username + appPassword', async () => { + const result = await getGitAuth({ + username: 'test-user', + appPassword: 'app-password', + }); + + expect(result).toEqual({ + username: 'test-user', + password: 'app-password', + }); + }); + + it('should prefer token over appPassword when both are provided', async () => { + const result = await getGitAuth({ + username: 'test-user', + token: 'api-token', + appPassword: 'app-password', + }); + + expect(result).toEqual({ + username: 'x-bitbucket-api-token-auth', + password: 'api-token', + }); + }); + + it('should throw error when no credentials provided', async () => { + await expect(getGitAuth({})).rejects.toThrow( + /Authorization has not been provided for Bitbucket Cloud/, + ); + }); + + it('should throw error when only username is provided', async () => { + await expect( + getGitAuth({ + username: 'test-user', + }), + ).rejects.toThrow( + /Authorization has not been provided for Bitbucket Cloud/, + ); + }); + }); +}); From 14741e28dd2f799c799e3d050098b603273c8806 Mon Sep 17 00:00:00 2001 From: Joseph Roberto Date: Fri, 16 Jan 2026 09:57:16 -0600 Subject: [PATCH 3/5] chore: Add changeset for Bitbucket-Cloud API token fix Signed-off-by: Joseph Roberto --- .changeset/floppy-parks-decide.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/floppy-parks-decide.md diff --git a/.changeset/floppy-parks-decide.md b/.changeset/floppy-parks-decide.md new file mode 100644 index 0000000000..3e90f27be9 --- /dev/null +++ b/.changeset/floppy-parks-decide.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend-module-bitbucket-cloud': patch +--- + +Created getGitAuth helper function for Bitbucket-Cloud git-related calls. This updates the remaining calls that were missed in the recent update to add API token authentication and should now fully enable API token functionality for Bitbucket-Cloud. From c835069af328a623340e449086fd38a4ea78cfde Mon Sep 17 00:00:00 2001 From: Joe Roberto <91907455+robertoj921@users.noreply.github.com> Date: Wed, 21 Jan 2026 08:21:41 -0600 Subject: [PATCH 4/5] Update .changeset/floppy-parks-decide.md Co-authored-by: Andre Wanlin <67169551+awanlin@users.noreply.github.com> Signed-off-by: Joe Roberto <91907455+robertoj921@users.noreply.github.com> --- .changeset/floppy-parks-decide.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/floppy-parks-decide.md b/.changeset/floppy-parks-decide.md index 3e90f27be9..30c1cfc9a5 100644 --- a/.changeset/floppy-parks-decide.md +++ b/.changeset/floppy-parks-decide.md @@ -2,4 +2,4 @@ '@backstage/plugin-scaffolder-backend-module-bitbucket-cloud': patch --- -Created getGitAuth helper function for Bitbucket-Cloud git-related calls. This updates the remaining calls that were missed in the recent update to add API token authentication and should now fully enable API token functionality for Bitbucket-Cloud. +Fully enable API token functionality for Bitbucket-Cloud. From cbb346d55f6036f3323e88c70962dde01b954275 Mon Sep 17 00:00:00 2001 From: Joe Roberto <91907455+robertoj921@users.noreply.github.com> Date: Wed, 21 Jan 2026 08:23:02 -0600 Subject: [PATCH 5/5] Update plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/helpers.ts Co-authored-by: Andre Wanlin <67169551+awanlin@users.noreply.github.com> Signed-off-by: Joe Roberto <91907455+robertoj921@users.noreply.github.com> --- .../src/actions/helpers.ts | 1 + 1 file changed, 1 insertion(+) 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 a2954570d8..3c7ee8f17a 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/helpers.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/helpers.ts @@ -136,6 +136,7 @@ export const getGitAuth = async (config: { // Username + API token (new method) // For git operations, use the static username 'x-bitbucket-api-token-auth' + // https://support.atlassian.com/bitbucket-cloud/docs/using-api-tokens/ if (config.username && config.token) { return { username: 'x-bitbucket-api-token-auth',