diff --git a/lib/modules/platform/bitbucket/readme.md b/lib/modules/platform/bitbucket/readme.md index 14f3a8bd571..867997d5991 100644 --- a/lib/modules/platform/bitbucket/readme.md +++ b/lib/modules/platform/bitbucket/readme.md @@ -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 | | ------------------------------------------------------------------------------------------------------------------------ | -------------------- | @@ -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` @@ -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) diff --git a/lib/util/http/auth.spec.ts b/lib/util/http/auth.spec.ts index 4d3f99ca6b8..9cc9cac385c 100644 --- a/lib/util/http/auth.spec.ts +++ b/lib/util/http/auth.spec.ts @@ -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=", @@ -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", @@ -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", @@ -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", @@ -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", @@ -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`, () => { diff --git a/lib/util/http/auth.ts b/lib/util/http/auth.ts index 2a8c0ca4195..c2d4bd78344 100644 --- a/lib/util/http/auth.ts +++ b/lib/util/http/auth.ts @@ -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< @@ -17,6 +19,7 @@ export type AuthGotOptions = Pick< | 'token' | 'username' | 'password' + | 'url' >; export function applyAuthorization( @@ -65,6 +68,36 @@ export function applyAuthorization( ); } } + } else if ( + options.hostType && + BITBUCKET_API_USING_HOST_TYPES.includes(options.hostType) + ) { + // Bitbucket Cloud /issues endpoint requires username+password authentication + // Match Bitbucket API pattern: /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)