Single page React app (with OAuth)

This sandbox provides an example of building and developing a single page app with Envoy.

The sandbox covers a number of Envoy’s features, including:

The app is built with React using Vite and demonstrates OAuth authentication using Envoy’s OAuth filter.

This covers a scenario where we want OAuth to both authenticate the user and provide credentials for further API interactions.

This is enabled by setting the OAuth configuration forward_bearer_token to true

36                authorization_endpoint: http://localhost:7000/authorize
37                redirect_uri: "%REQ(x-forwarded-proto)%://%REQ(:authority)%/authorize"
38                forward_bearer_token: true
39                pass_through_matcher:
40                  name: ":path"

Warning

Setting forward_bearer_token means the provided access token will be forwarded to any cluster/upstreams proxied by Envoy for this HTTP filter chain..

If untrusted upstreams are present, care will need to be taken to remove any sensitive cookies, such as BearerToken.

This can be achieved by setting request_headers_to_remove for the affected route.

A dummy “Myhub” backend is provided with a minimal OAuth provider and API for use in the example.

Setup is provided to build and update the app for production use, as well as a development environment with automatic code reloading.

The production and development environments are exposed on ports 10000 and 10001 respectively.

The Myhub backend can easily be replaced with Github or some other OAuth-based upstream service, and some guidance is provided on how to do this.

Step 1: Create a .local directory for sandbox customizations

Change to the examples/single-page-app directory, and create a directory to store sandbox customizations.

You can use .local which will be ignored by Git:

$ mkdir .local

Copy the ui/ directory to .local and set the UI_PATH. This will allow customizations without changing committed files.

$ cp -a ui .local
$ export UI_PATH=./.local/ui

Step 2: Generate an HMAC secret

Envoy’s OAuth filter requires an HMAC secret for encoding credentials.

Copy the default sandbox secrets to the customization directory, and create the required HMAC secret.

Replace MY_HMAC_SECRET_SEED with a phrase of your choosing:

$ cp -a secrets .local
$ HMAC_SECRET=$(echo "MY_HMAC_SECRET_SEED" | mkpasswd -s)
$ export HMAC_SECRET
$ envsubst < hmac-secret.tmpl.yml > .local/secrets/hmac-secret.yml

Export the path to the secrets folder for Docker:

$ export SECRETS_PATH=./.local/secrets

Step 3: Start the containers

First export UID to ensure files created by the containers are created with your user id.

Then bring up the Docker composition:

$ pwd
envoy/examples/single-page-app
$ export UID
$ docker compose pull
$ docker compose up --build -d
$ docker compose ps
NAME                          IMAGE                       COMMAND                                                    SERVICE   CREATED         STATUS         PORTS
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
single-page-app-envoy-1       single-page-app-envoy       "/docker-entrypoint.sh envoy -c /etc/envoy/envoy.yaml ..." envoy     2 minutes ago   Up 2 minutes           0.0.0.0:10000-10001->10000-10001/tcp, :::10000-10001->10000-10001/tcp
single-page-app-myhub-1       single-page-app-myhub       "/opt/myhub/app.py"                                        myhub     2 minutes ago   Up 2 minutes (healthy) 0.0.0.0:7000->7000/tcp, :::7000->7000/tcp
single-page-app-myhub-api-1   single-page-app-myhub-api   "/opt/myhub/app.py"                                        myhub-api 2 minutes ago   Up 2 minutes (healthy)
single-page-app-ui-1          single-page-app-ui          "/entrypoint.sh dev.sh"                                    ui        2 minutes ago   Up 2 minutes (healthy)

Step 4: Browse to the dev app and login

The development app should now be available at http://localhost:10001 and provide a login button:

../../_images/spa-login.png

Note

The dummy OAuth provider automatically trusts everyone as a hard-coded envoydemo user and redirects back to the app.

In a real world scenario the provider would authenticate and authorize the user before proceeding.

