Add Bitbucket Cloud OAuth support

Signed-off-by: Jake Smith <jakemgsmith@gmail.com>
This commit is contained in:
Jake Smith
2025-11-22 01:47:59 +00:00
committed by Fredrik Adelöw
parent e404b12604
commit 959e6ecc5a
15 changed files with 398 additions and 46 deletions
+12 -1
View File
@@ -32,6 +32,15 @@ integrations:
appPassword: my-password
```
OAuth 2.0 client credentials flow:
```yaml
integrations:
bitbucketCloud:
- clientId: client-id
clientSecret: client-secret
```
:::note Note
A public Bitbucket Cloud provider is added automatically at startup for
@@ -41,7 +50,7 @@ convenience, so you only need to list it if you want to supply credentials.
:::note Note
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.
The credential required for this type is either an [Api token](https://support.atlassian.com/bitbucket-cloud/docs/using-api-tokens/), an [App Password](https://support.atlassian.com/bitbucket-cloud/docs/app-passwords/) or an [OAuth 2.0 client credentials](https://support.atlassian.com/bitbucket-cloud/docs/use-oauth-on-bitbucket-cloud/). An Atlassian Account API key will not work.
:::
@@ -55,3 +64,5 @@ This one entry will have the following elements:
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.
- `clientId`: The OAuth client ID for Bitbucket Cloud (used with `clientSecret` for OAuth 2.0 client credentials flow).
- `clientSecret`: The OAuth client secret for Bitbucket Cloud (used with `clientId` for OAuth 2.0 client credentials flow).
@@ -68,7 +68,15 @@ export class BitbucketCloudUrlReader implements UrlReaderService {
) {
this.integration = integration;
this.deps = deps;
const { host, username, appPassword, token } = integration.config;
const { host, username, appPassword, token, clientId, clientSecret } =
integration.config;
// Validate: OAuth requires both clientId and clientSecret
if ((clientId && !clientSecret) || (clientSecret && !clientId)) {
throw new Error(
`Bitbucket Cloud integration has incomplete OAuth configuration. Both clientId and clientSecret are required.`,
);
}
if (username && !token && !appPassword) {
throw new Error(
@@ -91,7 +99,7 @@ export class BitbucketCloudUrlReader implements UrlReaderService {
url,
this.integration.config,
);
const requestOptions = getBitbucketCloudRequestOptions(
const requestOptions = await getBitbucketCloudRequestOptions(
this.integration.config,
);
@@ -149,7 +157,7 @@ export class BitbucketCloudUrlReader implements UrlReaderService {
);
const archiveResponse = await fetch(
downloadUrl,
getBitbucketCloudRequestOptions(this.integration.config),
await getBitbucketCloudRequestOptions(this.integration.config),
);
if (!archiveResponse.ok) {
const message = `Failed to read tree from ${url}, ${archiveResponse.status} ${archiveResponse.statusText}`;
@@ -250,7 +258,7 @@ export class BitbucketCloudUrlReader implements UrlReaderService {
const commitsResponse = await fetch(
commitsApiUrl,
getBitbucketCloudRequestOptions(this.integration.config),
await getBitbucketCloudRequestOptions(this.integration.config),
);
if (!commitsResponse.ok) {
const message = `Failed to retrieve commits from ${commitsApiUrl}, ${commitsResponse.status} ${commitsResponse.statusText}`;
+11 -1
View File
@@ -174,7 +174,7 @@ export interface Config {
* The username to use for authenticated requests.
* @visibility secret
*/
username: string;
username?: string;
/**
* Token used to authenticate requests.
* @visibility secret
@@ -186,6 +186,16 @@ export interface Config {
* @deprecated Use `token` instead.
*/
appPassword?: string;
/**
* OAuth client ID for Bitbucket Cloud.
* @visibility secret
*/
clientId?: string;
/**
* OAuth client secret for Bitbucket Cloud.
* @visibility secret
*/
clientSecret?: string;
/**
* PGP signing key for signing commits.
* @visibility secret
@@ -85,6 +85,39 @@ describe('readBitbucketCloudIntegrationConfig', () => {
).toThrow(/token/);
});
it('reads OAuth configuration', () => {
const output = readBitbucketCloudIntegrationConfig(
buildConfig({
clientId: 'my-client-id',
clientSecret: 'my-client-secret',
}),
);
expect(output).toEqual({
host: BITBUCKET_CLOUD_HOST,
apiBaseUrl: BITBUCKET_CLOUD_API_BASE_URL,
clientId: 'my-client-id',
clientSecret: 'my-client-secret',
});
});
it('rejects incomplete OAuth configuration', () => {
expect(() =>
readBitbucketCloudIntegrationConfig(
buildConfig({
clientId: 'my-client-id',
}),
),
).toThrow(/incomplete OAuth configuration/);
expect(() =>
readBitbucketCloudIntegrationConfig(
buildConfig({
clientSecret: 'my-client-secret',
}),
),
).toThrow(/incomplete OAuth configuration/);
});
it('credentials hidden on the frontend', async () => {
const frontendConfig = await buildFrontendConfig({
token: 't',
@@ -103,6 +136,24 @@ describe('readBitbucketCloudIntegrationConfig', () => {
]);
});
it('OAuth credentials hidden on the frontend', async () => {
const frontendConfig = await buildFrontendConfig({
clientId: 'my-client-id',
clientSecret: 'my-client-secret',
});
expect(
readBitbucketCloudIntegrationConfigs(
frontendConfig.getOptionalConfigArray('integrations.bitbucketCloud') ??
[],
),
).toEqual([
{
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', () => {
@@ -54,6 +54,20 @@ export type BitbucketCloudIntegrationConfig = {
*/
token?: string;
/**
* The OAuth client ID for Bitbucket Cloud.
*
* See https://support.atlassian.com/bitbucket-cloud/docs/use-oauth-on-bitbucket-cloud/
*/
clientId?: string;
/**
* The OAuth client secret for Bitbucket Cloud.
*
* See https://support.atlassian.com/bitbucket-cloud/docs/use-oauth-on-bitbucket-cloud/
*/
clientSecret?: string;
/** PGP private key for signing commits. */
commitSigningKey?: string;
};
@@ -71,24 +85,36 @@ export function readBitbucketCloudIntegrationConfig(
const apiBaseUrl = BITBUCKET_CLOUD_API_BASE_URL;
// If config is provided, we assume authenticated access is desired
// (as the anonymous one is provided by default).
const username = config.getString('username');
const username = config.getOptionalString('username');
// 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');
const clientId = config.getOptionalString('clientId')?.trim();
const clientSecret = config.getOptionalString('clientSecret')?.trim();
if (!token && !appPassword) {
// Validate: if username is provided, token or appPassword is required
if (username && !token && !appPassword) {
throw new Error(
`Bitbucket Cloud integration must be configured with as username and either a token or an appPassword.`,
);
}
// Validate: OAuth requires both clientId and clientSecret
if ((clientId && !clientSecret) || (clientSecret && !clientId)) {
throw new Error(
`Bitbucket Cloud integration has incomplete OAuth configuration. Both clientId and clientSecret are required.`,
);
}
return {
host,
apiBaseUrl,
username,
appPassword,
token,
clientId,
clientSecret,
commitSigningKey: config.getOptionalString('commitSigningKey'),
};
}
@@ -28,13 +28,15 @@ import {
// Mock constants
const BITBUCKET_CLOUD_HOST = 'bitbucket.org';
const BITBUCKET_CLOUD_API_BASE_URL = 'https://api.bitbucket.org/2.0';
const BITBUCKET_CLOUD_OAUTH_TOKEN_URL =
'https://bitbucket.org/site/oauth2/access_token';
describe('bitbucketCloud core', () => {
const worker = setupServer();
registerMswTestHooks(worker);
describe('getBitbucketCloudRequestOptions', () => {
it('insert basic auth when needed', () => {
it('insert basic auth when needed', async () => {
const withUsernameAndToken: BitbucketCloudIntegrationConfig = {
host: BITBUCKET_CLOUD_HOST,
apiBaseUrl: BITBUCKET_CLOUD_API_BASE_URL,
@@ -54,20 +56,98 @@ describe('bitbucketCloud core', () => {
apiBaseUrl: BITBUCKET_CLOUD_API_BASE_URL,
};
expect(
(getBitbucketCloudRequestOptions(withUsernameAndToken).headers as any)
(await getBitbucketCloudRequestOptions(withUsernameAndToken)).headers
.Authorization,
).toEqual('Basic c29tZS11c2VyQGRvbWFpbi5jb206bXktdG9rZW4=');
expect(
(
getBitbucketCloudRequestOptions(withUsernameAndPassword)
.headers as any
).Authorization,
(await getBitbucketCloudRequestOptions(withUsernameAndPassword)).headers
.Authorization,
).toEqual('Basic c29tZS11c2VyOm15LXNlY3JldA==');
expect(
(getBitbucketCloudRequestOptions(withoutUsername).headers as any)
(await getBitbucketCloudRequestOptions(withoutUsername)).headers
.Authorization,
).toBeUndefined();
});
it('uses OAuth Bearer token when clientId and clientSecret provided', async () => {
worker.use(
rest.post(BITBUCKET_CLOUD_OAUTH_TOKEN_URL, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
access_token: 'test-oauth-token',
expires_in: 3600,
}),
),
),
);
const withOAuth: BitbucketCloudIntegrationConfig = {
host: BITBUCKET_CLOUD_HOST,
apiBaseUrl: BITBUCKET_CLOUD_API_BASE_URL,
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
};
const result = await getBitbucketCloudRequestOptions(withOAuth);
expect(result.headers.Authorization).toEqual('Bearer test-oauth-token');
});
it('caches OAuth tokens', async () => {
let callCount = 0;
worker.use(
rest.post(BITBUCKET_CLOUD_OAUTH_TOKEN_URL, (_, res, ctx) => {
callCount++;
return res(
ctx.status(200),
ctx.json({
access_token: 'cached-oauth-token',
expires_in: 3600,
}),
);
}),
);
const withOAuth: BitbucketCloudIntegrationConfig = {
host: BITBUCKET_CLOUD_HOST,
apiBaseUrl: BITBUCKET_CLOUD_API_BASE_URL,
clientId: 'cache-test-client',
clientSecret: 'cache-test-secret',
};
// First call should fetch token
const result1 = await getBitbucketCloudRequestOptions(withOAuth);
expect(result1.headers.Authorization).toEqual(
'Bearer cached-oauth-token',
);
expect(callCount).toBe(1);
// Second call should use cached token
const result2 = await getBitbucketCloudRequestOptions(withOAuth);
expect(result2.headers.Authorization).toEqual(
'Bearer cached-oauth-token',
);
expect(callCount).toBe(1); // Should still be 1
});
it('handles OAuth token fetch errors', async () => {
worker.use(
rest.post(BITBUCKET_CLOUD_OAUTH_TOKEN_URL, (_, res, ctx) =>
res(ctx.status(401), ctx.json({ error: 'invalid_client' })),
),
);
const withOAuth: BitbucketCloudIntegrationConfig = {
host: BITBUCKET_CLOUD_HOST,
apiBaseUrl: BITBUCKET_CLOUD_API_BASE_URL,
clientId: 'invalid-client',
clientSecret: 'invalid-secret',
};
await expect(getBitbucketCloudRequestOptions(withOAuth)).rejects.toThrow(
/Failed to fetch OAuth token/,
);
});
});
describe('getBitbucketCloudFileFetchUrl', () => {
+126 -5
View File
@@ -14,9 +14,119 @@
* limitations under the License.
*/
import { createHash } from 'crypto';
import fetch from 'cross-fetch';
import parseGitUrl from 'git-url-parse';
import { BitbucketCloudIntegrationConfig } from './config';
import { DateTime } from 'luxon';
type OAuthTokenData = {
token: string;
expiresAt: DateTime;
};
// In-memory token cache keyed by hashed credentials
const tokenCache = new Map<string, OAuthTokenData>();
// Track in-flight token refresh requests to prevent concurrent fetches
const refreshPromises = new Map<string, Promise<string>>();
/**
* Creates a cache key from OAuth credentials by hashing them.
* This prevents storing credentials in memory in plain text.
*/
function createCacheKey(clientId: string, clientSecret: string): string {
return createHash('sha256')
.update(`${clientId}:${clientSecret}`)
.digest('hex');
}
/**
* Fetches an OAuth access token from Bitbucket Cloud using client credentials flow.
* Tokens are cached with a 10-minute grace period to account for clock skew.
* Implements concurrent refresh protection to prevent multiple simultaneous token requests.
*
* @param clientId - OAuth client ID
* @param clientSecret - OAuth client secret
* @public
*/
export async function getBitbucketCloudOAuthToken(
clientId: string,
clientSecret: string,
): Promise<string> {
const cacheKey = createCacheKey(clientId, clientSecret);
// Check cache
const cached = tokenCache.get(cacheKey);
if (cached && DateTime.now() < cached.expiresAt) {
return cached.token;
}
// Check if there's already a refresh in progress
const inFlight = refreshPromises.get(cacheKey);
if (inFlight) {
return inFlight;
}
// Start a new token fetch and track it
const refreshPromise = (async () => {
try {
// Fetch new token
const credentials = Buffer.from(
`${clientId}:${clientSecret}`,
'utf8',
).toString('base64');
const response = await fetch(
'https://bitbucket.org/site/oauth2/access_token',
{
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${credentials}`,
},
body: 'grant_type=client_credentials',
},
);
if (!response.ok) {
throw new Error(
`Failed to fetch OAuth token from Bitbucket Cloud: ${response.status} ${response.statusText}`,
);
}
const data = await response.json();
if (!data.access_token) {
throw new Error('OAuth token response missing access_token field');
}
// Calculate expiration with 10-minute grace period
const expiresIn = data.expires_in || 3600; // Default to 1 hour
const expiresAt = DateTime.now()
.plus({ seconds: expiresIn })
.minus({ minutes: 10 });
// Cache the token
tokenCache.set(cacheKey, {
token: data.access_token,
expiresAt,
});
return data.access_token;
} catch (error) {
throw new Error(
`Failed to fetch OAuth token for Bitbucket Cloud: ${error}`,
);
} finally {
// Clean up the in-flight promise tracking
refreshPromises.delete(cacheKey);
}
})();
refreshPromises.set(cacheKey, refreshPromise);
return refreshPromise;
}
/**
* Given a URL pointing to a path on a provider, returns the default branch.
@@ -34,7 +144,7 @@ export async function getBitbucketCloudDefaultBranch(
const branchUrl = `${config.apiBaseUrl}/repositories/${project}/${repoName}`;
const response = await fetch(
branchUrl,
getBitbucketCloudRequestOptions(config),
await getBitbucketCloudRequestOptions(config),
);
if (!response.ok) {
@@ -118,18 +228,29 @@ 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.
* Supports OAuth (clientId/clientSecret), username/token, and username/appPassword auth.
*
* @param config - The relevant provider config
* @public
*/
export function getBitbucketCloudRequestOptions(
export async function getBitbucketCloudRequestOptions(
config: BitbucketCloudIntegrationConfig,
): {
): Promise<{
headers: Record<string, string>;
} {
}> {
const headers: Record<string, string> = {};
// OAuth authentication (clientId/clientSecret)
if (config.clientId && config.clientSecret) {
const token = await getBitbucketCloudOAuthToken(
config.clientId,
config.clientSecret,
);
headers.Authorization = `Bearer ${token}`;
return { headers };
}
// Basic authentication (username + token/appPassword)
// TODO: appPassword can be removed once fully
// deprecated by BitBucket on 9th June 2026.
if (config.username && (config.token ?? config.appPassword)) {
@@ -24,5 +24,6 @@ export {
getBitbucketCloudDefaultBranch,
getBitbucketCloudDownloadUrl,
getBitbucketCloudFileFetchUrl,
getBitbucketCloudOAuthToken,
getBitbucketCloudRequestOptions,
} from './core';
@@ -14,7 +14,10 @@
* limitations under the License.
*/
import { BitbucketCloudIntegrationConfig } from '@backstage/integration';
import {
BitbucketCloudIntegrationConfig,
getBitbucketCloudOAuthToken,
} from '@backstage/integration';
import fetch, { Request } from 'cross-fetch';
import { Models } from './models';
import { WithPagination } from './pagination';
@@ -139,22 +142,32 @@ export class BitbucketCloudClient {
}
private async request(req: Request): Promise<Response> {
return fetch(req, { headers: this.getAuthHeaders() }).then(
(response: Response) => {
if (!response.ok) {
throw new Error(
`Unexpected response for ${req.method} ${req.url}. Expected 200 but got ${response.status} - ${response.statusText}`,
);
}
const headers = await this.getAuthHeaders();
return fetch(req, { headers }).then((response: Response) => {
if (!response.ok) {
throw new Error(
`Unexpected response for ${req.method} ${req.url}. Expected 200 but got ${response.status} - ${response.statusText}`,
);
}
return response;
},
);
return response;
});
}
private getAuthHeaders(): Record<string, string> {
private async getAuthHeaders(): Promise<Record<string, string>> {
const headers: Record<string, string> = {};
// OAuth authentication (clientId/clientSecret)
if (this.config.clientId && this.config.clientSecret) {
const token = await getBitbucketCloudOAuthToken(
this.config.clientId,
this.config.clientSecret,
);
headers.Authorization = `Bearer ${token}`;
return headers;
}
// Basic authentication (username/token or username/appPassword)
if (
this.config.username &&
(this.config.token ?? this.config.appPassword)
@@ -216,7 +216,7 @@ export function createPublishBitbucketCloudAction(options: {
);
}
const authorization = getAuthorizationHeader(
const authorization = await getAuthorizationHeader(
ctx.input.token ? { token: ctx.input.token } : integrationConfig.config,
);
@@ -22,11 +22,11 @@ jest.mock('bitbucket', () => ({
}));
describe('bitbucketCloud:branchRestriction:create', () => {
it('getBitbucketClient should return the correct headers with username and password', () => {
it('getBitbucketClient should return the correct headers with username and password', async () => {
expect.assertions(1);
const username = 'username';
const password = 'password';
getBitbucketClient({ username: username, appPassword: password });
await getBitbucketClient({ username: username, appPassword: password });
expect(Bitbucket).toHaveBeenCalledWith({
auth: {
username: username,
@@ -35,12 +35,14 @@ describe('bitbucketCloud:branchRestriction:create', () => {
});
});
it('getBitbucketClient should throw if only one of username or password is provided', () => {
it('getBitbucketClient should throw if only one of username or password is provided', async () => {
expect.assertions(2);
const username = 'username';
const password = 'password';
expect(() => getBitbucketClient({ username })).toThrow(Error);
expect(() => getBitbucketClient({ appPassword: password })).toThrow(Error);
await expect(getBitbucketClient({ username })).rejects.toThrow(Error);
await expect(getBitbucketClient({ appPassword: password })).rejects.toThrow(
Error,
);
});
});
@@ -37,6 +37,8 @@ const createBitbucketCloudBranchRestriction = async (opts: {
token?: string;
username?: string;
appPassword?: string;
clientId?: string;
clientSecret?: string;
};
}) => {
const {
@@ -52,7 +54,7 @@ const createBitbucketCloudBranchRestriction = async (opts: {
authorization,
} = opts;
const bitbucket = getBitbucketClient(authorization);
const bitbucket = await getBitbucketClient(authorization);
return await bitbucket.branchrestrictions.create({
_body: {
groups: groups,
@@ -68,7 +68,7 @@ export const createBitbucketPipelinesRunAction = (options: {
const host = 'bitbucket.org';
const integrationConfig = integrations.bitbucketCloud.byHost(host);
const authorization = getAuthorizationHeader(
const authorization = await getAuthorizationHeader(
token ? { token } : integrationConfig!.config,
);
let response: Response;
@@ -317,7 +317,7 @@ export function createPublishBitbucketCloudPullRequestAction(options: {
);
}
const authorization = getAuthorizationHeader(
const authorization = await getAuthorizationHeader(
ctx.input.token ? { token: ctx.input.token } : integrationConfig.config,
);
@@ -15,12 +15,28 @@
*/
import { Bitbucket } from 'bitbucket';
import { getBitbucketCloudOAuthToken } from '@backstage/integration';
export const getBitbucketClient = (config: {
export const getBitbucketClient = async (config: {
token?: string;
username?: string;
appPassword?: string;
clientId?: string;
clientSecret?: string;
}) => {
// If OAuth credentials provided, fetch token
if (config.clientId && config.clientSecret) {
const token = await getBitbucketCloudOAuthToken(
config.clientId,
config.clientSecret,
);
return new Bitbucket({
auth: {
token,
},
});
}
if (config.token) {
return new Bitbucket({
auth: {
@@ -38,15 +54,26 @@ export const getBitbucketClient = (config: {
});
}
throw new Error(
`Authorization has not been provided for Bitbucket Cloud. Please add either provide a username and token or username and appPassword to the Integrations 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 getAuthorizationHeader = (config: {
export const getAuthorizationHeader = async (config: {
username?: string;
appPassword?: string;
token?: string;
}) => {
clientId?: string;
clientSecret?: string;
}): Promise<string> => {
// OAuth authentication
if (config.clientId && config.clientSecret) {
const token = await getBitbucketCloudOAuthToken(
config.clientId,
config.clientSecret,
);
return `Bearer ${token}`;
}
// TODO: appPassword can be removed once fully
// deprecated by BitBucket on 9th June 2026.
if (config.username && (config.token ?? config.appPassword)) {
@@ -62,6 +89,6 @@ export const getAuthorizationHeader = (config: {
}
throw new Error(
`Authorization has not been provided for Bitbucket Cloud. Please add either provide a username and token or username and appPassword to the Integrations 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`,
);
};