[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.