Bitbucket Cloud - API Token Support
Signed-off-by: Andre Wanlin <awanlin@spotify.com>
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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}}`;
|
||||
}
|
||||
|
||||
|
||||
Vendored
+8
-2
@@ -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`,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user