]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-15428 support bitbucket.org auth
authorPierre <pierre.guillot@sonarsource.com>
Thu, 23 Sep 2021 15:27:25 +0000 (17:27 +0200)
committersonartech <sonartech@sonarsource.com>
Tue, 28 Sep 2021 20:03:12 +0000 (20:03 +0000)
26 files changed:
server/sonar-auth-bitbucket/build.gradle [new file with mode: 0644]
server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/BitbucketIdentityProvider.java [new file with mode: 0755]
server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/BitbucketModule.java [new file with mode: 0755]
server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/BitbucketScribeApi.java [new file with mode: 0755]
server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/BitbucketSettings.java [new file with mode: 0755]
server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonEmail.java [new file with mode: 0755]
server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonEmails.java [new file with mode: 0755]
server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonUser.java [new file with mode: 0755]
server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonWorkspace.java [new file with mode: 0644]
server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonWorkspaceMembership.java [new file with mode: 0755]
server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonWorkspaceMemberships.java [new file with mode: 0755]
server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/UserIdentityFactory.java [new file with mode: 0755]
server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/package-info.java [new file with mode: 0755]
server/sonar-auth-bitbucket/src/main/resources/org/sonar/l10n/authbitbucket.properties [new file with mode: 0755]
server/sonar-auth-bitbucket/src/main/resources/static/bitbucket.svg [new file with mode: 0755]
server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/BitbucketIdentityProviderTest.java [new file with mode: 0755]
server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/BitbucketModuleTest.java [new file with mode: 0755]
server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/BitbucketSettingsTest.java [new file with mode: 0755]
server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/GsonEmailsTest.java [new file with mode: 0755]
server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/GsonUserTest.java [new file with mode: 0755]
server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/IntegrationTest.java [new file with mode: 0644]
server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/UserIdentityFactoryTest.java [new file with mode: 0755]
server/sonar-web/public/images/alm/bitbucket-white.svg [new file with mode: 0644]
server/sonar-webserver/build.gradle
server/sonar-webserver/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java
settings.gradle

