OAuth2-based authentication on Istio-powered Kubernetes clusters

You have just installed your first Kubernetes cluster and installed Istio there to get the full advantage of Service Mesh. Thanks to really awesome quickstarts, the process was way simpler than you’d expect. Next, you installed the first service, either Nginx web server or some echo server. After setting up Istio’s Gateway and VirtualService, it’s suddenly available in your web browser. But, the browser warns you about unencrypted connections. A few searches in Google and you have cert-manager installed with Let’s Encrypt issuer set. Great success! Your webpage is available on https://myservice.example.com, the browser tells the connection is encrypted and you can’t stop smiling, looking at how the service mesh routes the requests after hitting F5 again and again.

Suddenly, you realize you didn’t enter any password when accessing the service. Wait, is it a public endpoint? You send the link to your friends, they all can access the url. Your private small service mesh is actually a public one, and the more endpoints you create, the more public it will be. You would be surprised, knowing how many services are there, on the Internet, protected by nothing else than a DNS entry that others do not know. Your service mesh deserves better than that!

Kowalski? Options!

There are several ways to provide authentication to your services on a public cluster, but only a few methods will use the native Istio and Envoy functionalities:

  • WebAssembly Modules provide built-in filter implementing “Basic Auth”. If you provide username and password in the configuration, it will enforce using these when requesting the services installed on Istio. But, it makes your configuration un-versionable in git (you wouldn’t store passwords in the repo, would you?)
  • OpenID Connect implementation like Dex (used by Kubeflow) will redirect unauthorized users to a nice login form with multiple login options: username+password, LDAP, OAuth2, and more. But, it lacks the “Remember me” checkbox, so it’s not very user friendly. Kubeflow uses additional authentication service just to provide sessions. But, two separate services just to enable authentication? Too much hassle.
  • oauth2-proxy is a really good solution, especially with awesome quickstart by Luke Addison. Istio has a filter to delegate authentication-related subflow to the external service. In this case the service is oauth2-proxy that redirects unauthenticated clients to the OAuth2 upstream (like Google, Facebook or Github), you authenticate there (or not, if you did it before) and then the component exchanges code for your access token that not only proves you’re authenticated, but also provides some basic information, like name, email or photo url (Istio can pass these as headers to your service!). But, this method requires a separate component just to make one http call (for token). There must be an easier way!
  • It is. Starting with Envoy 1.16.0 (Istio >= 1.8) there is a new filter called OAuth2. It does a token request (exactly how oauth2-proxy does), but makes it internally (directly from the Envoy component), so no additional tooling is needed. This feature is a pretty fresh one and there are not many tutorials on how to adopt it on the Istio cluster. So, let’s get down to business!

How does the OAuth2 Envoy Filter work?

When you access service with OAuth2 filter for the first time, it redirects you to authorization_endpoint - that is url of the external service, in case of Google it’s the modal that you have probably seen many times already:

Airflow-GPu

You can spot an application name (ML Ops platform sandbox in my case) and list of attributes that will be included in the token: name, email and profile picture. Then you select an account and google redirects you to the redirect_uri (configured in the filter specification), adding there a secret, temporary authorization code. This request is intercepted by the filter and it makes a request to token_endpoint, exchanging the code for a JWT token. Finally, filter sets 3 cookies:

  • BearerToken - with a token value,
  • OauthExpires - a timestamp indicating expiry of the token,
  • OauthHMAC - a fingerprint of the above to avoid tampering with the cookies.

If everything succeeds, you’re redirected to the original url (the one you wanted to access before request was intercepted by a filter) and every consecutive request is just quickly validated (for the correctness of the cookies) and forwarded to the downstream service.

It is a good practice to install the filter on the very first layer for external connectivity to your mesh, that is Istio Ingressgateway. Sample setup looks like the following:

apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
  name: oauth2-ingress
  namespace: istio-system
spec:
  workloadSelector:
    labels:
      istio: ingressgateway
  configPatches:
  - applyTo: CLUSTER
    match:
      cluster:
        service: oauth
    patch:
      operation: ADD
      value:
        name: oauth
        dns_lookup_family: V4_ONLY
        type: LOGICAL_DNS
        connect_timeout: 10s
        lb_policy: ROUND_ROBIN
        transport_socket:
          name: envoy.transport_sockets.tls
          typed_config:
            "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext
            sni: oauth2.googleapis.com
        load_assignment:
          cluster_name: oauth
          endpoints:
          - lb_endpoints:
            - endpoint:
                address:
                  socket_address:
                    address: oauth2.googleapis.com
                    port_value: 443
  - applyTo: HTTP_FILTER
    match:
      context: GATEWAY
      listener:
        filterChain:
          filter:
            name: "envoy.http_connection_manager"
            subFilter:
              name: "envoy.filters.http.jwt_authn"
    patch:
      operation: INSERT_BEFORE
      value:
       name: envoy.filters.http.oauth2
       typed_config:
         "@type": type.googleapis.com/envoy.extensions.filters.http.oauth2.v3alpha.OAuth2
         config:
          token_endpoint:
            cluster: oauth
            uri: https://oauth2.googleapis.com/token
            timeout: 3s
          authorization_endpoint: https://accounts.google.com/o/oauth2/v2/auth
          redirect_uri: "https://%REQ(:authority)%/_oauth2_callback"
          redirect_path_matcher:
            path:
              exact: /_oauth2_callback
          signout_path:
            path:
              exact: /signout
          credentials:
            client_id: myclientid.apps.googleusercontent.com
            token_secret:
              name: token
              sds_config:
                path: "/etc/istio/config/token-secret.yaml"
            hmac_secret:
              name: hmac
              sds_config:
                path: "/etc/istio/config/hmac-secret.yaml"