The sandbox is configured with an inverted match on pass_through_matcher.

This ignores all paths for OAuth other than:

  • /authorize.*

  • /hub.*

  • /login

  • /logout.

37                redirect_uri: "%REQ(x-forwarded-proto)%://%REQ(:authority)%/authorize"
38                forward_bearer_token: true
39                pass_through_matcher:
40                  name: ":path"
41                  string_match:
42                    safe_regex:
43                      regex: >-
44                        ^\/(authorize.*|login|logout)$
45                  invert_match: true
46                redirect_path_matcher:

When a user clicks login the app initiates the OAuth flow by calling the /login path in Envoy.

This redirects the user to the OAuth provider for authorization/authentication with a further redirect link:

34                  timeout: 3s
35                default_expires_in: 600s
36                authorization_endpoint: http://localhost:7000/authorize
37                redirect_uri: "%REQ(x-forwarded-proto)%://%REQ(:authority)%/authorize"
38                forward_bearer_token: true
39                pass_through_matcher:

On successful authorization/authentication the user is redirected back via this link to the app with the necessary OAuth authorization code to proceed:

44                        ^\/(authorize.*|login|logout)$
45                  invert_match: true
46                redirect_path_matcher:
47                  path:
48                    prefix: /authorize
49                signout_path:
50                  path:

Envoy then uses this authorization code with its client secret to confirm authorization and obtain an access token for the user:

50                  path:
51                    exact: /logout
52                credentials:
53                  client_id: "0123456789"
54                  token_secret:
55                    name: token
56                    sds_config:
57                      resource_api_version: V3
58                      path_config_source:
59                        path: /etc/envoy/secrets/myhub-token-secret.yml
60                  hmac_secret:
61                    name: hmac
1resources:
2- "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.Secret
3  name: token
4  generic_secret:
5    secret:
6      inline_string: VERY_SECRET_KEY

Once logged in, you should be able to make queries to the API using the OAuth credentials:

../../_images/spa-resources.png

Warning

Envoy’s OAuth implementation defaults to triggering the OAuth flow for all paths on the endpoint.

This can readily trigger an OAuth flood as assets are requested, and doom loops when the OAuth flows fail.

This can be avoided by restricting the paths that are used by the OAuth flow.

The sandbox example does this by inverting the pass_through_matcher to only match on the required OAuth paths.

Tip

The Myhub OAuth provider does not provide an expiry for issued credentials. Likewise Github may or may not depending on configuration. This is valid in terms of the OAuth2 specification.

If the authorization provider does not include an expiry, Envoy will, by default, fail the authentication.

This can be resolved by setting default_expires_in:

33                  uri: http://myhub:7000/authenticate
34                  timeout: 3s
35                default_expires_in: 600s
36                authorization_endpoint: http://localhost:7000/authorize
37                redirect_uri: "%REQ(x-forwarded-proto)%://%REQ(:authority)%/authorize"

Step 5: Make API queries

For the sandbox app, forward_bearer_token is set, and so Envoy also passes the acquired access token back to the user as a cookie:

../../_images/spa-cookies.png

This cookie is then passed through Envoy in any subsequent requests to the proxied Myhub API:

76              domains: ["*"]
77              routes:
78              - match:
79                  prefix: "/hub/"
80                route:
81                  host_rewrite_literal: api.myhub
82                  regex_rewrite:
83                    pattern:
84                      regex: '^/hub/(.*)'
85                    substitution: '/\1'
86                  cluster: hub-api
87              - match:
88                  prefix: "/"

Step 6: Live reload code changes

With your browser open on http://localhost:10001 make some change to the UI.

For example, you might change the page title:

$ sed -i s/Envoy\ single\ page\ app\ example/DEV\ APP/g .local/ui/index.html

The page should automatically refresh.

Likewise any changes to the Typescript app components in .local/ui/src/... should automatically reload in the browser.

This is enabled in Envoy by allowing the proxied connection to the Vite development backend to be “upgraded” to use Websockets:

