Skip to content

add OAuth authentication #262

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 18 commits into from
Apr 14, 2020
Merged
Show file tree
Hide file tree
Changes from 16 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
19 changes: 17 additions & 2 deletions docs/USER_GUIDE.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,23 @@ two credentials to configure:

. *Scan Credentials*: credentials used to access Bitbucket API in order to discover repositories, branches and pull requests.
If not set then anonymous access is used, so only public repositories, branches and pull requests are discovered and managed. Note that the
Webhooks auto-register feature requires scan credentials to be set. Only HTTP credentials are accepted in this field.
. *Checkout Credentials*: credentials used to checkout sources once the repository, branch or pull request is discovered. HTTP and SSH credentials
Webhooks auto-register feature requires scan credentials to be set. Only HTTP or OAuth credentials are accepted in this field.
. *Checkout Credentials*: credentials used to checkout sources once the repository, branch or pull request is discovered. HTTP, SSH and OAuthcredentials
are allowed. If not set then _Scan Credentials_ are used.

image::images/screenshot-3.png[scaledwidth=90%]

=== OAuth credentials

Bitbucket plugin can make use of OAuth credentials instead of the standard username/password.

First create a new OAuth consumer as instructed in https://confluence.atlassian.com/bitbucket/oauth-on-bitbucket-cloud-238027431.html[Bitbucket OAuth Documentation].
Don't forget to check _This is a private consumer_ and at least allow read access to the repositories and Pull requests. If you want the Bitbucket to install the Webhooks also allow the read and write access of the Webhooks

image::images/screenshot-10.png[scaledwidth=90%]

Then create new _Username with password credentials_, enter the Bitbucket OAuth consumer key in _Username_ field and the Bitbucket OAuth consumer secret in _Password_ field

image::images/screenshot-11.png[scaledwidth=90%]

image::images/screenshot-12.png[scaledwidth=90%]
Binary file added docs/images/screenshot-10.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/screenshot-11.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/screenshot-12.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 11 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,17 @@
<groupId>org.jenkins-ci.plugins</groupId>
<artifactId>authentication-tokens</artifactId>
<version>1.3</version>
</dependency>
<dependency>
<groupId>org.scribe</groupId>
<artifactId>scribe</artifactId>
<version>1.3.3</version>
<exclusions>
<exclusion>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,17 @@ public void configureRequest(HttpRequest request) {
// override to configure HttpRequest
}

/**
* Return the user to be used in the clone Uri. Override this if your
* authentication method needs to set the user in the repository Uri
*
* @return user name to use in the repository Uri
*/
public String getUserUri() {
// override to return a user
return "";
}

/**
* Generates context that sub-classes can use to determine if they would be able to authenticate against the
* provided server.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.cloudbees.jenkins.plugins.bitbucket.api.credentials;

import org.scribe.builder.api.DefaultApi20;
import org.scribe.extractors.AccessTokenExtractor;
import org.scribe.extractors.JsonTokenExtractor;
import org.scribe.model.OAuthConfig;
import org.scribe.model.Verb;
import org.scribe.oauth.OAuthService;

public class BitbucketOAuth extends DefaultApi20 {
public static final String OAUTH_ENDPOINT = "https://bitbucket.org/site/oauth2/";

@Override
public String getAccessTokenEndpoint() {
return OAUTH_ENDPOINT + "access_token";
}

@Override
public String getAuthorizationUrl(OAuthConfig config) {
return OAUTH_ENDPOINT + "authorize";
}

@Override
public Verb getAccessTokenVerb() {
return Verb.POST;
}

@Override
public AccessTokenExtractor getAccessTokenExtractor() {
return new JsonTokenExtractor();
}

@Override
public OAuthService createService(OAuthConfig config) {
return new BitbucketOAuthService(this, config);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.cloudbees.jenkins.plugins.bitbucket.api.credentials;

import com.cloudbees.jenkins.plugins.bitbucket.api.BitbucketAuthenticator;
import com.cloudbees.plugins.credentials.common.StandardUsernamePasswordCredentials;
import hudson.util.Secret;
import org.apache.http.HttpRequest;
import org.scribe.model.OAuthConfig;
import org.scribe.model.OAuthConstants;
import org.scribe.model.Token;

public class BitbucketOAuthAuthenticator extends BitbucketAuthenticator {

private Token token;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps a good idea to cache the token, I assume it has a validity time 🤔

Copy link
Member

@jetersen jetersen Apr 14, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can move forward without caching. Perhaps a future PR.


/**
* Constructor.
*
* @param credentials the key/pass that will be used
*/
public BitbucketOAuthAuthenticator(StandardUsernamePasswordCredentials credentials) {
super(credentials);

OAuthConfig config = new OAuthConfig(credentials.getUsername(), Secret.toString(credentials.getPassword()));

BitbucketOAuthService OAuthService = (BitbucketOAuthService) new BitbucketOAuth().createService(config);

token = OAuthService.getAccessToken(OAuthConstants.EMPTY_TOKEN, null);
}

