Merge pull request #32356 from robertoj921/bitbucket-cloud-api-token
Bitbucket cloud api token
This commit is contained in:
@@ -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 ??
|
||||
|
||||
+46
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+6
-36
@@ -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`,
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user