22        typed_config:
23          "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
24          upgrade_configs:
25          - upgrade_type: websocket
26          http_filters:
27          - name: envoy.filters.http.oauth2

You can view the logs for the development server with:

$ docker compose logs ui
single-page-app-ui-1  | Starting (dev.sh) with user: 1000 worker /home/worker
single-page-app-ui-1  | yarn run v1.22.19
single-page-app-ui-1  | $ vite --host 0.0.0.0 --port 3000
single-page-app-ui-1  |
single-page-app-ui-1  |   VITE v5.0.10  ready in 119 ms
single-page-app-ui-1  |
single-page-app-ui-1  |   ➜  Local:   http://localhost:3000/
single-page-app-ui-1  |   ➜  Network: http://172.30.0.5:3000/

You can also use docker attach should you want to interact with the process.

Tip

You can manage the Typescript package using Yarn:

$ docker compose run --rm ui yarn

Step 7: Log out of the app

On signing out, the app makes a request to Envoy’s configured signout_path:

47                  path:
48                    prefix: /authorize
49                signout_path:
50                  path:
51                    exact: /logout
52                credentials:
53                  client_id: "0123456789"

This clears the cookies and the credentials stored by Envoy before returning the user to the app home page.

The app also clears any stored data associated with the user session:

Step 8: Build production assets

First, create and set a custom xds/ directory.

You will need to rebuild Envoy to ensure it sees the correct directory:

$ mkdir .local/production
$ cp -a xds .local/production/
$ export XDS_PATH=./.local/production/xds
$ docker compose up --build -d envoy

You can build the production assets for the app with the following:

$ docker compose run --rm ui build.sh

After building the React app, the sandbox script automatically updates Envoy’s configuration with the static routes required to serve the app.

You can view the generated routes:

$ jq '.resources[0].filter_chains[0].filters[0].typed_config.route_config.virtual_hosts[0].routes' < .local/production/xds/lds.yml
[
  {
    "match": {
      "path": "/assets/index-dKz4clFg.js"
    },
    "direct_response": {
      "status": 200,
      "body": {
        "filename": "/var/www/html/assets/index-dKz4clFg.js"
      }
    },
    "response_headers_to_add": [
      {
        "header": {
          "key": "Content-Type",
          "value": "text/javascript"
        }
      }
    ]
  },
  {
    "match": {
      "path": "/myhub.svg"
    },
    "direct_response": {
      "status": 200,
      "body": {
        "filename": "/var/www/html/myhub.svg"
      }
    },
    "response_headers_to_add": [
      {
        "header": {
          "key": "Content-Type",
          "value": "image/svg+xml"
        }
      }
    ]
  },
  {
    "match": {
      "prefix": "/"
    },
    "direct_response": {
      "status": 200,
      "body": {
        "filename": "/var/www/html/index.html"
      }
    },
    "response_headers_to_add": [
      {
        "header": {
          "key": "Content-Type",
          "value": "text/html"
        }
      }
    ]
  }
]

Note

This setup configures Envoy to store the necessary files in memory.

This may be a good fit for the single page app use case, but would not scale well for many or large files.

Tip

When you make changes to the javascript/typescript files rebuilding the app creates new routes to the compiled assets.

In this case Envoy will update via xDS and use the newly routed assets.

If you make changes only to assets that do not get a new route - e.g. index.html - you should both rebuild the app and restart Envoy after:

$ docker compose run --rm ui build.sh
$ docker compose restart envoy

Step 9: Browse to the production server

You can browse to this server on https://localhost:10000

Unlike the development endpoint the production endpoint is configured with:

  • TLS (self-signed)

  • Gzip compression

  • Statically served assets

Step 10: Setup Github OAuth/API access

Tip

Setup for Github is explained in this sandbox, but it should be easy to adapt these instructions for other providers.

You will need to set up either a Github OAuth or full app. The latter provides more control and is generally preferable.

