aboutsummaryrefslogtreecommitdiffstats
path: root/server/sonar-auth-github
diff options
context:
space:
mode:
authorJulien Lancelot <julien.lancelot@sonarsource.com>2019-09-24 11:44:20 +0200
committerSonarTech <sonartech@sonarsource.com>2019-10-07 20:21:06 +0200
commit42f0cff638b6b7055acc6cf75bbb2215867d0474 (patch)
tree9c4bbdb045a1cabe844c14fdda2413c1b94a7fcb /server/sonar-auth-github
parentdaf5a60dd259039b97fd3598f894169d7ecc74e5 (diff)
downloadsonarqube-42f0cff638b6b7055acc6cf75bbb2215867d0474.tar.gz
sonarqube-42f0cff638b6b7055acc6cf75bbb2215867d0474.zip
SONAR-12471 Embed GitHub authentication
Diffstat (limited to 'server/sonar-auth-github')
-rw-r--r--server/sonar-auth-github/build.gradle26
-rw-r--r--server/sonar-auth-github/src/main/java/org/sonar/auth/github/GitHubIdentityProvider.java161
-rw-r--r--server/sonar-auth-github/src/main/java/org/sonar/auth/github/GitHubModule.java42
-rw-r--r--server/sonar-auth-github/src/main/java/org/sonar/auth/github/GitHubRestClient.java99
-rw-r--r--server/sonar-auth-github/src/main/java/org/sonar/auth/github/GitHubSettings.java191
-rw-r--r--server/sonar-auth-github/src/main/java/org/sonar/auth/github/GsonEmail.java66
-rw-r--r--server/sonar-auth-github/src/main/java/org/sonar/auth/github/GsonTeam.java77
-rw-r--r--server/sonar-auth-github/src/main/java/org/sonar/auth/github/GsonUser.java76
-rw-r--r--server/sonar-auth-github/src/main/java/org/sonar/auth/github/ScribeGitHubApi.java41
-rw-r--r--server/sonar-auth-github/src/main/java/org/sonar/auth/github/UserIdentityFactory.java32
-rw-r--r--server/sonar-auth-github/src/main/java/org/sonar/auth/github/UserIdentityFactoryImpl.java54
-rw-r--r--server/sonar-auth-github/src/main/java/org/sonar/auth/github/UserIdentityGenerator.java51
-rw-r--r--server/sonar-auth-github/src/main/java/org/sonar/auth/github/package-info.java23
-rw-r--r--server/sonar-auth-github/src/test/java/org/sonar/auth/github/GitHubIdentityProviderTest.java185
-rw-r--r--server/sonar-auth-github/src/test/java/org/sonar/auth/github/GitHubModuleTest.java37
-rw-r--r--server/sonar-auth-github/src/test/java/org/sonar/auth/github/GitHubSettingsTest.java162
-rw-r--r--server/sonar-auth-github/src/test/java/org/sonar/auth/github/GsonEmailTest.java60
-rw-r--r--server/sonar-auth-github/src/test/java/org/sonar/auth/github/GsonTeamTest.java74
-rw-r--r--server/sonar-auth-github/src/test/java/org/sonar/auth/github/GsonUserTest.java56
-rw-r--r--server/sonar-auth-github/src/test/java/org/sonar/auth/github/IntegrationTest.java471
-rw-r--r--server/sonar-auth-github/src/test/java/org/sonar/auth/github/UserIdentityFactoryImplTest.java120
21 files changed, 2104 insertions, 0 deletions
diff --git a/server/sonar-auth-github/build.gradle b/server/sonar-auth-github/build.gradle
new file mode 100644
index 00000000000..3bc4be0a024
--- /dev/null
+++ b/server/sonar-auth-github/build.gradle
@@ -0,0 +1,26 @@
+description = 'SonarQube :: Authentication :: GitHub'
+
+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')
+ compileOnly project(':sonar-ws')
+
+ 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-github/src/main/java/org/sonar/auth/github/GitHubIdentityProvider.java b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/GitHubIdentityProvider.java
new file mode 100644
index 00000000000..26d2d74c3dc
--- /dev/null
+++ b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/GitHubIdentityProvider.java
@@ -0,0 +1,161 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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.github;
+
+import com.github.scribejava.core.builder.ServiceBuilder;
+import com.github.scribejava.core.model.OAuth2AccessToken;
+import com.github.scribejava.core.oauth.OAuth20Service;
+import java.io.IOException;
+import java.util.concurrent.ExecutionException;
+import javax.servlet.http.HttpServletRequest;
+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 static com.google.common.base.Preconditions.checkState;
+import static java.lang.String.format;
+
+public class GitHubIdentityProvider implements OAuth2IdentityProvider {
+
+ static final String KEY = "github";
+
+ private final GitHubSettings settings;
+ private final UserIdentityFactory userIdentityFactory;
+ private final ScribeGitHubApi scribeApi;
+ private final GitHubRestClient gitHubRestClient;
+
+ public GitHubIdentityProvider(GitHubSettings settings, UserIdentityFactory userIdentityFactory, ScribeGitHubApi scribeApi, GitHubRestClient gitHubRestClient) {
+ this.settings = settings;
+ this.userIdentityFactory = userIdentityFactory;
+ this.scribeApi = scribeApi;
+ this.gitHubRestClient = gitHubRestClient;
+ }
+
+ @Override
+ public String getKey() {
+ return KEY;
+ }
+
+ @Override
+ public String getName() {
+ return "GitHub";
+ }
+
+ @Override
+ public Display getDisplay() {
+ return Display.builder()
+ .setIconPath("/images/github.svg")
+ .setBackgroundColor("#444444")
+ .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)
+ .defaultScope(getScope())
+ .build(scribeApi);
+ String url = scribe.getAuthorizationUrl(state);
+ context.redirectTo(url);
+ }
+
+ String getScope() {
+ return (settings.syncGroups() || isOrganizationMembershipRequired()) ? "user:email,read:org" : "user:email";
+ }
+
+ @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 {
+ context.verifyCsrfState();
+
+ HttpServletRequest request = context.getRequest();
+ OAuth20Service scribe = newScribeBuilder(context).build(scribeApi);
+ String code = request.getParameter("code");
+ OAuth2AccessToken accessToken = scribe.getAccessToken(code);
+
+ GsonUser user = gitHubRestClient.getUser(scribe, accessToken);
+ check(scribe, accessToken, user);
+
+ final String email;
+ if (user.getEmail() == null) {
+ // if the user has not specified a public email address in their profile
+ email = gitHubRestClient.getEmail(scribe, accessToken);
+ } else {
+ email = user.getEmail();
+ }
+
+ UserIdentity userIdentity = userIdentityFactory.create(user, email,
+ settings.syncGroups() ? gitHubRestClient.getTeams(scribe, accessToken) : null);
+ context.authenticate(userIdentity);
+ context.redirectToRequestedPage();
+ }
+
+ boolean isOrganizationMembershipRequired() {
+ return settings.organizations().length > 0;
+ }
+
+ private void check(OAuth20Service scribe, OAuth2AccessToken accessToken, GsonUser user) throws InterruptedException, ExecutionException, IOException {
+ if (isUnauthorized(scribe, accessToken, user.getLogin())) {
+ throw new UnauthorizedException(format("'%s' must be a member of at least one organization: '%s'", user.getLogin(), String.join("', '", settings.organizations())));
+ }
+ }
+
+ private boolean isUnauthorized(OAuth20Service scribe, OAuth2AccessToken accessToken, String login) throws IOException, ExecutionException, InterruptedException {
+ return isOrganizationMembershipRequired() && !isOrganizationsMember(scribe, accessToken, login);
+ }
+
+ private boolean isOrganizationsMember(OAuth20Service scribe, OAuth2AccessToken accessToken, String login) throws IOException, ExecutionException, InterruptedException {
+ for (String organization : settings.organizations()) {
+ if (gitHubRestClient.isOrganizationMember(scribe, accessToken, organization, login)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private ServiceBuilder newScribeBuilder(OAuth2IdentityProvider.OAuth2Context context) {
+ checkState(isEnabled(), "GitHub authentication is disabled");
+ return new ServiceBuilder(settings.clientId())
+ .apiSecret(settings.clientSecret())
+ .callback(context.getCallbackUrl());
+ }
+
+}
diff --git a/server/sonar-auth-github/src/main/java/org/sonar/auth/github/GitHubModule.java b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/GitHubModule.java
new file mode 100644
index 00000000000..1f349d13fce
--- /dev/null
+++ b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/GitHubModule.java
@@ -0,0 +1,42 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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.github;
+
+import java.util.List;
+import org.sonar.api.config.PropertyDefinition;
+import org.sonar.core.platform.Module;
+
+import static org.sonar.auth.github.GitHubSettings.definitions;
+
+public class GitHubModule extends Module {
+
+ @Override
+ protected void configureModule() {
+ add(
+ GitHubIdentityProvider.class,
+ GitHubSettings.class,
+ GitHubRestClient.class,
+ UserIdentityFactoryImpl.class,
+ ScribeGitHubApi.class);
+ List<PropertyDefinition> definitions = definitions();
+ add(definitions.toArray(new Object[definitions.size()]));
+ }
+
+}
diff --git a/server/sonar-auth-github/src/main/java/org/sonar/auth/github/GitHubRestClient.java b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/GitHubRestClient.java
new file mode 100644
index 00000000000..5c533680fa4
--- /dev/null
+++ b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/GitHubRestClient.java
@@ -0,0 +1,99 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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.github;
+
+import com.github.scribejava.core.model.OAuth2AccessToken;
+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.net.HttpURLConnection;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import org.sonar.api.utils.log.Logger;
+import org.sonar.api.utils.log.Loggers;
+
+import static java.lang.String.format;
+import static org.sonar.auth.OAuthRestClient.executePaginatedRequest;
+import static org.sonar.auth.OAuthRestClient.executeRequest;
+
+public class GitHubRestClient {
+
+ private static final Logger LOGGER = Loggers.get(GitHubRestClient.class);
+
+ private final GitHubSettings settings;
+
+ public GitHubRestClient(GitHubSettings settings) {
+ this.settings = settings;
+ }
+
+ GsonUser getUser(OAuth20Service scribe, OAuth2AccessToken accessToken) throws IOException {
+ String responseBody = executeRequest(settings.apiURL() + "user", scribe, accessToken).getBody();
+ LOGGER.trace("User response received : {}", responseBody);
+ return GsonUser.parse(responseBody);
+ }
+
+ String getEmail(OAuth20Service scribe, OAuth2AccessToken accessToken) throws IOException {
+ String responseBody = executeRequest(settings.apiURL() + "user/emails", scribe, accessToken).getBody();
+ LOGGER.trace("Emails response received : {}", responseBody);
+ List<GsonEmail> emails = GsonEmail.parse(responseBody);
+ return emails.stream()
+ .filter(email -> email.isPrimary() && email.isVerified())
+ .findFirst()
+ .map(GsonEmail::getEmail)
+ .orElse(null);
+ }
+
+ List<GsonTeam> getTeams(OAuth20Service scribe, OAuth2AccessToken accessToken) {
+ return executePaginatedRequest(settings.apiURL() + "user/teams", scribe, accessToken, GsonTeam::parse);
+ }
+
+ /**
+ * Check to see that login is a member of organization.
+ *
+ * A 204 response code indicates organization membership. 302 and 404 codes are not treated as exceptional,
+ * they indicate various ways in which a login is not a member of the organization.
+ *
+ * @see <a href="https://developer.github.com/v3/orgs/members/#response-if-requester-is-an-organization-member-and-user-is-a-member">GitHub members API</a>
+ */
+ boolean isOrganizationMember(OAuth20Service scribe, OAuth2AccessToken accessToken, String organization, String login)
+ throws IOException, ExecutionException, InterruptedException {
+ String requestUrl = settings.apiURL() + format("orgs/%s/members/%s", organization, login);
+ OAuthRequest request = new OAuthRequest(Verb.GET, requestUrl);
+ scribe.signRequest(accessToken, request);
+
+ Response response = scribe.execute(request);
+ int code = response.getCode();
+ switch (code) {
+ case HttpURLConnection.HTTP_MOVED_TEMP:
+ case HttpURLConnection.HTTP_NOT_FOUND:
+ case HttpURLConnection.HTTP_NO_CONTENT:
+ LOGGER.trace("Orgs response received : {}", code);
+ return code == HttpURLConnection.HTTP_NO_CONTENT;
+ default:
+ throw unexpectedResponseCode(requestUrl, response);
+ }
+ }
+
+ private static IllegalStateException unexpectedResponseCode(String requestUrl, Response response) throws IOException {
+ return new IllegalStateException(format("Fail to execute request '%s'. HTTP code: %s, response: %s", requestUrl, response.getCode(), response.getBody()));
+ }
+}
diff --git a/server/sonar-auth-github/src/main/java/org/sonar/auth/github/GitHubSettings.java b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/GitHubSettings.java
new file mode 100644
index 00000000000..7c3f7a2ced9
--- /dev/null
+++ b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/GitHubSettings.java
@@ -0,0 +1,191 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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.github;
+
+import java.util.Arrays;
+import java.util.List;
+import javax.annotation.CheckForNull;
+import javax.annotation.Nullable;
+import org.sonar.api.config.Configuration;
+import org.sonar.api.config.PropertyDefinition;
+
+import static java.lang.String.format;
+import static java.lang.String.valueOf;
+import static org.sonar.api.PropertyType.BOOLEAN;
+import static org.sonar.api.PropertyType.SINGLE_SELECT_LIST;
+import static org.sonar.api.PropertyType.STRING;
+
+public class GitHubSettings {
+
+ private static final String CLIENT_ID = "sonar.auth.github.clientId.secured";
+ private static final String CLIENT_SECRET = "sonar.auth.github.clientSecret.secured";
+ private static final String ENABLED = "sonar.auth.github.enabled";
+ private static final String ALLOW_USERS_TO_SIGN_UP = "sonar.auth.github.allowUsersToSignUp";
+ private static final String GROUPS_SYNC = "sonar.auth.github.groupsSync";
+ private static final String API_URL = "sonar.auth.github.apiUrl";
+ private static final String WEB_URL = "sonar.auth.github.webUrl";
+
+ static final String LOGIN_STRATEGY = "sonar.auth.github.loginStrategy";
+ static final String LOGIN_STRATEGY_UNIQUE = "Unique";
+ static final String LOGIN_STRATEGY_PROVIDER_ID = "Same as GitHub login";
+ static final String LOGIN_STRATEGY_DEFAULT_VALUE = LOGIN_STRATEGY_UNIQUE;
+
+ private static final String ORGANIZATIONS = "sonar.auth.github.organizations";
+
+ private static final String CATEGORY = "security";
+ private static final String SUBCATEGORY = "github";
+
+ private final Configuration configuration;
+
+ public GitHubSettings(Configuration configuration) {
+ this.configuration = configuration;
+ }
+
+ String clientId() {
+ return configuration.get(CLIENT_ID).orElse("");
+ }
+
+ String clientSecret() {
+ return configuration.get(CLIENT_SECRET).orElse("");
+ }
+
+ boolean isEnabled() {
+ return configuration.getBoolean(ENABLED).orElse(false) && !clientId().isEmpty() && !clientSecret().isEmpty();
+ }
+
+ boolean allowUsersToSignUp() {
+ return configuration.getBoolean(ALLOW_USERS_TO_SIGN_UP).orElse(false);
+ }
+
+ String loginStrategy() {
+ return configuration.get(LOGIN_STRATEGY).orElse("");
+ }
+
+ boolean syncGroups() {
+ return configuration.getBoolean(GROUPS_SYNC).orElse(false);
+ }
+
+ @CheckForNull
+ String webURL() {
+ return urlWithEndingSlash(configuration.get(WEB_URL).orElse(""));
+ }
+
+ @CheckForNull
+ String apiURL() {
+ return urlWithEndingSlash(configuration.get(API_URL).orElse(""));
+ }
+
+ String[] organizations() {
+ return configuration.getStringArray(ORGANIZATIONS);
+ }
+
+ @CheckForNull
+ private static String urlWithEndingSlash(@Nullable String url) {
+ if (url != null && !url.endsWith("/")) {
+ return url + "/";
+ }
+ return url;
+ }
+
+ public static List<PropertyDefinition> definitions() {
+ return Arrays.asList(
+ PropertyDefinition.builder(ENABLED)
+ .name("Enabled")
+ .description("Enable GitHub users to login. Value is ignored if client ID and secret are not defined.")
+ .category(CATEGORY)
+ .subCategory(SUBCATEGORY)
+ .type(BOOLEAN)
+ .defaultValue(valueOf(false))
+ .index(1)
+ .build(),
+ PropertyDefinition.builder(CLIENT_ID)
+ .name("Client ID")
+ .description("Client ID provided by GitHub when registering the application.")
+ .category(CATEGORY)
+ .subCategory(SUBCATEGORY)
+ .index(2)
+ .build(),
+ PropertyDefinition.builder(CLIENT_SECRET)
+ .name("Client Secret")
+ .description("Client password provided by GitHub when registering the application.")
+ .category(CATEGORY)
+ .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 to the server.")
+ .category(CATEGORY)
+ .subCategory(SUBCATEGORY)
+ .type(BOOLEAN)
+ .defaultValue(valueOf(true))
+ .index(4)
+ .build(),
+ PropertyDefinition.builder(LOGIN_STRATEGY)
+ .name("Login generation strategy")
+ .description(format("When the login strategy is set to '%s', the user's login will be auto-generated the first time so that it is unique. " +
+ "When the login strategy is set to '%s', the user's login will be the GitHub login.",
+ LOGIN_STRATEGY_UNIQUE, LOGIN_STRATEGY_PROVIDER_ID))
+ .category(CATEGORY)
+ .subCategory(SUBCATEGORY)
+ .type(SINGLE_SELECT_LIST)
+ .defaultValue(LOGIN_STRATEGY_DEFAULT_VALUE)
+ .options(LOGIN_STRATEGY_UNIQUE, LOGIN_STRATEGY_PROVIDER_ID)
+ .index(5)
+ .build(),
+ PropertyDefinition.builder(GROUPS_SYNC)
+ .name("Synchronize teams as groups")
+ .description("For each team he belongs to, the user will be associated to a group named 'Organisation/Team' (if it exists) in SonarQube.")
+ .category(CATEGORY)
+ .subCategory(SUBCATEGORY)
+ .type(BOOLEAN)
+ .defaultValue(valueOf(false))
+ .index(6)
+ .build(),
+ PropertyDefinition.builder(API_URL)
+ .name("The API url for a GitHub instance.")
+ .description("The API url for a GitHub instance. https://api.github.com/ for github.com, https://github.company.com/api/v3/ when using Github Enterprise")
+ .category(CATEGORY)
+ .subCategory(SUBCATEGORY)
+ .type(STRING)
+ .defaultValue(valueOf("https://api.github.com/"))
+ .index(7)
+ .build(),
+ PropertyDefinition.builder(WEB_URL)
+ .name("The WEB url for a GitHub instance.")
+ .description("The WEB url for a GitHub instance. " +
+ "https://github.com/ for github.com, https://github.company.com/ when using GitHub Enterprise.")
+ .category(CATEGORY)
+ .subCategory(SUBCATEGORY)
+ .type(STRING)
+ .defaultValue(valueOf("https://github.com/"))
+ .index(8)
+ .build(),
+ PropertyDefinition.builder(ORGANIZATIONS)
+ .name("Organizations")
+ .description("Only members of these organizations will be able to authenticate to the server. " +
+ "If a user is a member of any of the organizations listed they will be authenticated.")
+ .multiValues(true)
+ .category(CATEGORY)
+ .subCategory(SUBCATEGORY)
+ .index(9)
+ .build());
+ }
+}
diff --git a/server/sonar-auth-github/src/main/java/org/sonar/auth/github/GsonEmail.java b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/GsonEmail.java
new file mode 100644
index 00000000000..662f1f4adee
--- /dev/null
+++ b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/GsonEmail.java
@@ -0,0 +1,66 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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.github;
+
+import com.google.gson.Gson;
+import com.google.gson.reflect.TypeToken;
+import java.lang.reflect.Type;
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * Lite representation of JSON response of GET https://api.github.com/user/emails
+ */
+public class GsonEmail {
+
+ private String email;
+ private boolean verified;
+ private boolean primary;
+
+ public GsonEmail() {
+ // http://stackoverflow.com/a/18645370/229031
+ this("", false, false);
+ }
+
+ public GsonEmail(String email, boolean verified, boolean primary) {
+ this.email = email;
+ this.verified = verified;
+ this.primary = primary;
+ }
+
+ public String getEmail() {
+ return email;
+ }
+
+ public boolean isVerified() {
+ return verified;
+ }
+
+ public boolean isPrimary() {
+ return primary;
+ }
+
+ public static List<GsonEmail> parse(String json) {
+ Type collectionType = new TypeToken<Collection<GsonEmail>>() {
+ }.getType();
+ Gson gson = new Gson();
+ return gson.fromJson(json, collectionType);
+ }
+}
diff --git a/server/sonar-auth-github/src/main/java/org/sonar/auth/github/GsonTeam.java b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/GsonTeam.java
new file mode 100644
index 00000000000..8c51c8227a6
--- /dev/null
+++ b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/GsonTeam.java
@@ -0,0 +1,77 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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.github;
+
+import com.google.gson.Gson;
+import com.google.gson.reflect.TypeToken;
+import java.lang.reflect.Type;
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * Lite representation of JSON response of GET https://api.github.com/user/teams
+ */
+public class GsonTeam {
+
+ private String slug;
+ private GsonOrganization organization;
+
+ public GsonTeam() {
+ // http://stackoverflow.com/a/18645370/229031
+ this("", new GsonOrganization());
+ }
+
+ public GsonTeam(String slug, GsonOrganization organization) {
+ this.slug = slug;
+ this.organization = organization;
+ }
+
+ public String getId() {
+ return slug;
+ }
+
+ public String getOrganizationId() {
+ return organization.getLogin();
+ }
+
+ public static List<GsonTeam> parse(String json) {
+ Type collectionType = new TypeToken<Collection<GsonTeam>>() {
+ }.getType();
+ Gson gson = new Gson();
+ return gson.fromJson(json, collectionType);
+ }
+
+ public static class GsonOrganization {
+ private String login;
+
+ public GsonOrganization() {
+ // http://stackoverflow.com/a/18645370/229031
+ this("");
+ }
+
+ public GsonOrganization(String login) {
+ this.login = login;
+ }
+
+ public String getLogin() {
+ return login;
+ }
+ }
+}
diff --git a/server/sonar-auth-github/src/main/java/org/sonar/auth/github/GsonUser.java b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/GsonUser.java
new file mode 100644
index 00000000000..f113adf4187
--- /dev/null
+++ b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/GsonUser.java
@@ -0,0 +1,76 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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.github;
+
+import com.google.gson.Gson;
+import javax.annotation.CheckForNull;
+import javax.annotation.Nullable;
+
+/**
+ * Lite representation of JSON response of GET https://api.github.com/user
+ */
+public class GsonUser {
+ private String id;
+ private String login;
+ private String name;
+ private String email;
+
+ public GsonUser() {
+ // even if empty constructor is not required for Gson, it is strongly
+ // recommended:
+ // http://stackoverflow.com/a/18645370/229031
+ }
+
+ public GsonUser(String id, String login, @Nullable String name, @Nullable String email) {
+ this.id = id;
+ this.login = login;
+ this.name = name;
+ this.email = email;
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public String getLogin() {
+ return login;
+ }
+
+ /**
+ * Name is optional at GitHub
+ */
+ @CheckForNull
+ public String getName() {
+ return name;
+ }
+
+ /**
+ * Name is optional at GitHub
+ */
+ @CheckForNull
+ public String getEmail() {
+ return email;
+ }
+
+ public static GsonUser parse(String json) {
+ Gson gson = new Gson();
+ return gson.fromJson(json, GsonUser.class);
+ }
+}
diff --git a/server/sonar-auth-github/src/main/java/org/sonar/auth/github/ScribeGitHubApi.java b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/ScribeGitHubApi.java
new file mode 100644
index 00000000000..fc5828ef8b3
--- /dev/null
+++ b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/ScribeGitHubApi.java
@@ -0,0 +1,41 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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.github;
+
+import com.github.scribejava.apis.GitHubApi;
+
+public class ScribeGitHubApi extends GitHubApi {
+ private final GitHubSettings settings;
+
+ public ScribeGitHubApi(GitHubSettings settings) {
+ this.settings = settings;
+ }
+
+ @Override
+ public String getAccessTokenEndpoint() {
+ return settings.webURL() + "login/oauth/access_token";
+ }
+
+ @Override
+ protected String getAuthorizationBaseUrl() {
+ return settings.webURL() + "login/oauth/authorize";
+ }
+
+}
diff --git a/server/sonar-auth-github/src/main/java/org/sonar/auth/github/UserIdentityFactory.java b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/UserIdentityFactory.java
new file mode 100644
index 00000000000..4e9085f76cb
--- /dev/null
+++ b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/UserIdentityFactory.java
@@ -0,0 +1,32 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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.github;
+
+import java.util.List;
+import javax.annotation.Nullable;
+import org.sonar.api.server.authentication.UserIdentity;
+
+/**
+ * Converts GitHub JSON response to {@link UserIdentity}
+ */
+public interface UserIdentityFactory {
+
+ UserIdentity create(GsonUser user, @Nullable String email, @Nullable List<GsonTeam> teams);
+}
diff --git a/server/sonar-auth-github/src/main/java/org/sonar/auth/github/UserIdentityFactoryImpl.java b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/UserIdentityFactoryImpl.java
new file mode 100644
index 00000000000..a93798a17d6
--- /dev/null
+++ b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/UserIdentityFactoryImpl.java
@@ -0,0 +1,54 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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.github;
+
+import java.util.List;
+import java.util.stream.Collectors;
+import javax.annotation.Nullable;
+import org.sonar.api.server.authentication.UserIdentity;
+
+import static org.sonar.auth.github.UserIdentityGenerator.generateLogin;
+import static org.sonar.auth.github.UserIdentityGenerator.generateName;
+
+public class UserIdentityFactoryImpl implements UserIdentityFactory {
+
+ private final GitHubSettings settings;
+
+ public UserIdentityFactoryImpl(GitHubSettings settings) {
+ this.settings = settings;
+ }
+
+ @Override
+ public UserIdentity create(GsonUser user, @Nullable String email, @Nullable List<GsonTeam> teams) {
+ UserIdentity.Builder builder = UserIdentity.builder()
+ .setProviderId(user.getId())
+ .setProviderLogin(user.getLogin())
+ .setLogin(generateLogin(user, settings.loginStrategy()))
+ .setName(generateName(user))
+ .setEmail(email);
+ if (teams != null) {
+ builder.setGroups(teams.stream()
+ .map(team -> team.getOrganizationId() + "/" + team.getId())
+ .collect(Collectors.toSet()));
+ }
+ return builder.build();
+ }
+
+}
diff --git a/server/sonar-auth-github/src/main/java/org/sonar/auth/github/UserIdentityGenerator.java b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/UserIdentityGenerator.java
new file mode 100644
index 00000000000..4b3dbdf7dd0
--- /dev/null
+++ b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/UserIdentityGenerator.java
@@ -0,0 +1,51 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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.github;
+
+import static java.lang.String.format;
+import static org.sonar.auth.github.GitHubSettings.LOGIN_STRATEGY_PROVIDER_ID;
+import static org.sonar.auth.github.GitHubSettings.LOGIN_STRATEGY_UNIQUE;
+
+class UserIdentityGenerator {
+
+ private UserIdentityGenerator() {
+ // Only static method
+ }
+
+ static String generateLogin(GsonUser gsonUser, String loginStrategy) {
+ switch (loginStrategy) {
+ case LOGIN_STRATEGY_PROVIDER_ID:
+ return gsonUser.getLogin();
+ case LOGIN_STRATEGY_UNIQUE:
+ return generateUniqueLogin(gsonUser);
+ default:
+ throw new IllegalStateException(format("Login strategy not supported : %s", loginStrategy));
+ }
+ }
+
+ static String generateName(GsonUser gson) {
+ String name = gson.getName();
+ return name == null || name.isEmpty() ? gson.getLogin() : name;
+ }
+
+ private static String generateUniqueLogin(GsonUser gsonUser) {
+ return format("%s@%s", gsonUser.getLogin(), GitHubIdentityProvider.KEY);
+ }
+}
diff --git a/server/sonar-auth-github/src/main/java/org/sonar/auth/github/package-info.java b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/package-info.java
new file mode 100644
index 00000000000..153f45e5378
--- /dev/null
+++ b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/package-info.java
@@ -0,0 +1,23 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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.github;
+
+import javax.annotation.ParametersAreNonnullByDefault;
diff --git a/server/sonar-auth-github/src/test/java/org/sonar/auth/github/GitHubIdentityProviderTest.java b/server/sonar-auth-github/src/test/java/org/sonar/auth/github/GitHubIdentityProviderTest.java
new file mode 100644
index 00000000000..ac78fd84048
--- /dev/null
+++ b/server/sonar-auth-github/src/test/java/org/sonar/auth/github/GitHubIdentityProviderTest.java
@@ -0,0 +1,185 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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.github;
+
+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;
+import static org.sonar.auth.github.GitHubSettings.LOGIN_STRATEGY_DEFAULT_VALUE;
+
+public class GitHubIdentityProviderTest {
+
+ @Rule
+ public ExpectedException thrown = ExpectedException.none();
+
+ private MapSettings settings = new MapSettings();
+ private GitHubSettings gitHubSettings = new GitHubSettings(settings.asConfig());
+ private UserIdentityFactoryImpl userIdentityFactory = mock(UserIdentityFactoryImpl.class);
+ private ScribeGitHubApi scribeApi = new ScribeGitHubApi(gitHubSettings);
+ private GitHubRestClient gitHubRestClient = new GitHubRestClient(gitHubSettings);
+ private GitHubIdentityProvider underTest = new GitHubIdentityProvider(gitHubSettings, userIdentityFactory, scribeApi, gitHubRestClient);
+
+ @Test
+ public void check_fields() {
+ assertThat(underTest.getKey()).isEqualTo("github");
+ assertThat(underTest.getName()).isEqualTo("GitHub");
+ assertThat(underTest.getDisplay().getIconPath()).isEqualTo("/images/github.svg");
+ assertThat(underTest.getDisplay().getBackgroundColor()).isEqualTo("#444444");
+ }
+
+ @Test
+ public void is_enabled() {
+ settings.setProperty("sonar.auth.github.clientId.secured", "id");
+ settings.setProperty("sonar.auth.github.clientSecret.secured", "secret");
+ settings.setProperty("sonar.auth.github.loginStrategy", LOGIN_STRATEGY_DEFAULT_VALUE);
+ settings.setProperty("sonar.auth.github.enabled", true);
+ assertThat(underTest.isEnabled()).isTrue();
+
+ settings.setProperty("sonar.auth.github.enabled", false);
+ assertThat(underTest.isEnabled()).isFalse();
+ }
+
+ @Test
+ public void should_allow_users_to_signup() {
+ assertThat(underTest.allowsUsersToSignUp()).as("default").isFalse();
+
+ settings.setProperty("sonar.auth.github.allowUsersToSignUp", true);
+ assertThat(underTest.allowsUsersToSignUp()).isTrue();
+ }
+
+ @Test
+ public void init() {
+ setSettings(true);
+ OAuth2IdentityProvider.InitContext context = mock(OAuth2IdentityProvider.InitContext.class);
+ when(context.generateCsrfState()).thenReturn("state");
+ when(context.getCallbackUrl()).thenReturn("http://localhost/callback");
+ settings.setProperty("sonar.auth.github.webUrl", "https://github.com/");
+
+ underTest.init(context);
+
+ verify(context).redirectTo("https://github.com/login/oauth/authorize" +
+ "?response_type=code" +
+ "&client_id=id" +
+ "&redirect_uri=http%3A%2F%2Flocalhost%2Fcallback&scope=user%3Aemail" +
+ "&state=state");
+ }
+
+ @Test
+ public void init_when_group_sync() {
+ setSettings(true);
+ settings.setProperty("sonar.auth.github.groupsSync", "true");
+ settings.setProperty("sonar.auth.github.webUrl", "https://github.com/");
+ 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://github.com/login/oauth/authorize" +
+ "?response_type=code" +
+ "&client_id=id" +
+ "&redirect_uri=http%3A%2F%2Flocalhost%2Fcallback&scope=user%3Aemail%2Cread%3Aorg" +
+ "&state=state");
+ }
+
+ @Test
+ public void init_when_organizations() {
+ setSettings(true);
+ settings.setProperty("sonar.auth.github.organizations", "example");
+ settings.setProperty("sonar.auth.github.webUrl", "https://github.com/");
+ 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://github.com/login/oauth/authorize" +
+ "?response_type=code" +
+ "&client_id=id" +
+ "&redirect_uri=http%3A%2F%2Flocalhost%2Fcallback" +
+ "&scope=user%3Aemail%2Cread%3Aorg" +
+ "&state=state");
+ }
+
+ @Test
+ public void fail_to_init_when_disabled() {
+ setSettings(false);
+ OAuth2IdentityProvider.InitContext context = mock(OAuth2IdentityProvider.InitContext.class);
+
+ thrown.expect(IllegalStateException.class);
+ thrown.expectMessage("GitHub authentication is disabled");
+ underTest.init(context);
+ }
+
+ @Test
+ public void scope_includes_org_when_necessary() {
+ setSettings(false);
+
+ settings.setProperty("sonar.auth.github.groupsSync", false);
+ settings.setProperty("sonar.auth.github.organizations", "");
+ assertThat(underTest.getScope()).isEqualTo("user:email");
+
+ settings.setProperty("sonar.auth.github.groupsSync", true);
+ settings.setProperty("sonar.auth.github.organizations", "");
+ assertThat(underTest.getScope()).isEqualTo("user:email,read:org");
+
+ settings.setProperty("sonar.auth.github.groupsSync", false);
+ settings.setProperty("sonar.auth.github.organizations", "example");
+ assertThat(underTest.getScope()).isEqualTo("user:email,read:org");
+
+ settings.setProperty("sonar.auth.github.groupsSync", true);
+ settings.setProperty("sonar.auth.github.organizations", "example");
+ assertThat(underTest.getScope()).isEqualTo("user:email,read:org");
+ }
+
+ @Test
+ public void organization_membership_required() {
+ setSettings(true);
+ settings.setProperty("sonar.auth.github.organizations", "example");
+ assertThat(underTest.isOrganizationMembershipRequired()).isTrue();
+ settings.setProperty("sonar.auth.github.organizations", "example0, example1");
+ assertThat(underTest.isOrganizationMembershipRequired()).isTrue();
+ }
+
+ @Test
+ public void organization_membership_not_required() {
+ setSettings(true);
+ settings.setProperty("sonar.auth.github.organizations", "");
+ assertThat(underTest.isOrganizationMembershipRequired()).isFalse();
+ }
+
+ private void setSettings(boolean enabled) {
+ if (enabled) {
+ settings.setProperty("sonar.auth.github.clientId.secured", "id");
+ settings.setProperty("sonar.auth.github.clientSecret.secured", "secret");
+ settings.setProperty("sonar.auth.github.loginStrategy", LOGIN_STRATEGY_DEFAULT_VALUE);
+ settings.setProperty("sonar.auth.github.enabled", true);
+ } else {
+ settings.setProperty("sonar.auth.github.enabled", false);
+ }
+ }
+}
diff --git a/server/sonar-auth-github/src/test/java/org/sonar/auth/github/GitHubModuleTest.java b/server/sonar-auth-github/src/test/java/org/sonar/auth/github/GitHubModuleTest.java
new file mode 100644
index 00000000000..0589b7a2e27
--- /dev/null
+++ b/server/sonar-auth-github/src/test/java/org/sonar/auth/github/GitHubModuleTest.java
@@ -0,0 +1,37 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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.github;
+
+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 GitHubModuleTest {
+
+ @Test
+ public void verify_count_of_added_components() {
+ ComponentContainer container = new ComponentContainer();
+ new GitHubModule().configure(container);
+ assertThat(container.size()).isEqualTo(COMPONENTS_IN_EMPTY_COMPONENT_CONTAINER + 14);
+ }
+
+}
diff --git a/server/sonar-auth-github/src/test/java/org/sonar/auth/github/GitHubSettingsTest.java b/server/sonar-auth-github/src/test/java/org/sonar/auth/github/GitHubSettingsTest.java
new file mode 100644
index 00000000000..422990b7e85
--- /dev/null
+++ b/server/sonar-auth-github/src/test/java/org/sonar/auth/github/GitHubSettingsTest.java
@@ -0,0 +1,162 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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.github;
+
+import org.junit.Test;
+import org.sonar.api.config.PropertyDefinitions;
+import org.sonar.api.config.internal.MapSettings;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.sonar.auth.github.GitHubSettings.LOGIN_STRATEGY_DEFAULT_VALUE;
+import static org.sonar.auth.github.GitHubSettings.LOGIN_STRATEGY_PROVIDER_ID;
+
+public class GitHubSettingsTest {
+
+ private MapSettings settings = new MapSettings(new PropertyDefinitions(GitHubSettings.definitions()));
+
+ private GitHubSettings underTest = new GitHubSettings(settings.asConfig());
+
+ @Test
+ public void is_enabled() {
+ settings.setProperty("sonar.auth.github.clientId.secured", "id");
+ settings.setProperty("sonar.auth.github.clientSecret.secured", "secret");
+ settings.setProperty("sonar.auth.github.loginStrategy", LOGIN_STRATEGY_DEFAULT_VALUE);
+
+ settings.setProperty("sonar.auth.github.enabled", true);
+ assertThat(underTest.isEnabled()).isTrue();
+
+ settings.setProperty("sonar.auth.github.enabled", false);
+ assertThat(underTest.isEnabled()).isFalse();
+ }
+
+ @Test
+ public void is_enabled_always_return_false_when_client_id_is_null() {
+ settings.setProperty("sonar.auth.github.enabled", true);
+ settings.setProperty("sonar.auth.github.clientId.secured", (String) null);
+ settings.setProperty("sonar.auth.github.clientSecret.secured", "secret");
+ settings.setProperty("sonar.auth.github.loginStrategy", LOGIN_STRATEGY_DEFAULT_VALUE);
+
+ assertThat(underTest.isEnabled()).isFalse();
+ }
+
+ @Test
+ public void is_enabled_always_return_false_when_client_secret_is_null() {
+ settings.setProperty("sonar.auth.github.enabled", true);
+ settings.setProperty("sonar.auth.github.clientId.secured", "id");
+ settings.setProperty("sonar.auth.github.clientSecret.secured", (String) null);
+ settings.setProperty("sonar.auth.github.loginStrategy", LOGIN_STRATEGY_DEFAULT_VALUE);
+
+ assertThat(underTest.isEnabled()).isFalse();
+ }
+
+ @Test
+ public void return_client_id() {
+ settings.setProperty("sonar.auth.github.clientId.secured", "id");
+ assertThat(underTest.clientId()).isEqualTo("id");
+ }
+
+ @Test
+ public void return_client_secret() {
+ settings.setProperty("sonar.auth.github.clientSecret.secured", "secret");
+ assertThat(underTest.clientSecret()).isEqualTo("secret");
+ }
+
+ @Test
+ public void return_login_strategy() {
+ settings.setProperty("sonar.auth.github.loginStrategy", LOGIN_STRATEGY_PROVIDER_ID);
+ assertThat(underTest.loginStrategy()).isEqualTo(LOGIN_STRATEGY_PROVIDER_ID);
+ }
+
+ @Test
+ public void allow_users_to_sign_up() {
+ settings.setProperty("sonar.auth.github.allowUsersToSignUp", "true");
+ assertThat(underTest.allowUsersToSignUp()).isTrue();
+
+ settings.setProperty("sonar.auth.github.allowUsersToSignUp", "false");
+ assertThat(underTest.allowUsersToSignUp()).isFalse();
+
+ // default value
+ settings.setProperty("sonar.auth.github.allowUsersToSignUp", (String) null);
+ assertThat(underTest.allowUsersToSignUp()).isTrue();
+ }
+
+ @Test
+ public void sync_groups() {
+ settings.setProperty("sonar.auth.github.groupsSync", "true");
+ assertThat(underTest.syncGroups()).isTrue();
+
+ settings.setProperty("sonar.auth.github.groupsSync", "false");
+ assertThat(underTest.syncGroups()).isFalse();
+
+ // default value
+ settings.setProperty("sonar.auth.github.groupsSync", (String) null);
+ assertThat(underTest.syncGroups()).isFalse();
+ }
+
+ @Test
+ public void apiUrl_must_have_ending_slash() {
+ settings.setProperty("sonar.auth.github.apiUrl", "https://github.com");
+ assertThat(underTest.apiURL()).isEqualTo("https://github.com/");
+
+ settings.setProperty("sonar.auth.github.apiUrl", "https://github.com/");
+ assertThat(underTest.apiURL()).isEqualTo("https://github.com/");
+ }
+
+ @Test
+ public void webUrl_must_have_ending_slash() {
+ settings.setProperty("sonar.auth.github.webUrl", "https://github.com");
+ assertThat(underTest.webURL()).isEqualTo("https://github.com/");
+
+ settings.setProperty("sonar.auth.github.webUrl", "https://github.com/");
+ assertThat(underTest.webURL()).isEqualTo("https://github.com/");
+ }
+
+ @Test
+ public void return_organizations_single() {
+ String setting = "example";
+ settings.setProperty("sonar.auth.github.organizations", setting);
+ String[] expected = new String[] {"example"};
+ String[] actual = underTest.organizations();
+ assertThat(actual).isEqualTo(expected);
+ }
+
+ @Test
+ public void return_organizations_multiple() {
+ String setting = "example0,example1";
+ settings.setProperty("sonar.auth.github.organizations", setting);
+ String[] expected = new String[] {"example0", "example1"};
+ String[] actual = underTest.organizations();
+ assertThat(actual).isEqualTo(expected);
+ }
+
+ @Test
+ public void return_organizations_empty_list() {
+ String[] setting = null;
+ settings.setProperty("sonar.auth.github.organizations", setting);
+ String[] expected = new String[] {};
+ String[] actual = underTest.organizations();
+ assertThat(actual).isEqualTo(expected);
+ }
+
+ @Test
+ public void definitions() {
+ assertThat(GitHubSettings.definitions()).hasSize(9);
+ }
+}
diff --git a/server/sonar-auth-github/src/test/java/org/sonar/auth/github/GsonEmailTest.java b/server/sonar-auth-github/src/test/java/org/sonar/auth/github/GsonEmailTest.java
new file mode 100644
index 00000000000..40d1d1d589c
--- /dev/null
+++ b/server/sonar-auth-github/src/test/java/org/sonar/auth/github/GsonEmailTest.java
@@ -0,0 +1,60 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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.github;
+
+import java.util.List;
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class GsonEmailTest {
+
+ @Test
+ public void parse() {
+ List<GsonEmail> underTest = GsonEmail.parse(
+ "[\n" +
+ " {\n" +
+ " \"email\": \"octocat@github.com\",\n" +
+ " \"verified\": true,\n" +
+ " \"primary\": true\n" +
+ " },\n" +
+ " {\n" +
+ " \"email\": \"support@github.com\",\n" +
+ " \"verified\": false,\n" +
+ " \"primary\": false\n" +
+ " }\n" +
+ "]");
+ assertThat(underTest).hasSize(2);
+
+ assertThat(underTest.get(0).getEmail()).isEqualTo("octocat@github.com");
+ assertThat(underTest.get(0).isVerified()).isTrue();
+ assertThat(underTest.get(0).isPrimary()).isTrue();
+
+ assertThat(underTest.get(1).getEmail()).isEqualTo("support@github.com");
+ assertThat(underTest.get(1).isVerified()).isFalse();
+ assertThat(underTest.get(1).isPrimary()).isFalse();
+ }
+
+ @Test
+ public void should_have_no_arg_constructor() {
+ assertThat(new GsonEmail().getEmail()).isEqualTo("");
+ }
+
+}
diff --git a/server/sonar-auth-github/src/test/java/org/sonar/auth/github/GsonTeamTest.java b/server/sonar-auth-github/src/test/java/org/sonar/auth/github/GsonTeamTest.java
new file mode 100644
index 00000000000..01b346ff883
--- /dev/null
+++ b/server/sonar-auth-github/src/test/java/org/sonar/auth/github/GsonTeamTest.java
@@ -0,0 +1,74 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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.github;
+
+import java.util.List;
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class GsonTeamTest {
+
+ @Test
+ public void parse_one_team() {
+ List<GsonTeam> underTest = GsonTeam.parse(
+ "[\n" +
+ " {\n" +
+ " \"name\": \"Developers\",\n" +
+ " \"slug\": \"developers\",\n" +
+ " \"organization\": {\n" +
+ " \"login\": \"SonarSource\"\n" +
+ " }\n" +
+ " }\n" +
+ "]");
+ assertThat(underTest).hasSize(1);
+
+ assertThat(underTest.get(0).getId()).isEqualTo("developers");
+ assertThat(underTest.get(0).getOrganizationId()).isEqualTo("SonarSource");
+ }
+
+ @Test
+ public void parse_two_teams() {
+ List<GsonTeam> underTest = GsonTeam.parse(
+ "[\n" +
+ " {\n" +
+ " \"name\": \"Developers\",\n" +
+ " \"slug\": \"developers\",\n" +
+ " \"organization\": {\n" +
+ " \"login\": \"SonarSource\"\n" +
+ " }\n" +
+ " },\n" +
+ " {\n" +
+ " \"login\": \"SonarSource Developers\",\n" +
+ " \"organization\": {\n" +
+ " \"login\": \"SonarQubeCommunity\"\n" +
+ " }\n" +
+ " }\n" +
+ "]");
+ assertThat(underTest).hasSize(2);
+ }
+
+ @Test
+ public void should_have_no_arg_constructor() {
+ assertThat(new GsonTeam().getId()).isEqualTo("");
+ assertThat(new GsonTeam.GsonOrganization().getLogin()).isEqualTo("");
+ }
+
+}
diff --git a/server/sonar-auth-github/src/test/java/org/sonar/auth/github/GsonUserTest.java b/server/sonar-auth-github/src/test/java/org/sonar/auth/github/GsonUserTest.java
new file mode 100644
index 00000000000..7e15cacab5d
--- /dev/null
+++ b/server/sonar-auth-github/src/test/java/org/sonar/auth/github/GsonUserTest.java
@@ -0,0 +1,56 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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.github;
+
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class GsonUserTest {
+
+ @Test
+ public void parse_json() {
+ GsonUser user = GsonUser.parse(
+ "{\n" +
+ " \"login\": \"octocat\",\n" +
+ " \"id\": 1,\n" +
+ " \"name\": \"monalisa octocat\",\n" +
+ " \"email\": \"octocat@github.com\"\n" +
+ "}");
+ assertThat(user.getId()).isEqualTo("1");
+ assertThat(user.getLogin()).isEqualTo("octocat");
+ assertThat(user.getName()).isEqualTo("monalisa octocat");
+ assertThat(user.getEmail()).isEqualTo("octocat@github.com");
+ }
+
+ @Test
+ public void name_can_be_null() {
+ GsonUser underTest = GsonUser.parse("{login:octocat, email:octocat@github.com}");
+ assertThat(underTest.getLogin()).isEqualTo("octocat");
+ assertThat(underTest.getName()).isNull();
+ }
+
+ @Test
+ public void email_can_be_null() {
+ GsonUser underTest = GsonUser.parse("{login:octocat}");
+ assertThat(underTest.getLogin()).isEqualTo("octocat");
+ assertThat(underTest.getEmail()).isNull();
+ }
+}
diff --git a/server/sonar-auth-github/src/test/java/org/sonar/auth/github/IntegrationTest.java b/server/sonar-auth-github/src/test/java/org/sonar/auth/github/IntegrationTest.java
new file mode 100644
index 00000000000..e3ef3551850
--- /dev/null
+++ b/server/sonar-auth-github/src/test/java/org/sonar/auth/github/IntegrationTest.java
@@ -0,0 +1,471 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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.github;
+
+import java.io.IOException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.TreeSet;
+import java.util.concurrent.atomic.AtomicBoolean;
+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 static java.lang.String.format;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.fail;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class IntegrationTest {
+
+ private static final String CALLBACK_URL = "http://localhost/oauth/callback/github";
+
+ @Rule
+ public MockWebServer github = new MockWebServer();
+
+ // load settings with default values
+ private MapSettings settings = new MapSettings(new PropertyDefinitions(GitHubSettings.definitions()));
+ private GitHubSettings gitHubSettings = new GitHubSettings(settings.asConfig());
+ private UserIdentityFactoryImpl userIdentityFactory = new UserIdentityFactoryImpl(gitHubSettings);
+ private ScribeGitHubApi scribeApi = new ScribeGitHubApi(gitHubSettings);
+ private GitHubRestClient gitHubRestClient = new GitHubRestClient(gitHubSettings);
+
+ private String gitHubUrl;
+
+ private GitHubIdentityProvider underTest = new GitHubIdentityProvider(gitHubSettings, userIdentityFactory, scribeApi, gitHubRestClient);
+
+ @Before
+ public void enable() {
+ gitHubUrl = format("http://%s:%d", github.getHostName(), github.getPort());
+ settings.setProperty("sonar.auth.github.clientId.secured", "the_id");
+ settings.setProperty("sonar.auth.github.clientSecret.secured", "the_secret");
+ settings.setProperty("sonar.auth.github.enabled", true);
+ settings.setProperty("sonar.auth.github.apiUrl", gitHubUrl);
+ settings.setProperty("sonar.auth.github.webUrl", gitHubUrl);
+ }
+
+ /**
+ * First phase: SonarQube redirects browser to GitHub authentication form, requesting the
+ * minimal access rights ("scope") to get user profile (login, name, email and others).
+ */
+ @Test
+ public void redirect_browser_to_github_authentication_form() throws Exception {
+ DumbInitContext context = new DumbInitContext("the-csrf-state");
+ underTest.init(context);
+
+ assertThat(context.redirectedTo).isEqualTo(
+ gitHubSettings.webURL() +
+ "login/oauth/authorize" +
+ "?response_type=code" +
+ "&client_id=the_id" +
+ "&redirect_uri=" + URLEncoder.encode(CALLBACK_URL, StandardCharsets.UTF_8.name()) +
+ "&scope=" + URLEncoder.encode("user:email", StandardCharsets.UTF_8.name()) +
+ "&state=the-csrf-state");
+ }
+
+ /**
+ * Second phase: GitHub redirects browser to SonarQube at /oauth/callback/github?code={the verifier code}.
+ * This SonarQube web service sends two requests to GitHub:
+ * <ul>
+ * <li>get an access token</li>
+ * <li>get the profile of the authenticated user</li>
+ * </ul>
+ */
+ @Test
+ public void callback_on_successful_authentication() throws IOException, InterruptedException {
+ github.enqueue(newSuccessfulAccessTokenResponse());
+ // response of api.github.com/user
+ github.enqueue(new MockResponse().setBody("{\"id\":\"ABCD\", \"login\":\"octocat\", \"name\":\"monalisa octocat\",\"email\":\"octocat@github.com\"}"));
+
+ HttpServletRequest request = newRequest("the-verifier-code");
+ DumbCallbackContext callbackContext = new DumbCallbackContext(request);
+ underTest.callback(callbackContext);
+
+ assertThat(callbackContext.csrfStateVerified.get()).isTrue();
+ assertThat(callbackContext.userIdentity.getProviderId()).isEqualTo("ABCD");
+ assertThat(callbackContext.userIdentity.getLogin()).isEqualTo("octocat@github");
+ assertThat(callbackContext.userIdentity.getName()).isEqualTo("monalisa octocat");
+ assertThat(callbackContext.userIdentity.getEmail()).isEqualTo("octocat@github.com");
+ assertThat(callbackContext.redirectedToRequestedPage.get()).isTrue();
+
+ // Verify the requests sent to GitHub
+ RecordedRequest accessTokenGitHubRequest = github.takeRequest();
+ assertThat(accessTokenGitHubRequest.getMethod()).isEqualTo("POST");
+ assertThat(accessTokenGitHubRequest.getPath()).isEqualTo("/login/oauth/access_token");
+ assertThat(accessTokenGitHubRequest.getBody().readUtf8()).isEqualTo(
+ "code=the-verifier-code" +
+ "&redirect_uri=" + URLEncoder.encode(CALLBACK_URL, StandardCharsets.UTF_8.name()) +
+ "&grant_type=authorization_code");
+
+ RecordedRequest profileGitHubRequest = github.takeRequest();
+ assertThat(profileGitHubRequest.getMethod()).isEqualTo("GET");
+ assertThat(profileGitHubRequest.getPath()).isEqualTo("/user");
+ }
+
+ @Test
+ public void should_retrieve_private_primary_verified_email_address() {
+ github.enqueue(newSuccessfulAccessTokenResponse());
+ // response of api.github.com/user
+ github.enqueue(new MockResponse().setBody("{\"id\":\"ABCD\", \"login\":\"octocat\", \"name\":\"monalisa octocat\",\"email\":null}"));
+ // response of api.github.com/user/emails
+ github.enqueue(new MockResponse().setBody(
+ "[\n" +
+ " {\n" +
+ " \"email\": \"support@github.com\",\n" +
+ " \"verified\": false,\n" +
+ " \"primary\": false\n" +
+ " },\n" +
+ " {\n" +
+ " \"email\": \"octocat@github.com\",\n" +
+ " \"verified\": true,\n" +
+ " \"primary\": true\n" +
+ " },\n" +
+ "]"));
+
+ HttpServletRequest request = newRequest("the-verifier-code");
+ DumbCallbackContext callbackContext = new DumbCallbackContext(request);
+ underTest.callback(callbackContext);
+
+ assertThat(callbackContext.csrfStateVerified.get()).isTrue();
+ assertThat(callbackContext.userIdentity.getProviderId()).isEqualTo("ABCD");
+ assertThat(callbackContext.userIdentity.getLogin()).isEqualTo("octocat@github");
+ assertThat(callbackContext.userIdentity.getName()).isEqualTo("monalisa octocat");
+ assertThat(callbackContext.userIdentity.getEmail()).isEqualTo("octocat@github.com");
+ assertThat(callbackContext.redirectedToRequestedPage.get()).isTrue();
+ }
+
+ @Test
+ public void should_not_fail_if_no_email() {
+ github.enqueue(newSuccessfulAccessTokenResponse());
+ // response of api.github.com/user
+ github.enqueue(new MockResponse().setBody("{\"id\":\"ABCD\", \"login\":\"octocat\", \"name\":\"monalisa octocat\",\"email\":null}"));
+ // response of api.github.com/user/emails
+ github.enqueue(new MockResponse().setBody("[]"));
+
+ HttpServletRequest request = newRequest("the-verifier-code");
+ DumbCallbackContext callbackContext = new DumbCallbackContext(request);
+ underTest.callback(callbackContext);
+
+ assertThat(callbackContext.csrfStateVerified.get()).isTrue();
+ assertThat(callbackContext.userIdentity.getProviderId()).isEqualTo("ABCD");
+ assertThat(callbackContext.userIdentity.getLogin()).isEqualTo("octocat@github");
+ assertThat(callbackContext.userIdentity.getName()).isEqualTo("monalisa octocat");
+ assertThat(callbackContext.userIdentity.getEmail()).isNull();
+ assertThat(callbackContext.redirectedToRequestedPage.get()).isTrue();
+ }
+
+ @Test
+ public void redirect_browser_to_github_authentication_form_with_group_sync() throws Exception {
+ settings.setProperty("sonar.auth.github.groupsSync", true);
+ DumbInitContext context = new DumbInitContext("the-csrf-state");
+ underTest.init(context);
+ assertThat(context.redirectedTo).isEqualTo(
+ gitHubSettings.webURL() +
+ "login/oauth/authorize" +
+ "?response_type=code" +
+ "&client_id=the_id" +
+ "&redirect_uri=" + URLEncoder.encode(CALLBACK_URL, StandardCharsets.UTF_8.name()) +
+ "&scope=" + URLEncoder.encode("user:email,read:org", StandardCharsets.UTF_8.name()) +
+ "&state=the-csrf-state");
+ }
+
+ @Test
+ public void callback_on_successful_authentication_with_group_sync() {
+ settings.setProperty("sonar.auth.github.groupsSync", true);
+
+ github.enqueue(newSuccessfulAccessTokenResponse());
+ // response of api.github.com/user
+ github.enqueue(new MockResponse().setBody("{\"id\":\"ABCD\", \"login\":\"octocat\", \"name\":\"monalisa octocat\",\"email\":\"octocat@github.com\"}"));
+ // response of api.github.com/user/teams
+ github.enqueue(new MockResponse().setBody("[\n" +
+ " {\n" +
+ " \"slug\": \"developers\",\n" +
+ " \"organization\": {\n" +
+ " \"login\": \"SonarSource\"\n" +
+ " }\n" +
+ " }\n" +
+ "]"));
+
+ HttpServletRequest request = newRequest("the-verifier-code");
+ DumbCallbackContext callbackContext = new DumbCallbackContext(request);
+ underTest.callback(callbackContext);
+
+ assertThat(callbackContext.userIdentity.getGroups()).containsOnly("SonarSource/developers");
+ }
+
+ @Test
+ public void callback_on_successful_authentication_with_group_sync_on_many_pages() {
+ settings.setProperty("sonar.auth.github.groupsSync", true);
+
+ github.enqueue(newSuccessfulAccessTokenResponse());
+ // response of api.github.com/user
+ github.enqueue(new MockResponse().setBody("{\"id\":\"ABCD\", \"login\":\"octocat\", \"name\":\"monalisa octocat\",\"email\":\"octocat@github.com\"}"));
+ // responses of api.github.com/user/teams
+ github.enqueue(new MockResponse()
+ .setHeader("Link", "<" + gitHubUrl + "/user/teams?per_page=100&page=2>; rel=\"next\", <" + gitHubUrl + "/user/teams?per_page=100&page=2>; rel=\"last\"")
+ .setBody("[\n" +
+ " {\n" +
+ " \"slug\": \"developers\",\n" +
+ " \"organization\": {\n" +
+ " \"login\": \"SonarSource\"\n" +
+ " }\n" +
+ " }\n" +
+ "]"));
+ github.enqueue(new MockResponse()
+ .setHeader("Link", "<" + gitHubUrl + "/user/teams?per_page=100&page=1>; rel=\"prev\", <" + gitHubUrl + "/user/teams?per_page=100&page=1>; rel=\"first\"")
+ .setBody("[\n" +
+ " {\n" +
+ " \"slug\": \"sonarsource-developers\",\n" +
+ " \"organization\": {\n" +
+ " \"login\": \"SonarQubeCommunity\"\n" +
+ " }\n" +
+ " }\n" +
+ "]"));
+
+ HttpServletRequest request = newRequest("the-verifier-code");
+ DumbCallbackContext callbackContext = new DumbCallbackContext(request);
+ underTest.callback(callbackContext);
+
+ assertThat(new TreeSet<>(callbackContext.userIdentity.getGroups())).containsOnly("SonarQubeCommunity/sonarsource-developers", "SonarSource/developers");
+ }
+
+ @Test
+ public void redirect_browser_to_github_authentication_form_with_organizations() throws Exception {
+ settings.setProperty("sonar.auth.github.organizations", "example0, example1");
+ DumbInitContext context = new DumbInitContext("the-csrf-state");
+ underTest.init(context);
+ assertThat(context.redirectedTo).isEqualTo(
+ gitHubSettings.webURL() +
+ "login/oauth/authorize" +
+ "?response_type=code" +
+ "&client_id=the_id" +
+ "&redirect_uri=" + URLEncoder.encode(CALLBACK_URL, StandardCharsets.UTF_8.name()) +
+ "&scope=" + URLEncoder.encode("user:email,read:org", StandardCharsets.UTF_8.name()) +
+ "&state=the-csrf-state");
+ }
+
+ @Test
+ public void callback_on_successful_authentication_with_organizations_with_membership() {
+ settings.setProperty("sonar.auth.github.organizations", "example0, example1");
+
+ github.enqueue(newSuccessfulAccessTokenResponse());
+ // response of api.github.com/user
+ github.enqueue(new MockResponse().setBody("{\"id\":\"ABCD\", \"login\":\"octocat\", \"name\":\"monalisa octocat\",\"email\":\"octocat@github.com\"}"));
+ // response of api.github.com/orgs/example0/members/user
+ github.enqueue(new MockResponse().setResponseCode(204));
+
+ HttpServletRequest request = newRequest("the-verifier-code");
+ DumbCallbackContext callbackContext = new DumbCallbackContext(request);
+ underTest.callback(callbackContext);
+
+ assertThat(callbackContext.csrfStateVerified.get()).isTrue();
+ assertThat(callbackContext.userIdentity).isNotNull();
+ assertThat(callbackContext.redirectedToRequestedPage.get()).isTrue();
+ }
+
+ @Test
+ public void callback_on_successful_authentication_with_organizations_without_membership() {
+ settings.setProperty("sonar.auth.github.organizations", "first_org,second_org");
+ settings.setProperty("sonar.auth.github.loginStrategy", GitHubSettings.LOGIN_STRATEGY_PROVIDER_ID);
+
+ github.enqueue(newSuccessfulAccessTokenResponse());
+ // response of api.github.com/user
+ github.enqueue(new MockResponse().setBody("{\"id\":\"ABCD\", \"login\":\"octocat\", \"name\":\"monalisa octocat\",\"email\":\"octocat@github.com\"}"));
+ // response of api.github.com/orgs/first_org/members/user
+ github.enqueue(new MockResponse().setResponseCode(404).setBody("{}"));
+ // response of api.github.com/orgs/second_org/members/user
+ github.enqueue(new MockResponse().setResponseCode(404).setBody("{}"));
+
+ HttpServletRequest request = newRequest("the-verifier-code");
+ DumbCallbackContext callbackContext = new DumbCallbackContext(request);
+ try {
+ underTest.callback(callbackContext);
+ fail("exception expected");
+ } catch (UnauthorizedException e) {
+ assertThat(e.getMessage()).isEqualTo("'octocat' must be a member of at least one organization: 'first_org', 'second_org'");
+ }
+ }
+
+ @Test
+ public void callback_on_successful_authentication_with_organizations_without_membership_with_unique_login_strategy() {
+ settings.setProperty("sonar.auth.github.organizations", "example");
+ settings.setProperty("sonar.auth.github.loginStrategy", GitHubSettings.LOGIN_STRATEGY_UNIQUE);
+
+ github.enqueue(newSuccessfulAccessTokenResponse());
+ // response of api.github.com/user
+ github.enqueue(new MockResponse().setBody("{\"id\":\"ABCD\", \"login\":\"octocat\", \"name\":\"monalisa octocat\",\"email\":\"octocat@github.com\"}"));
+ // response of api.github.com/orgs/example0/members/user
+ github.enqueue(new MockResponse().setResponseCode(404).setBody("{}"));
+
+ HttpServletRequest request = newRequest("the-verifier-code");
+ DumbCallbackContext callbackContext = new DumbCallbackContext(request);
+ try {
+ underTest.callback(callbackContext);
+ fail("exception expected");
+ } catch (UnauthorizedException e) {
+ assertThat(e.getMessage()).isEqualTo("'octocat' must be a member of at least one organization: 'example'");
+ }
+ }
+
+ @Test
+ public void callback_throws_ISE_if_error_when_requesting_user_profile() {
+ github.enqueue(newSuccessfulAccessTokenResponse());
+ // api.github.com/user crashes
+ github.enqueue(new MockResponse().setResponseCode(500).setBody("{error}"));
+
+ DumbCallbackContext callbackContext = new DumbCallbackContext(newRequest("the-verifier-code"));
+ try {
+ underTest.callback(callbackContext);
+ fail("exception expected");
+ } catch (IllegalStateException e) {
+ assertThat(e.getMessage()).isEqualTo("Fail to execute request '" + gitHubSettings.apiURL() + "user'. HTTP code: 500, response: {error}");
+ }
+
+ assertThat(callbackContext.csrfStateVerified.get()).isTrue();
+ assertThat(callbackContext.userIdentity).isNull();
+ assertThat(callbackContext.redirectedToRequestedPage.get()).isFalse();
+ }
+
+ @Test
+ public void callback_throws_ISE_if_error_when_checking_membership() {
+ settings.setProperty("sonar.auth.github.organizations", "example");
+
+ github.enqueue(newSuccessfulAccessTokenResponse());
+ // response of api.github.com/user
+ github.enqueue(new MockResponse().setBody("{\"id\":\"ABCD\", \"login\":\"octocat\", \"name\":\"monalisa octocat\",\"email\":\"octocat@github.com\"}"));
+ // crash of api.github.com/orgs/example/members/user
+ github.enqueue(new MockResponse().setResponseCode(500).setBody("{error}"));
+
+ HttpServletRequest request = newRequest("the-verifier-code");
+ DumbCallbackContext callbackContext = new DumbCallbackContext(request);
+ try {
+ underTest.callback(callbackContext);
+ fail("exception expected");
+ } catch (IllegalStateException e) {
+ assertThat(e.getMessage()).isEqualTo("Fail to execute request '" + gitHubSettings.apiURL() + "orgs/example/members/octocat'. HTTP code: 500, response: {error}");
+ }
+ }
+
+ /**
+ * Response sent by GitHub to SonarQube when generating an access token
+ */
+ private static MockResponse newSuccessfulAccessTokenResponse() {
+ // github does not return the standard JSON format but plain-text
+ // see https://developer.github.com/v3/oauth/
+ return new MockResponse().setBody("access_token=e72e16c7e42f292c6912e7710c838347ae178b4a&scope=user%2Cgist&token_type=bearer");
+ }
+
+ 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(false);
+ 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 parameterName) {
+ throw new UnsupportedOperationException("not used");
+ }
+
+ @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-github/src/test/java/org/sonar/auth/github/UserIdentityFactoryImplTest.java b/server/sonar-auth-github/src/test/java/org/sonar/auth/github/UserIdentityFactoryImplTest.java
new file mode 100644
index 00000000000..cad51844221
--- /dev/null
+++ b/server/sonar-auth-github/src/test/java/org/sonar/auth/github/UserIdentityFactoryImplTest.java
@@ -0,0 +1,120 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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.github;
+
+import java.util.Arrays;
+import java.util.List;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.sonar.api.config.PropertyDefinitions;
+import org.sonar.api.config.internal.MapSettings;
+import org.sonar.api.server.authentication.UserIdentity;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class UserIdentityFactoryImplTest {
+
+ @Rule
+ public ExpectedException expectedException = ExpectedException.none();
+
+ private MapSettings settings = new MapSettings(new PropertyDefinitions(GitHubSettings.definitions()));
+ private UserIdentityFactoryImpl underTest = new UserIdentityFactoryImpl(new GitHubSettings(settings.asConfig()));
+
+ /**
+ * Keep the same login as at GitHub
+ */
+ @Test
+ public void create_for_provider_strategy() {
+ GsonUser gson = new GsonUser("ABCD", "octocat", "monalisa octocat", "octocat@github.com");
+ settings.setProperty(GitHubSettings.LOGIN_STRATEGY, GitHubSettings.LOGIN_STRATEGY_PROVIDER_ID);
+
+ UserIdentity identity = underTest.create(gson, gson.getEmail(), null);
+
+ assertThat(identity.getProviderId()).isEqualTo("ABCD");
+ assertThat(identity.getLogin()).isEqualTo("octocat");
+ assertThat(identity.getName()).isEqualTo("monalisa octocat");
+ assertThat(identity.getEmail()).isEqualTo("octocat@github.com");
+ }
+
+ @Test
+ public void no_email() {
+ GsonUser gson = new GsonUser("ABCD", "octocat", "monalisa octocat", null);
+ settings.setProperty(GitHubSettings.LOGIN_STRATEGY, GitHubSettings.LOGIN_STRATEGY_PROVIDER_ID);
+
+ UserIdentity identity = underTest.create(gson, null, null);
+
+ assertThat(identity.getLogin()).isEqualTo("octocat");
+ assertThat(identity.getName()).isEqualTo("monalisa octocat");
+ assertThat(identity.getEmail()).isNull();
+ }
+
+ @Test
+ public void create_for_provider_strategy_with_teams() {
+ GsonUser gson = new GsonUser("ABCD", "octocat", "monalisa octocat", "octocat@github.com");
+ List<GsonTeam> teams = Arrays.asList(
+ new GsonTeam("developers", new GsonTeam.GsonOrganization("SonarSource")));
+ settings.setProperty(GitHubSettings.LOGIN_STRATEGY, GitHubSettings.LOGIN_STRATEGY_PROVIDER_ID);
+
+ UserIdentity identity = underTest.create(gson, null, teams);
+
+ assertThat(identity.getGroups()).containsOnly("SonarSource/developers");
+ }
+
+ @Test
+ public void create_for_unique_login_strategy() {
+ GsonUser gson = new GsonUser("ABCD", "octocat", "monalisa octocat", "octocat@github.com");
+ settings.setProperty(GitHubSettings.LOGIN_STRATEGY, GitHubSettings.LOGIN_STRATEGY_UNIQUE);
+
+ UserIdentity identity = underTest.create(gson, null, null);
+
+ assertThat(identity.getLogin()).isEqualTo("octocat@github");
+ assertThat(identity.getName()).isEqualTo("monalisa octocat");
+ assertThat(identity.getEmail()).isNull();
+ }
+
+ @Test
+ public void empty_name_is_replaced_by_provider_login() {
+ GsonUser gson = new GsonUser("ABCD", "octocat", "", "octocat@github.com");
+
+ UserIdentity identity = underTest.create(gson, null, null);
+
+ assertThat(identity.getName()).isEqualTo("octocat");
+ }
+
+ @Test
+ public void null_name_is_replaced_by_provider_login() {
+ GsonUser gson = new GsonUser("ABCD", "octocat", null, "octocat@github.com");
+
+ UserIdentity identity = underTest.create(gson, null, null);
+
+ assertThat(identity.getName()).isEqualTo("octocat");
+ }
+
+ @Test
+ public void throw_ISE_if_strategy_is_not_supported() {
+ settings.setProperty(GitHubSettings.LOGIN_STRATEGY, "xxx");
+
+ expectedException.expect(IllegalStateException.class);
+ expectedException.expectMessage("Login strategy not supported : xxx");
+
+ underTest.create(new GsonUser("ABCD", "octocat", "octocat", "octocat@github.com"), null, null);
+ }
+}