[SAP IAS] Add SAP Cloud Identity Services (IAS) oauth provider See https://help.sap.com/docs/cloud-identity-services/cloud-identity-services/openid-connect Change-Id: Ibb637f51148ef20f2d79b8c88519be38ebc1da11
diff --git a/BUILD b/BUILD index 81871ad..089ec90 100644 --- a/BUILD +++ b/BUILD
@@ -23,6 +23,11 @@ "@commons-codec//jar:neverlink", "@jackson-core//jar", "@jackson-databind//jar", + "@json//jar", + "@sap-env//jar", + "@sap-java-api//jar", + "@sap-java-security//jar", + "@sap-xsuaa-token-client//jar", "@scribejava-apis//jar", "@scribejava-core//jar", ],
diff --git a/external_plugin_deps.bzl b/external_plugin_deps.bzl index aaab198..0025bad 100644 --- a/external_plugin_deps.bzl +++ b/external_plugin_deps.bzl
@@ -3,6 +3,7 @@ def external_plugin_deps(omit_commons_codec = True): JACKSON_VERS = "2.10.2" SCRIBEJAVA_VERS = "6.9.0" + SAP_SECURITY_VERS = "3.6.0" maven_jar( name = "scribejava-core", artifact = "com.github.scribejava:scribejava-core:" + SCRIBEJAVA_VERS, @@ -31,6 +32,31 @@ artifact = "com.fasterxml.jackson.core:jackson-core:" + JACKSON_VERS, sha1 = "73d4322a6bda684f676a2b5fe918361c4e5c7cca", ) + maven_jar( + name = "sap-java-security", + artifact = "com.sap.cloud.security:java-security:" + SAP_SECURITY_VERS, + sha1 = "6e6bef72b84110538b1d72d93c6e4b94988e7113", + ) + maven_jar( + name = "sap-java-api", + artifact = "com.sap.cloud.security:java-api:" + SAP_SECURITY_VERS, + sha1 = "a954bcf647f3e8ed7fb980bc55f08788f9b984e9", + ) + maven_jar( + name = "sap-env", + artifact = "com.sap.cloud.security:env:" + SAP_SECURITY_VERS, + sha1 = "b0d84d8a7d0a73fef9329941226e725d0de00322", + ) + maven_jar( + name = "sap-xsuaa-token-client", + artifact = "com.sap.cloud.security.xsuaa:token-client:" + SAP_SECURITY_VERS, + sha1 = "dea8c6f5cb7d02c014dd19006a249df376bcbf38", + ) + maven_jar( + name = "json", + artifact = "org.json:json:20250517", + sha1 = "d67181bbd819ccceb929b580a4e2fcb0c8b17cd8", + ) if not omit_commons_codec: maven_jar( name = "commons-codec",
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 9b826ed..a3494c2 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/oauth/HttpModule.java +++ b/src/main/java/com/googlesource/gerrit/plugins/oauth/HttpModule.java
@@ -30,6 +30,7 @@ import com.googlesource.gerrit.plugins.oauth.keycloak.KeycloakOAuthService; import com.googlesource.gerrit.plugins.oauth.lemon.LemonLDAPOAuthService; import com.googlesource.gerrit.plugins.oauth.phabricator.PhabricatorOAuthService; +import com.googlesource.gerrit.plugins.oauth.sap.SAPIasOAuthService; import com.googlesource.gerrit.plugins.oauth.tuleap.TuleapOAuthService; class HttpModule extends AbstractModule { @@ -58,5 +59,6 @@ install(new OAuthServiceModule(cfgFactory, LemonLDAPOAuthService.class)); install(new OAuthServiceModule(cfgFactory, PhabricatorOAuthService.class)); install(new OAuthServiceModule(cfgFactory, TuleapOAuthService.class)); + install(new OAuthServiceModule(cfgFactory, SAPIasOAuthService.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 b93fce5..442a52a 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/oauth/InitOAuth.java +++ b/src/main/java/com/googlesource/gerrit/plugins/oauth/InitOAuth.java
@@ -38,6 +38,7 @@ import com.googlesource.gerrit.plugins.oauth.keycloak.KeycloakOAuthService; import com.googlesource.gerrit.plugins.oauth.lemon.LemonLDAPOAuthService; import com.googlesource.gerrit.plugins.oauth.phabricator.PhabricatorOAuthService; +import com.googlesource.gerrit.plugins.oauth.sap.SAPIasOAuthService; import com.googlesource.gerrit.plugins.oauth.tuleap.TuleapOAuthService; import java.net.URI; @@ -62,6 +63,7 @@ private final ConsoleUI ui; private final Section.Factory sections; private final String pluginName; + private final Section iasOAuthProviderSection; private final Section googleOAuthProviderSection; private final Section githubOAuthProviderSection; private final Section bitbucketOAuthProviderSection; @@ -104,6 +106,7 @@ this.auth0OAuthProviderSection = getConfigSection(Auth0OAuthService.class); this.authentikOAuthProviderSection = getConfigSection(AuthentikOAuthService.class); this.cognitoOAuthProviderSection = getConfigSection(CognitoOAuthService.class); + this.iasOAuthProviderSection = getConfigSection(SAPIasOAuthService.class); } @Override @@ -159,6 +162,13 @@ checkRootUrl(gitlabOAuthProviderSection.string("GitLab Root URL", ROOT_URL, null)); } + boolean configureIASOAuthProvider = + ui.yesno( + isConfigured(iasOAuthProviderSection), "Use SAP IAS OAuth provider for Gerrit login ?"); + if (configureIASOAuthProvider && configureOAuth(iasOAuthProviderSection)) { + checkRootUrl(iasOAuthProviderSection.string("SAP IAS Root URL", ROOT_URL, null)); + } + boolean configureLemonLDAPOAuthProvider = ui.yesno( isConfigured(lemonldapOAuthProviderSection),
diff --git a/src/main/java/com/googlesource/gerrit/plugins/oauth/sap/SAPIasApi.java b/src/main/java/com/googlesource/gerrit/plugins/oauth/sap/SAPIasApi.java new file mode 100644 index 0000000..023332b --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/oauth/sap/SAPIasApi.java
@@ -0,0 +1,38 @@ +// Copyright (C) 2025 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.sap; + +import com.github.scribejava.core.builder.api.DefaultApi20; + +public class SAPIasApi 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; + + SAPIasApi(String rootUrl) { + this.rootUrl = rootUrl; + } + + @Override + public String getAuthorizationBaseUrl() { + return String.format(AUTHORIZE_URL, rootUrl); + } + + @Override + public String getAccessTokenEndpoint() { + return String.format(ACCESS_TOKEN_URL, rootUrl); + } +}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/oauth/sap/SAPIasOAuthService.java b/src/main/java/com/googlesource/gerrit/plugins/oauth/sap/SAPIasOAuthService.java new file mode 100644 index 0000000..adac216 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/oauth/sap/SAPIasOAuthService.java
@@ -0,0 +1,135 @@ +// Copyright (C) 2025 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.sap; + +import static org.slf4j.LoggerFactory.getLogger; + +import com.github.scribejava.core.builder.ServiceBuilder; +import com.github.scribejava.core.model.OAuth2AccessToken; +import com.github.scribejava.core.oauth.OAuth20Service; +import com.google.common.base.Strings; +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.inject.Inject; +import com.google.inject.Provider; +import com.google.inject.ProvisionException; +import com.google.inject.Singleton; +import com.googlesource.gerrit.plugins.oauth.InitOAuth; +import com.googlesource.gerrit.plugins.oauth.OAuthPluginConfigFactory; +import com.googlesource.gerrit.plugins.oauth.OAuthServiceProviderConfig; +import com.googlesource.gerrit.plugins.oauth.OAuthServiceProviderExternalIdScheme; +import com.sap.cloud.security.json.DefaultJsonObject; +import com.sap.cloud.security.token.SapIdToken; +import java.io.IOException; +import java.net.URI; +import java.util.concurrent.ExecutionException; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.slf4j.Logger; + +@Singleton +@OAuthServiceProviderConfig(name = SAPIasOAuthService.PROVIDER_NAME) +public class SAPIasOAuthService implements OAuthServiceProvider { + private static final Logger log = getLogger(SAPIasOAuthService.class); + static final String PROVIDER_NAME = "sapias"; + private final OAuth20Service service; + private final String serviceName; + private final String rootUrl; + private final boolean linkExistingGerrit; + private final String extIdScheme; + + @Inject + SAPIasOAuthService( + OAuthPluginConfigFactory cfgFactory, @CanonicalWebUrl Provider<String> urlProvider) { + PluginConfig cfg = cfgFactory.create(PROVIDER_NAME); + String canonicalWebUrl = 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, "SAP IAS"); + linkExistingGerrit = cfg.getBoolean(InitOAuth.LINK_TO_EXISTING_GERRIT_ACCOUNT, false); + service = + new ServiceBuilder(cfg.getString(InitOAuth.CLIENT_ID)) + .apiSecret(cfg.getString(InitOAuth.CLIENT_SECRET)) + .callback(canonicalWebUrl + "oauth") + .defaultScope("openid profile email") + .build(new SAPIasApi(rootUrl)); + extIdScheme = OAuthServiceProviderExternalIdScheme.create(PROVIDER_NAME); + } + + @Override + public OAuthUserInfo getUserInfo(OAuthToken token) throws IOException { + OAuth2AccessToken t = new OAuth2AccessToken(token.getToken(), token.getRaw()); + return getUserInfo(t); + } + + public OAuthUserInfo getUserInfo(OAuth2AccessToken token) { + SapIdToken sapToken = new SapIdToken(getIdToken(token)); + String username = sapToken.getClaimAsString("sub"); + String externalId = this.extIdScheme + ":" + username; + String email = sapToken.getClaimAsString("email"); + String firstName = sapToken.getClaimAsString("first_name"); + String lastName = sapToken.getClaimAsString("last_name"); + String displayName = + Strings.emptyToNull( + Stream.of(firstName, lastName) + .filter(s -> !Strings.isNullOrEmpty(s)) + .collect(Collectors.joining(" "))); + String claimedIdentity = linkExistingGerrit ? "gerrit:" + username : null; + return new OAuthUserInfo(externalId, username, email, displayName, claimedIdentity); + } + + @Override + public OAuthToken getAccessToken(OAuthVerifier rv) { + try { + OAuth2AccessToken accessToken = service.getAccessToken(rv.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; + } + + private String getIdToken(OAuth2AccessToken token) { + try { + String raw = token.getRawResponse(); + return new DefaultJsonObject(raw).getAsString("id_token"); + } catch (IllegalStateException e) { + return token.getAccessToken(); + } + } +}
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md index 2373dec..1772eeb 100644 --- a/src/main/resources/Documentation/config.md +++ b/src/main/resources/Documentation/config.md
@@ -92,6 +92,12 @@ root-url = "<root url>" # for example, https://cognito.example.com client-id = "<client-id>" client-secret = "<client-secret>" + + [plugin "@PLUGIN@-sapias-oauth"] + root-url = "<root url>" # for example, https://sapias.example.com + client-id = "<client-id>" + client-secret = "<client-secret>" + link-to-existing-gerrit-accounts = false ``` When one from the sections above is omitted, OAuth SSO is used. @@ -346,4 +352,15 @@ If you have used LDAP before and have accounts with an externalIDs like `gerrit:firstname.lastname` and a user in Authentik/Cognito with username `firstname.lastname` logs in it will link the Authentik/Cognito account to that Gerrit account. -When all users has logged in once in Gerrit with their Authentik/Cognito account it's recommended that the configuration option is removed. \ No newline at end of file +When all users has logged in once in Gerrit with their Authentik/Cognito account it's recommended that the configuration option is removed. + +### SAP IAS + +When setting up an Application for Gerrit in SAP Cloud Identity Service follow +["Configuring OpenID Connect"](https://help.sap.com/docs/cloud-identity-services/cloud-identity-services/openid-connect). +For end user authentication follow +["Using Authorization Code Flow"](https://help.sap.com/docs/cloud-identity-services/cloud-identity-services/using-authorization-code-flow). +Then configure the URL of your IAS tenant and client-id and client-secret in gerrit.config. + +You can optionally set `link-to-existing-gerrit-accounts = true` if you want the provider to link a account based +on the username instead of trying to create a new account, see migration from LDAP.