Skip to content
73 changes: 73 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Bitbucket Cloud OAuth support

## Purpose

This branch adds Bitbucket Cloud OAuth consumer authentication to the Jenkins Git plugin.
The behavior is deliberately narrow: it applies only to Git-over-HTTPS remotes hosted at
`bitbucket.org`.

GitHub, GitLab, Bitbucket Server, SSH remotes, app passwords, and credentials that already
contain an access token continue through the existing Git plugin credential path unchanged.

## Credential flow

Configure a Jenkins username/password credential with:

- username: the Bitbucket OAuth consumer key;
- password: the Bitbucket OAuth consumer secret.

For an `https://bitbucket.org/<workspace>/<repository>.git` remote, the plugin:

1. Recognizes the credential as a Bitbucket OAuth consumer key/secret pair.
2. Requests an access token from `https://bitbucket.org/site/oauth2/access_token` with the
OAuth client-credentials grant.
3. Caches the short-lived token and refreshes it before expiration.
4. Supplies Git with a transient username/password credential using the required
`x-token-auth` username and the access token as its password.

The token is not inserted into or persisted in the repository URL. OAuth error responses and
secrets are not written to the Jenkins log.

## Scope safeguards

OAuth conversion requires all of the following:

- the remote uses HTTPS;
- the host is exactly `bitbucket.org`;
- the selected credential is a username/password credential;
- the username and password match Bitbucket's OAuth consumer key/secret shape.

If any condition is false, the original credential is returned unchanged. In particular,
`ssh://git@bitbucket.org/...` and `git@bitbucket.org:...` never invoke the OAuth exchange.

## Implementation

- `BitbucketOAuthHelper` classifies the remote and credential, then creates the transient Git
credential.
- `BitbucketOAuthTokenClient` performs and caches the client-credentials token exchange.
- `GitSCM` applies the helper to checkout and polling credentials.
- `UserRemoteConfig` applies the same behavior to the repository URL validation command.

## Tests

Run the focused tests:

```bash
mvn -Dtest=BitbucketOAuthHelperTest,BitbucketOAuthTokenClientTest test
```

The tests cover:

- Bitbucket Cloud remote recognition;
- OAuth consumer conversion;
- non-Bitbucket providers;
- Bitbucket app-password and access-token credentials;
- Bitbucket SSH and SCP-style remotes;
- token request method and authorization header;
- successful token response parsing;
- HTTP authentication failures without response-body disclosure;
- successful responses missing an access token.

