design/jwt-verification-design.md
This document describes a design for performing JSON Web Token (JWT) verification for requests to virtual hosts hosted by Contour.
JSON Web Token (JWT) is an open standard (RFC 7519) that defines a compact and self-contained way for securely transmitting information between parties as a JSON object. This information can be verified and trusted because it is digitally signed. (ref. https://jwt.io/introduction)
Envoy Proxy has built-in support for verifying JWTs that are attached to incoming requests, via the JWT Authentication HTTP filter. (Note, this document will use the term JWT verification to describe this process). Specifically, Envoy can verify the signature, audience, issuer and time restrictions of a JWT. If verification fails, the request will be rejected. It's important to note that this filter does not itself obtain a JWT for an incoming request; the request must already have one attached.
Contour does not currently have support for configuring the JWT authentication filter.
This document proposes a design for adding that support to Contour's custom resource, HTTPProxy.
Contour's HTTPProxy resource will get a new optional field, spec.virtualhost.jwtProviders, to define the details of how to verify JWTs for requests to a given virtual host.
This field will only be supported for virtual hosts for which Envoy is terminating TLS.
The structure of this field will be similar to the Envoy filter's providers field, with some simplifications.
Specifically, a provider will define an issuer, 0+ audiences, and a JSON Web Key Set (JWKS) that can be used to verify a JWT (see the Envoy documentation for more information).
Any number of providers can be defined for a virtual host, to allow different routes to be verified differently.
At most one provider can be marked as the "default", meaning it will automatically be applied as a requirement to all routes unless they explicitly opt out (more below).
HTTPProxy routes will also get a new optional field, spec.routes.jwtVerificationPolicy, to provide details on how to apply JWT verification.
The JWT verification policy will allow a specific named provider to be required for the route if there is no default or if a provider other than the default should apply for the route.
It will also allow explicitly opting out of using the proxy's default provider.
Contour will validate the contents of spec.virtualhost.jwtProviders and spec.routes.jwtVerificationPolicy if present, and will configure the JWT authentication filter on the HTTP Connection Manager for the relevant virtual host.
Contour will also add a CDS cluster for the remote JWKS, as required by the Envoy configuration.
JWT verification will only be supported for TLS-enabled virtual hosts because (a) JWTs generally should not be transmitted in cleartext; and (b) all plain HTTP virtual hosts share a single HTTP Connection Manager & associated filter config, which creates challenges when configuring different JWT verification providers and rules for different virtual hosts. This constraint may be revisited at a later date if a compelling use case is identified.
The detailed structure of the new fields is shown via YAML below:
apiVersion: projectcontour.io/v1
kind: HTTPProxy
metadata:
name: jwt-verification
spec:
virtualhost:
fqdn: example.com
tls:
secretName: tls-cert
jwtProviders:
-
# name is a unique name for the provider.
name: provider-1
# default is whether or not this provider should be applied
# to routes by default. At most one provider can have this
# flag set to true.
default: true
# issuer (optional) must match the "iss" field in the JWT.
# If not specified, the "iss" field is not checked.
issuer: foo.com
# audiences (optional) allowlist for the "aud" field in the JWT.
# If not specified, the "aud" field is not checked.
audiences:
- audience-1
- audience-2
# remoteJWKS is an HTTP endpoint that returns the JWKS
# to use to verify the JWT signature. Exactly one of
# remoteJWKS or localJWKS must be set.
remoteJWKS:
httpURI:
uri: https://example.com/jwks.json
timeout: 1s
# Upstream TLS validation options. If not provided,
# the TLS server cert will not be verified.
validation:
caSecret: ca-crt
subjectName: example.com
# cacheDuration is how long to cache the fetched JWKS
# locally.
cacheDuration: 5m
# localJWKS can be used instead of remoteJWKS and defines
# an in-cluster secret containing the JWKS for this provider.
# Exactly one of localJWKS or remoteJWKS must be set.
localJWKS:
secretName: my-jwks
key: jwks.json
routes:
# This route specifies jwtProvider: provider-1, which requires requests
# to have a JWT has issuer=foo.com, audience of either "audience-1" or
# "audience-2", and signature must be able to be verified using the JWKS
# at https://example.com/jwks.json).
- conditions:
- prefix: /
jwtVerificationPolicy:
# requires opts into requiring a particular provider if it
# is not the default. In this case, it can be omitted since
# provider-1 is the default; it's shown only for explanation.
requires: provider-1
# disabled allows disabling JWT verification for specific
# routes. In this case, it can be omitted because it's false,
# but it's shown for explanation.
disabled: false
services:
- name: s1
port: 80
# This route disables JWT verification (it would otherwise be applied
# by default, requiring the default provider-1), so requests
# with paths starting with /js are excluded from JWT verification.
# Note that Contour orders routes such that longer prefixes take
# priority over shorter prefixes, so requests starting with /js will
# be handled by this route rather than the previous catch-all route.
- conditions:
- prefix: /js
jwtVerificationPolicy:
disabled: true
services:
- name: s1
port: 80
It is worth highlighting some of the configuration options that Envoy's filter has, that Contour will not expose, at least not initially:
Authorization header using the Bearer schema; and (2) the access_token query parameter, in that order).requires_any, requires_all)Contour's API is structured such that these and other more complex/less common options may be added at a later date, if there is user demand for them.
A complete/valid HTTPProxy using JWT verification is shown below:
apiVersion: projectcontour.io/v1
kind: HTTPProxy
metadata:
name: jwt-verification-proxy
spec:
virtualhost:
fqdn: example.com
tls:
secretName: tls-cert
jwtProviders:
- name: provider-1
default: true
issuer: example.com
audiences:
- audience-1
- audience-2
remoteJWKS:
httpURI:
uri: https://example.com/jwks.json
timeout: 1s
cacheDuration: 5m
routes:
- conditions:
- prefix: /
services:
- name: s1
port: 80
- conditions:
- prefix: /css
jwtVerificationPolicy:
disabled: true
services:
- name: s1
port: 80
Envoy also has an OAuth2 HTTP filter, which supports end-to-end OAuth2/OIDC flows. This provides related but separate functionality to the JWT authentication filter. This excellent blog post shows an example of how to use the OAuth2 and JWT filters together in Envoy. Contour may pursue adding support for the OAuth2 filter, but it will be designed and implemented separately.
An alternate API design is to define JWT verification rules separately from routing rules, as shown in the below YAML:
apiVersion: projectcontour.io/v1
kind: HTTPProxy
metadata:
name: jwt-verification-proxy
spec:
virtualhost:
fqdn: example.com
tls:
secretName: tls-cert
jwtVerificationPolicy:
providers:
- name: provider-1
issuer: example.com
audiences:
- audience-1
- audience-2
remoteJWKS:
httpURI:
uri: https://example.com/jwks.json
timeout: 1s
cacheDuration: 5m
rules:
- match:
prefix: /js
- match:
prefix: /
providerName: provider-1
routes:
- conditions:
- prefix: /
services:
- name: s1
port: 80
This design has the potential benefit of reducing duplication in the routing rules, by not requiring an entire route to be duplicated in order to express an "except" condition for JWT verification. It also allows the user to have direct control over the order in which JWT rules are applied, versus being subject to Contour's rules for sorting routes.
However, this design results in two separate places where route-related behavior are defined, and also creates cognitive overhead for users by having two separate ways of ordering match criteria.
This option largely follows the design laid out above.
JWT providers are defined on the root HTTPProxy.
Routes on either the root HTTPProxy, or any included HTTPProxies, can then opt into JWT verification by specifying jwtProvider: [name].
This option puts the responsibility for opting into JWT verification on the owner of the child HTTPProxy.
In this option, the Include type itself can opt into JWT verification, by specifying jwtProvider: [name].
All routes in the included HTTPProxy are then opted into JWT verification.
An additional field is added to routes to explicitly opt out of JWT verification (disableJWTVerification: true).
This option allows the owner of the root HTTPProxy to opt the child HTTPProxy's routes into JWT verification. The downside of this model is that it is somewhat inconsistent with the single HTTPProxy model, where routes have to explicitly opt into verification.
In this option, each root HTTPProxy only allows a single JWT provider.
All routes in the root HTTPProxy, as well as its included HTTPProxies, have JWT verification enabled by default.
An additional field is added to routes to explicitly opt out of JWT verification (disableJWTVerification: true).
This option allows the owner of the root HTTPProxy to opt all routes (including any child HTTPProxies' routes) into JWT verification. The owner of the child HTTPProxies can still opt out specific routes if needed.
The downside of this option is that it does limit each root HTTPProxy to a single provider.
JWT verification will be an optional feature that is disabled by default. Existing users should not be affected by its addition.
HTTPProxy with a single provider, marked as the default:
apiVersion: projectcontour.io/v1
kind: HTTPProxy
metadata:
name: jwt-verification-proxy
spec:
virtualhost:
fqdn: example.com
tls:
secretName: tls-cert
jwtProviders:
# This provider is marked as the default so
# will be applied to all routes unless they
# opt out.
- name: provider-1
default: true
issuer: example.com
audiences:
- audience-1
- audience-2
remoteJWKS:
httpURI:
uri: https://example.com/jwks.json
timeout: 1s
cacheDuration: 5m
routes:
# The first route has the default "provider-1"
# provider applied as a requirement.
- conditions:
- prefix: /
services:
- name: s1
port: 80
# The "/css" route disables all JWT verification.
- conditions:
- prefix: /css
jwtVerificationPolicy:
disabled: true
services:
- name: s1
port: 80
HTTPProxy with a single provider, NOT marked as the default:
# Note, this proxy definition is functionally the same
# as the previous example, but it uses opt-in behavior
# instead of opt-out behavior.
apiVersion: projectcontour.io/v1
kind: HTTPProxy
metadata:
name: jwt-verification-proxy
spec:
virtualhost:
fqdn: example.com
tls:
secretName: tls-cert
jwtProviders:
- name: provider-1
issuer: example.com
audiences:
- audience-1
- audience-2
remoteJWKS:
httpURI:
uri: https://example.com/jwks.json
timeout: 1s
cacheDuration: 5m
routes:
# The first route has "provider-1"
# specified as a requirement.
- conditions:
- prefix: /
jwtVerificationPolicy:
require: provider-1
services:
- name: s1
port: 80
# The "/css" route does not have JWT
# verification applied because there
# is no default provider and it does
# not explicitly specify one.
- conditions:
- prefix: /css
services:
- name: s1
port: 80
HTTPProxy with multiple providers, with one marked as the default:
apiVersion: projectcontour.io/v1
kind: HTTPProxy
metadata:
name: jwt-verification-proxy
spec:
virtualhost:
fqdn: example.com
tls:
secretName: tls-cert
jwtProviders:
# This provider is marked as the default so
# will be applied to all routes unless they
# opt out.
- name: provider-1
default: true
issuer: example.com
audiences:
- audience-1
- audience-2
remoteJWKS:
httpURI:
uri: https://example.com/jwks.json
timeout: 1s
cacheDuration: 5m
# This is another provider that routes can
# opt into.
- name: provider-2
issuer: foo.com
remoteJWKS:
httpURI:
uri: https://foo.com/jwks.json
timeout: 1s
cacheDuration: 5m
routes:
# The first route has the default "provider-1"
# provider applied as a requirement.
- conditions:
- prefix: /
services:
- name: s1
port: 80
# The "/foo" route requires "provider-2" instead
# of the default.
- conditions:
- prefix: /foo
jwtVerificationPolicy:
require: provider-2
services:
- name: s2
port: 80
# The "/css" route disables all JWT verification.
- conditions:
- prefix: /css
jwtVerificationPolicy:
disabled: true
services:
- name: s1
port: 80
HTTPProxy with a single provider, marked as the default, using inclusion:
apiVersion: projectcontour.io/v1
kind: HTTPProxy
metadata:
name: jwt-verification-root-proxy
spec:
virtualhost:
fqdn: example.com
tls:
secretName: tls-cert
jwtProviders:
# This provider is marked as the default so
# will be applied to all routes in both the
# root proxy and its included proxies unless
# they opt out.
- name: provider-1
default: true
issuer: example.com
audiences:
- audience-1
- audience-2
remoteJWKS:
httpURI:
uri: https://example.com/jwks.json
timeout: 1s
cacheDuration: 5m
includes:
- name: jwt-verification-child-proxy
conditions:
- prefix: /service-1
---
apiVersion: projectcontour.io/v1
kind: HTTPProxy
metadata:
name: jwt-verification-child-proxy
routes:
# The first route has the default "provider-1"
# provider applied as a requirement.
- conditions:
- prefix: /
services:
- name: s1
port: 80
# The "/css" route disables all JWT verification.
- conditions:
- prefix: /css
jwtVerificationPolicy:
disabled: true
services:
- name: s1
port: 80