Merge pull request #31848 from jksmth/bitbucket-oauth-support
Bitbucket oauth support
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend-module-bitbucket-cloud': minor
|
||||
'@backstage/plugin-bitbucket-cloud-common': patch
|
||||
'@backstage/integration': minor
|
||||
'@backstage/backend-defaults': patch
|
||||
---
|
||||
|
||||
Added support for Bitbucket Cloud OAuth. This introduces an alternative authentication method using a workspace OAuth consumer, alongside App Passwords (deprecated) and API tokens. OAuth does not require a bot or service account and avoids token expiry issues.
|
||||
|
||||
**BREAKING CHANGES**
|
||||
|
||||
- **@backstage/integration** (`src/bitbucketCloud/core.ts`)
|
||||
|
||||
- `getBitbucketCloudRequestOptions` now returns a `Promise` and **must** be awaited.
|
||||
|
||||
- **@backstage/plugin-scaffolder-backend-module-bitbucket-cloud** (`src/actions/helpers.ts`)
|
||||
- `getBitbucketClient` now returns a `Promise` and **must** be awaited.
|
||||
- `getAuthorizationHeader` now returns a `Promise` and **must** be awaited.
|
||||
|
||||
**OAuth usage example**
|
||||
|
||||
```yaml
|
||||
integrations:
|
||||
bitbucketCloud:
|
||||
- clientId: client-id
|
||||
clientSecret: client-secret
|
||||
```
|
||||
@@ -109,8 +109,12 @@ integrations:
|
||||
token: ${GITLAB_TOKEN}
|
||||
### Example for how to add a bitbucket cloud integration
|
||||
# bitbucketCloud:
|
||||
# # Using API token
|
||||
# - username: ${BITBUCKET_USERNAME}
|
||||
# token: ${BITBUCKET_API_TOKEN}
|
||||
# # Using OAuth
|
||||
# - clientId: ${BITBUCKET_CLIENT_ID}
|
||||
# clientSecret: ${BITBUCKET_CLIENT_SECRET}
|
||||
### Example for how to add your bitbucket server instance using the API:
|
||||
# - host: server.bitbucket.com
|
||||
# apiBaseUrl: server.bitbucket.com
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
Vendored
+11
-1
@@ -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
|
||||
|
||||
@@ -250,6 +250,8 @@ export type BitbucketCloudIntegrationConfig = {
|
||||
username?: string;
|
||||
appPassword?: string;
|
||||
token?: string;
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
commitSigningKey?: string;
|
||||
};
|
||||
|
||||
@@ -450,12 +452,18 @@ export function getBitbucketCloudFileFetchUrl(
|
||||
config: BitbucketCloudIntegrationConfig,
|
||||
): string;
|
||||
|
||||
// @public
|
||||
export function getBitbucketCloudOAuthToken(
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
): Promise<string>;
|
||||
|
||||
// @public
|
||||
export function getBitbucketCloudRequestOptions(
|
||||
config: BitbucketCloudIntegrationConfig,
|
||||
): {
|
||||
): Promise<{
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
}>;
|
||||
|
||||
// @public @deprecated
|
||||
export function getBitbucketDefaultBranch(
|
||||
|
||||
@@ -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,72 @@ 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('handles OAuth token fetch errors', async () => {
|
||||
// Test error handling
|
||||
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: 'test-client-id',
|
||||
clientSecret: 'test-client-secret',
|
||||
};
|
||||
|
||||
await expect(getBitbucketCloudRequestOptions(withOAuth)).rejects.toThrow(
|
||||
/Failed to fetch OAuth token/,
|
||||
);
|
||||
});
|
||||
|
||||
it('uses OAuth Bearer token and caches it', async () => {
|
||||
// Test OAuth + caching
|
||||
let callCount = 0;
|
||||
worker.use(
|
||||
rest.post(BITBUCKET_CLOUD_OAUTH_TOKEN_URL, (_, res, ctx) => {
|
||||
callCount++;
|
||||
return 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',
|
||||
};
|
||||
|
||||
// First call should fetch token
|
||||
const result1 = await getBitbucketCloudRequestOptions(withOAuth);
|
||||
expect(result1.headers.Authorization).toEqual('Bearer test-oauth-token');
|
||||
expect(callCount).toBe(1);
|
||||
|
||||
// Second call should use cached token (proves caching works)
|
||||
const result2 = await getBitbucketCloudRequestOptions(withOAuth);
|
||||
expect(result2.headers.Authorization).toEqual('Bearer test-oauth-token');
|
||||
expect(callCount).toBe(1); // Still 1, proving cache was used
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBitbucketCloudFileFetchUrl', () => {
|
||||
|
||||
@@ -17,6 +17,100 @@
|
||||
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 (single entry since there's only one Bitbucket Cloud integration)
|
||||
let cachedToken: OAuthTokenData | undefined;
|
||||
|
||||
// Track in-flight token refresh request to prevent concurrent fetches
|
||||
let refreshPromise: Promise<string> | undefined;
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
// Check cache
|
||||
if (cachedToken && DateTime.now() < cachedToken.expiresAt) {
|
||||
return cachedToken.token;
|
||||
}
|
||||
|
||||
// Check if there's already a refresh in progress
|
||||
if (refreshPromise) {
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
// Start a new token fetch and track it
|
||||
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
|
||||
cachedToken = {
|
||||
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
|
||||
refreshPromise = undefined;
|
||||
}
|
||||
})();
|
||||
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a URL pointing to a path on a provider, returns the default branch.
|
||||
@@ -34,7 +128,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 +212,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)
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
|
||||
import { Config } from '@backstage/config';
|
||||
import { getAuthorizationHeader } from './helpers';
|
||||
import { getBitbucketCloudOAuthToken } from '@backstage/integration';
|
||||
import { examples } from './bitbucketCloud.examples';
|
||||
|
||||
const createRepository = async (opts: {
|
||||
@@ -216,7 +217,7 @@ export function createPublishBitbucketCloudAction(options: {
|
||||
);
|
||||
}
|
||||
|
||||
const authorization = getAuthorizationHeader(
|
||||
const authorization = await getAuthorizationHeader(
|
||||
ctx.input.token ? { token: ctx.input.token } : integrationConfig.config,
|
||||
);
|
||||
|
||||
@@ -249,6 +250,18 @@ export function createPublishBitbucketCloudAction(options: {
|
||||
username: 'x-token-auth',
|
||||
password: ctx.input.token,
|
||||
};
|
||||
} else if (
|
||||
integrationConfig.config.clientId &&
|
||||
integrationConfig.config.clientSecret
|
||||
) {
|
||||
const token = await getBitbucketCloudOAuthToken(
|
||||
integrationConfig.config.clientId,
|
||||
integrationConfig.config.clientSecret,
|
||||
);
|
||||
auth = {
|
||||
username: 'x-token-auth',
|
||||
password: token,
|
||||
};
|
||||
} else {
|
||||
if (
|
||||
!integrationConfig.config.username ||
|
||||
|
||||
+7
-5
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+3
-1
@@ -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,
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+14
-1
@@ -28,6 +28,7 @@ import {
|
||||
import { Config } from '@backstage/config';
|
||||
import fs from 'fs-extra';
|
||||
import { getAuthorizationHeader } from './helpers';
|
||||
import { getBitbucketCloudOAuthToken } from '@backstage/integration';
|
||||
import { examples } from './bitbucketCloudPullRequest.examples';
|
||||
|
||||
const createPullRequest = async (opts: {
|
||||
@@ -317,7 +318,7 @@ export function createPublishBitbucketCloudPullRequestAction(options: {
|
||||
);
|
||||
}
|
||||
|
||||
const authorization = getAuthorizationHeader(
|
||||
const authorization = await getAuthorizationHeader(
|
||||
ctx.input.token ? { token: ctx.input.token } : integrationConfig.config,
|
||||
);
|
||||
|
||||
@@ -365,6 +366,18 @@ export function createPublishBitbucketCloudPullRequestAction(options: {
|
||||
username: 'x-token-auth',
|
||||
password: ctx.input.token,
|
||||
};
|
||||
} else if (
|
||||
integrationConfig.config.clientId &&
|
||||
integrationConfig.config.clientSecret
|
||||
) {
|
||||
const token = await getBitbucketCloudOAuthToken(
|
||||
integrationConfig.config.clientId,
|
||||
integrationConfig.config.clientSecret,
|
||||
);
|
||||
auth = {
|
||||
username: 'x-token-auth',
|
||||
password: token,
|
||||
};
|
||||
} else {
|
||||
if (
|
||||
!integrationConfig.config.username ||
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -69,7 +69,7 @@ spec:
|
||||
url: https://github.com/backstage/community/tree/main/backstage-community-sessions
|
||||
- id: publish
|
||||
name: Publish
|
||||
action: publish:bitbucket
|
||||
action: publish:bitbucketCloud
|
||||
input:
|
||||
description: This is ${{ parameters.name }}
|
||||
repoUrl: ${{ parameters.repoUrl }}
|
||||
@@ -81,10 +81,28 @@ spec:
|
||||
repoContentsUrl: ${{ steps['publish'].output.repoContentsUrl }}
|
||||
catalogInfoPath: '/catalog-info.yaml'
|
||||
|
||||
- id: create-pr-file
|
||||
name: Create PR File
|
||||
action: fetch:plain
|
||||
input:
|
||||
targetPath: ./pr-demo
|
||||
url: https://github.com/backstage/community/blob/main/README.md
|
||||
- id: create-pr
|
||||
name: Create Pull Request
|
||||
action: publish:bitbucketCloud:pull-request
|
||||
input:
|
||||
repoUrl: ${{ parameters.repoUrl }}
|
||||
title: Test PR for ${{ parameters.name }}
|
||||
description: This is a test pull request created by the scaffolder template to test OAuth support
|
||||
sourceBranch: scaffolder-pr-${{ parameters.name }}
|
||||
targetBranch: master
|
||||
|
||||
output:
|
||||
links:
|
||||
- title: Repository
|
||||
url: ${{ steps['publish'].output.remoteUrl }}
|
||||
- title: Pull Request
|
||||
url: ${{ steps['create-pr'].output.pullRequestUrl }}
|
||||
- title: Open in catalog
|
||||
icon: catalog
|
||||
entityRef: ${{ steps['register'].output.entityRef }}
|
||||
|
||||
Reference in New Issue
Block a user