Bitbucket Cloud - API Token Support

Signed-off-by: Andre Wanlin <awanlin@spotify.com>
This commit is contained in:
Andre Wanlin
2025-11-17 12:49:43 -06:00
parent dd54fffedc
commit fa255f530a
12 changed files with 223 additions and 84 deletions
+18
View File
@@ -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
```
+1 -1
View File
@@ -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
+15 -3
View File
@@ -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.
@@ -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}}`;
}
+8 -2
View File
@@ -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
@@ -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<BitbucketCloudIntegrationConfig>,
): Promise<Config> {
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<BitbucketCloudIntegrationConfig>): Config {
return new ConfigReader(data);
}
async function buildFrontendConfig(
data: Partial<BitbucketCloudIntegrationConfig>,
): Promise<Config> {
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',
});
});
@@ -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'),
};
}
@@ -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();
});
});
@@ -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<string, string> } {
): {
headers: Record<string, string>;
} {
const headers: Record<string, 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',
);
headers.Authorization = `Basic ${buffer.toString('base64')}`;
}
return {
headers,
};
return { headers };
}
@@ -155,14 +155,17 @@ export class BitbucketCloudClient {
private getAuthHeaders(): Record<string, string> {
const headers: Record<string, string> = {};
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;
@@ -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`,
);
};
@@ -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`,
);
};