Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
21 changes: 12 additions & 9 deletions src/main/java/hudson/plugins/git/UserRemoteConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,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 102 in src/main/java/hudson/plugins/git/UserRemoteConfig.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 102 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 @@ -266,15 +278,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
68 changes: 68 additions & 0 deletions src/main/java/jenkins/plugins/git/BitbucketOAuthHelper.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
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 Pattern BITBUCKET_CLOUD_HOST = Pattern.compile(
"^(?:(?:https?|ssh)://(?:[^@/]+@)?bitbucket\\.org(?:/|$)|[^@/:]+@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 33 in src/main/java/jenkins/plugins/git/BitbucketOAuthHelper.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

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

/**
* Returns a transient Git transport credential for a Bitbucket Cloud OAuth token.
* The configured Git remote remains unchanged.
*/
@NonNull
public static StandardUsernameCredentials credentialsFor(
@CheckForNull String remote, @NonNull StandardUsernameCredentials credentials) {
if (!isBitbucketCloudRemote(remote) || !(credentials instanceof StandardUsernamePasswordCredentials usernamePassword)) {

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

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 46 is only partially covered, one branch is missing
return credentials;
}
try {
return new UsernamePasswordCredentialsImpl(
usernamePassword.getScope(),
usernamePassword.getId(),
usernamePassword.getDescription(),
OAUTH_USERNAME,
usernamePassword.getPassword().getPlainText());
} catch (hudson.model.Descriptor.FormException exception) {
throw new IllegalArgumentException("Unable to create Bitbucket OAuth credential", exception);
}
}

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

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

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 56-66 are not covered by tests
}
}
42 changes: 42 additions & 0 deletions src/test/java/jenkins/plugins/git/BitbucketOAuthHelperTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package jenkins.plugins.git;

import com.cloudbees.plugins.credentials.CredentialsScope;
import com.cloudbees.plugins.credentials.common.StandardUsernameCredentials;
import com.cloudbees.plugins.credentials.impl.UsernamePasswordCredentialsImpl;
import org.junit.jupiter.api.Test;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;

class BitbucketOAuthHelperTest {

@Test
void shouldRecognizeBitbucketCloudRemotes() {
assertThat(BitbucketOAuthHelper.isBitbucketCloudRemote("https://bitbucket.org/team/repo.git"), is(true));
assertThat(BitbucketOAuthHelper.isBitbucketCloudRemote("ssh://git@bitbucket.org/team/repo.git"), is(true));
assertThat(BitbucketOAuthHelper.isBitbucketCloudRemote("git@bitbucket.org:team/repo.git"), is(true));
assertThat(BitbucketOAuthHelper.isBitbucketCloudRemote("https://github.com/team/repo.git"), is(false));
assertThat(BitbucketOAuthHelper.isBitbucketCloudRemote(null), is(false));
}

@Test
void shouldProvideTransientOAuthCredentialForBitbucketCloud() throws Exception {
StandardUsernameCredentials original = new UsernamePasswordCredentialsImpl(
CredentialsScope.GLOBAL, "oauth", "OAuth token", "ignored", "example-token");

StandardUsernameCredentials adapted = BitbucketOAuthHelper.credentialsFor(
"https://bitbucket.org/team/repo.git", original);

assertThat(adapted.getUsername(), is("x-token-auth"));
assertThat(adapted.getId(), is("oauth"));
assertThat(((UsernamePasswordCredentialsImpl) adapted).getPassword().getPlainText(), is("example-token"));
}

@Test
void shouldLeaveNonBitbucketCredentialsUnchanged() throws Exception {
StandardUsernameCredentials original = new UsernamePasswordCredentialsImpl(
CredentialsScope.GLOBAL, "oauth", "OAuth token", "ignored", "example-token");

assertThat(BitbucketOAuthHelper.credentialsFor("https://github.com/team/repo.git", original), is(original));
}
}
Loading