Fixing Envoy Oauth2 redirect urls when installed in internal proxies


Envoy Proxy is a mature layer 7 proxy that has combined so many features over the past years. This maturity along with performance, solid documentation, and the wide set of features have made it a preferred option than other proxies like Nginx. And because of that, many cloud-native systems have been built on top of Envoy i.e. Envoy Gateway, and Istio. One of the features that fits very well with Envoy is authenticating ingress traffic with Oauth2 against an identity provider (IDP) i.e. Google. This means to offload Oauth from being an application logic into a layer that is hit first before your traffic reaches the application.

What if you have a multi-layered proxy architecture?

It is pretty common to have a gateway that directs public traffic to your internal services. And those internal services live behind an in-between proxy between the service and the gateway. The problem will pop up under two simultaneous conditions:

  1. You implement Oauth authentication at the internal proxy layer not the gateway
  2. Your gateway communicates with the internal proxy with internal hostnames, and terminates public hostnames at the gateway layer

oauth-diagram

Why is that an issue?

It happens because of how Oauth2 works. The main idea behind Oauth2 is to delegate user authentication to a trusted entity, hence redirection is required. You configure your Envoy to redirect unauthenticated traffic to the IDP. Fine! Now you have to tell the IDP how to communicate back to you once the user allows this authentication. But remember, the internal proxy that initiated the Oauth flow thinks that the client requested the service with an internal hostname.

That’s exactly what happened with Envoy. It depends on two variables to complete the Oauth flow. The two variable might seem to serve the same purpose when you first look at the implementation, but they don’t:

  1. redirect_uri this is what the IDP is told to call back once it finishes the user authentication.
  2. original_uri this is what Envoy redirects to after a successful Oauth callback from the IDP

Previously, the original_uri was fetched from the client request http host header, which is an internal hostname now since Envoy uses internal hostnames. That internal hostname was embedded into the Oauth call to the IDP, then fetched from the callback before redirecting the user to that url. This caused the following issues:

  • The user browser is redirected to an internal hostname its DNS resolvers doesn’t know about.
  • The user is unconditionally redirected to whatever uri fetched from the callback request under the assumption that Envoy is the one embedding this uri in the first call. This is true, but it raises some security concerns.

Solution

The first issue had two parts to the solution,

  1. Instead of directly fetching the host header and embedding it in the IDP call, allow it to be a configurable value.
  2. Many proxies, including envoy, implement X-Forwarded-Host header or similar where it saves the previous hop hostname. This is useful for tracing, especially if you do hostname rewrites. Now, if the public gateway implements this header, you can utilize it to construct the original_uri back to the public url to look like:
    "%REQ(x-forwarded-proto)%://%REQ(x-forwarded-host)%/callback
    Rendering the url this way makes much sense if your application is served with many public hostnames.

The second issue was resolved by implementing an allow list (exact or *. wildcard) of hostnames that Envoy can accept upon a callback. This prevents redirecting the users to an intended domain.

# Example Oauth configuration with the new changes
# The example uses Github as an IDP
  listeners:
  - name: listener_0
    address:
      socket_address:
        address: 0.0.0.0
        port_value: 10000
    filter_chains:
    - filters:
      - name: envoy.filters.network.http_connection_manager
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
          stat_prefix: ingress_http
          codec_type: AUTO
          route_config:
            name: local_route
            virtual_hosts:
            - name: local_service
              domains: ["*"]
              routes:
              - match: { prefix: "/" }
                # usually this shouldn't be here, 
                # and it should be an application that receives the traffic after oauth
                direct_response:
                  status: 200
                  body:
                    inline_string: "OAuth2 login succeeded. You are authenticated.\n"
          http_filters:
          - name: envoy.filters.http.oauth2
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.filters.http.oauth2.v3.OAuth2
              config:
                # IDP configuration
                token_endpoint:
                  cluster: github_oauth
                  uri: https://github.com/login/oauth/access_token
                  timeout: 5s
                authorization_endpoint: https://github.com/login/oauth/authorize
                # this can also be "%REQ(x-forwarded-proto)%://%REQ(x-forwarded-host)%/callback
                redirect_uri: "http://example-app.com:10000/callback"
                redirect_path_matcher:
                  path:
                    exact: /callback
                signout_path:
                  path:
                    exact: /signout
                credentials:
                  client_id: "<client-id>"
                  token_secret:
                    name: token
                  hmac_secret:
                    name: hmac
                forward_bearer_token: true
                # GitHub expects client_id/client_secret in the POST body.
                auth_type: URL_ENCODED_BODY
                # GitHub does not return `expires_in`; fall back to 1 hour.
                default_expires_in: 3600s
                auth_scopes:
                - read:user
                - user:email
                # New: restrict the host of the formatted `redirect_uri`, the
                # formatted `original_request_uri`, and the URL decoded from the
                # OAuth2 `state` parameter to this allow list. Anything outside
                # results in a 401.
                allowed_redirect_domains:
                - "example-app.com"
                - "*.example-app.com"
                # New: base URL (scheme + host) used to build the original
                # request URL that is encoded into the `state` parameter. Falls
                # back to `:scheme://:authority`
                # this can be rendered as "%REQ(x-forwarded-proto)%://%REQ(x-forwarded-host)%:8080"
                original_request_uri: "http://example-app.com:8080"
          - name: envoy.filters.http.router
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router

References: