/* * SonarQube * Copyright (C) 2009-2025 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.List; import java.util.TreeSet; import java.util.concurrent.atomic.AtomicBoolean; import jakarta.servlet.http.HttpServletRequest; 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.server.http.HttpRequest; import org.sonar.api.server.http.HttpResponse; import org.sonar.api.utils.System2; import org.sonar.auth.github.scribe.ScribeServiceBuilder; import org.sonar.db.DbTester; import org.sonar.server.http.JakartaHttpRequest; import org.sonar.server.property.InternalProperties; import org.sonar.server.property.InternalPropertiesImpl; import static java.lang.String.format; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.fail; import static org.mockito.ArgumentMatchers.any; 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(); @Rule public DbTester db = DbTester.create(System2.INSTANCE); // load settings with default values private MapSettings settings = new MapSettings(new PropertyDefinitions(System2.INSTANCE, GitHubSettings.definitions())); private InternalProperties internalProperties = new InternalPropertiesImpl(db.getDbClient()); private GitHubSettings gitHubSettings = new GitHubSettings(settings.asConfig(), internalProperties, db.getDbClient()); private UserIdentityFactoryImpl userIdentityFactory = new UserIdentityFactoryImpl(); private ScribeGitHubApi scribeApi = new ScribeGitHubApi(gitHubSettings); private GitHubRestClient gitHubRestClient = new GitHubRestClient(gitHubSettings); private GithubApplicationClient githubAppClient = mock(); private ScribeServiceBuilder scribeServiceBuilder = new ScribeServiceBuilder(); private String gitHubUrl; private GitHubIdentityProvider underTest = new GitHubIdentityProvider(gitHubSettings, userIdentityFactory, scribeApi, gitHubRestClient, githubAppClient, scribeServiceBuilder); @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); settings.setProperty("sonar.auth.github.appId", "1"); settings.setProperty("sonar.auth.github.privateKey.secured", "private_key"); } /** * 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: * */ @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\"}")); // response of api.github.com/user/orgs github.enqueue(new MockResponse().setBody(""" [ { "login": "second_org" } ]""")); HttpServletRequest request = newRequest("the-verifier-code"); DumbCallbackContext callbackContext = new DumbCallbackContext(request); mockInstallations(); underTest.callback(callbackContext); assertThat(callbackContext.csrfStateVerified.get()).isTrue(); assertThat(callbackContext.userIdentity.getProviderId()).isEqualTo("ABCD"); assertThat(callbackContext.userIdentity.getProviderLogin()).isEqualTo("octocat"); 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/orgs github.enqueue(new MockResponse().setBody(""" [ { "login": "second_org" } ]""")); // response of api.github.com/user/emails github.enqueue(new MockResponse().setBody(""" [ { "email": "support@github.com", "verified": false, "primary": false }, { "email": "octocat@github.com", "verified": true, "primary": true }, ]""")); HttpServletRequest request = newRequest("the-verifier-code"); DumbCallbackContext callbackContext = new DumbCallbackContext(request); mockInstallations(); underTest.callback(callbackContext); assertThat(callbackContext.csrfStateVerified.get()).isTrue(); assertThat(callbackContext.userIdentity.getProviderId()).isEqualTo("ABCD"); assertThat(callbackContext.userIdentity.getProviderLogin()).isEqualTo("octocat"); 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/orgs/first_org/members/user github.enqueue(new MockResponse().setBody(""" [ { "login": "second_org" } ]""")); // response of api.github.com/user/emails github.enqueue(new MockResponse().setBody("[]")); HttpServletRequest request = newRequest("the-verifier-code"); DumbCallbackContext callbackContext = new DumbCallbackContext(request); mockInstallations(); underTest.callback(callbackContext); assertThat(callbackContext.csrfStateVerified.get()).isTrue(); assertThat(callbackContext.userIdentity.getProviderId()).isEqualTo("ABCD"); assertThat(callbackContext.userIdentity.getProviderLogin()).isEqualTo("octocat"); 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/orgs github.enqueue(new MockResponse().setBody(""" [ { "login": "second_org" } ]""")); // response of api.github.com/user/teams github.enqueue(new MockResponse().setBody(""" [ { "slug": "developers", "organization": { "login": "SonarSource" } } ]""")); HttpServletRequest request = newRequest("the-verifier-code"); DumbCallbackContext callbackContext = new DumbCallbackContext(request); mockInstallations(); 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\"}")); // response of api.github.com/user/orgs github.enqueue(new MockResponse().setBody(""" [ { "login": "second_org" } ]""")); // 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(""" [ { "slug": "developers", "organization": { "login": "SonarSource" } } ]""")); 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(""" [ { "slug": "sonarsource-developers", "organization": { "login": "SonarQubeCommunity" } } ]""")); HttpServletRequest request = newRequest("the-verifier-code"); DumbCallbackContext callbackContext = new DumbCallbackContext(request); mockInstallations(); 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/user/orgs github.enqueue(new MockResponse().setBody(""" [ { "login": "example1" } ]""")); 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"); 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/orgs github.enqueue(new MockResponse().setBody("[]")); HttpServletRequest request = newRequest("the-verifier-code"); DumbCallbackContext callbackContext = new DumbCallbackContext(request); assertThatThrownBy(() -> underTest.callback(callbackContext)) .isInstanceOf(UnauthorizedException.class) .hasMessage("'octocat' must be a member of at least one organization: 'first_org', 'second_org'"); } @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/user/orgs 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() + "user/orgs?per_page=100'. 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 void mockInstallations() { when(githubAppClient.getWhitelistedGithubAppInstallations(any())).thenReturn(List.of( new GithubAppInstallation("1", "first_org", new GithubBinding.Permissions(), false), new GithubAppInstallation("2", "second_org", new GithubBinding.Permissions(), false))); } 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 HttpRequest getHttpRequest() { return new JakartaHttpRequest(request); } @Override public HttpResponse getHttpResponse() { 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 HttpRequest getHttpRequest() { return null; } @Override public HttpResponse getHttpResponse() { return null; } } }