From af4813b4bce4c3291c4cbb06d315e6f949fcf955 Mon Sep 17 00:00:00 2001 From: Nick Clark Date: Mon, 6 Jul 2026 22:07:16 -0400 Subject: [PATCH 1/5] feat: add OAuth support for Git authentication (cherry picked from commit 875063d223cbf87986f9e2c16941ec16fffe936a) --- .../hudson/plugins/git/UserRemoteConfig.java | 16 +++ .../plugins/git/BitbucketOAuthHelper.java | 104 ++++++++++++++++++ .../plugins/git/BitbucketOAuthHelperTest.java | 41 +++++++ 3 files changed, 161 insertions(+) create mode 100644 src/main/java/jenkins/plugins/git/BitbucketOAuthHelper.java create mode 100644 src/test/java/jenkins/plugins/git/BitbucketOAuthHelperTest.java diff --git a/src/main/java/hudson/plugins/git/UserRemoteConfig.java b/src/main/java/hudson/plugins/git/UserRemoteConfig.java index ba1d869f0a..e4ebb44b71 100644 --- a/src/main/java/hudson/plugins/git/UserRemoteConfig.java +++ b/src/main/java/hudson/plugins/git/UserRemoteConfig.java @@ -4,6 +4,7 @@ import com.cloudbees.plugins.credentials.common.StandardCredentials; import com.cloudbees.plugins.credentials.common.StandardListBoxModel; import com.cloudbees.plugins.credentials.common.StandardUsernameCredentials; +import com.cloudbees.plugins.credentials.common.StandardUsernamePasswordCredentials; import edu.umd.cs.findbugs.annotations.CheckForNull; import hudson.EnvVars; import hudson.Extension; @@ -20,6 +21,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; @@ -63,6 +65,20 @@ public UserRemoteConfig(String url, String name, String refspec, @CheckForNull S if (FIPS140.useCompliantAlgorithms() && StringUtils.isNotEmpty(this.credentialsId) && StringUtils.startsWith(this.url, "http:")) { throw new IllegalArgumentException(Messages.git_fips_url_notsecured()); } + if (StringUtils.isNotBlank(this.url) && StringUtils.isNotBlank(this.credentialsId)) { + Jenkins jenkins = Jenkins.getInstanceOrNull(); + if (jenkins != null && BitbucketOAuthHelper.isBitbucketCloudRemote(this.url)) { + StandardCredentials credential = CredentialsProvider.findCredentialByIdInItem( + this.credentialsId, + StandardCredentials.class, + jenkins, + ACL.SYSTEM2, + GitURIRequirementsBuilder.fromUri(this.url).build()); + if (credential instanceof StandardUsernamePasswordCredentials usernamePasswordCredentials) { + this.url = BitbucketOAuthHelper.buildOAuthRemoteUrl(this.url, usernamePasswordCredentials); + } + } + } } @Exported 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..735523d54b --- /dev/null +++ b/src/main/java/jenkins/plugins/git/BitbucketOAuthHelper.java @@ -0,0 +1,104 @@ +package jenkins.plugins.git; + +import com.cloudbees.plugins.credentials.common.StandardUsernamePasswordCredentials; +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.NonNull; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.regex.Pattern; + +/** + * Helper utilities for Bitbucket OAuth-aware remote handling. + * + *

The Bitbucket Cloud API accepts an OAuth or app-password token via the + * x-token-auth scheme. When a build runs against a Bitbucket Cloud repository, + * the git remote can be rewritten to include that token in a way that keeps the + * existing git transport behavior intact.

+ */ +public final class BitbucketOAuthHelper { + + private static final Pattern BITBUCKET_CLOUD_HOST = Pattern.compile("^(?:https?|ssh)://(?:[^@/]+@)?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(); + } + + /** + * Rewrites a Bitbucket Cloud remote to include an OAuth token in the userinfo + * portion of the URL when a token is present. + * + *

This preserves the remote shape for git while avoiding any changes for + * non-Bitbucket remotes.

+ */ + @NonNull + public static String buildOAuthRemoteUrl(@CheckForNull String remote, @CheckForNull StandardUsernamePasswordCredentials credentials) { + if (credentials == null || credentials.getPassword() == null) { + return buildOAuthRemoteUrl(remote, (String) null); + } + return buildOAuthRemoteUrl(remote, credentials.getPassword().getPlainText()); + } + + @NonNull + public static String buildOAuthRemoteUrl(@CheckForNull String remote, @CheckForNull String token) { + if (!isBitbucketCloudRemote(remote) || token == null || token.isBlank()) { + return remote == null ? "" : remote; + } + + try { + URI uri = new URI(remote); + String scheme = uri.getScheme(); + String userInfo = "x-token-auth:" + token; + String host = uri.getHost(); + String path = uri.getRawPath(); + String query = uri.getRawQuery(); + String fragment = uri.getRawFragment(); + + if (scheme == null || host == null) { + return remote; + } + + return new URI(scheme, userInfo, host, uri.getPort(), path, query, fragment).toString(); + } catch (URISyntaxException e) { + return remote; + } + } + + /** + * Masks the token portion of a remote URL so that logs do not expose secrets. + */ + @NonNull + public static String maskTokenForLogging(@CheckForNull String remote) { + if (remote == null || remote.isBlank()) { + return ""; + } + + String sanitized = remote; + int atIndex = sanitized.lastIndexOf('@'); + if (atIndex > -1) { + int colonIndex = sanitized.indexOf(':', sanitized.indexOf("//") + 2); + if (colonIndex > -1 && colonIndex < atIndex) { + sanitized = sanitized.substring(0, colonIndex + 1) + "***" + sanitized.substring(atIndex); + } + } + return sanitized; + } + + /** + * 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..5baffe1771 --- /dev/null +++ b/src/test/java/jenkins/plugins/git/BitbucketOAuthHelperTest.java @@ -0,0 +1,41 @@ +package jenkins.plugins.git; + +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.nullValue; + +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("https://github.com/team/repo.git"), is(false)); + assertThat(BitbucketOAuthHelper.isBitbucketCloudRemote(null), is(false)); + } + + @Test + void shouldBuildOAuthRemoteUrlsForBitbucketCloud() { + String remote = "https://bitbucket.org/team/repo.git"; + String oauthRemote = BitbucketOAuthHelper.buildOAuthRemoteUrl(remote, "example-token"); + + assertThat(oauthRemote, equalTo("https://x-token-auth:example-token@bitbucket.org/team/repo.git")); + } + + @Test + void shouldLeaveNonBitbucketRemotesUnchanged() { + String remote = "https://github.com/team/repo.git"; + assertThat(BitbucketOAuthHelper.buildOAuthRemoteUrl(remote, "example-token"), equalTo(remote)); + } + + @Test + void shouldMaskTokensForSafeLogging() { + String masked = BitbucketOAuthHelper.maskTokenForLogging("https://x-token-auth:example-token@bitbucket.org/team/repo.git"); + assertThat(masked, equalTo("https://x-token-auth:***@bitbucket.org/team/repo.git")); + assertThat(masked, not(nullValue())); + } +} From a671f4bc5efc99f48429ca616516dfd0a61fad06 Mon Sep 17 00:00:00 2001 From: Nick Clark Date: Mon, 6 Jul 2026 22:15:01 -0400 Subject: [PATCH 2/5] fix: correct syntax issues in OAuth implementation (cherry picked from commit d4569fb8a0911accbcb1152899731ce711a326b1) --- .../hudson/plugins/git/UserRemoteConfig.java | 30 +++++++++---------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/src/main/java/hudson/plugins/git/UserRemoteConfig.java b/src/main/java/hudson/plugins/git/UserRemoteConfig.java index e4ebb44b71..76254c8b2d 100644 --- a/src/main/java/hudson/plugins/git/UserRemoteConfig.java +++ b/src/main/java/hudson/plugins/git/UserRemoteConfig.java @@ -68,12 +68,7 @@ public UserRemoteConfig(String url, String name, String refspec, @CheckForNull S if (StringUtils.isNotBlank(this.url) && StringUtils.isNotBlank(this.credentialsId)) { Jenkins jenkins = Jenkins.getInstanceOrNull(); if (jenkins != null && BitbucketOAuthHelper.isBitbucketCloudRemote(this.url)) { - StandardCredentials credential = CredentialsProvider.findCredentialByIdInItem( - this.credentialsId, - StandardCredentials.class, - jenkins, - ACL.SYSTEM2, - GitURIRequirementsBuilder.fromUri(this.url).build()); + StandardCredentials credential = lookupCredentials(this.credentialsId, this.url); if (credential instanceof StandardUsernamePasswordCredentials usernamePasswordCredentials) { this.url = BitbucketOAuthHelper.buildOAuthRemoteUrl(this.url, usernamePasswordCredentials); } @@ -114,6 +109,18 @@ public String toString() { private final static Pattern SCP_LIKE = Pattern.compile("(.*):(.*)"); + private static StandardCredentials lookupCredentials(@CheckForNull String credentialId, @CheckForNull String uri) { + if (credentialId == null || uri == null) { + return null; + } + return CredentialsProvider.findCredentialByIdInItem( + credentialId, + StandardCredentials.class, + (Item) null, + ACL.SYSTEM2, + GitURIRequirementsBuilder.fromUri(uri).build()); + } + @Extension public static class DescriptorImpl extends Descriptor { @@ -224,7 +231,7 @@ public FormValidation doCheckUrl(@AncestorInPath Item item, GitClient git = Git.with(TaskListener.NULL, environment) .using(GitTool.getDefaultInstallation().getGitExe()) .getClient(); - StandardCredentials credential = lookupCredentials(item, credentialsId, url); + StandardCredentials credential = lookupCredentials(credentialsId, url); git.addDefaultCredentials(credential); // Should not track credentials use in any checkURL method, rather should track @@ -282,15 +289,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 ""; From 02ec3636fd96b628baa8f61336f5ba6fe4d52b00 Mon Sep 17 00:00:00 2001 From: Nick Clark Date: Tue, 7 Jul 2026 00:26:50 -0400 Subject: [PATCH 3/5] Fixing some issues found from github actions (cherry picked from commit 6562329e6fa31262d14312aea0194fbd303c8c8a) --- src/main/java/jenkins/plugins/git/BitbucketOAuthHelper.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/jenkins/plugins/git/BitbucketOAuthHelper.java b/src/main/java/jenkins/plugins/git/BitbucketOAuthHelper.java index 735523d54b..a5d1a1e3d5 100644 --- a/src/main/java/jenkins/plugins/git/BitbucketOAuthHelper.java +++ b/src/main/java/jenkins/plugins/git/BitbucketOAuthHelper.java @@ -43,7 +43,7 @@ public static boolean isBitbucketCloudRemote(@CheckForNull String remote) { */ @NonNull public static String buildOAuthRemoteUrl(@CheckForNull String remote, @CheckForNull StandardUsernamePasswordCredentials credentials) { - if (credentials == null || credentials.getPassword() == null) { + if (credentials == null) { return buildOAuthRemoteUrl(remote, (String) null); } return buildOAuthRemoteUrl(remote, credentials.getPassword().getPlainText()); From d07fadb330ee0c90fd7e29f51a9490f9f6ade9ed Mon Sep 17 00:00:00 2001 From: Nick Clark Date: Tue, 7 Jul 2026 15:28:35 -0400 Subject: [PATCH 4/5] Avoid persisting Bitbucket OAuth tokens in remote URLs --- .../hudson/plugins/git/UserRemoteConfig.java | 17 +++-------------- .../plugins/git/BitbucketOAuthHelper.java | 5 +++-- .../plugins/git/BitbucketOAuthHelperTest.java | 10 ++++++++++ 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/main/java/hudson/plugins/git/UserRemoteConfig.java b/src/main/java/hudson/plugins/git/UserRemoteConfig.java index 76254c8b2d..bcab6b0b18 100644 --- a/src/main/java/hudson/plugins/git/UserRemoteConfig.java +++ b/src/main/java/hudson/plugins/git/UserRemoteConfig.java @@ -4,7 +4,6 @@ import com.cloudbees.plugins.credentials.common.StandardCredentials; import com.cloudbees.plugins.credentials.common.StandardListBoxModel; import com.cloudbees.plugins.credentials.common.StandardUsernameCredentials; -import com.cloudbees.plugins.credentials.common.StandardUsernamePasswordCredentials; import edu.umd.cs.findbugs.annotations.CheckForNull; import hudson.EnvVars; import hudson.Extension; @@ -21,7 +20,6 @@ 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; @@ -65,15 +63,6 @@ public UserRemoteConfig(String url, String name, String refspec, @CheckForNull S if (FIPS140.useCompliantAlgorithms() && StringUtils.isNotEmpty(this.credentialsId) && StringUtils.startsWith(this.url, "http:")) { throw new IllegalArgumentException(Messages.git_fips_url_notsecured()); } - if (StringUtils.isNotBlank(this.url) && StringUtils.isNotBlank(this.credentialsId)) { - Jenkins jenkins = Jenkins.getInstanceOrNull(); - if (jenkins != null && BitbucketOAuthHelper.isBitbucketCloudRemote(this.url)) { - StandardCredentials credential = lookupCredentials(this.credentialsId, this.url); - if (credential instanceof StandardUsernamePasswordCredentials usernamePasswordCredentials) { - this.url = BitbucketOAuthHelper.buildOAuthRemoteUrl(this.url, usernamePasswordCredentials); - } - } - } } @Exported @@ -109,14 +98,14 @@ public String toString() { private final static Pattern SCP_LIKE = Pattern.compile("(.*):(.*)"); - private static StandardCredentials lookupCredentials(@CheckForNull String credentialId, @CheckForNull String uri) { + 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) null, + item, ACL.SYSTEM2, GitURIRequirementsBuilder.fromUri(uri).build()); } @@ -231,7 +220,7 @@ public FormValidation doCheckUrl(@AncestorInPath Item item, GitClient git = Git.with(TaskListener.NULL, environment) .using(GitTool.getDefaultInstallation().getGitExe()) .getClient(); - StandardCredentials credential = lookupCredentials(credentialsId, url); + StandardCredentials credential = lookupCredentials(item, credentialsId, url); git.addDefaultCredentials(credential); // Should not track credentials use in any checkURL method, rather should track diff --git a/src/main/java/jenkins/plugins/git/BitbucketOAuthHelper.java b/src/main/java/jenkins/plugins/git/BitbucketOAuthHelper.java index a5d1a1e3d5..2e44d51899 100644 --- a/src/main/java/jenkins/plugins/git/BitbucketOAuthHelper.java +++ b/src/main/java/jenkins/plugins/git/BitbucketOAuthHelper.java @@ -18,7 +18,8 @@ */ public final class BitbucketOAuthHelper { - private static final Pattern BITBUCKET_CLOUD_HOST = Pattern.compile("^(?:https?|ssh)://(?:[^@/]+@)?bitbucket\\.org(?:/|$)"); + private static final Pattern BITBUCKET_CLOUD_HOST = Pattern.compile( + "^(?:(?:https?|ssh)://(?:[^@/]+@)?bitbucket\\.org(?:/|$)|[^@/:]+@bitbucket\\.org:.+)"); private BitbucketOAuthHelper() { // Utility class. @@ -64,7 +65,7 @@ public static String buildOAuthRemoteUrl(@CheckForNull String remote, @CheckForN String query = uri.getRawQuery(); String fragment = uri.getRawFragment(); - if (scheme == null || host == null) { + if (scheme == null || host == null || !("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme))) { return remote; } diff --git a/src/test/java/jenkins/plugins/git/BitbucketOAuthHelperTest.java b/src/test/java/jenkins/plugins/git/BitbucketOAuthHelperTest.java index 5baffe1771..6524c1ae1b 100644 --- a/src/test/java/jenkins/plugins/git/BitbucketOAuthHelperTest.java +++ b/src/test/java/jenkins/plugins/git/BitbucketOAuthHelperTest.java @@ -14,6 +14,7 @@ class BitbucketOAuthHelperTest { 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)); } @@ -32,6 +33,15 @@ void shouldLeaveNonBitbucketRemotesUnchanged() { assertThat(BitbucketOAuthHelper.buildOAuthRemoteUrl(remote, "example-token"), equalTo(remote)); } + @Test + void shouldLeaveNonHttpBitbucketRemotesUnchanged() { + String sshRemote = "ssh://git@bitbucket.org/team/repo.git"; + String scpRemote = "git@bitbucket.org:team/repo.git"; + + assertThat(BitbucketOAuthHelper.buildOAuthRemoteUrl(sshRemote, "example-token"), equalTo(sshRemote)); + assertThat(BitbucketOAuthHelper.buildOAuthRemoteUrl(scpRemote, "example-token"), equalTo(scpRemote)); + } + @Test void shouldMaskTokensForSafeLogging() { String masked = BitbucketOAuthHelper.maskTokenForLogging("https://x-token-auth:example-token@bitbucket.org/team/repo.git"); From fbee35f1903ba28d2c53d4b432bcfa00a41238da Mon Sep 17 00:00:00 2001 From: Nick Clark Date: Fri, 17 Jul 2026 12:16:10 -0400 Subject: [PATCH 5/5] Fixing OAUTH for this plugin --- src/main/java/hudson/plugins/git/GitSCM.java | 3 +- .../plugins/git/BitbucketOAuthHelper.java | 79 +++++-------------- .../plugins/git/BitbucketOAuthHelperTest.java | 39 ++++----- 3 files changed, 38 insertions(+), 83 deletions(-) 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/jenkins/plugins/git/BitbucketOAuthHelper.java b/src/main/java/jenkins/plugins/git/BitbucketOAuthHelper.java index 2e44d51899..6f22cf0c95 100644 --- a/src/main/java/jenkins/plugins/git/BitbucketOAuthHelper.java +++ b/src/main/java/jenkins/plugins/git/BitbucketOAuthHelper.java @@ -1,23 +1,24 @@ 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.net.URI; -import java.net.URISyntaxException; import java.util.regex.Pattern; /** * Helper utilities for Bitbucket OAuth-aware remote handling. * - *

The Bitbucket Cloud API accepts an OAuth or app-password token via the - * x-token-auth scheme. When a build runs against a Bitbucket Cloud repository, - * the git remote can be rewritten to include that token in a way that keeps the - * existing git transport behavior intact.

+ *

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:.+)"); @@ -36,63 +37,25 @@ public static boolean isBitbucketCloudRemote(@CheckForNull String remote) { } /** - * Rewrites a Bitbucket Cloud remote to include an OAuth token in the userinfo - * portion of the URL when a token is present. - * - *

This preserves the remote shape for git while avoiding any changes for - * non-Bitbucket remotes.

+ * Returns a transient Git transport credential for a Bitbucket Cloud OAuth token. + * The configured Git remote remains unchanged. */ @NonNull - public static String buildOAuthRemoteUrl(@CheckForNull String remote, @CheckForNull StandardUsernamePasswordCredentials credentials) { - if (credentials == null) { - return buildOAuthRemoteUrl(remote, (String) null); - } - return buildOAuthRemoteUrl(remote, credentials.getPassword().getPlainText()); - } - - @NonNull - public static String buildOAuthRemoteUrl(@CheckForNull String remote, @CheckForNull String token) { - if (!isBitbucketCloudRemote(remote) || token == null || token.isBlank()) { - return remote == null ? "" : remote; + public static StandardUsernameCredentials credentialsFor( + @CheckForNull String remote, @NonNull StandardUsernameCredentials credentials) { + if (!isBitbucketCloudRemote(remote) || !(credentials instanceof StandardUsernamePasswordCredentials usernamePassword)) { + return credentials; } - try { - URI uri = new URI(remote); - String scheme = uri.getScheme(); - String userInfo = "x-token-auth:" + token; - String host = uri.getHost(); - String path = uri.getRawPath(); - String query = uri.getRawQuery(); - String fragment = uri.getRawFragment(); - - if (scheme == null || host == null || !("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme))) { - return remote; - } - - return new URI(scheme, userInfo, host, uri.getPort(), path, query, fragment).toString(); - } catch (URISyntaxException e) { - return remote; - } - } - - /** - * Masks the token portion of a remote URL so that logs do not expose secrets. - */ - @NonNull - public static String maskTokenForLogging(@CheckForNull String remote) { - if (remote == null || remote.isBlank()) { - return ""; - } - - String sanitized = remote; - int atIndex = sanitized.lastIndexOf('@'); - if (atIndex > -1) { - int colonIndex = sanitized.indexOf(':', sanitized.indexOf("//") + 2); - if (colonIndex > -1 && colonIndex < atIndex) { - sanitized = sanitized.substring(0, colonIndex + 1) + "***" + sanitized.substring(atIndex); - } + 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); } - return sanitized; } /** diff --git a/src/test/java/jenkins/plugins/git/BitbucketOAuthHelperTest.java b/src/test/java/jenkins/plugins/git/BitbucketOAuthHelperTest.java index 6524c1ae1b..ea817ff237 100644 --- a/src/test/java/jenkins/plugins/git/BitbucketOAuthHelperTest.java +++ b/src/test/java/jenkins/plugins/git/BitbucketOAuthHelperTest.java @@ -1,12 +1,12 @@ 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.equalTo; import static org.hamcrest.Matchers.is; -import static org.hamcrest.Matchers.not; -import static org.hamcrest.Matchers.nullValue; class BitbucketOAuthHelperTest { @@ -20,32 +20,23 @@ void shouldRecognizeBitbucketCloudRemotes() { } @Test - void shouldBuildOAuthRemoteUrlsForBitbucketCloud() { - String remote = "https://bitbucket.org/team/repo.git"; - String oauthRemote = BitbucketOAuthHelper.buildOAuthRemoteUrl(remote, "example-token"); + void shouldProvideTransientOAuthCredentialForBitbucketCloud() throws Exception { + StandardUsernameCredentials original = new UsernamePasswordCredentialsImpl( + CredentialsScope.GLOBAL, "oauth", "OAuth token", "ignored", "example-token"); - assertThat(oauthRemote, equalTo("https://x-token-auth:example-token@bitbucket.org/team/repo.git")); - } + StandardUsernameCredentials adapted = BitbucketOAuthHelper.credentialsFor( + "https://bitbucket.org/team/repo.git", original); - @Test - void shouldLeaveNonBitbucketRemotesUnchanged() { - String remote = "https://github.com/team/repo.git"; - assertThat(BitbucketOAuthHelper.buildOAuthRemoteUrl(remote, "example-token"), equalTo(remote)); + assertThat(adapted.getUsername(), is("x-token-auth")); + assertThat(adapted.getId(), is("oauth")); + assertThat(((UsernamePasswordCredentialsImpl) adapted).getPassword().getPlainText(), is("example-token")); } @Test - void shouldLeaveNonHttpBitbucketRemotesUnchanged() { - String sshRemote = "ssh://git@bitbucket.org/team/repo.git"; - String scpRemote = "git@bitbucket.org:team/repo.git"; + void shouldLeaveNonBitbucketCredentialsUnchanged() throws Exception { + StandardUsernameCredentials original = new UsernamePasswordCredentialsImpl( + CredentialsScope.GLOBAL, "oauth", "OAuth token", "ignored", "example-token"); - assertThat(BitbucketOAuthHelper.buildOAuthRemoteUrl(sshRemote, "example-token"), equalTo(sshRemote)); - assertThat(BitbucketOAuthHelper.buildOAuthRemoteUrl(scpRemote, "example-token"), equalTo(scpRemote)); - } - - @Test - void shouldMaskTokensForSafeLogging() { - String masked = BitbucketOAuthHelper.maskTokenForLogging("https://x-token-auth:example-token@bitbucket.org/team/repo.git"); - assertThat(masked, equalTo("https://x-token-auth:***@bitbucket.org/team/repo.git")); - assertThat(masked, not(nullValue())); + assertThat(BitbucketOAuthHelper.credentialsFor("https://github.com/team/repo.git", original), is(original)); } }