This can be done either at the user or organization levels:

../../_images/spa-github-oauth.png

Note

When setting up Github OAuth you will need to provide the redirect URI

This must match the configured URI in Envoy

For the purposes of this example set it to https://localhost:10000.

You will need a separate OAuth app for development.

Depending on your use case, you may also want to set up any permissions required for your app.

Once you have this set up, you will need the provided client id and secret.

Step 11: Update Envoy’s configuration to use Github

Add the Github provided client secret:

$ TOKEN_SECRET="GITHUB PROVIDED CLIENT SECRET"
$ export TOKEN_SECRET
$ envsubst < secrets/token-secret.tmpl.yml > .local/secrets/github-token-secret.yml

The file created will be available in the container under /etc/envoy/secrets

Tip

The following instructions use sed, but you may wish to make the necessary replacements using your editor.

For each configuration there are 2 places to update, one for the development listener and the other for production.

Create a copy of the Envoy config and tell Docker to use it:

$ cp -a envoy.yml .local/envoy.yml
$ export ENVOY_CONFIG=.local/envoy.yml

For the OAuth configuration in .local/envoy.yml set the Github provided client secret:

$ sed -i s@client_id:\ \"0123456789\"@client_id:\ \"$GITHUB_PROVIDED_CLIENT_ID\"@g .local/envoy.yml

Replace the authorization_endpoint with https://github.com/login/oauth/authorize:

$ sed -i s@authorization_endpoint:\ http://localhost:7000/authorize@authorization_endpoint:\ https://github.com/login/oauth/authorize@g .local/envoy.yml

Replace the token_endpoint > uri with https://github.com/login/oauth/access_token:

$ sed -i s@uri:\ http://myhub:7000/authenticate@uri:\ https://github.com/login/oauth/access_token@g .local/envoy.yml

Point the token_secret > path to the github-token-secret.yml created above:

$ sed -i s@path:\ /etc/envoy/secrets/myhub-token-secret.yml@path:\ /etc/envoy/secrets/github-token-secret.yml@g .local/envoy.yml

Replace the host rewrites:

$ sed -i s@host_rewrite_literal:\ api.myhub@host_rewrite_literal:\ api.github.com@g .local/envoy.yml

Finally add (or replace the myhub* clusters with) the github and github-api clusters Github configured clusters:

$ cat _github-clusters.yml >> .local/envoy.yml

Step 12: Update the app configuration to use Github

We need to tell the app the name of the provider.

Currently providers for Myhub and Github are implemented:

 7export const AuthProviders: IAuthProviders = {
 8  "myhub": {
 9    "name": "Myhub",
10    "icon": MyhubIcon},
11  "github": {
12    "name": "Github",
13    "icon": GithubIcon}}

If you followed the above steps, the Vite app environment settings are read from .local/ui/.env*:

$ echo "VITE_APP_AUTH_PROVIDER=github" > .local/ui/.env.local

Step 13: Rebuild the app and restart Envoy

$ docker compose run --rm ui build.sh
$ docker compose up --build -d envoy

Tip

Note the use of up --build -d rather than restart.

This is necessary as we have changed envoy.yml which is loaded into the container at build time.

Browse to the production server https://localhost:10000

You can now log in and use the Github APIs.:

../../_images/spa-login-github.png

See also

Envoy OAuth filter

Configuration reference for Envoy’s OAuth filter.

Envoy OAuth filter API

API reference for Envoy’s OAuth filter.

OAuth2 specification

OAuth 2.0 is the industry-standard protocol for authorization.

React

The library for web and native user interfaces.

Vite

Next Generation Frontend Tooling.

Envoy Gzip Compression API

API and configuration reference for Envoy’s gzip compression.

Securing Envoy quick start guide

Outline of key concepts for securing Envoy.

Github OAuth apps

Information about setting up Github OAuth apps.

Github API

References for Github’s APIs.