An end-to-end Jenkins verification should select an OAuth consumer credential for a private
Bitbucket Cloud HTTPS repository and confirm both repository validation and checkout. The OAuth
consumer must include repository read permission.
3 changes: 2 additions & 1 deletion src/main/java/hudson/plugins/git/GitSCM.java
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
import hudson.util.FormValidation;
import hudson.util.ListBoxModel;
import jenkins.model.Jenkins;
import jenkins.plugins.git.BitbucketOAuthHelper;
import jenkins.plugins.git.GitHooksConfiguration;
import jenkins.plugins.git.GitSCMMatrixUtil;
import jenkins.plugins.git.GitToolChooser;
Expand Down Expand Up @@ -921,7 +922,7 @@ private GitClient createClient(TaskListener listener, EnvVars environment, @NonN
String url = getParameterString(uc.getUrl(), environment);
StandardUsernameCredentials credentials = lookupScanCredentials(build, url, ucCredentialsId);
if (credentials != null) {
c.addCredentials(url, credentials);
c.addCredentials(url, BitbucketOAuthHelper.credentialsFor(url, credentials));
if(!isHideCredentials()) {
listener.getLogger().printf("using credential %s%n", credentials.getId());
}
Expand Down
28 changes: 18 additions & 10 deletions src/main/java/hudson/plugins/git/UserRemoteConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import hudson.util.FormValidation;
import hudson.util.ListBoxModel;
import jenkins.model.Jenkins;
import jenkins.plugins.git.BitbucketOAuthHelper;
import jenkins.plugins.git.GitSCMSource;
import jenkins.security.FIPS140;
import org.apache.commons.lang3.StringUtils;
Expand Down Expand Up @@ -98,6 +99,18 @@

private final static Pattern SCP_LIKE = Pattern.compile("(.*):(.*)");

private static StandardCredentials lookupCredentials(@CheckForNull Item item, @CheckForNull String credentialId, @CheckForNull String uri) {
if (credentialId == null || uri == null) {

Check warning on line 103 in src/main/java/hudson/plugins/git/UserRemoteConfig.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 103 is only partially covered, one branch is missing
return null;
}
return CredentialsProvider.findCredentialByIdInItem(
credentialId,
StandardCredentials.class,
item,
ACL.SYSTEM2,
GitURIRequirementsBuilder.fromUri(uri).build());
}

@Extension
public static class DescriptorImpl extends Descriptor<UserRemoteConfig> {

Expand Down Expand Up @@ -209,7 +222,11 @@
.using(GitTool.getDefaultInstallation().getGitExe())
.getClient();
StandardCredentials credential = lookupCredentials(item, credentialsId, url);
git.addDefaultCredentials(credential);
if (credential instanceof StandardUsernameCredentials usernameCredential) {
git.addDefaultCredentials(BitbucketOAuthHelper.credentialsFor(url, usernameCredential));
} else {
git.addDefaultCredentials(credential);
}

// Should not track credentials use in any checkURL method, rather should track
// credentials use at the point where the credential is used to perform an
Expand Down Expand Up @@ -266,15 +283,6 @@
return FormValidation.ok();
}

private static StandardCredentials lookupCredentials(@CheckForNull Item project, String credentialId, String uri) {
return (credentialId == null) ? null : CredentialsProvider.findCredentialByIdInItem(
credentialId,
StandardCredentials.class,
project,
ACL.SYSTEM2,
GitURIRequirementsBuilder.fromUri(uri).build());
}

@Override
public String getDisplayName() {
return "";
Expand Down
110 changes: 110 additions & 0 deletions src/main/java/jenkins/plugins/git/BitbucketOAuthHelper.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package jenkins.plugins.git;

import com.cloudbees.plugins.credentials.common.StandardUsernameCredentials;
import com.cloudbees.plugins.credentials.common.StandardUsernamePasswordCredentials;
import com.cloudbees.plugins.credentials.impl.UsernamePasswordCredentialsImpl;
import edu.umd.cs.findbugs.annotations.CheckForNull;
import edu.umd.cs.findbugs.annotations.NonNull;

import java.util.regex.Pattern;

/**
* Helper utilities for Bitbucket OAuth-aware remote handling.
*
* <p>Bitbucket Cloud accepts OAuth access tokens for Git-over-HTTPS with the
* {@value #OAUTH_USERNAME} username. The token is supplied to the Git client
* as a password credential; it is never added to the configured remote URL.</p>
*/
public final class BitbucketOAuthHelper {

static final String OAUTH_USERNAME = "x-token-auth";
private static final int OAUTH_CLIENT_KEY_LENGTH = 18;
private static final int OAUTH_CLIENT_SECRET_LENGTH = 32;

private static final Pattern BITBUCKET_CLOUD_HOST = Pattern.compile(
"^(?:(?:https?|ssh)://(?:[^@/]+@)?bitbucket\\.org(?:/|$)|[^@/:]+@bitbucket\\.org:.+)");
private static final Pattern BITBUCKET_CLOUD_HTTPS_HOST = Pattern.compile(
"^https://(?:[^@/]+@)?bitbucket\\.org(?:/|$)");

private BitbucketOAuthHelper() {
// Utility class.
}

/**
* Returns true when the supplied remote target is a Bitbucket Cloud repository.
*/
public static boolean isBitbucketCloudRemote(@CheckForNull String remote) {
if (remote == null || remote.isBlank()) {

Check warning on line 37 in src/main/java/jenkins/plugins/git/BitbucketOAuthHelper.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 37 is only partially covered, one branch is missing
return false;
}
return BITBUCKET_CLOUD_HOST.matcher(remote).find();
}

/**
* Returns a transient Git transport credential when an HTTPS Bitbucket Cloud
* remote is paired with OAuth consumer credentials.
*
* <p>Other providers, Bitbucket SSH remotes, app passwords, and credentials
* that already contain an access token are returned unchanged. The configured
* Git remote is never rewritten.</p>
*/
@NonNull
public static StandardUsernameCredentials credentialsFor(
@CheckForNull String remote, @NonNull StandardUsernameCredentials credentials) {
return credentialsFor(remote, credentials, BitbucketOAuthTokenClient::accessToken);
}

static StandardUsernameCredentials credentialsFor(
@CheckForNull String remote,
@NonNull StandardUsernameCredentials credentials,
@NonNull OAuthTokenProvider tokenProvider) {
if (!isBitbucketCloudHttpsRemote(remote)

Check warning on line 61 in src/main/java/jenkins/plugins/git/BitbucketOAuthHelper.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 61 is only partially covered, one branch is missing
|| !(credentials instanceof StandardUsernamePasswordCredentials usernamePassword)
|| !isOAuthConsumer(usernamePassword)) {
return credentials;
}
try {
String accessToken = tokenProvider.accessToken(
usernamePassword.getId(),
usernamePassword.getUsername(),
usernamePassword.getPassword().getPlainText());
return new UsernamePasswordCredentialsImpl(
usernamePassword.getScope(),
usernamePassword.getId(),
usernamePassword.getDescription(),
OAUTH_USERNAME,
accessToken);
} catch (hudson.model.Descriptor.FormException exception) {
throw new IllegalArgumentException("Unable to create Bitbucket OAuth credential", exception);

Check warning on line 78 in src/main/java/jenkins/plugins/git/BitbucketOAuthHelper.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 77-78 are not covered by tests
}
}

private static boolean isOAuthConsumer(StandardUsernamePasswordCredentials credentials) {
// Keep this aligned with BitbucketOAuthCredentialMatcher in the
// bitbucket-branch-source plugin. It distinguishes consumer key/secret
// pairs from user credentials and app passwords.
String clientKey = credentials.getUsername();
String clientSecret = credentials.getPassword().getPlainText();
return clientKey.length() == OAUTH_CLIENT_KEY_LENGTH
&& clientSecret.length() == OAUTH_CLIENT_SECRET_LENGTH

Check warning on line 89 in src/main/java/jenkins/plugins/git/BitbucketOAuthHelper.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 89 is only partially covered, one branch is missing
&& !clientKey.contains(".")

Check warning on line 90 in src/main/java/jenkins/plugins/git/BitbucketOAuthHelper.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 90 is only partially covered, one branch is missing
&& !clientKey.contains("@");

Check warning on line 91 in src/main/java/jenkins/plugins/git/BitbucketOAuthHelper.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 91 is only partially covered, one branch is missing
}

private static boolean isBitbucketCloudHttpsRemote(@CheckForNull String remote) {
return remote != null && BITBUCKET_CLOUD_HTTPS_HOST.matcher(remote).find();

Check warning on line 95 in src/main/java/jenkins/plugins/git/BitbucketOAuthHelper.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 95 is only partially covered, one branch is missing
}

@FunctionalInterface
interface OAuthTokenProvider {
String accessToken(String credentialsId, String clientKey, String clientSecret);
}

/**
* Returns the provider name used in user-facing messages.
*/
@NonNull
public static String providerName() {
return "Bitbucket";

Check warning on line 108 in src/main/java/jenkins/plugins/git/BitbucketOAuthHelper.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 108 is not covered by tests
}
}
124 changes: 124 additions & 0 deletions src/main/java/jenkins/plugins/git/BitbucketOAuthTokenClient.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package jenkins.plugins.git;

import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import net.sf.json.JSONObject;

/**
* Exchanges Bitbucket OAuth consumer credentials for short-lived access tokens.
*/
final class BitbucketOAuthTokenClient {

private static final URI TOKEN_ENDPOINT = URI.create("https://bitbucket.org/site/oauth2/access_token");
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(30);
private static final Duration EXPIRY_MARGIN = Duration.ofSeconds(60);
private static final Map<String, CachedToken> TOKENS = new ConcurrentHashMap<>();
private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder()
.connectTimeout(REQUEST_TIMEOUT)
.build();

private BitbucketOAuthTokenClient() {
// Utility class.
}

static String accessToken(String credentialsId, String clientKey, String clientSecret) {
String cacheKey = cacheKey(credentialsId, clientKey, clientSecret);
CachedToken cached = TOKENS.get(cacheKey);
if (cached != null && cached.isUsable()) {
return cached.value;
}

synchronized (TOKENS) {

Check warning on line 43 in src/main/java/jenkins/plugins/git/BitbucketOAuthTokenClient.java

View check run for this annotation

ci.jenkins.io / SpotBugs

JLM_JSR166_UTILCONCURRENT_MONITORENTER

NORMAL: Synchronization performed on java.util.concurrent.ConcurrentHashMap in jenkins.plugins.git.BitbucketOAuthTokenClient.accessToken(String, String, String)
Raw output
<p> This method performs synchronization on an object that is an instance of a class from the java.util.concurrent package (or its subclasses). Instances of these classes have their own concurrency control mechanisms that are orthogonal to the synchronization provided by the Java keyword <code>synchronized</code>. For example, synchronizing on an <code>AtomicBoolean</code> will not prevent other threads from modifying the <code>AtomicBoolean</code>.</p> <p>Such code may be correct, but should be carefully reviewed and documented, and may confuse people who have to maintain the code at a later date. </p>
cached = TOKENS.get(cacheKey);
if (cached != null && cached.isUsable()) {
return cached.value;
}
CachedToken refreshed = requestToken(clientKey, clientSecret);
TOKENS.put(cacheKey, refreshed);
return refreshed.value;
}
}

private static CachedToken requestToken(String clientKey, String clientSecret) {
return requestToken(HTTP_CLIENT, TOKEN_ENDPOINT, clientKey, clientSecret);

Check warning on line 55 in src/main/java/jenkins/plugins/git/BitbucketOAuthTokenClient.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 37-55 are not covered by tests
}

static CachedToken requestToken(
HttpClient httpClient, URI tokenEndpoint, String clientKey, String clientSecret) {
String basicCredential = Base64.getEncoder().encodeToString(
(clientKey + ":" + clientSecret).getBytes(StandardCharsets.UTF_8));
String body = "grant_type=" + URLEncoder.encode("client_credentials", StandardCharsets.UTF_8);
HttpRequest request = HttpRequest.newBuilder(tokenEndpoint)
.timeout(REQUEST_TIMEOUT)
.header("Authorization", "Basic " + basicCredential)
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();

try {
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() < 200 || response.statusCode() >= 300) {

Check warning on line 72 in src/main/java/jenkins/plugins/git/BitbucketOAuthTokenClient.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 72 is only partially covered, one branch is missing
throw new IllegalArgumentException(
"Bitbucket OAuth token request failed with HTTP " + response.statusCode());
}

JSONObject json = JSONObject.fromObject(response.body());
String token = json.optString("access_token", "");
if (token.isBlank()) {
throw new IllegalArgumentException("Bitbucket OAuth response did not include an access token");
}
long expiresIn = json.optLong("expires_in", 7200L);
return new CachedToken(token, Instant.now().plusSeconds(expiresIn));
} catch (IOException exception) {
throw new IllegalArgumentException("Unable to contact the Bitbucket OAuth token endpoint", exception);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new IllegalArgumentException("Interrupted while requesting a Bitbucket OAuth token", exception);
}
}

private static String cacheKey(String credentialsId, String clientKey, String clientSecret) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] value = digest.digest(
(credentialsId + '\0' + clientKey + '\0' + clientSecret).getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(value);
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 is unavailable", exception);

Check warning on line 99 in src/main/java/jenkins/plugins/git/BitbucketOAuthTokenClient.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 84-99 are not covered by tests
}
}

static final class CachedToken {
private final String value;
private final Instant expiresAt;

private CachedToken(String value, Instant expiresAt) {
this.value = value;
this.expiresAt = expiresAt;
}

private boolean isUsable() {
return Instant.now().plus(EXPIRY_MARGIN).isBefore(expiresAt);

Check warning on line 113 in src/main/java/jenkins/plugins/git/BitbucketOAuthTokenClient.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 113 is not covered by tests
}

String value() {
return value;
}

Instant expiresAt() {
return expiresAt;
}
}
}
Loading
Loading