Add support for Tuleap OAuth2/OIDC provider Tuleap instances can provide an OIDC server: https://docs.tuleap.org/user-guide/oauth2.html Change-Id: I1625b4cfa5b885257743ac2447fb795a478d86d0
diff --git a/README.md b/README.md index 3e70431..606b565 100644 --- a/README.md +++ b/README.md
@@ -19,6 +19,7 @@ * [LemonLDAP::NG](https://lemonldap-ng.org) * [Azure (previously named Office365)](https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-v2-protocols) * [Phabricator](https://secure.phabricator.com/book/phabcontrib/article/using_oauthserver/) +* [Tuleap](https://docs.tuleap.org/user-guide/oauth2.html) See the [Wiki](https://github.com/davido/gerrit-oauth-provider/wiki) what it can do for you.
diff --git a/src/main/java/com/googlesource/gerrit/plugins/oauth/HttpModule.java b/src/main/java/com/googlesource/gerrit/plugins/oauth/HttpModule.java index b9517b2..45dea40 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/oauth/HttpModule.java +++ b/src/main/java/com/googlesource/gerrit/plugins/oauth/HttpModule.java
@@ -137,5 +137,12 @@ .annotatedWith(Exports.named(PhabricatorOAuthService.CONFIG_SUFFIX)) .to(PhabricatorOAuthService.class); } + + cfg = cfgFactory.getFromGerritConfig(pluginName + TuleapOAuthService.CONFIG_SUFFIX); + if (cfg.getString(InitOAuth.CLIENT_ID) != null) { + bind(OAuthServiceProvider.class) + .annotatedWith(Exports.named(TuleapOAuthService.CONFIG_SUFFIX)) + .to(TuleapOAuthService.class); + } } }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/oauth/InitOAuth.java b/src/main/java/com/googlesource/gerrit/plugins/oauth/InitOAuth.java index c46d088..16e410f 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/oauth/InitOAuth.java +++ b/src/main/java/com/googlesource/gerrit/plugins/oauth/InitOAuth.java
@@ -54,6 +54,7 @@ private final Section azureActiveDirectoryAuthProviderSection; private final Section airVantageOAuthProviderSection; private final Section phabricatorOAuthProviderSection; + private final Section tuleapOAuthProviderSection; @Inject InitOAuth(ConsoleUI ui, Section.Factory sections, @PluginName String pluginName) { @@ -84,6 +85,8 @@ sections.get(PLUGIN_SECTION, pluginName + AirVantageOAuthService.CONFIG_SUFFIX); this.phabricatorOAuthProviderSection = sections.get(PLUGIN_SECTION, pluginName + PhabricatorOAuthService.CONFIG_SUFFIX); + this.tuleapOAuthProviderSection = + sections.get(PLUGIN_SECTION, pluginName + TuleapOAuthService.CONFIG_SUFFIX); } @Override @@ -203,6 +206,14 @@ if (configurePhabricatorOAuthProvider && configureOAuth(phabricatorOAuthProviderSection)) { checkRootUrl(phabricatorOAuthProviderSection.string("Phabricator Root URL", ROOT_URL, null)); } + + boolean configureTuleapOAuthProvider = + ui.yesno( + isConfigured(tuleapOAuthProviderSection), + "Use Tuleap OAuth provider for Gerrit login ?"); + if (configureTuleapOAuthProvider && configureOAuth(tuleapOAuthProviderSection)) { + checkRootUrl(tuleapOAuthProviderSection.string("Tuleap Root URL", ROOT_URL, null)); + } } /**
diff --git a/src/main/java/com/googlesource/gerrit/plugins/oauth/TuleapApi.java b/src/main/java/com/googlesource/gerrit/plugins/oauth/TuleapApi.java new file mode 100644 index 0000000..60f9e03 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/oauth/TuleapApi.java
@@ -0,0 +1,38 @@ +// Copyright (C) 2023 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.googlesource.gerrit.plugins.oauth; + +import com.github.scribejava.core.builder.api.DefaultApi20; + +public class TuleapApi extends DefaultApi20 { + private static final String AUTHORIZE_URL = "%s/oauth2/authorize"; + private static final String ACCESS_TOKEN_URL = "%s/oauth2/token"; + + private final String rootUrl; + + public TuleapApi(String rootUrl) { + this.rootUrl = rootUrl; + } + + @Override + public String getAccessTokenEndpoint() { + return String.format(ACCESS_TOKEN_URL, rootUrl); + } + + @Override + protected String getAuthorizationBaseUrl() { + return String.format(AUTHORIZE_URL, rootUrl); + } +}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/oauth/TuleapOAuthService.java b/src/main/java/com/googlesource/gerrit/plugins/oauth/TuleapOAuthService.java new file mode 100644 index 0000000..ef84957 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/oauth/TuleapOAuthService.java
@@ -0,0 +1,141 @@ +// Copyright (C) 2023 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.googlesource.gerrit.plugins.oauth; + +import static com.google.gerrit.json.OutputFormat.JSON; + +import com.github.scribejava.core.builder.ServiceBuilder; +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 com.google.common.base.CharMatcher; +import com.google.gerrit.extensions.annotations.PluginName; +import com.google.gerrit.extensions.auth.oauth.OAuthServiceProvider; +import com.google.gerrit.extensions.auth.oauth.OAuthToken; +import com.google.gerrit.extensions.auth.oauth.OAuthUserInfo; +import com.google.gerrit.extensions.auth.oauth.OAuthVerifier; +import com.google.gerrit.server.config.CanonicalWebUrl; +import com.google.gerrit.server.config.PluginConfig; +import com.google.gerrit.server.config.PluginConfigFactory; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.inject.Inject; +import com.google.inject.Provider; +import com.google.inject.ProvisionException; +import java.io.IOException; +import java.net.URI; +import java.util.concurrent.ExecutionException; +import javax.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class TuleapOAuthService implements OAuthServiceProvider { + private static final Logger log = LoggerFactory.getLogger(TuleapOAuthService.class); + private static final String TULEAP_PROVIDER_PREFIX = "tuleap-oauth:"; + static final String CONFIG_SUFFIX = "-tuleap-oauth"; + private static final String PROTECTED_RESOURCE_URL = "%s/oauth2/userinfo"; + private final OAuth20Service service; + private final String serviceName; + private final String rootUrl; + + @Inject + TuleapOAuthService( + PluginConfigFactory cfgFactory, + @PluginName String pluginName, + @CanonicalWebUrl Provider<String> urlProvider) { + PluginConfig cfg = cfgFactory.getFromGerritConfig(pluginName + CONFIG_SUFFIX); + String canonicalWebUrl = CharMatcher.is('/').trimTrailingFrom(urlProvider.get()) + "/"; + + rootUrl = cfg.getString(InitOAuth.ROOT_URL); + if (!URI.create(rootUrl).isAbsolute()) { + throw new ProvisionException("Root URL must be absolute URL"); + } + serviceName = cfg.getString(InitOAuth.SERVICE_NAME, "Tuleap"); + + service = + new ServiceBuilder(cfg.getString(InitOAuth.CLIENT_ID)) + .apiSecret(cfg.getString(InitOAuth.CLIENT_SECRET)) + .callback(canonicalWebUrl + "oauth") + .defaultScope("openid profile email") + .build(new TuleapApi(rootUrl)); + } + + @Override + public OAuthUserInfo getUserInfo(OAuthToken token) throws IOException { + OAuthRequest request = + new OAuthRequest(Verb.GET, String.format(PROTECTED_RESOURCE_URL, rootUrl)); + OAuth2AccessToken t = new OAuth2AccessToken(token.getToken(), token.getRaw()); + service.signRequest(t, request); + + try (Response response = service.execute(request)) { + if (response.getCode() != HttpServletResponse.SC_OK) { + throw new IOException( + String.format( + "Status %s (%s) for request %s", + response.getCode(), response.getBody(), request.getUrl())); + } + JsonElement userJson = JSON.newGson().fromJson(response.getBody(), JsonElement.class); + if (log.isDebugEnabled()) { + log.debug("User info response: {}", response.getBody()); + } + JsonObject jsonObject = userJson.getAsJsonObject(); + if (jsonObject == null || jsonObject.isJsonNull()) { + throw new IOException("Response doesn't contain 'user' field" + jsonObject); + } + JsonElement id = jsonObject.get("sub"); + JsonElement username = jsonObject.get("preferred_username"); + JsonElement email = jsonObject.get("email"); + JsonElement name = jsonObject.get("name"); + return new OAuthUserInfo( + TULEAP_PROVIDER_PREFIX + id.getAsString(), + username == null || username.isJsonNull() ? null : username.getAsString(), + email == null || email.isJsonNull() ? null : email.getAsString(), + name == null || name.isJsonNull() ? null : name.getAsString(), + id.getAsString()); + } catch (ExecutionException | InterruptedException e) { + throw new RuntimeException("Cannot retrieve user info resource", e); + } + } + + @Override + public OAuthToken getAccessToken(OAuthVerifier verifier) { + try { + OAuth2AccessToken accessToken = service.getAccessToken(verifier.getValue()); + return new OAuthToken( + accessToken.getAccessToken(), accessToken.getTokenType(), accessToken.getRawResponse()); + } catch (InterruptedException | ExecutionException | IOException e) { + String msg = "Cannot retrieve access token"; + log.error(msg, e); + throw new RuntimeException(msg, e); + } + } + + @Override + public String getAuthorizationUrl() { + return service.getAuthorizationUrl(); + } + + @Override + public String getVersion() { + return service.getVersion(); + } + + @Override + public String getName() { + return serviceName; + } +}
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md index 0ccb809..8283b1f 100644 --- a/src/main/resources/Documentation/config.md +++ b/src/main/resources/Documentation/config.md
@@ -69,6 +69,11 @@ client-secret = "<client-secret>" use-preferred-username = true # Optional, if false will not send preferred_username from Keycloak to leave username unset + [plugin "@PLUGIN@-tuleap-oauth"] + service-name = "<custom service name (optional)>" + root-url = "<root url>" # for example, https://tuleap.example.com + client-id = "<client-id>" + client-secret = "<client-secret>" ``` When one from the sections above is omitted, OAuth SSO is used. @@ -245,6 +250,12 @@ Client application. See [Using the Phabricator OAuth Server](https://secure.phabricator.com/book/phabcontrib/article/using_oauthserver/). +### Tuleap + +The client-id and client-secret for Tuleap can be obtained by registering a +Client application. +See [Registering a new application](https://docs.tuleap.org/user-guide/oauth2.html#client-registration). + ### Azure (previously named Office365) Were previously named Office365 but both `plugin.gerrit-oauth-provider-azure-oauth` and `plugin.gerrit-oauth-provider-office365-oauth` is supported by the Azure OAuth.
diff --git a/src/test/java/com/googlesource/gerrit/plugins/oauth/TuleapApiTest.java b/src/test/java/com/googlesource/gerrit/plugins/oauth/TuleapApiTest.java new file mode 100644 index 0000000..792e27d --- /dev/null +++ b/src/test/java/com/googlesource/gerrit/plugins/oauth/TuleapApiTest.java
@@ -0,0 +1,35 @@ +// Copyright (C) 2023 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.googlesource.gerrit.plugins.oauth; + +import static com.google.common.truth.Truth.assertThat; + +import com.github.scribejava.core.extractors.OAuth2AccessTokenJsonExtractor; +import org.junit.Before; +import org.junit.Test; + +public class TuleapApiTest { + private TuleapApi api; + + @Before + public void setUp() { + api = new TuleapApi("https://tuleap.example.com"); + } + + @Test + public void testAccessTokenExtractor() { + assertThat(api.getAccessTokenExtractor()).isInstanceOf(OAuth2AccessTokenJsonExtractor.class); + } +}