Discovery OAuth: support generic OAuth via well-known discovery URL

This is ported from https://github.com/davido/gerrit-oauth-provider/pull/179,
with below changes:

  - Adapt to the latest master branch.
  - Formatted by GJF to follow the project style.
  - Add doc.
  - Translated Chinese code comments to English.

Credit goes to the origin author @Philogag.

Feature: https://github.com/davido/gerrit-oauth-provider/issues/134
Change-Id: I2145a365cc9ddc21972a7c66d87aabd4cb1c1eb8
Signed-off-by: Kai Liu <kraml.liu@gmail.com>
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 6ba00aa..9a60b59 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/oauth/HttpModule.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/oauth/HttpModule.java
@@ -24,6 +24,7 @@
 import com.googlesource.gerrit.plugins.oauth.cas.CasOAuthService;
 import com.googlesource.gerrit.plugins.oauth.cognito.CognitoOAuthService;
 import com.googlesource.gerrit.plugins.oauth.dex.DexOAuthService;
+import com.googlesource.gerrit.plugins.oauth.discovery.DiscoveryOAuthService;
 import com.googlesource.gerrit.plugins.oauth.facebook.FacebookOAuthService;
 import com.googlesource.gerrit.plugins.oauth.github.GitHubOAuthService;
 import com.googlesource.gerrit.plugins.oauth.gitlab.GitLabOAuthService;
@@ -52,6 +53,7 @@
     install(new OAuthServiceModule(cfgFactory, CasOAuthService.class));
     install(new OAuthServiceModule(cfgFactory, CognitoOAuthService.class));
     install(new OAuthServiceModule(cfgFactory, DexOAuthService.class));
