Merge pull request #32356 from robertoj921/bitbucket-cloud-api-token

Bitbucket cloud api token
This commit is contained in:
Andre Wanlin
2026-01-26 16:57:01 -06:00
committed by GitHub
7 changed files with 470 additions and 76 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend-module-bitbucket-cloud': patch
---
Fully enable API token functionality for Bitbucket-Cloud.
@@ -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: {},
});
});
});
@@ -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 ??
@@ -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',
);
});
});
@@ -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:
@@ -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/,
);
});
});
});
@@ -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,54 @@ 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'
// https://support.atlassian.com/bitbucket-cloud/docs/using-api-tokens/
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`,
);
};