--- /dev/null
+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'
+}
--- /dev/null
+/*
+ * 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;
+ }
+
+}
--- /dev/null
+/*
+ * 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));
+ }
+
+}
--- /dev/null
+/*
+ * 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";
+ }
+}
--- /dev/null
+/*
+ * 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()
+ );
+ }
+
+}
--- /dev/null
+/*
+ * 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;
+ }
+}
--- /dev/null
+/*
+ * 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;
+ }
+}
--- /dev/null
+/*
+ * 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);
+ }
+}
--- /dev/null
+/*
+ * 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;
+ }
+
+}
--- /dev/null
+/*
+ * 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;
+ }
+
+}
--- /dev/null
+/*
+ * 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);
+ }
+
+}
--- /dev/null
+/*
+ * 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;
+ }
+
+}
--- /dev/null
+/*
+ * 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;
--- /dev/null
+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>'<value_of_sonar.core.serverBaseURL_property>/oauth2/callback'</code>, and the permission should be set to Account:Read</li></ul>
+
+
--- /dev/null
+<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
--- /dev/null
+/*
+ * 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);
+ }
+ }
+
+}
--- /dev/null
+/*
+ * 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);
+ }
+
+}
--- /dev/null
+/*
+ * 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);
+ }
+
+}
--- /dev/null
+/*
+ * 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();
+ }
+}
--- /dev/null
+/*
+ * 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");
+ }
+
+}
--- /dev/null
+/*
+ * 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;
+ }
+ }
+}
--- /dev/null
+/*
+ * 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");
+ }
+
+}
--- /dev/null
+<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
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')
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;
// authentication
AuthenticationModule.class,
AuthenticationWsModule.class,
+ BitbucketModule.class,
GitHubModule.class,
GitLabModule.class,
LdapModule.class,
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'