+    install(new OAuthServiceModule(cfgFactory, DiscoveryOAuthService.class));
     install(new OAuthServiceModule(cfgFactory, FacebookOAuthService.class));
     install(new OAuthServiceModule(cfgFactory, GitHubOAuthService.class));
     install(new OAuthServiceModule(cfgFactory, GitLabOAuthService.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 dbd8cd5..b28b754 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/oauth/InitOAuth.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/oauth/InitOAuth.java
@@ -31,6 +31,7 @@
 import com.googlesource.gerrit.plugins.oauth.cas.CasOAuthService;
 import com.googlesource.gerrit.plugins.oauth.cognito.CognitoOAuthService;
 import com.googlesource.gerrit.plugins.oauth.dex.DexOAuthService;
+import com.googlesource.gerrit.plugins.oauth.discovery.DiscoveryOAuthService;
 import com.googlesource.gerrit.plugins.oauth.facebook.FacebookOAuthService;
 import com.googlesource.gerrit.plugins.oauth.github.GitHubOAuthService;
 import com.googlesource.gerrit.plugins.oauth.gitlab.GitLabOAuthService;
@@ -81,6 +82,7 @@
   private final Section auth0OAuthProviderSection;
   private final Section authentikOAuthProviderSection;
   private final Section cognitoOAuthProviderSection;
+  private final Section discoveryOAuthProviderSection;
 
   @Inject
   InitOAuth(ConsoleUI ui, Section.Factory sections, @PluginName String pluginName) {
@@ -105,6 +107,7 @@
     this.authentikOAuthProviderSection = getConfigSection(AuthentikOAuthService.class);
     this.cognitoOAuthProviderSection = getConfigSection(CognitoOAuthService.class);
     this.iasOAuthProviderSection = getConfigSection(SAPIasOAuthService.class);
+    this.discoveryOAuthProviderSection = getConfigSection(DiscoveryOAuthService.class);
   }
 
   @Override
@@ -252,6 +255,16 @@
       cognitoOAuthProviderSection.string(
           "Link to existing Gerrit LDAP accounts?", LINK_TO_EXISTING_GERRIT_ACCOUNT, "false");
     }
+
+    boolean configureDiscoveryOAuthProvider =
+        ui.yesno(
+            isConfigured(discoveryOAuthProviderSection),
+            "Use Well Known Discovery OAuth provider for Gerrit login?");
+    if (configureDiscoveryOAuthProvider && configureOAuth(discoveryOAuthProviderSection)) {
+      checkRootUrl(
+          discoveryOAuthProviderSection.string(
+              "Discovery Root URL(before `/.well-known')", ROOT_URL, null));
+    }
   }
 
   /**
diff --git a/src/main/java/com/googlesource/gerrit/plugins/oauth/discovery/DiscoveryApi.java b/src/main/java/com/googlesource/gerrit/plugins/oauth/discovery/DiscoveryApi.java
new file mode 100644
index 0000000..4583e89
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/oauth/discovery/DiscoveryApi.java
@@ -0,0 +1,44 @@
+// 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.discovery;
+
+import com.github.scribejava.core.builder.api.DefaultApi20;
+import com.github.scribejava.core.oauth2.clientauthentication.ClientAuthentication;
+import com.github.scribejava.core.oauth2.clientauthentication.HttpBasicAuthenticationScheme;
+
+public class DiscoveryApi extends DefaultApi20 {
+  private final String authorizationUrl;
+  private final String accessTokenEndpoint;
+
+  public DiscoveryApi(String authorizationUrl, String accessTokenEndpoint) {
+    this.authorizationUrl = authorizationUrl;
+    this.accessTokenEndpoint = accessTokenEndpoint;
+  }
+
+  @Override
+  public String getAuthorizationBaseUrl() {
+    return authorizationUrl;
+  }
+
+  @Override
+  public String getAccessTokenEndpoint() {
+    return accessTokenEndpoint;
+  }
+
+  @Override
+  public ClientAuthentication getClientAuthentication() {
+    return HttpBasicAuthenticationScheme.instance();
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/oauth/discovery/DiscoveryOAuthService.java b/src/main/java/com/googlesource/gerrit/plugins/oauth/discovery/DiscoveryOAuthService.java
new file mode 100644
index 0000000..24f732f
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/oauth/discovery/DiscoveryOAuthService.java
@@ -0,0 +1,202 @@
+// 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.discovery;
+
+import static com.google.gerrit.json.OutputFormat.JSON;
+import static javax.servlet.http.HttpServletResponse.SC_OK;
+import static org.slf4j.LoggerFactory.getLogger;
+
+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.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.PluginConfig;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.inject.Inject;
+import com.google.inject.ProvisionException;
+import com.google.inject.Singleton;
+import com.googlesource.gerrit.plugins.oauth.InitOAuth;
+import com.googlesource.gerrit.plugins.oauth.OAuth20ServiceFactory;
+import com.googlesource.gerrit.plugins.oauth.OAuthPluginConfigFactory;
+import com.googlesource.gerrit.plugins.oauth.OAuthServiceProviderConfig;
+import com.googlesource.gerrit.plugins.oauth.OAuthServiceProviderExternalIdScheme;
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.net.HttpURLConnection;
+import java.net.URI;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.ExecutionException;
+import org.slf4j.Logger;
+
+@Singleton
+@OAuthServiceProviderConfig(name = DiscoveryOAuthService.PROVIDER_NAME)
+public class DiscoveryOAuthService implements OAuthServiceProvider {
+  private static final Logger log = getLogger(DiscoveryOAuthService.class);
+  public static final String PROVIDER_NAME = "discovery";
+  private static final String WELL_KNOWN_PATH = "/.well-known/openid-configuration";
+  private final OAuth20Service service;
+  private final String extIdScheme;
+  private final String userinfoEndpoint;
+
+  @Inject
+  DiscoveryOAuthService(
+      OAuthPluginConfigFactory cfgFactory, OAuth20ServiceFactory oauth20ServiceFactory) {
+    PluginConfig cfg = cfgFactory.create(PROVIDER_NAME);
+
+    String rootUrl = cfg.getString(InitOAuth.ROOT_URL);
+    if (!URI.create(rootUrl).isAbsolute()) {
+      throw new ProvisionException("Root URL must be absolute URL");
+    }
+
+    // Fetch the entrypoint from discovery
+    DiscoveryOpenIdConnect discovery = fetchDiscoveryDocument(rootUrl + WELL_KNOWN_PATH);
+
+    // Log the discovery endpoints for debugging
+    log.info(
+        "OpenID Connect discovery:\n"
+            + "issuer: {}\n"
+            + "endpoint:\n"
+            + "\tauth: {}\n"
+            + "\ttoken: {}\n"
+            + "\tuser_info: {}",
+        discovery.getIssuer(),
+        discovery.getAuthorizationEndpoint(),
+        discovery.getTokenEndpoint(),
+        discovery.getUserinfoEndpoint());
+
+    this.userinfoEndpoint = discovery.getUserinfoEndpoint();
+
+    service =
+        oauth20ServiceFactory.create(
+            PROVIDER_NAME,
+            new DiscoveryApi(discovery.getAuthorizationEndpoint(), discovery.getTokenEndpoint()),
+            "openid profile email");
+    extIdScheme = OAuthServiceProviderExternalIdScheme.create(PROVIDER_NAME);
+  }
+
+  private DiscoveryOpenIdConnect fetchDiscoveryDocument(String discoveryUrl) {
+    try {
+      URL url = new URL(discoveryUrl);
+      HttpURLConnection connection = (HttpURLConnection) url.openConnection();
+      connection.setRequestMethod("GET");
+      connection.setConnectTimeout(5000);
+      connection.setReadTimeout(5000);
+
+      int responseCode = connection.getResponseCode();
+      if (responseCode != SC_OK) {
+        throw new IOException("Failed to fetch discovery document: " + responseCode);
+      }
+
+      StringBuilder response = new StringBuilder();
+      try (BufferedReader reader =
+          new BufferedReader(
+              new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) {
+        String line;
+        while ((line = reader.readLine()) != null) {
+          response.append(line);
+        }
+      }
+      return JSON.newGson().fromJson(response.toString(), DiscoveryOpenIdConnect.class);
+    } catch (IOException e) {
+      throw new ProvisionException("Cannot fetch OpenID Connect discovery document", e);
+    }
+  }
+
+  @Override
+  public OAuthUserInfo getUserInfo(OAuthToken token) throws IOException {
+    OAuthRequest request = new OAuthRequest(Verb.GET, userinfoEndpoint);
+    OAuth2AccessToken t = new OAuth2AccessToken(token.getToken(), token.getRaw());
+    service.signRequest(t, request);
+
+    try (Response response = service.execute(request)) {
+      if (response.getCode() != 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 valid user info: " + jsonObject);
+      }
+
+      // Try to get user info from standard fields
+      JsonElement sub = jsonObject.get("sub");
+      JsonElement username = getPreferredValue(jsonObject, "preferred_username", "username");
+      JsonElement email = jsonObject.get("email");
+      JsonElement name = getPreferredValue(jsonObject, "name", "display_name");
+
+      return new OAuthUserInfo(
+          extIdScheme + ":" + (sub != null ? sub.getAsString() : ""),
+          username != null && !username.isJsonNull() ? username.getAsString() : null,
+          email != null && !email.isJsonNull() ? email.getAsString() : null,
+          name != null && !name.isJsonNull() ? name.getAsString() : null,
+          null);
+    } catch (ExecutionException | InterruptedException e) {
+      throw new RuntimeException("Cannot retrieve user info resource", e);
+    }
+  }
+
+  // Get the prefered one from multiple possible vaules
+  private JsonElement getPreferredValue(JsonObject obj, String... keys) {
+    for (String key : keys) {
+      JsonElement value = obj.get(key);
+      if (value != null && !value.isJsonNull()) {
+        return value;
+      }
+    }
+    return null;
+  }
+
+  @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 "Discovery OAuth2";
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/oauth/discovery/DiscoveryOpenIdConnect.java b/src/main/java/com/googlesource/gerrit/plugins/oauth/discovery/DiscoveryOpenIdConnect.java
new file mode 100644
index 0000000..2301663
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/oauth/discovery/DiscoveryOpenIdConnect.java
@@ -0,0 +1,55 @@
+// 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.discovery;
+
+import com.google.gson.annotations.SerializedName;
+
+public class DiscoveryOpenIdConnect {
+  @SerializedName("issuer")
+  private String issuer;
+
+  @SerializedName("authorization_endpoint")
+  private String authorizationEndpoint;
+
+  @SerializedName("token_endpoint")
+  private String tokenEndpoint;
+
+  @SerializedName("userinfo_endpoint")
+  private String userinfoEndpoint;
+
+  @SerializedName("jwks_uri")
+  private String jwksUri;
+
+  // Getters
+  public String getIssuer() {
+    return issuer;
+  }
+
+  public String getAuthorizationEndpoint() {
+    return authorizationEndpoint;
+  }
+
+  public String getTokenEndpoint() {
+    return tokenEndpoint;
+  }
+
+  public String getUserinfoEndpoint() {
+    return userinfoEndpoint;
+  }
+
+  public String getJwksUri() {
+    return jwksUri;
+  }
+}
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index a1c214a..f13f111 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -94,6 +94,11 @@
     link-to-existing-gerrit-accounts = false
     enable-pkce = false
     enable-resource-owner-password-flow = false
+
+  [plugin "@PLUGIN@-discovery-oauth"]
+    root-url = "<root url>" # The part before /.well-known. for example, https://kanidm.example.com/oauth2/openid/gerrit
+    client-id = "<client-id>"
+    client-secret = "<client-secret>"
 ```
 
 When one from the sections above is omitted, OAuth SSO is used.
@@ -368,3 +373,17 @@
 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`.
+
+### OpenID Connect Discovery 1.0 URL
+
+This supports the [OpenID Connect Discovery 1.0 spec](https://openid.net/specs/openid-connect-discovery-1_0.html), which is supported by many providers.
+
+The discovery URL looks like below:
+
+  https://idm.example.com/oauth2/openid/:client_id:/.well-known/openid-configuration
+
+Set the `root-url` to the part before `/.well-known` of the discovery URL of your OAuth server, and client-id and client-secret in gerrit.config.
+
+Tested providers:
+- Authelia
+- Kanidm