diff --git a/src/main/java/hudson/plugins/git/GitSCM.java b/src/main/java/hudson/plugins/git/GitSCM.java index 430e065702..36abfd2d24 100644 --- a/src/main/java/hudson/plugins/git/GitSCM.java +++ b/src/main/java/hudson/plugins/git/GitSCM.java @@ -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; @@ -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()); } diff --git a/src/main/java/hudson/plugins/git/UserRemoteConfig.java b/src/main/java/hudson/plugins/git/UserRemoteConfig.java index ba1d869f0a..bcab6b0b18 100644 --- a/src/main/java/hudson/plugins/git/UserRemoteConfig.java +++ b/src/main/java/hudson/plugins/git/UserRemoteConfig.java @@ -98,6 +98,18 @@ public String toString() { 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) { + return null; + } + return CredentialsProvider.findCredentialByIdInItem( + credentialId, + StandardCredentials.class, + item, + ACL.SYSTEM2, + GitURIRequirementsBuilder.fromUri(uri).build()); + } + @Extension public static class DescriptorImpl extends Descriptor { @@ -266,15 +278,6 @@ public FormValidation doCheckRefspec(@QueryParameter String name, 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 ""; diff --git a/src/main/java/jenkins/plugins/git/BitbucketOAuthHelper.java b/src/main/java/jenkins/plugins/git/BitbucketOAuthHelper.java new file mode 100644 index 0000000000..6f22cf0c95 --- /dev/null +++ b/src/main/java/jenkins/plugins/git/BitbucketOAuthHelper.java @@ -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. + * + *

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.

+ */ +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()) { + 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)) { + 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"; + } +} diff --git a/src/test/java/jenkins/plugins/git/BitbucketOAuthHelperTest.java b/src/test/java/jenkins/plugins/git/BitbucketOAuthHelperTest.java new file mode 100644 index 0000000000..ea817ff237 --- /dev/null +++ b/src/test/java/jenkins/plugins/git/BitbucketOAuthHelperTest.java @@ -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)); + } +}