diff --git a/server/sonar-auth-bitbucket/build.gradle b/server/sonar-auth-bitbucket/build.gradle
new file mode 100644 (file)
index 0000000..2e5f80a
--- /dev/null
@@ -0,0 +1,25 @@
+description = 'SonarQube :: Authentication :: Bitbucket'
+
+configurations {
+    testCompile.extendsFrom compileOnly
+}
+
+dependencies {
+    // please keep the list ordered
+
+    compile 'com.github.scribejava:scribejava-apis'
+    compile 'com.github.scribejava:scribejava-core'
+    compile 'com.google.code.gson:gson'
+    compile project(':server:sonar-auth-common')
+
+    compileOnly 'com.google.code.findbugs:jsr305'
+    compileOnly 'com.squareup.okhttp3:okhttp'
+    compileOnly 'javax.servlet:javax.servlet-api'
+    compileOnly project(':sonar-core')
+
+    testCompile 'com.squareup.okhttp3:mockwebserver'
+    testCompile 'com.squareup.okhttp3:okhttp'
+    testCompile 'junit:junit'
+    testCompile 'org.assertj:assertj-core'
+    testCompile 'org.mockito:mockito-core'
+}
diff --git a/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/BitbucketIdentityProvider.java b/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/BitbucketIdentityProvider.java
new file mode 100755 (executable)
index 0000000..c05d36c
--- /dev/null
@@ -0,0 +1,195 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.auth.bitbucket;
+
+import com.github.scribejava.core.builder.ServiceBuilder;
+import com.github.scribejava.core.builder.ServiceBuilderOAuth20;
+import com.github.scribejava.core.model.OAuth2AccessToken;
+import com.github.scribejava.core.model.OAuthConstants;
+import com.github.scribejava.core.model.OAuthRequest;
+import com.github.scribejava.core.model.Response;
+import com.github.scribejava.core.model.Verb;
+import com.github.scribejava.core.oauth.OAuth20Service;
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import javax.annotation.CheckForNull;
+import javax.servlet.http.HttpServletRequest;
+import org.sonar.api.server.ServerSide;
+import org.sonar.api.server.authentication.Display;
+import org.sonar.api.server.authentication.OAuth2IdentityProvider;
+import org.sonar.api.server.authentication.UnauthorizedException;
+import org.sonar.api.server.authentication.UserIdentity;
+import org.sonar.api.utils.log.Logger;
+import org.sonar.api.utils.log.Loggers;
+
+import static com.google.common.base.Preconditions.checkState;
+import static java.lang.String.format;
+import static java.util.Arrays.asList;
+import static java.util.stream.Collectors.toSet;
+
+@ServerSide
+public class BitbucketIdentityProvider implements OAuth2IdentityProvider {
+
+  private static final Logger LOGGER = Loggers.get(BitbucketIdentityProvider.class);
+
+  public static final String REQUIRED_SCOPE = "account";
+  public static final String KEY = "bitbucket";
+
+  private final BitbucketSettings settings;
+  private final UserIdentityFactory userIdentityFactory;
+  private final BitbucketScribeApi scribeApi;
+
+  public BitbucketIdentityProvider(BitbucketSettings settings, UserIdentityFactory userIdentityFactory, BitbucketScribeApi scribeApi) {
+    this.settings = settings;
+    this.userIdentityFactory = userIdentityFactory;
+    this.scribeApi = scribeApi;
+  }
+
+  @Override
+  public String getKey() {
+    return KEY;
+  }
+
+  @Override
+  public String getName() {
+    return "Bitbucket";
+  }
+
+  @Override
+  public Display getDisplay() {
+    return Display.builder()
+      .setIconPath("/images/alm/bitbucket-white.svg")
+      .setBackgroundColor("#0052cc")
+      .build();
+  }
+
+  @Override
+  public boolean isEnabled() {
+    return settings.isEnabled();
+  }
+
+  @Override
+  public boolean allowsUsersToSignUp() {
+    return settings.allowUsersToSignUp();
+  }
+
+  @Override
+  public void init(InitContext context) {
+    String state = context.generateCsrfState();
+    OAuth20Service scribe = newScribeBuilder(context).build(scribeApi);
+    String url = scribe.getAuthorizationUrl(state);
+    context.redirectTo(url);
+  }
+
+  private ServiceBuilderOAuth20 newScribeBuilder(OAuth2Context context) {
+    checkState(isEnabled(), "Bitbucket authentication is disabled");
+    return new ServiceBuilder(settings.clientId())
+      .apiSecret(settings.clientSecret())
+      .callback(context.getCallbackUrl())
+      .defaultScope(REQUIRED_SCOPE);
+  }
+
+  @Override
+  public void callback(CallbackContext context) {
+    try {
+      onCallback(context);
+    } catch (IOException | ExecutionException e) {
+      throw new IllegalStateException(e);
+    } catch (InterruptedException e) {
+      Thread.currentThread().interrupt();
+      throw new IllegalStateException(e);
+    }
+  }
+
+  private void onCallback(CallbackContext context) throws InterruptedException, ExecutionException, IOException {
+    HttpServletRequest request = context.getRequest();
+    OAuth20Service scribe = newScribeBuilder(context).build(scribeApi);
+    String code = request.getParameter(OAuthConstants.CODE);
+    OAuth2AccessToken accessToken = scribe.getAccessToken(code);
+
+    GsonUser gsonUser = requestUser(scribe, accessToken);
+    GsonEmails gsonEmails = requestEmails(scribe, accessToken);
+
+    checkTeamRestriction(scribe, accessToken, gsonUser);
+
+    UserIdentity userIdentity = userIdentityFactory.create(gsonUser, gsonEmails);
+    context.authenticate(userIdentity);
+    context.redirectToRequestedPage();
+  }
+
+  private GsonUser requestUser(OAuth20Service service, OAuth2AccessToken accessToken) throws InterruptedException, ExecutionException, IOException {
+    OAuthRequest userRequest = new OAuthRequest(Verb.GET, settings.apiURL() + "2.0/user");
+    service.signRequest(accessToken, userRequest);
+    Response userResponse = service.execute(userRequest);
+
+    if (!userResponse.isSuccessful()) {
+      throw new IllegalStateException(format("Can not get Bitbucket user profile. HTTP code: %s, response: %s",
+        userResponse.getCode(), userResponse.getBody()));
+    }
+    String userResponseBody = userResponse.getBody();
+    return GsonUser.parse(userResponseBody);
+  }
+
+  @CheckForNull
+  private GsonEmails requestEmails(OAuth20Service service, OAuth2AccessToken accessToken) throws InterruptedException, ExecutionException, IOException {
+    OAuthRequest userRequest = new OAuthRequest(Verb.GET, settings.apiURL() + "2.0/user/emails");
+    service.signRequest(accessToken, userRequest);
+    Response emailsResponse = service.execute(userRequest);
+    if (emailsResponse.isSuccessful()) {
+      return GsonEmails.parse(emailsResponse.getBody());
+    }
+    return null;
+  }
+
+  private void checkTeamRestriction(OAuth20Service service, OAuth2AccessToken accessToken, GsonUser user) throws InterruptedException, ExecutionException, IOException {
+    String[] workspaceAllowed = settings.workspaceAllowedList();
+    if (workspaceAllowed != null && workspaceAllowed.length > 0) {
+      GsonWorkspaceMemberships userWorkspaces = requestWorkspaces(service, accessToken);
+      String errorMessage = format("User %s is not part of allowed workspaces list", user.getUsername());
+      if (userWorkspaces == null || userWorkspaces.getWorkspaces() == null) {
+        throw new UnauthorizedException(errorMessage);
+      } else {
+        Set<String> uniqueUserWorkspaces = new HashSet<>();
+        uniqueUserWorkspaces.addAll(userWorkspaces.getWorkspaces().stream().map(w -> w.getWorkspace().getName()).collect(toSet()));
+        uniqueUserWorkspaces.addAll(userWorkspaces.getWorkspaces().stream().map(w -> w.getWorkspace().getSlug()).collect(toSet()));
+        List<String> workspaceAllowedList = asList(workspaceAllowed);
+        if (uniqueUserWorkspaces.stream().noneMatch(workspaceAllowedList::contains)) {
+          throw new UnauthorizedException(errorMessage);
+        }
+      }
+    }
+  }
+
+  @CheckForNull
+  private GsonWorkspaceMemberships requestWorkspaces(OAuth20Service service, OAuth2AccessToken accessToken) throws InterruptedException, ExecutionException, IOException {
+    OAuthRequest userRequest = new OAuthRequest(Verb.GET, settings.apiURL() + "2.0/user/permissions/workspaces?q=permission=\"member\"");
+    service.signRequest(accessToken, userRequest);
+    Response teamsResponse = service.execute(userRequest);
+    if (teamsResponse.isSuccessful()) {
+      return GsonWorkspaceMemberships.parse(teamsResponse.getBody());
+    }
+    LOGGER.warn("Fail to retrieve the teams of Bitbucket user: {}", teamsResponse.getBody());
+    return null;
+  }
+
+}
diff --git a/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/BitbucketModule.java b/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/BitbucketModule.java
new file mode 100755 (executable)
index 0000000..00c8072
--- /dev/null
@@ -0,0 +1,41 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.auth.bitbucket;
+
+import java.util.List;
+import org.sonar.api.config.PropertyDefinition;
+import org.sonar.core.platform.Module;
+
+import static org.sonar.auth.bitbucket.BitbucketSettings.definitions;
+
+public class BitbucketModule extends Module {
+
+  @Override
+  protected void configureModule() {
+    add(
+      BitbucketIdentityProvider.class,
+      BitbucketSettings.class,
+      UserIdentityFactory.class,
+      BitbucketScribeApi.class);
+    List<PropertyDefinition> definitions = definitions();
+    add(definitions.toArray(Object[]::new));
+  }
+
+}
diff --git a/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/BitbucketScribeApi.java b/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/BitbucketScribeApi.java
new file mode 100755 (executable)
index 0000000..bda66be
--- /dev/null
@@ -0,0 +1,49 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.auth.bitbucket;
+
+import com.github.scribejava.core.builder.api.DefaultApi20;
+import com.github.scribejava.core.model.Verb;
+import org.sonar.api.server.ServerSide;
+
+@ServerSide
+public class BitbucketScribeApi extends DefaultApi20 {
+
+  private final BitbucketSettings settings;
+
+  public BitbucketScribeApi(BitbucketSettings settings) {
+    this.settings = settings;
+  }
+
+  @Override
+  public String getAccessTokenEndpoint() {
+    return settings.webURL() + "site/oauth2/access_token";
+  }
+
+  @Override
+  public Verb getAccessTokenVerb() {
+    return Verb.POST;
+  }
+
+  @Override
+  protected String getAuthorizationBaseUrl() {
+    return settings.webURL() + "site/oauth2/authorize";
+  }
+}
diff --git a/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/BitbucketSettings.java b/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/BitbucketSettings.java
new file mode 100755 (executable)
index 0000000..687cf70
--- /dev/null
@@ -0,0 +1,128 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.auth.bitbucket;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.function.Supplier;
+import javax.annotation.CheckForNull;
+import org.sonar.api.CoreProperties;
+import org.sonar.api.PropertyType;
+import org.sonar.api.config.Configuration;
+import org.sonar.api.config.PropertyDefinition;
+import org.sonar.api.server.ServerSide;
+
+import static java.lang.String.format;
+
+@ServerSide
+public class BitbucketSettings {
+
+  private static final Supplier<? extends IllegalStateException> DEFAULT_VALUE_MISSING = () -> new IllegalStateException("Should have a default value");
+  public static final String CONSUMER_KEY = "sonar.auth.bitbucket.clientId.secured";
+  public static final String CONSUMER_SECRET = "sonar.auth.bitbucket.clientSecret.secured";
+  public static final String ENABLED = "sonar.auth.bitbucket.enabled";
+  public static final String ALLOW_USERS_TO_SIGN_UP = "sonar.auth.bitbucket.allowUsersToSignUp";
+  public static final String WORKSPACE_ALLOWED_LIST = "sonar.auth.bitbucket.workspaces";
+  public static final String DEFAULT_API_URL = "https://api.bitbucket.org/";
+  public static final String DEFAULT_WEB_URL = "https://bitbucket.org/";
+  public static final String SUBCATEGORY = "bitbucket";
+
+  private final Configuration config;
+
+  public BitbucketSettings(Configuration config) {
+    this.config = config;
+  }
+
+  @CheckForNull
+  public String clientId() {
+    return config.get(CONSUMER_KEY).orElse(null);
+  }
+
+  @CheckForNull
+  public String clientSecret() {
+    return config.get(CONSUMER_SECRET).orElse(null);
+  }
+
+  public boolean isEnabled() {
+    return config.getBoolean(ENABLED).orElseThrow(DEFAULT_VALUE_MISSING) && clientId() != null && clientSecret() != null;
+  }
+
+  public boolean allowUsersToSignUp() {
+    return config.getBoolean(ALLOW_USERS_TO_SIGN_UP).orElseThrow(DEFAULT_VALUE_MISSING);
+  }
+
+  public String[] workspaceAllowedList() {
+    return config.getStringArray(WORKSPACE_ALLOWED_LIST);
+  }
+
+  public String webURL() {
+    return DEFAULT_WEB_URL;
+  }
+
+  public String apiURL() {
+    return DEFAULT_API_URL;
+  }
+
+  public static List<PropertyDefinition> definitions() {
+    return Arrays.asList(
+      PropertyDefinition.builder(ENABLED)
+        .name("Enabled")
+        .description("Enable Bitbucket users to login. Value is ignored if consumer key and secret are not defined.")
+        .category(CoreProperties.CATEGORY_ALM_INTEGRATION)
+        .subCategory(SUBCATEGORY)
+        .type(PropertyType.BOOLEAN)
+        .defaultValue(String.valueOf(false))
+        .index(1)
+        .build(),
+      PropertyDefinition.builder(CONSUMER_KEY)
+        .name("OAuth consumer key")
+        .description("Consumer key provided by Bitbucket when registering the consumer.")
+        .category(CoreProperties.CATEGORY_ALM_INTEGRATION)
+        .subCategory(SUBCATEGORY)
+        .index(2)
+        .build(),
+      PropertyDefinition.builder(CONSUMER_SECRET)
+        .name("OAuth consumer secret")
+        .description("Consumer secret provided by Bitbucket when registering the consumer.")
+        .category(CoreProperties.CATEGORY_ALM_INTEGRATION)
+        .subCategory(SUBCATEGORY)
+        .index(3)
+        .build(),
+      PropertyDefinition.builder(ALLOW_USERS_TO_SIGN_UP)
+        .name("Allow users to sign-up")
+        .description("Allow new users to authenticate. When set to 'false', only existing users will be able to authenticate.")
+        .category(CoreProperties.CATEGORY_ALM_INTEGRATION)
+        .subCategory(SUBCATEGORY)
+        .type(PropertyType.BOOLEAN)
+        .defaultValue(String.valueOf(true))
+        .index(4)
+        .build(),
+      PropertyDefinition.builder(WORKSPACE_ALLOWED_LIST)
+        .name("Workspaces")
+        .description("Only members of at least one of these workspace will be able to authenticate. Keep empty to disable workspace restriction.")
+        .category(CoreProperties.CATEGORY_ALM_INTEGRATION)
+        .subCategory(SUBCATEGORY)
+        .multiValues(true)
+        .index(5)
+        .build()
+    );
+  }
+
+}
diff --git a/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonEmail.java b/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonEmail.java
new file mode 100755 (executable)
index 0000000..ac75eaa
--- /dev/null
@@ -0,0 +1,44 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.auth.bitbucket;
+
+import com.google.gson.annotations.SerializedName;
+
+public class GsonEmail {
+  @SerializedName("is_primary")
+  private boolean isPrimary;
+
+  @SerializedName("email")
+  private String email;
+
+  public GsonEmail() {
+    // even if empty constructor is not required for Gson, it is strongly
+    // recommended:
+    // http://stackoverflow.com/a/18645370/229031
+  }
+
+  public boolean isPrimary() {
+    return isPrimary;
+  }
+
+  public String getEmail() {
+    return email;
+  }
+}
diff --git a/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonEmails.java b/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonEmails.java
new file mode 100755 (executable)
index 0000000..9fc2918
--- /dev/null
@@ -0,0 +1,56 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.auth.bitbucket;
+
+import com.google.gson.Gson;
+import com.google.gson.annotations.SerializedName;
+import java.util.List;
+import javax.annotation.CheckForNull;
+
+public class GsonEmails {
+
+  @SerializedName("values")
+  private List<GsonEmail> emails;
+
+  public GsonEmails() {
+    // even if empty constructor is not required for Gson, it is strongly
+    // recommended:
+    // http://stackoverflow.com/a/18645370/229031
+  }
+
+  public List<GsonEmail> getEmails() {
+    return emails;
+  }
+
+  public static GsonEmails parse(String json) {
+    Gson gson = new Gson();
+    return gson.fromJson(json, GsonEmails.class);
+  }
+
+  @CheckForNull
+  public String extractPrimaryEmail() {
+    for (GsonEmail gsonEmail : emails) {
+      if (gsonEmail.isPrimary()) {
+        return gsonEmail.getEmail();
+      }
+    }
+    return null;
+  }
+}
diff --git a/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonUser.java b/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonUser.java
new file mode 100755 (executable)
index 0000000..6399e2a
--- /dev/null
@@ -0,0 +1,69 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.auth.bitbucket;
+
+import com.google.gson.Gson;
+import com.google.gson.annotations.SerializedName;
+import javax.annotation.CheckForNull;
+import javax.annotation.Nullable;
+
+/**
+ * Lite representation of JSON response of GET https://api.bitbucket.org/2.0/user
+ */
+public class GsonUser {
+  @SerializedName("username")
+  private String username;
+
+  @SerializedName("display_name")
+  private String displayName;
+
+  @SerializedName("uuid")
+  private String uuid;
+
+  public GsonUser() {
+    // even if empty constructor is not required for Gson, it is strongly
+    // recommended:
+    // http://stackoverflow.com/a/18645370/229031
+  }
+
+  GsonUser(String username, @Nullable  String displayName, String uuid) {
+    this.username = username;
+    this.displayName = displayName;
+    this.uuid = uuid;
+  }
+
+  public String getUsername() {
+    return username;
+  }
+
+  @CheckForNull
+  public String getDisplayName() {
+    return displayName;
+  }
+
+  public String getUuid() {
+    return uuid;
+  }
+
+  public static GsonUser parse(String json) {
+    Gson gson = new Gson();
+    return gson.fromJson(json, GsonUser.class);
+  }
+}
diff --git a/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonWorkspace.java b/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonWorkspace.java
new file mode 100644 (file)
index 0000000..38d4ddb
--- /dev/null
@@ -0,0 +1,49 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.auth.bitbucket;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * Lite representation of team https://api.bitbucket.org/2.0/user/permissions/workspaces
+ */
+public class GsonWorkspace {
+
+  @SerializedName("name")
+  private String name;
+
+  @SerializedName("slug")
+  private String slug;
+
+  public GsonWorkspace() {
+    // even if empty constructor is not required for Gson, it is strongly
+    // recommended:
+    // http://stackoverflow.com/a/18645370/229031
+  }
+
+  public String getName() {
+    return name;
+  }
+
+  public String getSlug() {
+    return slug;
+  }
+
+}
diff --git a/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonWorkspaceMembership.java b/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonWorkspaceMembership.java
new file mode 100755 (executable)
index 0000000..75fe9ad
--- /dev/null
@@ -0,0 +1,42 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.auth.bitbucket;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * Lite representation of team https://api.bitbucket.org/2.0/user/permissions/workspaces
+ */
+public class GsonWorkspaceMembership {
+
+  @SerializedName("workspace")
+  private GsonWorkspace workspace;
+
+  public GsonWorkspaceMembership() {
+    // even if empty constructor is not required for Gson, it is strongly
+    // recommended:
+    // http://stackoverflow.com/a/18645370/229031
+  }
+
+  public GsonWorkspace getWorkspace() {
+    return workspace;
+  }
+
+}
diff --git a/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonWorkspaceMemberships.java b/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonWorkspaceMemberships.java
new file mode 100755 (executable)
index 0000000..f327e75
--- /dev/null
@@ -0,0 +1,49 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.auth.bitbucket;
+
+import com.google.gson.Gson;
+import com.google.gson.annotations.SerializedName;
+import java.util.List;
+
+/**
+ * Lite representation of JSON response of GET https://api.bitbucket.org/2.0/user/permissions/workspaces
+ */
+public class GsonWorkspaceMemberships {
+
+  @SerializedName("values")
+  private List<GsonWorkspaceMembership> values;
+
+  public GsonWorkspaceMemberships() {
+    // even if empty constructor is not required for Gson, it is strongly
+    // recommended:
+    // http://stackoverflow.com/a/18645370/229031
+  }
+
+  public List<GsonWorkspaceMembership> getWorkspaces() {
+    return values;
+  }
+
+  public static GsonWorkspaceMemberships parse(String json) {
+    Gson gson = new Gson();
+    return gson.fromJson(json, GsonWorkspaceMemberships.class);
+  }
+
+}
diff --git a/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/UserIdentityFactory.java b/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/UserIdentityFactory.java
new file mode 100755 (executable)
index 0000000..96e1e34
--- /dev/null
@@ -0,0 +1,47 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.auth.bitbucket;
+
+import javax.annotation.Nullable;
+import org.sonar.api.server.ServerSide;
+import org.sonar.api.server.authentication.UserIdentity;
+
+import static java.lang.String.format;
+
+@ServerSide
+public class UserIdentityFactory {
+
+  public UserIdentity create(GsonUser gsonUser, @Nullable GsonEmails gsonEmails) {
+    UserIdentity.Builder builder = UserIdentity.builder()
+      .setProviderId(gsonUser.getUuid())
+      .setProviderLogin(gsonUser.getUsername())
+      .setName(generateName(gsonUser));
+    if (gsonEmails != null) {
+      builder.setEmail(gsonEmails.extractPrimaryEmail());
+    }
+    return builder.build();
+  }
+
+  private static String generateName(GsonUser gson) {
+    String name = gson.getDisplayName();
+    return name == null || name.isEmpty() ? gson.getUsername() : name;
+  }
+
+}
diff --git a/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/package-info.java b/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/package-info.java
new file mode 100755 (executable)
index 0000000..899620d
--- /dev/null
@@ -0,0 +1,23 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+@ParametersAreNonnullByDefault
+package org.sonar.auth.bitbucket;
+
+import javax.annotation.ParametersAreNonnullByDefault;
diff --git a/server/sonar-auth-bitbucket/src/main/resources/org/sonar/l10n/authbitbucket.properties b/server/sonar-auth-bitbucket/src/main/resources/org/sonar/l10n/authbitbucket.properties
new file mode 100755 (executable)
index 0000000..6e8776c
--- /dev/null
@@ -0,0 +1,4 @@
+property.category.security.bitbucket=Bitbucket
+property.category.security.bitbucket.description=In order to enable Bitbucket authentication:<ul><li>SonarQube must be publicly accessible through HTTPS only</li><li>The property 'sonar.core.serverBaseURL' must be set to this public HTTPS URL</li><li>In your Bitbucket profile, you need to create a OAuth consumer for which the 'Callback URL' must be set to <code>'&lt;value_of_sonar.core.serverBaseURL_property&gt;/oauth2/callback'</code>, and the permission should be set to  Account:Read</li></ul>
+
+
diff --git a/server/sonar-auth-bitbucket/src/main/resources/static/bitbucket.svg b/server/sonar-auth-bitbucket/src/main/resources/static/bitbucket.svg
new file mode 100755 (executable)
index 0000000..c9aeed9
--- /dev/null
@@ -0,0 +1,10 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" focusable="false" role="presentation">
+  <defs>
+    <linearGradient id="a-acb7415e-40c7-472a-ade9-3b99b6a8fba4" x1="97.526%" x2="46.927%" y1="25.488%" y2="78.776%">
+      <stop offset="0%" stop-color="#FFF" stop-opacity=".4"/>
+      <stop offset="100%" stop-color="#FFF"/>
+    </linearGradient>
+  </defs>
+  <path fill="url(#a-acb7415e-40c7-472a-ade9-3b99b6a8fba4)" d="M20.063 9.297h-5.279l-.886 5.16h-3.656l-4.317 5.116a.763.763 0 0 0 .492.186h11.458a.562.562 0 0 0 .563-.472l1.625-9.99z" transform="matrix(1.33 0 0 1.33 -4 -3.8)"/>
+  <path fill="#FFF" d="M1.11252 1.52a.74879.74879 0 0 0-.74879.86583L3.5411 21.6296a1.01479 1.01479 0 0 0 .99484.84721l5.89589-7.049h-.82726l-1.29808-6.8628h14.37863l1.0108-6.1712a.7448.7448 0 0 0-.73815-.87381H1.11252z"/>
+</svg>
\ No newline at end of file
diff --git a/server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/BitbucketIdentityProviderTest.java b/server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/BitbucketIdentityProviderTest.java
new file mode 100755 (executable)
index 0000000..d135fb1
--- /dev/null
@@ -0,0 +1,93 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.auth.bitbucket;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.sonar.api.config.internal.MapSettings;
+import org.sonar.api.server.authentication.OAuth2IdentityProvider;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class BitbucketIdentityProviderTest {
+
+  @Rule
+  public ExpectedException thrown = ExpectedException.none();
+
+  private final MapSettings settings = new MapSettings();
+  private final BitbucketSettings bitbucketSettings = new BitbucketSettings(settings.asConfig());
+  private final UserIdentityFactory userIdentityFactory = mock(UserIdentityFactory.class);
+  private final BitbucketScribeApi scribeApi = new BitbucketScribeApi(bitbucketSettings);
+  private final BitbucketIdentityProvider underTest = new BitbucketIdentityProvider(bitbucketSettings, userIdentityFactory, scribeApi);
+
+  @Test
+  public void check_fields() {
+    assertThat(underTest.getKey()).isEqualTo("bitbucket");
+    assertThat(underTest.getName()).isEqualTo("Bitbucket");
+    assertThat(underTest.getDisplay().getIconPath()).isEqualTo("/images/alm/bitbucket-white.svg");
+    assertThat(underTest.getDisplay().getBackgroundColor()).isEqualTo("#0052cc");
+  }
+
+  @Test
+  public void is_enabled() {
+    enableBitbucketAuthentication(true);
+    assertThat(underTest.isEnabled()).isTrue();
+
+    settings.setProperty("sonar.auth.bitbucket.enabled", false);
+    assertThat(underTest.isEnabled()).isFalse();
+  }
+
+  @Test
+  public void init() {
+    enableBitbucketAuthentication(true);
+    OAuth2IdentityProvider.InitContext context = mock(OAuth2IdentityProvider.InitContext.class);
+    when(context.generateCsrfState()).thenReturn("state");
+    when(context.getCallbackUrl()).thenReturn("http://localhost/callback");
+
+    underTest.init(context);
+
+    verify(context).redirectTo("https://bitbucket.org/site/oauth2/authorize?response_type=code&client_id=id&redirect_uri=http%3A%2F%2Flocalhost%2Fcallback&scope=account&state=state");
+  }
+
+  @Test
+  public void fail_to_init_when_disabled() {
+    enableBitbucketAuthentication(false);
+    OAuth2IdentityProvider.InitContext context = mock(OAuth2IdentityProvider.InitContext.class);
+
+    thrown.expect(IllegalStateException.class);
+    thrown.expectMessage("Bitbucket authentication is disabled");
+    underTest.init(context);
+  }
+
+  private void enableBitbucketAuthentication(boolean enabled) {
+    if (enabled) {
+      settings.setProperty("sonar.auth.bitbucket.clientId.secured", "id");
+      settings.setProperty("sonar.auth.bitbucket.clientSecret.secured", "secret");
+      settings.setProperty("sonar.auth.bitbucket.enabled", true);
+    } else {
+      settings.setProperty("sonar.auth.bitbucket.enabled", false);
+    }
+  }
+
+}
diff --git a/server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/BitbucketModuleTest.java b/server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/BitbucketModuleTest.java
new file mode 100755 (executable)
index 0000000..274bbf7
--- /dev/null
@@ -0,0 +1,37 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.auth.bitbucket;
+
+import org.junit.Test;
+import org.sonar.core.platform.ComponentContainer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.sonar.core.platform.ComponentContainer.COMPONENTS_IN_EMPTY_COMPONENT_CONTAINER;
+
+public class BitbucketModuleTest {
+
+  @Test
+  public void verify_count_of_added_components() {
+    ComponentContainer container = new ComponentContainer();
+    new BitbucketModule().configure(container);
+    assertThat(container.size()).isEqualTo(COMPONENTS_IN_EMPTY_COMPONENT_CONTAINER + 9);
+  }
+
+}
diff --git a/server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/BitbucketSettingsTest.java b/server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/BitbucketSettingsTest.java
new file mode 100755 (executable)
index 0000000..f372478
--- /dev/null
@@ -0,0 +1,99 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.auth.bitbucket;
+
+import org.junit.Test;
+import org.sonar.api.config.internal.MapSettings;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class BitbucketSettingsTest {
+
+  private MapSettings settings = new MapSettings();
+
+  private BitbucketSettings underTest = new BitbucketSettings(settings.asConfig());
+
+  @Test
+  public void is_enabled() {
+    settings.setProperty("sonar.auth.bitbucket.clientId.secured", "id");
+    settings.setProperty("sonar.auth.bitbucket.clientSecret.secured", "secret");
+
+    settings.setProperty("sonar.auth.bitbucket.enabled", true);
+    assertThat(underTest.isEnabled()).isTrue();
+
+    settings.setProperty("sonar.auth.bitbucket.enabled", false);
+    assertThat(underTest.isEnabled()).isFalse();
+  }
+
+  @Test
+  public void is_enabled_always_return_false_when_client_id_is_null() {
+    settings.setProperty("sonar.auth.bitbucket.enabled", true);
+    settings.setProperty("sonar.auth.bitbucket.clientId.secured", (String) null);
+    settings.setProperty("sonar.auth.bitbucket.clientSecret.secured", "secret");
+
+    assertThat(underTest.isEnabled()).isFalse();
+  }
+
+  @Test
+  public void is_enabled_always_return_false_when_client_secret_is_null() {
+    settings.setProperty("sonar.auth.bitbucket.enabled", true);
+    settings.setProperty("sonar.auth.bitbucket.clientId.secured", "id");
+    settings.setProperty("sonar.auth.bitbucket.clientSecret.secured", (String) null);
+
+    assertThat(underTest.isEnabled()).isFalse();
+  }
+
+  @Test
+  public void return_client_id() {
+    settings.setProperty("sonar.auth.bitbucket.clientId.secured", "id");
+    assertThat(underTest.clientId()).isEqualTo("id");
+  }
+
+  @Test
+  public void return_client_secret() {
+    settings.setProperty("sonar.auth.bitbucket.clientSecret.secured", "secret");
+    assertThat(underTest.clientSecret()).isEqualTo("secret");
+  }
+
+  @Test
+  public void allow_users_to_sign_up() {
+    settings.setProperty("sonar.auth.bitbucket.allowUsersToSignUp", "true");
+    assertThat(underTest.allowUsersToSignUp()).isTrue();
+
+    settings.setProperty("sonar.auth.bitbucket.allowUsersToSignUp", "false");
+    assertThat(underTest.allowUsersToSignUp()).isFalse();
+  }
+
+  @Test
+  public void default_apiUrl() {
+    assertThat(underTest.apiURL()).isEqualTo("https://api.bitbucket.org/");
+  }
+
+  @Test
+  public void default_webUrl() {
+    assertThat(underTest.webURL()).isEqualTo("https://bitbucket.org/");
+  }
+
+  @Test
+  public void definitions() {
+    assertThat(BitbucketSettings.definitions()).hasSize(5);
+  }
+
+}
diff --git a/server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/GsonEmailsTest.java b/server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/GsonEmailsTest.java
new file mode 100755 (executable)
index 0000000..27870a2
--- /dev/null
@@ -0,0 +1,101 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.auth.bitbucket;
+
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class GsonEmailsTest {
+
+  @Test
+  public void testParse() {
+    String json = "{" +
+      "\"pagelen\": 10," +
+      "\"values\": [" +
+      "{" +
+      "\"is_primary\": true," +
+      "\"is_confirmed\": true," +
+      "\"type\": \"email\"," +
+      "\"email\": \"foo@bar.com\"," +
+      "\"links\": {" +
+      "\"self\": {" +
+      "\"href\": \"https://api.bitbucket.org/2.0/user/emails/foo@bar.com\"" +
+      "}" +
+      "}" +
+      "}" +
+      "]," +
+      "\"page\": 1," +
+      "\"size\": 1" +
+      "}";
+    GsonEmails emails = GsonEmails.parse(json);
+    assertThat(emails.getEmails()).hasSize(1);
+    assertThat(emails.getEmails().get(0).isPrimary()).isTrue();
+    assertThat(emails.getEmails().get(0).getEmail()).isEqualTo("foo@bar.com");
+  }
+
+  @Test
+  public void test_extractPrimaryEmail() {
+    String json = "{" +
+      "\"pagelen\": 10," +
+      "\"values\": [" +
+      "{" +
+      "\"is_primary\": false," +
+      "\"is_confirmed\": true," +
+      "\"type\": \"email\"," +
+      "\"email\": \"secondary@bar.com\"," +
+      "\"links\": {" +
+      "\"self\": {" +
+      "\"href\": \"https://api.bitbucket.org/2.0/user/emails/secondary@bar.com\"" +
+      "}" +
+      "}" +
+      "}," +
+      "{" +
+      "\"is_primary\": true," +
+      "\"is_confirmed\": true," +
+      "\"type\": \"email\"," +
+      "\"email\": \"primary@bar.com\"," +
+      "\"links\": {" +
+      "\"self\": {" +
+      "\"href\": \"https://api.bitbucket.org/2.0/user/emails/primary@bar.com\"" +
+      "}" +
+      "}" +
+      "}" +
+      "]," +
+      "\"page\": 1," +
+      "\"size\": 2" +
+      "}";
+    String email = GsonEmails.parse(json).extractPrimaryEmail();
+    assertThat(email).isEqualTo("primary@bar.com");
+  }
+
+  @Test
+  public void test_extractPrimaryEmail_not_found() {
+    String json = "{" +
+      "\"pagelen\": 10," +
+      "\"values\": [" +
+      "]," +
+      "\"page\": 1," +
+      "\"size\": 0" +
+      "}";
+    String email = GsonEmails.parse(json).extractPrimaryEmail();
+    assertThat(email).isNull();
+  }
+}
diff --git a/server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/GsonUserTest.java b/server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/GsonUserTest.java
new file mode 100755 (executable)
index 0000000..b47658e
--- /dev/null
@@ -0,0 +1,37 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.auth.bitbucket;
+
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class GsonUserTest {
+
+  @Test
+  public void parse_from_json() {
+    GsonUser underTest = GsonUser.parse("{\"username\":\"john\", \"display_name\":\"John\", \"uuid\":\"ABCD\"}");
+
+    assertThat(underTest.getUsername()).isEqualTo("john");
+    assertThat(underTest.getDisplayName()).isEqualTo("John");
+    assertThat(underTest.getUuid()).isEqualTo("ABCD");
+  }
+
+}
diff --git a/server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/IntegrationTest.java b/server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/IntegrationTest.java
new file mode 100644 (file)
index 0000000..a230f21
--- /dev/null
@@ -0,0 +1,300 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.auth.bitbucket;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.stream.Collectors;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import okhttp3.mockwebserver.RecordedRequest;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.sonar.api.config.PropertyDefinitions;
+import org.sonar.api.config.internal.MapSettings;
+import org.sonar.api.server.authentication.OAuth2IdentityProvider;
+import org.sonar.api.server.authentication.UnauthorizedException;
+import org.sonar.api.server.authentication.UserIdentity;
+import org.sonar.api.utils.System2;
+
+import static java.lang.String.format;
+import static java.net.URLEncoder.encode;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.when;
+
+public class IntegrationTest {
+
+  private static final String CALLBACK_URL = "http://localhost/oauth/callback/bitbucket";
+
+  @Rule
+  public MockWebServer bitbucket = new MockWebServer();
+
+  // load settings with default values
+  private final MapSettings settings = new MapSettings(new PropertyDefinitions(System2.INSTANCE, BitbucketSettings.definitions()));
+
+  private final BitbucketSettings bitbucketSettings = spy(new BitbucketSettings(settings.asConfig()));
+  private final UserIdentityFactory userIdentityFactory = new UserIdentityFactory();
+  private final BitbucketScribeApi scribeApi = new BitbucketScribeApi(bitbucketSettings);
+  private final BitbucketIdentityProvider underTest = new BitbucketIdentityProvider(bitbucketSettings, userIdentityFactory, scribeApi);
+
+  @Before
+  public void setUp() {
+    settings.setProperty("sonar.auth.bitbucket.clientId.secured", "the_id");
+    settings.setProperty("sonar.auth.bitbucket.clientSecret.secured", "the_secret");
+    settings.setProperty("sonar.auth.bitbucket.enabled", true);
+    when(bitbucketSettings.webURL()).thenReturn(format("http://%s:%d/", bitbucket.getHostName(), bitbucket.getPort()));
+    when(bitbucketSettings.apiURL()).thenReturn(format("http://%s:%d/", bitbucket.getHostName(), bitbucket.getPort()));
+  }
+
+  /**
+   * First phase: SonarQube redirects browser to Bitbucket authentication form, requesting the
+   * minimal access rights ("scope") to get user profile.
+   */
+  @Test
+  public void redirect_browser_to_bitbucket_authentication_form() throws Exception {
+    DumbInitContext context = new DumbInitContext("the-csrf-state");
+    underTest.init(context);
+    assertThat(context.redirectedTo)
+      .startsWith(bitbucket.url("site/oauth2/authorize").toString())
+      .contains("scope=" + encode("account", StandardCharsets.UTF_8.name()));
+  }
+
+  /**
+   * Second phase: Bitbucket redirects browser to SonarQube at /oauth/callback/bitbucket?code={the verifier code}.
+   * This SonarQube web service sends three requests to Bitbucket:
+   * <ul>
+   *   <li>get an access token</li>
+   *   <li>get the profile (login, name) of the authenticated user</li>
+   *   <li>get the emails of the authenticated user</li>
+   * </ul>
+   */
+  @Test
+  public void authenticate_successfully() throws Exception {
+    bitbucket.enqueue(newSuccessfulAccessTokenResponse());
+    bitbucket.enqueue(newUserResponse("john", "John", "john-uuid"));
+    bitbucket.enqueue(newPrimaryEmailResponse("john@bitbucket.org"));
+
+    HttpServletRequest request = newRequest("the-verifier-code");
+    DumbCallbackContext callbackContext = new DumbCallbackContext(request);
+    underTest.callback(callbackContext);
+
+    assertThat(callbackContext.csrfStateVerified.get()).isTrue();
+    assertThat(callbackContext.userIdentity.getName()).isEqualTo("John");
+    assertThat(callbackContext.userIdentity.getEmail()).isEqualTo("john@bitbucket.org");
+    assertThat(callbackContext.redirectedToRequestedPage.get()).isTrue();
+
+    // Verify the requests sent to Bitbucket
+    RecordedRequest accessTokenRequest = bitbucket.takeRequest();
+    assertThat(accessTokenRequest.getPath()).startsWith("/site/oauth2/access_token");
+    RecordedRequest userRequest = bitbucket.takeRequest();
+    assertThat(userRequest.getPath()).startsWith("/2.0/user");
+    RecordedRequest emailRequest = bitbucket.takeRequest();
+    assertThat(emailRequest.getPath()).startsWith("/2.0/user/emails");
+    // do not request user workspaces, workspace restriction is disabled by default
+    assertThat(bitbucket.getRequestCount()).isEqualTo(3);
+  }
+
+  @Test
+  public void callback_throws_ISE_if_error_when_requesting_user_profile() {
+    bitbucket.enqueue(newSuccessfulAccessTokenResponse());
+    // https://api.bitbucket.org/2.0/user fails
+    bitbucket.enqueue(new MockResponse().setResponseCode(500).setBody("{error}"));
+
+    DumbCallbackContext callbackContext = new DumbCallbackContext(newRequest("the-verifier-code"));
+
+    assertThatThrownBy(() -> underTest.callback(callbackContext))
+      .hasMessage("Can not get Bitbucket user profile. HTTP code: 500, response: {error}")
+      .isInstanceOf(IllegalStateException.class);
+
+    assertThat(callbackContext.csrfStateVerified.get()).isTrue();
+    assertThat(callbackContext.userIdentity).isNull();
+    assertThat(callbackContext.redirectedToRequestedPage.get()).isFalse();
+  }
+
+  @Test
+  public void allow_authentication_if_user_is_member_of_one_restricted_workspace() {
+    settings.setProperty("sonar.auth.bitbucket.workspaces", new String[] {"workspace1", "workspace2"});
+
+    bitbucket.enqueue(newSuccessfulAccessTokenResponse());
+    bitbucket.enqueue(newUserResponse("john", "John", "john-uuid"));
+    bitbucket.enqueue(newPrimaryEmailResponse("john@bitbucket.org"));
+    bitbucket.enqueue(newWorkspacesResponse("workspace3", "workspace2"));
+
+    HttpServletRequest request = newRequest("the-verifier-code");
+    DumbCallbackContext callbackContext = new DumbCallbackContext(request);
+    underTest.callback(callbackContext);
+
+    assertThat(callbackContext.userIdentity.getEmail()).isEqualTo("john@bitbucket.org");
+    assertThat(callbackContext.userIdentity.getProviderLogin()).isEqualTo("john");
+    assertThat(callbackContext.userIdentity.getProviderId()).isEqualTo("john-uuid");
+    assertThat(callbackContext.redirectedToRequestedPage.get()).isTrue();
+  }
+
+  @Test
+  public void forbid_authentication_if_user_is_not_member_of_one_restricted_workspace() {
+    settings.setProperty("sonar.auth.bitbucket.workspaces", new String[] {"workspace1", "workspace2"});
+
+    bitbucket.enqueue(newSuccessfulAccessTokenResponse());
+    bitbucket.enqueue(newUserResponse("john", "John", "john-uuid"));
+    bitbucket.enqueue(newPrimaryEmailResponse("john@bitbucket.org"));
+    bitbucket.enqueue(newWorkspacesResponse("workspace3"));
+    DumbCallbackContext context = new DumbCallbackContext(newRequest("the-verifier-code"));
+
+    assertThatThrownBy(() -> underTest.callback(context))
+      .isInstanceOf(UnauthorizedException.class);
+  }
+
+  @Test
+  public void forbid_authentication_if_user_is_not_member_of_any_workspace() {
+    settings.setProperty("sonar.auth.bitbucket.workspaces", new String[] {"workspace1", "workspace2"});
+
+    bitbucket.enqueue(newSuccessfulAccessTokenResponse());
+    bitbucket.enqueue(newUserResponse("john", "John", "john-uuid"));
+    bitbucket.enqueue(newPrimaryEmailResponse("john@bitbucket.org"));
+    bitbucket.enqueue(newWorkspacesResponse(/* no workspaces */));
+    DumbCallbackContext context = new DumbCallbackContext(newRequest("the-verifier-code"));
+
+    assertThatThrownBy(() -> underTest.callback(context))
+      .isInstanceOf(UnauthorizedException.class);
+  }
+
+  /**
+   * Response sent by Bitbucket to SonarQube when generating an access token
+   */
+  private static MockResponse newSuccessfulAccessTokenResponse() {
+    return new MockResponse().setBody("{\"access_token\":\"e72e16c7e42f292c6912e7710c838347ae178b4a\",\"scope\":\"user\"}");
+  }
+
+  /**
+   * Response of https://api.bitbucket.org/2.0/user
+   */
+  private static MockResponse newUserResponse(String login, String name, String uuid) {
+    return new MockResponse().setBody("{\"username\":\"" + login + "\", \"display_name\":\"" + name + "\", \"uuid\":\"" + uuid + "\"}");
+  }
+
+  /**
+   * Response of https://api.bitbucket.org/2.0/user/permissions/workspaces?q=permission="member"
+   */
+  private static MockResponse newWorkspacesResponse(String... workspaces) {
+    String s = Arrays.stream(workspaces)
+      .map(w -> "{\"workspace\":{\"name\":\"" + w + "\",\"slug\":\"" + w + "\"}}")
+      .collect(Collectors.joining(","));
+    return new MockResponse().setBody("{\"values\":[" + s + "]}");
+  }
+
+  /**
+   * Response of https://api.bitbucket.org/2.0/user/emails
+   */
+  private static MockResponse newPrimaryEmailResponse(String email) {
+    return new MockResponse().setBody("{\"values\":[{\"active\": true,\"email\":\"" + email + "\",\"is_primary\": true}]}");
+  }
+
+  private static HttpServletRequest newRequest(String verifierCode) {
+    HttpServletRequest request = mock(HttpServletRequest.class);
+    when(request.getParameter("code")).thenReturn(verifierCode);
+    return request;
+  }
+
+  private static class DumbCallbackContext implements OAuth2IdentityProvider.CallbackContext {
+    final HttpServletRequest request;
+    final AtomicBoolean csrfStateVerified = new AtomicBoolean(true);
+    final AtomicBoolean redirectedToRequestedPage = new AtomicBoolean(false);
+    UserIdentity userIdentity = null;
+
+    public DumbCallbackContext(HttpServletRequest request) {
+      this.request = request;
+    }
+
+    @Override
+    public void verifyCsrfState() {
+      this.csrfStateVerified.set(true);
+    }
+
+    @Override
+    public void verifyCsrfState(String s) {
+    }
+
+    @Override
+    public void redirectToRequestedPage() {
+      redirectedToRequestedPage.set(true);
+    }
+
+    @Override
+    public void authenticate(UserIdentity userIdentity) {
+      this.userIdentity = userIdentity;
+    }
+
+    @Override
+    public String getCallbackUrl() {
+      return CALLBACK_URL;
+    }
+
+    @Override
+    public HttpServletRequest getRequest() {
+      return request;
+    }
+
+    @Override
+    public HttpServletResponse getResponse() {
+      throw new UnsupportedOperationException("not used");
+    }
+  }
+
+  private static class DumbInitContext implements OAuth2IdentityProvider.InitContext {
+    String redirectedTo = null;
+    private final String generatedCsrfState;
+
+    public DumbInitContext(String generatedCsrfState) {
+      this.generatedCsrfState = generatedCsrfState;
+    }
+
+    @Override
+    public String generateCsrfState() {
+      return generatedCsrfState;
+    }
+
+    @Override
+    public void redirectTo(String url) {
+      this.redirectedTo = url;
+    }
+
+    @Override
+    public String getCallbackUrl() {
+      return CALLBACK_URL;
+    }
+
+    @Override
+    public HttpServletRequest getRequest() {
+      return null;
+    }
+
+    @Override
+    public HttpServletResponse getResponse() {
+      return null;
+    }
+  }
+}
diff --git a/server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/UserIdentityFactoryTest.java b/server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/UserIdentityFactoryTest.java
new file mode 100755 (executable)
index 0000000..abf75a0
--- /dev/null
@@ -0,0 +1,56 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.auth.bitbucket;
+
+import org.junit.Test;
+import org.sonar.api.server.authentication.UserIdentity;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class UserIdentityFactoryTest {
+
+  private UserIdentityFactory underTest = new UserIdentityFactory();
+
+  @Test
+  public void create_login() {
+    GsonUser gson = new GsonUser("john", "John", "ABCD");
+    UserIdentity identity = underTest.create(gson, null);
+    assertThat(identity.getName()).isEqualTo("John");
+    assertThat(identity.getEmail()).isNull();
+    assertThat(identity.getProviderId()).isEqualTo("ABCD");
+  }
+
+  @Test
+  public void empty_name_is_replaced_by_provider_login() {
+    GsonUser gson = new GsonUser("john", "", "ABCD");
+
+    UserIdentity identity = underTest.create(gson, null);
+    assertThat(identity.getName()).isEqualTo("john");
+  }
+
+  @Test
+  public void null_name_is_replaced_by_provider_login() {
+    GsonUser gson = new GsonUser("john", null, "ABCD");
+
+    UserIdentity identity = underTest.create(gson, null);
+    assertThat(identity.getName()).isEqualTo("john");
+  }
+
+}
diff --git a/server/sonar-web/public/images/alm/bitbucket-white.svg b/server/sonar-web/public/images/alm/bitbucket-white.svg
new file mode 100644 (file)
index 0000000..3255ad0
--- /dev/null
@@ -0,0 +1,10 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" focusable="false" role="presentation">
+  <defs>
+    <linearGradient id="a" x1="97.526%" x2="46.927%" y1="25.488%" y2="78.776%">
+      <stop offset="0%" stop-color="#FFF" stop-opacity=".4"/>
+      <stop offset="100%" stop-color="#FFF"/>
+    </linearGradient>
+  </defs>
+  <path fill="url(#a)" d="M20.063 9.297h-5.279l-.886 5.16h-3.656l-4.317 5.116a.763.763 0 0 0 .492.186h11.458a.562.562 0 0 0 .563-.472l1.625-9.99z" transform="matrix(1.33 0 0 1.33 -4 -3.8)"/>
+  <path fill="#FFF" d="M1.11252 1.52a.74879.74879 0 0 0-.74879.86583L3.5411 21.6296a1.01479 1.01479 0 0 0 .99484.84721l5.89589-7.049h-.82726l-1.29808-6.8628h14.37863l1.0108-6.1712a.7448.7448 0 0 0-.73815-.87381H1.11252z"/>
+</svg>
\ No newline at end of file
index 2a38762339ac3e3bd7bee0ddc9cc58f9824cf07d..89cf5af83735245e7057e19cff8f0296181aa89f 100644 (file)
@@ -12,6 +12,7 @@ dependencies {
   compile 'com.google.guava:guava'
   compile 'org.apache.tomcat.embed:tomcat-embed-core'
   compile project(':sonar-core')
+  compile project(':server:sonar-auth-bitbucket')
   compile project(':server:sonar-auth-github')
   compile project(':server:sonar-auth-gitlab')
   compile project(':server:sonar-auth-ldap')
index 318df07af2da9effbf08f94f43701779cdda6b5c..7bb17f7c582e5c616da3322fa40a84fdbfca54f5 100644 (file)
@@ -34,6 +34,7 @@ import org.sonar.api.resources.Languages;
 import org.sonar.api.resources.ResourceTypes;
 import org.sonar.api.rules.AnnotationRuleParser;
 import org.sonar.api.server.rule.RulesDefinitionXmlLoader;
+import org.sonar.auth.bitbucket.BitbucketModule;
 import org.sonar.auth.github.GitHubModule;
 import org.sonar.auth.gitlab.GitLabModule;
 import org.sonar.auth.ldap.LdapModule;
@@ -344,6 +345,7 @@ public class PlatformLevel4 extends PlatformLevel {
       // authentication
       AuthenticationModule.class,
       AuthenticationWsModule.class,
+      BitbucketModule.class,
       GitHubModule.class,
       GitLabModule.class,
       LdapModule.class,
index e1812401f482ad14fc265ac969a835034683cf9d..9852b8d959a930c07cf75f826fbc5c328be1b016 100644 (file)
@@ -15,6 +15,7 @@ rootProject.name = 'sonarqube'
 include 'plugins:sonar-xoo-plugin'
 
 include 'server:sonar-auth-common'
+include 'server:sonar-auth-bitbucket'
 include 'server:sonar-auth-github'
 include 'server:sonar-auth-gitlab'
 include 'server:sonar-auth-ldap'