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