diff --git a/docs/integrations/bitbucketCloud/locations.md b/docs/integrations/bitbucketCloud/locations.md index 6b735171a4..807076c9e9 100644 --- a/docs/integrations/bitbucketCloud/locations.md +++ b/docs/integrations/bitbucketCloud/locations.md @@ -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). diff --git a/packages/backend-defaults/src/entrypoints/urlReader/lib/BitbucketCloudUrlReader.ts b/packages/backend-defaults/src/entrypoints/urlReader/lib/BitbucketCloudUrlReader.ts index ff6ec3bb27..73302d37e5 100644 --- a/packages/backend-defaults/src/entrypoints/urlReader/lib/BitbucketCloudUrlReader.ts +++ b/packages/backend-defaults/src/entrypoints/urlReader/lib/BitbucketCloudUrlReader.ts @@ -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}`; diff --git a/packages/integration/config.d.ts b/packages/integration/config.d.ts index a49c4cc0d6..4278a8fcfe 100644 --- a/packages/integration/config.d.ts +++ b/packages/integration/config.d.ts @@ -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 diff --git a/packages/integration/src/bitbucketCloud/config.test.ts b/packages/integration/src/bitbucketCloud/config.test.ts index 9488c8b5f7..57e8803a14 100644 --- a/packages/integration/src/bitbucketCloud/config.test.ts +++ b/packages/integration/src/bitbucketCloud/config.test.ts @@ -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', () => { diff --git a/packages/integration/src/bitbucketCloud/config.ts b/packages/integration/src/bitbucketCloud/config.ts index 0e08e95a79..e47b215f98 100644 --- a/packages/integration/src/bitbucketCloud/config.ts +++ b/packages/integration/src/bitbucketCloud/config.ts @@ -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'), }; } diff --git a/packages/integration/src/bitbucketCloud/core.test.ts b/packages/integration/src/bitbucketCloud/core.test.ts index fa8fc2ae43..5573dec735 100644 --- a/packages/integration/src/bitbucketCloud/core.test.ts +++ b/packages/integration/src/bitbucketCloud/core.test.ts @@ -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', () => { diff --git a/packages/integration/src/bitbucketCloud/core.ts b/packages/integration/src/bitbucketCloud/core.ts index a97de872de..7455a488fe 100644 --- a/packages/integration/src/bitbucketCloud/core.ts +++ b/packages/integration/src/bitbucketCloud/core.ts @@ -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(); + +// Track in-flight token refresh requests to prevent concurrent fetches +const refreshPromises = new Map>(); + +/** + * 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 { + 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; -} { +}> { const headers: Record = {}; + // 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)) { diff --git a/packages/integration/src/bitbucketCloud/index.ts b/packages/integration/src/bitbucketCloud/index.ts index 7119d3ea90..d0c11136b3 100644 --- a/packages/integration/src/bitbucketCloud/index.ts +++ b/packages/integration/src/bitbucketCloud/index.ts @@ -24,5 +24,6 @@ export { getBitbucketCloudDefaultBranch, getBitbucketCloudDownloadUrl, getBitbucketCloudFileFetchUrl, + getBitbucketCloudOAuthToken, getBitbucketCloudRequestOptions, } from './core'; diff --git a/plugins/bitbucket-cloud-common/src/BitbucketCloudClient.ts b/plugins/bitbucket-cloud-common/src/BitbucketCloudClient.ts index adbf08cf40..eb40b51b5b 100644 --- a/plugins/bitbucket-cloud-common/src/BitbucketCloudClient.ts +++ b/plugins/bitbucket-cloud-common/src/BitbucketCloudClient.ts @@ -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 { - 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 { + private async getAuthHeaders(): Promise> { const headers: Record = {}; + // 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) diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloud.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloud.ts index 722bf01303..16a7e676e1 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloud.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloud.ts @@ -216,7 +216,7 @@ export function createPublishBitbucketCloudAction(options: { ); } - const authorization = getAuthorizationHeader( + const authorization = await getAuthorizationHeader( ctx.input.token ? { token: ctx.input.token } : integrationConfig.config, ); diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudBranchRestriction.test.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudBranchRestriction.test.ts index 55ee430091..2ac54894d5 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudBranchRestriction.test.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudBranchRestriction.test.ts @@ -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, + ); }); }); diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudBranchRestriction.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudBranchRestriction.ts index 233818e00a..a12f63f980 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudBranchRestriction.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudBranchRestriction.ts @@ -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, diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPipelinesRun.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPipelinesRun.ts index 4bf9226db7..372f45c928 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPipelinesRun.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPipelinesRun.ts @@ -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; diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPullRequest.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPullRequest.ts index 9969f13eb2..08e19f89bf 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPullRequest.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPullRequest.ts @@ -317,7 +317,7 @@ export function createPublishBitbucketCloudPullRequestAction(options: { ); } - const authorization = getAuthorizationHeader( + const authorization = await getAuthorizationHeader( ctx.input.token ? { token: ctx.input.token } : integrationConfig.config, ); diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/helpers.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/helpers.ts index 2e68d4e8b8..47a212fcab 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/helpers.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/helpers.ts @@ -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 => { + // 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`, ); };