[SAP IAS] Optionally allow the Resource Owner Password Flow

Change-Id: If8a57ac4d487fece946b76a49fddeba3d63bc192
diff --git a/src/main/java/com/googlesource/gerrit/plugins/oauth/sap/SAPIasOAuthLoginProvider.java b/src/main/java/com/googlesource/gerrit/plugins/oauth/sap/SAPIasOAuthLoginProvider.java
index 5d31e8c..a029cec 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/oauth/sap/SAPIasOAuthLoginProvider.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/oauth/sap/SAPIasOAuthLoginProvider.java
@@ -14,30 +14,87 @@
 
 package com.googlesource.gerrit.plugins.oauth.sap;
 
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
 import com.github.scribejava.core.model.OAuth2AccessToken;
+import com.google.common.base.Splitter;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.auth.oauth.OAuthLoginProvider;
 import com.google.gerrit.extensions.auth.oauth.OAuthUserInfo;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import com.google.gson.JsonPrimitive;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import com.googlesource.gerrit.plugins.oauth.OAuthPluginConfigFactory;
 import com.googlesource.gerrit.plugins.oauth.OAuthServiceProviderConfig;
+import com.googlesource.gerrit.plugins.oauth.OAuthServiceProviderExternalIdScheme;
 import java.io.IOException;
+import java.util.Base64;
+import java.util.List;
+import java.util.Optional;
 
 @Singleton
 @OAuthServiceProviderConfig(name = SAPIasOAuthService.PROVIDER_NAME)
 public class SAPIasOAuthLoginProvider implements OAuthLoginProvider {
+  private static final String USER_NAME_ATTRIBUTE = "sub";
+
   private final SAPIasOAuthService service;
+  private final boolean enableResourceOwnerPasswordFlow;
+  private final ExternalIds externalIds;
+  private final ExternalIdKeyFactory externalIdKeyFactory;
+  private final String extIdScheme;
 
   @Inject
-  SAPIasOAuthLoginProvider(SAPIasOAuthService service) {
+  SAPIasOAuthLoginProvider(
+      SAPIasOAuthService service,
+      OAuthPluginConfigFactory cfgFactory,
+      ExternalIds externalIds,
+      ExternalIdKeyFactory externalIdKeyFactory) {
     this.service = service;
+    this.enableResourceOwnerPasswordFlow =
+        cfgFactory
+            .create(SAPIasOAuthService.PROVIDER_NAME)
+            .getBoolean("enable-resource-owner-password-flow", false);
+    this.externalIds = externalIds;
+    this.externalIdKeyFactory = externalIdKeyFactory;
+    this.extIdScheme =
+        OAuthServiceProviderExternalIdScheme.create(SAPIasOAuthService.PROVIDER_NAME);
   }
 
   @Override
-  public OAuthUserInfo login(String username, String token) throws IOException {
-    if (token == null) {
+  public OAuthUserInfo login(String username, String secret) throws IOException {
+    if (secret == null) {
       throw new IOException("Authentication error");
     }
-    OAuth2AccessToken accessToken = new OAuth2AccessToken(token);
+    OAuth2AccessToken accessToken;
+    if (isAccessToken(secret)) {
+      accessToken = new OAuth2AccessToken(secret);
+    } else if (enableResourceOwnerPasswordFlow) {
+      if (username == null) {
+        throw new IOException("Authentication error");
+      }
+      Optional<Account.Id> accountId =
+          externalIds
+              .get(externalIdKeyFactory.create(SCHEME_USERNAME, username))
+              .map(ExternalId::accountId);
+      if (accountId.isEmpty()) {
+        throw new IOException("Authentication error");
+      }
+      ExternalId extId =
+          externalIds.byAccount(accountId.get()).stream()
+              .filter(e -> e.key().isScheme(this.extIdScheme))
+              .findAny()
+              .orElseThrow(() -> new IOException("Authentication error"));
+      accessToken = service.getAccessToken(extId.email(), secret);
+    } else {
+      throw new IOException("Authentication error");
+    }
     OAuthUserInfo userInfo = service.getUserInfo(accessToken);
     // A username does not have to be provided, but if it is, it should match
     // the username provided by the IDP to prevent confusion. The username is
@@ -48,4 +105,51 @@
     }
     return userInfo;
   }
+
+  private boolean isAccessToken(String accessToken) {
+    try {
+      JsonObject jsonWebToken = toJsonWebToken(accessToken);
+      return getAttribute(jsonWebToken, USER_NAME_ATTRIBUTE) != null;
+    } catch (IOException e) {
+      return false;
+    }
+  }
+
+  private static String getAttribute(JsonObject json, String name) {
+    JsonPrimitive prim = getAsJsonPrimitive(json, name);
+    return prim != null && prim.isString() ? prim.getAsString() : null;
+  }
+
+  private static JsonPrimitive getAsJsonPrimitive(JsonObject json, String name) {
+    JsonElement attr = json.get(name);
+    if (attr == null || !attr.isJsonPrimitive()) {
+      return null;
+    }
+    return attr.getAsJsonPrimitive();
+  }
+
+  public static JsonObject getAsJsonObject(String s) {
+    JsonElement json = JsonParser.parseString(s);
+    if (!json.isJsonObject()) {
+      return new JsonObject();
+    }
+    return json.getAsJsonObject();
+  }
+
+  private JsonObject toJsonWebToken(String accessToken) throws IOException {
+    List<String> segments = getSegments(accessToken);
+    return getAsJsonObject(decodeBase64(segments.get(1)));
+  }
+
+  private String decodeBase64(String s) {
+    return new String(Base64.getDecoder().decode(s), UTF_8);
+  }
+
+  private List<String> getSegments(String accessToken) throws IOException {
+    List<String> segments = Splitter.on('.').splitToList(accessToken);
+    if (segments.size() != 3) {
+      throw new IOException("Invalid token: must be of the form 'header.token.signature'");
+    }
+    return segments;
+  }
 }
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
index 78a3739..a41c500 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/oauth/sap/SAPIasOAuthService.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/oauth/sap/SAPIasOAuthService.java
@@ -127,6 +127,16 @@
     return authorizationUrlBuilder.build();
   }
 
+  public OAuth2AccessToken getAccessToken(String externalUsername, String password) {
+    try {
+      return service.getAccessTokenPasswordGrant(externalUsername, password);
+    } catch (IOException | InterruptedException | ExecutionException e) {
+      String msg = "Cannot retrieve access token";
+      log.error(msg, e);
+      throw new RuntimeException(msg, e);
+    }
+  }
+
   @Override
   public String getVersion() {
     return service.getVersion();
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index addd12e..3ce8f25 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -99,6 +99,7 @@
     client-secret = "<client-secret>"
     link-to-existing-gerrit-accounts = false
     enable-pkce = false
+    enable-resource-owner-password-flow = false
 ```
 
 When one from the sections above is omitted, OAuth SSO is used.
@@ -368,3 +369,8 @@
 
 You can optionally set `enable-pkce = true` if you want to use PKCE as part of the authorization workflow
 during login.
+
+If login via password for git over HTTp or REST API should still be possible, the Resource
+Owner Password Flow can optionally be enabled. This OAuth flow lets the user send the password
+and Gerrit will use that password to authenticate the user against the OAuth server. Note, that
+this flow is not considered to be secure. To enable this flow set `enable-resource-owner-password-flow = true`.