The first part of the filter creates configuration of the oauth cluster, as the filter uses standard Envoy proxy functions to make HTTP requests. The second part adds a filter itself. You can notice a few configuration options mentioned above, plus 3 we didn’t describe yet:

  • client_id - this is public ID of your OAuth2 provider, used for authorization endpoint redirection and token exchange
  • token_secret - also known as “client_secret”, it’s a private part of OAuth2 setup that is required for token exchange. You shouldn’t store it in git, even the filter requires it to be available as a secure local file on istio ingressgateway pod
  • hmac_secret - value used for fingerprinting the token, stored securely in the same method as token_secret.

The sample file with secrets can be injected into ingressgateway pod using configmap:

apiVersion: v1
kind: ConfigMap
metadata:
  name: istio-oauth2
  namespace: istio-system
data:
  token-secret.yaml: |-
    resources:
      - "@type": "type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.Secret"
        name: token
        generic_secret:
          secret:
            inline_string: "..."
  hmac-secret.yaml: |-
    resources:
      - "@type": "type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.Secret"
        name: hmac
        generic_secret:
          secret:
            # generated using `head -c 32 /dev/urandom | base64`
            inline_bytes: XYJ7ibKwXwmRrO/yL/37ZV+T3Q/WB+xfhmVlio+wmc0=

Google OAuth2 case

If you plan to use Google-based authentication, there are two additional things to consider.

First issue is that v1.17 of Envoy uses static “user” scope when doing redirection to authorization endpoint. It’s not a valid OAuth2 scope for google, so the redirect fails with an error message from Google. If you use Envoy v1.18, it can be overridden using auth_scopes parameter, but if you’re still on 1.17, you can inject small Lua script that would modify the parameter:

  - applyTo: HTTP_FILTER
    match:
      context: GATEWAY
      listener:
        filterChain:
          filter:
            name: "envoy.http_connection_manager"
            subFilter:
              name: "envoy.router"
    patch:
      operation: INSERT_BEFORE
      value:
       name: envoy.filters.http.lua
       typed_config:
         "@type": type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua
         inline_code: |
            function envoy_on_response(response_handle)
               if (response_handle:headers():get("location") ~= nil and response_handle:headers():get("location"):sub(1,44) == "https://accounts.google.com/o/oauth2/v2/auth") then
                location = response_handle:headers():get("location")
                location = location:gsub("scope=user", "scope=profile openid email")
                response_handle:headers():replace("location", location)
              end
            end

Secondly, Google token exchange endpoint returns two token:

  • id_token - JWT token containing all the requested attributes of the user
  • access_token - starting with ya29, allowing access to google services (but not providing any user details without extra call)

Envoy OAuth2 filter copies access_token, so it can be used for authentication only, not for authorization of the specific user.

What about CI/CD?

With the current setup we secured access to the services installed on Istio while they are accessed from the web browser. However, we still do not have a nice way to access the APIs in browserless mode, for example to call them using curl or from CI/CD processes.

Thankfully, Istio supports authentication (and authorization!) using decoded values from JWT tokens. The only requirement is to generate the token and pass it as a HTTP header with key Authorization and value Bearer: token. Request like this one should skip the OAuth2 filter we just configured, it’s supported by pass_through_matcher parameter:

pass_through_matcher:
- name: authorization
  prefix_match: Bearer

Now, we need to validate the token. First, we need to make sure it’s properly signed. We generate tokens using gcloud auth print-identity-token command (with service account key injected), and these are issued for specific audience. The following setup validates if JWT token was issued using this command:

apiVersion: security.istio.io/v1beta1
kind: RequestAuthentication
metadata:
  name: jwt-authentication
  namespace: istio-system
spec:
  selector:
    matchLabels:
      app: istio-ingressgateway
  jwtRules:
  - issuer: https://accounts.google.com
    jwksUri: https://www.googleapis.com/oauth2/v3/certs
    forwardOriginalToken: true
    audiences:
    - 32555940559.apps.googleusercontent.com # google token generator

Now, we need to create authorization policy to allow only tokens generated for given service account (or lack of this token):

apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
  name: known-user
  namespace: istio-system
spec:
  selector:
    matchLabels:
      app: istio-ingressgateway
  rules:
  - when: # Lack of Authorization header will push user to oauth2 filter
    - key: request.headers[Authorization]
      notValues:
      - 'Bearer*'
  - when: # CI/CD
    - key: request.auth.audiences
      values: ['32555940559.apps.googleusercontent.com']
    - key: request.auth.presenter
      values:
      - ml-ops-ci@gid-ml-ops-sandbox.iam.gserviceaccount.com

With above setup we set explicitly that we allow 2 types of requests:

  • the ones without Authorization header (that were already validated by OAuth2 filter)
  • the ones with valid Authorization header, generated by gcloud command for given service account.

Summary

Starting with Envoy 1.17, authentication and authorization to Istio clusters doesn’t require setting up external services if you decide to use OAuth2. It’s a secure method, as you don’t have to store password hashes, maintain MFA and keep user data - you just need to trust your OAuth2 provider’s token that confirms the user was properly authenticated.

What is important to mention is that this method works on both public and private clusters - this method is often used to secure public clusters, but if you have internal Istio cluster you can authenticate users using already available identity providers like Active Directory (via ADFS) or LDAP (via Ory Hydra) without a need to look for specific Istio filters.

I must admit that the amount of YAMLs to put on the Kubernetes cluster is huge and the setup is very verbose - all the code listings in this blog post have over 150 lines of code! But once applied and tested, it doesn’t require any extra work while adding new services or endpoints, so you should never be worried again about authentication to your service mesh.

Written on August 11, 2021