/**
* Set up request with token in header
*/
@Override
public void configureRequest(HttpRequest request) {
request.addHeader(OAuthConstants.HEADER, "Bearer " + this.token.getToken());
}

@Override
public String getUserUri() {
return "x-token-auth:{" + token.getToken() + "}";
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.cloudbees.jenkins.plugins.bitbucket.api.credentials;

import com.cloudbees.jenkins.plugins.bitbucket.api.BitbucketAuthenticator;
import com.cloudbees.plugins.credentials.CredentialsMatcher;
import com.cloudbees.plugins.credentials.common.StandardUsernamePasswordCredentials;
import edu.umd.cs.findbugs.annotations.NonNull;
import hudson.Extension;
import jenkins.authentication.tokens.api.AuthenticationTokenContext;
import jenkins.authentication.tokens.api.AuthenticationTokenSource;


/**
* Source for OAuth authenticators.
*/
@Extension
public class BitbucketOAuthAuthenticatorSource extends AuthenticationTokenSource<BitbucketOAuthAuthenticator, StandardUsernamePasswordCredentials> {

/**
* Constructor.
*/
public BitbucketOAuthAuthenticatorSource() {
super(BitbucketOAuthAuthenticator.class, StandardUsernamePasswordCredentials.class);
}

/**
* Converts username/password credentials to an authenticator.
*
* @param standardUsernamePasswordCredentials the username/password combo
* @return an authenticator that will use them.
*/
@NonNull
@Override
public BitbucketOAuthAuthenticator convert(
@NonNull StandardUsernamePasswordCredentials standardUsernamePasswordCredentials) {
return new BitbucketOAuthAuthenticator(standardUsernamePasswordCredentials);
}

/**
* Whether this source works in the given context. For client certs, only HTTPS
* BitbucketServer instances make sense
*
* @param ctx the context
* @return whether or not this can authenticate given the context
*/
@Override
public boolean isFit(AuthenticationTokenContext ctx) {
return ctx.mustHave(BitbucketAuthenticator.SCHEME, "https") && ctx.mustHave(
BitbucketAuthenticator.BITBUCKET_INSTANCE_TYPE, BitbucketAuthenticator.BITBUCKET_INSTANCE_TYPE_CLOUD);
}

/**
* {@inheritDoc}
*/
@Override
public CredentialsMatcher matcher() {
return new BitbucketOAuthCredentialMatcher();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.cloudbees.jenkins.plugins.bitbucket.api.credentials;

import com.cloudbees.plugins.credentials.Credentials;
import com.cloudbees.plugins.credentials.CredentialsMatcher;
import com.cloudbees.plugins.credentials.common.UsernamePasswordCredentials;
import hudson.util.Secret;

public class BitbucketOAuthCredentialMatcher implements CredentialsMatcher, CredentialsMatcher.CQL {
private static int keyLenght = 18;
private static int secretLenght = 32;

private static final long serialVersionUID = 6458784517693211197L;

/**
* {@inheritDoc}
*/
@Override
public boolean matches(Credentials item) {
if (!(item instanceof UsernamePasswordCredentials))
return false;

UsernamePasswordCredentials usernamePasswordCredential = ((UsernamePasswordCredentials) item);
String username = usernamePasswordCredential.getUsername();
boolean isEMail = username.contains(".") && username.contains("@");
boolean validSecretLenght = Secret.toString(usernamePasswordCredential.getPassword()).length() == secretLenght;
boolean validKeyLenght = username.length() == keyLenght;

return !isEMail && validKeyLenght && validSecretLenght;
}

/**
* {@inheritDoc}
*/
@Override
public String describe() {
return String.format(
"(username.lenght == %d && password.lenght == %d && !(username CONTAINS \".\" && username CONTAINS \"@\")",
keyLenght, secretLenght);
}


}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.cloudbees.jenkins.plugins.bitbucket.api.credentials;


import java.nio.charset.StandardCharsets;
import org.eclipse.jgit.util.Base64;
import org.scribe.builder.api.DefaultApi20;
import org.scribe.model.OAuthConfig;
import org.scribe.model.OAuthConstants;
import org.scribe.model.OAuthRequest;
import org.scribe.model.Response;
import org.scribe.model.Token;
import org.scribe.model.Verifier;
import org.scribe.oauth.OAuth20ServiceImpl;

public class BitbucketOAuthService extends OAuth20ServiceImpl {
private static final String GRANT_TYPE_KEY = "grant_type";
private static final String GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials";

private DefaultApi20 api;
private OAuthConfig config;

public BitbucketOAuthService(DefaultApi20 api, OAuthConfig config) {
super(api, config);
this.api = api;
this.config = config;
}

@Override
public Token getAccessToken(Token requestToken, Verifier verifier) {
OAuthRequest request = new OAuthRequest(api.getAccessTokenVerb(), api.getAccessTokenEndpoint());
request.addHeader(OAuthConstants.HEADER, this.getHttpBasicAuthHeaderValue());
request.addBodyParameter(GRANT_TYPE_KEY, GRANT_TYPE_CLIENT_CREDENTIALS);
Response response = request.send();

return api.getAccessTokenExtractor().extract(response.getBody());
}

@Override
public void signRequest(Token accessToken, OAuthRequest request) {
request.addHeader(OAuthConstants.HEADER, this.getBearerAuthHeaderValue(accessToken));
}

private String getHttpBasicAuthHeaderValue() {
String authStr = config.getApiKey() + ":" + config.getApiSecret();

return "Basic " + Base64.encodeBytes(authStr.getBytes(StandardCharsets.UTF_8));
}

private String getBearerAuthHeaderValue(Token token) {
return "Bearer " + token.getToken();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,12 @@ public String getRepositoryUri(@NonNull BitbucketRepositoryType type,
case GIT:
switch (protocol) {
case HTTP:
if(authenticator != null ) {
String username = authenticator.getUserUri();
if (!username.isEmpty()) {
return "https://" + username + "@bitbucket.org/" + owner + "/" + repository + ".git";
}
}
return "https://bitbucket.org/" + owner + "/" + repository + ".git";
case SSH:
return "[email protected]:" + owner + "/" + repository + ".git";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.cloudbees.jenkins.plugins.bitbucket;

import com.cloudbees.jenkins.plugins.bitbucket.api.BitbucketAuthenticator;
import com.cloudbees.jenkins.plugins.bitbucket.api.credentials.BitbucketUsernamePasswordAuthenticator;
import com.cloudbees.jenkins.plugins.bitbucket.endpoints.BitbucketCloudEndpoint;
import com.cloudbees.plugins.credentials.Credentials;
import com.cloudbees.plugins.credentials.CredentialsMatchers;
Expand All @@ -19,6 +20,7 @@
import org.jvnet.hudson.test.JenkinsRule;

import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.isA;
import static org.hamcrest.Matchers.notNullValue;
import static org.hamcrest.Matchers.nullValue;
import static org.junit.Assert.assertThat;
Expand Down Expand Up @@ -62,7 +64,9 @@ public void passwordCredentialsTest() {
AuthenticationTokenContext ctx = BitbucketAuthenticator.authenticationContext((null));
Credentials c = CredentialsMatchers.firstOrNull(list, AuthenticationTokens.matcher(ctx));
assertThat(c, notNullValue());
assertThat(AuthenticationTokens.convert(ctx, c), notNullValue());
Object a = AuthenticationTokens.convert(ctx, c);
assertThat(a, notNullValue());
assertThat(a, isA(BitbucketUsernamePasswordAuthenticator.class));
}

@Test
Expand Down