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(); + } }