Skip to content
Draft
39 changes: 36 additions & 3 deletions lib/modules/platform/bitbucket/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,32 @@ After you installed the hosted app, please read the [reading list](../../../read

## Authentication

First, [create an API token](https://support.atlassian.com/bitbucket-cloud/docs/create-an-api-token/) for the bot account.
Give the bot API token the following permission scopes:
### Recommended: Workspace Access Tokens

**We recommend using [Workspace Access Tokens](https://support.atlassian.com/bitbucket-cloud/docs/workspace-access-tokens/)** instead of personal access tokens.
Workspace Access Tokens provide higher API rate limits than personal access tokens, which is important for repositories with many dependencies.

Create a Workspace Access Token with the following permission scopes:

| Scopes |
| -------------------- |
| Account: Read |
| Repository: Read |
| Repository: Write |
| Pull Requests: Read |
| Pull Requests: Write |

Let Renovate use your Workspace Access Token by doing _one_ of the following:

- Set your token as an environment variable `RENOVATE_TOKEN`
- Set your token when you run Renovate in the CLI with `--token=`
- Set your token as a `token` in your `config.js` file

### Alternative: Personal Access Tokens

If you prefer to use a personal access token, [create an API token](https://support.atlassian.com/bitbucket-cloud/docs/create-an-api-token/) for the bot account with the following scopes:

Note: if you're only using a Personal Access Token for Dependency Dashboard issues you can restrict this further.

| Permission | Scope |
| ------------------------------------------------------------------------------------------------------------------------ | -------------------- |
Expand All @@ -37,7 +61,7 @@ Give the bot API token the following permission scopes:

The bot also needs to validate the workspace membership status of pull-request reviewers, for that, [create a new user group](https://support.atlassian.com/bitbucket-cloud/docs/organize-workspace-members-into-groups/) in the workspace with the **Create repositories** permission and add the bot user to it.

Let Renovate use your API token by doing _one_ of the following:
Let Renovate use your personal API token by doing _one_ of the following:

- Set your API token as a `password` in your `config.js` file
- Set your API token as an environment variable `RENOVATE_PASSWORD`
Expand All @@ -48,6 +72,15 @@ Remember to:
- Set the `username` for the bot account, which is your Atlassian account email. You can find your email through "Personal Bitbucket settings" on the "Email aliases" page for your account
- Set `platform=bitbucket` somewhere in your Renovate config file

### Authentication Flow

Renovate uses different authentication methods depending on the API endpoint:

- **For `/issues` endpoints**: When both `username`+`password` and `token` are provided, Renovate will prefer username+password authentication (Basic auth) for issues-related API calls
- **For all other endpoints**: When a `token` is available, Renovate will use Bearer token authentication

This hybrid approach ensures compatibility with Bitbucket Cloud's API requirements while maximizing the benefits of higher rate limits when using Workspace Access Tokens.

## Unsupported platform features/concepts

- Adding assignees to PRs not supported (does not seem to be a Bitbucket concept)
Expand Down
190 changes: 132 additions & 58 deletions lib/util/http/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,17 @@ describe('util/http/auth', () => {
`);
});

it('gitea password', () => {
const opts: GotOptions = {
headers: {},
hostType: 'gitea',
password: 'XXXX',
};
describe('gitea', () => {
it('gitea password', () => {
const opts: GotOptions = {
headers: {},
hostType: 'gitea',
password: 'XXXX',
};

applyAuthorization(opts);
applyAuthorization(opts);

expect(opts).toMatchInlineSnapshot(`
expect(opts).toMatchInlineSnapshot(`
{
"headers": {
"authorization": "Basic OlhYWFg=",
Expand All @@ -43,18 +44,18 @@ describe('util/http/auth', () => {
"password": "XXXX",
}
`);
});
});

it('gittea token', () => {
const opts: GotOptions = {
headers: {},
token: 'XXXX',
hostType: 'gitea',
};
it('gittea token', () => {
const opts: GotOptions = {
headers: {},
token: 'XXXX',
hostType: 'gitea',
};

applyAuthorization(opts);
applyAuthorization(opts);

expect(opts).toMatchInlineSnapshot(`
expect(opts).toMatchInlineSnapshot(`
{
"headers": {
"authorization": "Bearer XXXX",
Expand All @@ -63,35 +64,37 @@ describe('util/http/auth', () => {
"token": "XXXX",
}
`);
});
});

it('github token', () => {
const opts: GotOptions = {
headers: {},
token: 'XXX',
hostType: 'github',
};
describe('github', () => {
it('github token', () => {
const opts: GotOptions = {
headers: {},
token: 'XXX',
hostType: 'github',
};

applyAuthorization(opts);
applyAuthorization(opts);

expect(opts).toEqual({
headers: {
authorization: 'token XXX',
},
hostType: 'github',
token: 'XXX',
expect(opts).toEqual({
headers: {
authorization: 'token XXX',
},
hostType: 'github',
token: 'XXX',
});
});
});

it('github token for datasource using github api', () => {
const opts: GotOptions = {
headers: {},
token: 'ZZZZ',
hostType: 'github-releases',
};
applyAuthorization(opts);
it('github token for datasource using github api', () => {
const opts: GotOptions = {
headers: {},
token: 'ZZZZ',
hostType: 'github-releases',
};
applyAuthorization(opts);

expect(opts).toMatchInlineSnapshot(`
expect(opts).toMatchInlineSnapshot(`
{
"headers": {
"authorization": "token ZZZZ",
Expand All @@ -100,19 +103,21 @@ describe('util/http/auth', () => {
"token": "ZZZZ",
}
`);
});
});

it(`gitlab personal access token`, () => {
const opts: GotOptions = {
headers: {},
// Personal Access Token is exactly 20 characters long
token: '0123456789012345test',
hostType: 'gitlab',
};
describe('gitlab', () => {
it(`gitlab personal access token`, () => {
const opts: GotOptions = {
headers: {},
// Personal Access Token is exactly 20 characters long
token: '0123456789012345test',
hostType: 'gitlab',
};

applyAuthorization(opts);
applyAuthorization(opts);

expect(opts).toMatchInlineSnapshot(`
expect(opts).toMatchInlineSnapshot(`
{
"headers": {
"Private-token": "0123456789012345test",
Expand All @@ -121,19 +126,19 @@ describe('util/http/auth', () => {
"token": "0123456789012345test",
}
`);
});
});

it(`gitlab oauth token`, () => {
const opts: GotOptions = {
headers: {},
token:
'a40bdd925a0c0b9c4cdd19d101c0df3b2bcd063ab7ad6706f03bcffcec01test',
hostType: 'gitlab',
};
it(`gitlab oauth token`, () => {
const opts: GotOptions = {
headers: {},
token:
'a40bdd925a0c0b9c4cdd19d101c0df3b2bcd063ab7ad6706f03bcffcec01test',
hostType: 'gitlab',
};

applyAuthorization(opts);
applyAuthorization(opts);

expect(opts).toMatchInlineSnapshot(`
expect(opts).toMatchInlineSnapshot(`
{
"headers": {
"authorization": "Bearer a40bdd925a0c0b9c4cdd19d101c0df3b2bcd063ab7ad6706f03bcffcec01test",
Expand All @@ -142,6 +147,75 @@ describe('util/http/auth', () => {
"token": "a40bdd925a0c0b9c4cdd19d101c0df3b2bcd063ab7ad6706f03bcffcec01test",
}
`);
});
});

describe('bitbucket', () => {
it(`bitbucket username + password`, () => {
const opts: GotOptions = {
headers: {},
username: 'user@org.com',
password: '0123456789012345test',
hostType: 'bitbucket',
url: 'https://api.bitbucket.com/2.0/repositories/foo/bar/pullrequests',
};

applyAuthorization(opts);

expect(opts).toMatchObject({
headers: {
authorization: 'Basic dXNlckBvcmcuY29tOjAxMjM0NTY3ODkwMTIzNDV0ZXN0',
},
hostType: 'bitbucket',
password: '0123456789012345test',
url: 'https://api.bitbucket.com/2.0/repositories/foo/bar/pullrequests',
username: 'user@org.com',
});
});

it(`bitbucket api token`, () => {
const opts: GotOptions = {
headers: {},
token: '0123456789012345test',
hostType: 'bitbucket',
url: 'https://api.bitbucket.com/2.0/repositories/foo/bar/pullrequests',
};

applyAuthorization(opts);

expect(opts).toMatchObject({
headers: {
authorization: 'Bearer 0123456789012345test',
},
hostType: 'bitbucket',
token: '0123456789012345test',
url: 'https://api.bitbucket.com/2.0/repositories/foo/bar/pullrequests',
});
});

it(`bitbucket mutli-auth use username+password for /issues`, () => {
const opts: GotOptions = {
headers: {},
username: 'user@org.com',
password: '0123456789012345test',
token: '0123456789012345test',
hostType: 'bitbucket',
url: 'https://api.bitbucket.com/2.0/repositories/foo/bar/issues',
};

applyAuthorization(opts);

expect(opts).toMatchObject({
headers: {
authorization: 'Basic dXNlckBvcmcuY29tOjAxMjM0NTY3ODkwMTIzNDV0ZXN0',
},
hostType: 'bitbucket',
password: '0123456789012345test',
token: '0123456789012345test',
url: 'https://api.bitbucket.com/2.0/repositories/foo/bar/issues',
username: 'user@org.com',
});
});
});

it(`npm basic token`, () => {
Expand Down
34 changes: 34 additions & 0 deletions lib/util/http/auth.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { isNonEmptyString, isString } from '@sindresorhus/is';
import type { Options } from 'got';
import {
BITBUCKET_API_USING_HOST_TYPES,
FORGEJO_API_USING_HOST_TYPES,
GITEA_API_USING_HOST_TYPES,
GITHUB_API_USING_HOST_TYPES,
GITLAB_API_USING_HOST_TYPES,
} from '../../constants/index.ts';
import { regEx } from '../regex.ts';
import type { GotOptions } from './types.ts';

export type AuthGotOptions = Pick<
Expand All @@ -17,6 +19,7 @@ export type AuthGotOptions = Pick<
| 'token'
| 'username'
| 'password'
| 'url'
>;

export function applyAuthorization<GotOptions extends AuthGotOptions>(
Expand Down Expand Up @@ -65,6 +68,37 @@ export function applyAuthorization<GotOptions extends AuthGotOptions>(
);
}
}
} else if (
options.hostType &&
BITBUCKET_API_USING_HOST_TYPES.includes(options.hostType)
) {
// Bitbucket Cloud /issues endpoint requires username+password authentication
// For other endpoints, prefer workspace access tokens which have higher rate-limits
// Match Bitbucket API pattern: /2.0/repositories/{workspace}/{repo}/issues
const url =
typeof options.url === 'string'
? options.url
: (options.url?.href ?? '');
const isIssuesEndpoint = regEx(
/\/repositories\/[^/]+\/[^/]+\/issues/,
).test(url);
if (
isIssuesEndpoint &&
options.username !== undefined &&
options.password !== undefined
) {
// Use username+password for /issues endpoint
const auth = Buffer.from(
`${options.username}:${options.password}`,
).toString('base64');
options.headers.authorization = `Basic ${auth}`;
delete options.username;
delete options.password;
} else {
// Use Bearer token for other endpoints
options.headers.authorization = `Bearer ${options.token}`;
}
delete options.token;
} else if (
options.hostType &&
GITLAB_API_USING_HOST_TYPES.includes(options.hostType)
Expand Down