Build OAuth redirect URL when X-Forwarded-Host is present

In the past, when a Gerrit multi-site topology was used featuring a
primary domain, i.e example.com (serving as a geo-location load
balancer), along with multiple Gerrit sites like review-1.example.com
and review-2.example.com, the initiation of the GitHub sign-in flow
within any of the Gerrit sites triggered a redirection to GitHub,
referred to as the user's GitHub identity request [1]. During this
process, the redirect_uri query parameter was constructed using
information from the gerrit.canonicalWebUrl property defined in the
etc/gerrit.config file [2]. It's important to note that this property
occasionally contained a URL with a host matching the primary domain,
example.com. Consequently, when users attempted to sign in via GitHub,
they were redirected to this main domain.

However, with this updated approach, the redirect_uri is now constructed
based on the X-Forwarded-Host header if it is present in the request
(otherwise from gerrit.canonicalWebUrl). This means that when a sign-in
flow is initiated, for instance, from review-1.example.com, the
Forwarded-Host header will accurately contain the value
review-1.example.com. As a result, the redirection URL will utilize this
value as its host.

References:
[1] https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#web-application-flow
[2] https://gerrit-review.googlesource.com/Documentation/config-gerrit.html

Bug: Issue 297231231
Change-Id: I233824a202041c7a7d1905ef784ae2b6f8d23160
diff --git a/README.md b/README.md
index 5a95019..812657c 100644
--- a/README.md
+++ b/README.md
@@ -156,6 +156,49 @@
   * Add the webhook secret as `webhookSecret` entry in `github` section of
     `etc/secure.config`.
 
+### Enhancing GitHub Sign-In Redirection in a Gerrit Multi-Site setup
+
+To ensure the success of the "Sign in flow," two critical aspects must be
+addressed:
+
+1. **GitHub OAuth Application Registration**:
+When registering Gerrit as a [GitHub OAuth application](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app),
+it is imperative that the property "Authorization callback URL" includes the
+`primary domain` as the host, i.e `https://my-domain.org/oauth`. This is
+essential to ensure that Gerrit sites like `review-1.my-domain.org` or
+`review-2.my-domain.org` are correctly redirected once authorization has been
+granted by GitHub. This requirement aligns with the documentation on
+[redirect_uri](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#redirect-urls):
+ ```
+ The redirect_uri parameter is optional. If omitted, GitHub will redirect
+ users to the callback URL configured in the OAuth app settings. If
+ provided, the redirect URL's host (excluding sub-domains) and port must
+ exactly match the callback URL. The redirect URL's path must reference
+ a subdirectory of the callback URL.
+```
+
+For a callback URL like `https://my-domain.org/oauth`:
+- Valid `redirect_uri` examples:
+```
+1. https://my-domain.org/oauth
+2. https://my-domain.org/oauth/subdir/other
+3. https://review-1.my-domain.org/oauth
+4. https://review-2.my-domain.org/oauth
+5. https://review-1.my-domain.org/oauth/subdir/other
+```
+- Invalid `redirect_uri` examples:
+```
+1. https://my-domain.org/bar
+2. https://my-domain.org/
+3. https://my-domain.org:8080/oauth
+4. https://review-1.my-domain.org:8080/oauth
+5. https://my-domain.com
+```
+
+2. **Propagation of the X-Forwarded-Host Header**:
+It is essential to ensure the propagation from the upstream proxy of the
+header [X-Forwarded-Host](https://www.rfc-editor.org/rfc/rfc7239.html).
+
 ### Contributing to the GitHub plugin
 
 The GitHub plugin uses the lombok library, which provides a set of
diff --git a/github-oauth/src/main/java/com/googlesource/gerrit/plugins/github/oauth/GitHubOAuthConfig.java b/github-oauth/src/main/java/com/googlesource/gerrit/plugins/github/oauth/GitHubOAuthConfig.java
index 7ef81d1..6c47ea7 100644
--- a/github-oauth/src/main/java/com/googlesource/gerrit/plugins/github/oauth/GitHubOAuthConfig.java
+++ b/github-oauth/src/main/java/com/googlesource/gerrit/plugins/github/oauth/GitHubOAuthConfig.java
@@ -19,6 +19,7 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Preconditions;
 import com.google.common.base.Strings;
+import com.google.common.net.HttpHeaders;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.httpd.CanonicalWebUrl;
 import com.google.gerrit.server.config.ConfigUtil;
@@ -28,15 +29,19 @@
 import com.googlesource.gerrit.plugins.github.oauth.OAuthProtocol.Scope;
 import java.io.FileInputStream;
 import java.io.IOException;
+import java.net.MalformedURLException;
+import java.net.URL;
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.util.ArrayList;
 import java.util.Comparator;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.concurrent.TimeUnit;
 import java.util.function.Function;
 import java.util.stream.Collectors;
+import java.util.stream.Stream;
 import javax.servlet.http.HttpServletRequest;
 import lombok.Getter;
 import org.eclipse.jgit.lib.Config;
@@ -149,9 +154,32 @@
   }
 
   public String getOAuthFinalRedirectUrl(HttpServletRequest req) {
-    return req == null
-        ? GERRIT_OAUTH_FINAL
-        : trimTrailingSlash(canonicalWebUrl.get(req)) + GERRIT_OAUTH_FINAL;
+    if (req == null) {
+      return GERRIT_OAUTH_FINAL;
+    }
+
+    String canonicalWebUrlAsString = canonicalWebUrl.get(req);
+    String forwardedHost = req.getHeader(HttpHeaders.X_FORWARDED_HOST);
+    Optional<String> clientHost = extractClientHost(forwardedHost);
+    try {
+      if (clientHost.isPresent()) {
+        URL canonicalWebUrlAsURL = new URL(canonicalWebUrlAsString);
+        return new URL(
+                canonicalWebUrlAsURL.getProtocol(),
+                clientHost.get(),
+                canonicalWebUrlAsURL.getPort(),
+                GERRIT_OAUTH_FINAL)
+            .toString();
+      }
+
+      return trimTrailingSlash(canonicalWebUrlAsString) + GERRIT_OAUTH_FINAL;
+    } catch (MalformedURLException ex) {
+      throw new IllegalStateException(
+          String.format(
+              "Error building the OAuth final redirect url from canonical url: %s and X-Forwarded-Host header: %s",
+              canonicalWebUrlAsString, forwardedHost),
+          ex);
+    }
   }
 
   public String getScopeSelectionUrl(HttpServletRequest req) {
@@ -304,4 +332,19 @@
 
     return passwordDevice;
   }
+
+  /**
+   * This method extracts the client host.
+   *
+   * @param hosts String that represents a list of hosts, i.e "test.example.io,
+   *     subdomain1.example.io" where the first host from the leftmost is the client host while the
+   *     rest are the private/internal hosts.
+   * @return return Optional with the value client host if exists otherwise Optional empty.
+   */
+  private Optional<String> extractClientHost(String hosts) {
+    if (Strings.isNullOrEmpty(hosts)) {
+      return Optional.empty();
+    }
+    return Stream.of(hosts.split(",")).map(String::trim).filter(h -> !h.isEmpty()).findFirst();
+  }
 }