From 36515e13c4c254db7dc28ddeb1042e66ee270497 Mon Sep 17 00:00:00 2001 From: danehans Date: Tue, 31 Jan 2023 17:44:58 -0800 Subject: [PATCH] Adds JWT Authn Support to XDS Translator Signed-off-by: danehans --- api/v1alpha1/authenticationfilter_types.go | 15 +- .../validation/authenticationfilter.go | 62 +- .../validation/authenticationfilter_test.go | 126 +++- ...lid-multi-match-multi-authenfilter.in.yaml | 4 + ...id-multi-match-multi-authenfilter.out.yaml | 4 + internal/ir/xds.go | 7 +- ...y.envoyproxy.io_authenticationfilters.yaml | 18 +- internal/xds/translator/authentication.go | 546 ++++++++++++++++++ internal/xds/translator/listener.go | 6 +- internal/xds/translator/ratelimit.go | 9 +- internal/xds/translator/route.go | 11 +- .../authn-multi-route-multi-provider.yaml | 51 ++ .../authn-multi-route-single-provider.yaml | 37 ++ .../testdata/in/xds-ir/authn-ratelimit.yaml | 58 ++ .../authn-single-route-single-match.yaml | 22 + ...n-multi-route-multi-provider.clusters.yaml | 86 +++ ...-multi-route-multi-provider.listeners.yaml | 103 ++++ ...thn-multi-route-multi-provider.routes.yaml | 22 + ...-multi-route-single-provider.clusters.yaml | 61 ++ ...multi-route-single-provider.listeners.yaml | 73 +++ ...hn-multi-route-single-provider.routes.yaml | 22 + .../out/xds-ir/authn-ratelimit.clusters.yaml | 96 +++ .../out/xds-ir/authn-ratelimit.listeners.yaml | 69 +++ .../out/xds-ir/authn-ratelimit.routes.yaml | 42 ++ ...hn-single-route-single-match.clusters.yaml | 43 ++ ...n-single-route-single-match.listeners.yaml | 60 ++ ...uthn-single-route-single-match.routes.yaml | 14 + internal/xds/translator/translator.go | 10 +- internal/xds/translator/translator_test.go | 12 + 29 files changed, 1642 insertions(+), 47 deletions(-) create mode 100644 internal/xds/translator/authentication.go create mode 100644 internal/xds/translator/testdata/in/xds-ir/authn-multi-route-multi-provider.yaml create mode 100644 internal/xds/translator/testdata/in/xds-ir/authn-multi-route-single-provider.yaml create mode 100644 internal/xds/translator/testdata/in/xds-ir/authn-ratelimit.yaml create mode 100644 internal/xds/translator/testdata/in/xds-ir/authn-single-route-single-match.yaml create mode 100644 internal/xds/translator/testdata/out/xds-ir/authn-multi-route-multi-provider.clusters.yaml create mode 100644 internal/xds/translator/testdata/out/xds-ir/authn-multi-route-multi-provider.listeners.yaml create mode 100644 internal/xds/translator/testdata/out/xds-ir/authn-multi-route-multi-provider.routes.yaml create mode 100644 internal/xds/translator/testdata/out/xds-ir/authn-multi-route-single-provider.clusters.yaml create mode 100644 internal/xds/translator/testdata/out/xds-ir/authn-multi-route-single-provider.listeners.yaml create mode 100644 internal/xds/translator/testdata/out/xds-ir/authn-multi-route-single-provider.routes.yaml create mode 100644 internal/xds/translator/testdata/out/xds-ir/authn-ratelimit.clusters.yaml create mode 100644 internal/xds/translator/testdata/out/xds-ir/authn-ratelimit.listeners.yaml create mode 100644 internal/xds/translator/testdata/out/xds-ir/authn-ratelimit.routes.yaml create mode 100644 internal/xds/translator/testdata/out/xds-ir/authn-single-route-single-match.clusters.yaml create mode 100644 internal/xds/translator/testdata/out/xds-ir/authn-single-route-single-match.listeners.yaml create mode 100644 internal/xds/translator/testdata/out/xds-ir/authn-single-route-single-match.routes.yaml diff --git a/api/v1alpha1/authenticationfilter_types.go b/api/v1alpha1/authenticationfilter_types.go index 7d23cc9e35..d921445a24 100644 --- a/api/v1alpha1/authenticationfilter_types.go +++ b/api/v1alpha1/authenticationfilter_types.go @@ -66,13 +66,18 @@ type JwtAuthenticationFilterProvider struct { // +kubebuilder:validation:MaxLength=253 Name string `json:"name"` - // Issuer is the principal that issued the JWT. For additional details, see: + // Issuer is the principal that issued the JWT and takes the form of a URL or email address. + // For additional details, see: // - // https://tools.ietf.org/html/rfc7519#section-4.1.1 + // URL format: https://tools.ietf.org/html/rfc7519#section-4.1.1 + // Email format: https://rfc-editor.org/rfc/rfc5322.html // - // Example: + // URL Example: // issuer: https://auth.example.com // + // Email Example: + // issuer: jdoe@example.com + // // If not provided, the JWT issuer is not checked. // // +kubebuilder:validation:MaxLength=253 @@ -104,8 +109,8 @@ type JwtAuthenticationFilterProvider struct { // RemoteJWKS defines how to fetch and cache JSON Web Key Sets (JWKS) from a remote // HTTP/HTTPS endpoint. type RemoteJWKS struct { - // URI is the HTTP/HTTPS URI to fetch the JWKS. When using an HTTPS endpoint, - // Envoy's system trust bundle is used to validate the server certificate. + // URI is the HTTPS URI to fetch the JWKS. Envoy's system trust bundle is used to + // validate the server certificate. // // Example: // uri: https://www.foo.com/oauth2/v1/certs diff --git a/api/v1alpha1/validation/authenticationfilter.go b/api/v1alpha1/validation/authenticationfilter.go index 7dcb3c7367..cfed66c354 100644 --- a/api/v1alpha1/validation/authenticationfilter.go +++ b/api/v1alpha1/validation/authenticationfilter.go @@ -8,9 +8,11 @@ package validation import ( "errors" "fmt" + "net/mail" "net/url" utilerrors "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/apimachinery/pkg/util/validation" egv1a1 "github.com/envoyproxy/gateway/api/v1alpha1" ) @@ -48,32 +50,56 @@ func validateAuthenticationFilterSpec(spec *egv1a1.AuthenticationFilterSpec) err return utilerrors.NewAggregate(errs) } - for i := range spec.JwtProviders { - provider := spec.JwtProviders[i] - if err := ValidateJwtProvider(&provider); err != nil { - errs = append(errs, err) - } + if err := ValidateJwtProviders(spec.JwtProviders); err != nil { + errs = append(errs, err) } return utilerrors.NewAggregate(errs) } -// ValidateJwtProvider validates the provided JWT authentication filter provider. -func ValidateJwtProvider(jwt *egv1a1.JwtAuthenticationFilterProvider) error { +// ValidateJwtProviders validates the provided JWT authentication filter providers. +func ValidateJwtProviders(providers []egv1a1.JwtAuthenticationFilterProvider) error { var errs []error - switch { - case len(jwt.Name) == 0: - errs = append(errs, errors.New("name must be set for jwt provider")) - case len(jwt.Issuer) != 0: - if _, err := url.ParseRequestURI(jwt.Issuer); err != nil { - errs = append(errs, fmt.Errorf("invalid issuer URI: %v", err)) + var names []string + for _, provider := range providers { + switch { + case len(provider.Name) == 0: + errs = append(errs, errors.New("jwt provider cannot be an empty string")) + case len(provider.Issuer) != 0: + // Issuer can take the format of a URL or an email address. + if _, err := url.ParseRequestURI(provider.Issuer); err != nil { + _, err := mail.ParseAddress(provider.Issuer) + if err != nil { + errs = append(errs, fmt.Errorf("invalid issuer; must be a URL or email address: %v", err)) + } + } + case len(provider.RemoteJWKS.URI) == 0: + errs = append(errs, fmt.Errorf("uri must be set for remote JWKS provider: %s", provider.Name)) + } + if _, err := url.ParseRequestURI(provider.RemoteJWKS.URI); err != nil { + errs = append(errs, fmt.Errorf("invalid remote JWKS URI: %v", err)) + } + + if len(errs) == 0 { + if strErrs := validation.IsQualifiedName(provider.Name); len(strErrs) != 0 { + for _, strErr := range strErrs { + errs = append(errs, errors.New(strErr)) + } + } + // Ensure uniqueness among provider names. + if names == nil { + names = append(names, provider.Name) + } else { + for _, name := range names { + if name == provider.Name { + errs = append(errs, fmt.Errorf("provider name %s must be unique", provider.Name)) + } else { + names = append(names, provider.Name) + } + } + } } - case len(jwt.RemoteJWKS.URI) == 0: - errs = append(errs, fmt.Errorf("uri must be set for remote JWKS provider: %s", jwt.Name)) - } - if _, err := url.ParseRequestURI(jwt.RemoteJWKS.URI); err != nil { - errs = append(errs, fmt.Errorf("invalid remote JWKS URI: %v", err)) } return utilerrors.NewAggregate(errs) diff --git a/api/v1alpha1/validation/authenticationfilter_test.go b/api/v1alpha1/validation/authenticationfilter_test.go index c105ed2fb4..c56f0153bc 100644 --- a/api/v1alpha1/validation/authenticationfilter_test.go +++ b/api/v1alpha1/validation/authenticationfilter_test.go @@ -26,7 +26,7 @@ func TestValidateAuthenticationFilter(t *testing.T) { expected: false, }, { - name: "valid authentication filter", + name: "valid authentication filter with url", filter: &egv1a1.AuthenticationFilter{ TypeMeta: metav1.TypeMeta{ Kind: egv1a1.KindAuthenticationFilter, @@ -52,6 +52,60 @@ func TestValidateAuthenticationFilter(t *testing.T) { }, expected: true, }, + { + name: "valid authentication filter with email", + filter: &egv1a1.AuthenticationFilter{ + TypeMeta: metav1.TypeMeta{ + Kind: egv1a1.KindAuthenticationFilter, + APIVersion: egv1a1.GroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test", + Name: "test", + }, + Spec: egv1a1.AuthenticationFilterSpec{ + Type: egv1a1.JwtAuthenticationFilterProviderType, + JwtProviders: []egv1a1.JwtAuthenticationFilterProvider{ + { + Name: "test", + Issuer: "test@test.local", + Audiences: []string{"test.local"}, + RemoteJWKS: egv1a1.RemoteJWKS{ + URI: "https://test.local/jwt/public-key/jwks.json", + }, + }, + }, + }, + }, + expected: true, + }, + { + name: "unqualified authentication provider name", + filter: &egv1a1.AuthenticationFilter{ + TypeMeta: metav1.TypeMeta{ + Kind: egv1a1.KindAuthenticationFilter, + APIVersion: egv1a1.GroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test", + Name: "test", + }, + Spec: egv1a1.AuthenticationFilterSpec{ + Type: egv1a1.JwtAuthenticationFilterProviderType, + JwtProviders: []egv1a1.JwtAuthenticationFilterProvider{ + { + Name: "unqualified_...", + Issuer: "https://www.test.local", + Audiences: []string{"test.local"}, + RemoteJWKS: egv1a1.RemoteJWKS{ + URI: "https://test.local/jwt/public-key/jwks.json", + }, + }, + }, + }, + }, + expected: false, + }, { name: "unspecified provider name", filter: &egv1a1.AuthenticationFilter{ @@ -79,6 +133,49 @@ func TestValidateAuthenticationFilter(t *testing.T) { }, expected: false, }, + { + name: "non unique provider names", + filter: &egv1a1.AuthenticationFilter{ + TypeMeta: metav1.TypeMeta{ + Kind: egv1a1.KindAuthenticationFilter, + APIVersion: egv1a1.GroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test", + Name: "test", + }, + Spec: egv1a1.AuthenticationFilterSpec{ + Type: egv1a1.JwtAuthenticationFilterProviderType, + JwtProviders: []egv1a1.JwtAuthenticationFilterProvider{ + { + Name: "unique", + Issuer: "https://www.test.local", + Audiences: []string{"test.local"}, + RemoteJWKS: egv1a1.RemoteJWKS{ + URI: "https://test.local/jwt/public-key/jwks.json", + }, + }, + { + Name: "non-unique", + Issuer: "https://www.test.local", + Audiences: []string{"test.local"}, + RemoteJWKS: egv1a1.RemoteJWKS{ + URI: "https://test.local/jwt/public-key/jwks.json", + }, + }, + { + Name: "non-unique", + Issuer: "https://www.test.local", + Audiences: []string{"test.local"}, + RemoteJWKS: egv1a1.RemoteJWKS{ + URI: "https://test.local/jwt/public-key/jwks.json", + }, + }, + }, + }, + }, + expected: false, + }, { name: "invalid issuer uri", filter: &egv1a1.AuthenticationFilter{ @@ -106,6 +203,33 @@ func TestValidateAuthenticationFilter(t *testing.T) { }, expected: false, }, + { + name: "inivalid issuer email", + filter: &egv1a1.AuthenticationFilter{ + TypeMeta: metav1.TypeMeta{ + Kind: egv1a1.KindAuthenticationFilter, + APIVersion: egv1a1.GroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test", + Name: "test", + }, + Spec: egv1a1.AuthenticationFilterSpec{ + Type: egv1a1.JwtAuthenticationFilterProviderType, + JwtProviders: []egv1a1.JwtAuthenticationFilterProvider{ + { + Name: "test", + Issuer: "test@!123...", + Audiences: []string{"test.local"}, + RemoteJWKS: egv1a1.RemoteJWKS{ + URI: "https://test.local/jwt/public-key/jwks.json", + }, + }, + }, + }, + }, + expected: false, + }, { name: "invalid remote jwks uri", filter: &egv1a1.AuthenticationFilter{ diff --git a/internal/gatewayapi/testdata/httproute-with-valid-multi-match-multi-authenfilter.in.yaml b/internal/gatewayapi/testdata/httproute-with-valid-multi-match-multi-authenfilter.in.yaml index a34d6ab313..eef5dbfa36 100644 --- a/internal/gatewayapi/testdata/httproute-with-valid-multi-match-multi-authenfilter.in.yaml +++ b/internal/gatewayapi/testdata/httproute-with-valid-multi-match-multi-authenfilter.in.yaml @@ -79,3 +79,7 @@ authenticationFilters: issuer: https://www.test2.local remoteJWKS: uri: https://test2.local/jwt/public-key/jwks.json + - name: test3 + issuer: https://www.test3.local + remoteJWKS: + uri: https://test3.local/jwt/public-key/jwks.json diff --git a/internal/gatewayapi/testdata/httproute-with-valid-multi-match-multi-authenfilter.out.yaml b/internal/gatewayapi/testdata/httproute-with-valid-multi-match-multi-authenfilter.out.yaml index 869e740caa..80259405a9 100644 --- a/internal/gatewayapi/testdata/httproute-with-valid-multi-match-multi-authenfilter.out.yaml +++ b/internal/gatewayapi/testdata/httproute-with-valid-multi-match-multi-authenfilter.out.yaml @@ -117,6 +117,10 @@ xdsIR: issuer: https://www.test2.local remoteJWKS: uri: https://test2.local/jwt/public-key/jwks.json + - name: test3 + issuer: https://www.test3.local + remoteJWKS: + uri: https://test3.local/jwt/public-key/jwks.json destinations: - host: 7.7.7.7 port: 8080 diff --git a/internal/ir/xds.go b/internal/ir/xds.go index adc30efae8..92d0c64e6a 100644 --- a/internal/ir/xds.go +++ b/internal/ir/xds.go @@ -360,11 +360,8 @@ func (h HTTPRoute) Validate() error { func (j *JwtRequestAuthentication) Validate() error { var errs error - for i := range j.Providers { - provider := j.Providers[i] - if err := validation.ValidateJwtProvider(&provider); err != nil { - errs = multierror.Append(errs, err) - } + if err := validation.ValidateJwtProviders(j.Providers); err != nil { + errs = multierror.Append(errs, err) } return errs diff --git a/internal/provider/kubernetes/config/crd/bases/gateway.envoyproxy.io_authenticationfilters.yaml b/internal/provider/kubernetes/config/crd/bases/gateway.envoyproxy.io_authenticationfilters.yaml index 6823f56478..7bf8b8684c 100644 --- a/internal/provider/kubernetes/config/crd/bases/gateway.envoyproxy.io_authenticationfilters.yaml +++ b/internal/provider/kubernetes/config/crd/bases/gateway.envoyproxy.io_authenticationfilters.yaml @@ -55,10 +55,13 @@ spec: maxItems: 8 type: array issuer: - description: "Issuer is the principal that issued the JWT.\tFor - additional details, see: \n https://tools.ietf.org/html/rfc7519#section-4.1.1 - \n Example: issuer: https://auth.example.com \n If not provided, - the JWT issuer is not checked." + description: "Issuer is the principal that issued the JWT and + takes the form of a URL or email address. For additional details, + see: \n URL format: https://tools.ietf.org/html/rfc7519#section-4.1.1 + Email format: https://rfc-editor.org/rfc/rfc5322.html \n URL + Example: issuer: https://auth.example.com \n Email Example: + issuer: jdoe@example.com \n If not provided, the JWT issuer + is not checked." maxLength: 253 type: string name: @@ -73,10 +76,9 @@ spec: Web Key Sets (JWKS) from a remote HTTP/HTTPS endpoint. properties: uri: - description: "URI is the HTTP/HTTPS URI to fetch the JWKS. - When using an HTTPS endpoint, Envoy's system trust bundle - is used to validate the server certificate. \n Example: - uri: https://www.foo.com/oauth2/v1/certs" + description: "URI is the HTTPS URI to fetch the JWKS. Envoy's + system trust bundle is used to validate the server certificate. + \n Example: uri: https://www.foo.com/oauth2/v1/certs" maxLength: 253 minLength: 1 type: string diff --git a/internal/xds/translator/authentication.go b/internal/xds/translator/authentication.go new file mode 100644 index 0000000000..14c703fbac --- /dev/null +++ b/internal/xds/translator/authentication.go @@ -0,0 +1,546 @@ +// Copyright Envoy Gateway Authors +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +package translator + +import ( + "errors" + "fmt" + "net" + "net/url" + "strconv" + "strings" + "time" + + cluster "github.com/envoyproxy/go-control-plane/envoy/config/cluster/v3" + core "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" + endpoint "github.com/envoyproxy/go-control-plane/envoy/config/endpoint/v3" + listener "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3" + routev3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3" + jwtext "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/jwt_authn/v3" + hcm "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/v3" + tls "github.com/envoyproxy/go-control-plane/envoy/extensions/transport_sockets/tls/v3" + "github.com/envoyproxy/go-control-plane/pkg/resource/v3" + "github.com/envoyproxy/go-control-plane/pkg/wellknown" + wkt "github.com/envoyproxy/go-control-plane/pkg/wellknown" + "google.golang.org/protobuf/types/known/anypb" + "google.golang.org/protobuf/types/known/durationpb" + + "github.com/envoyproxy/gateway/api/v1alpha1" + "github.com/envoyproxy/gateway/internal/ir" + "github.com/envoyproxy/gateway/internal/xds/types" +) + +const ( + jwtAuthenFilter = "envoy.filters.http.jwt_authn" + envoyTrustBundle = "/etc/ssl/certs/ca-certificates.crt" +) + +// patchHCMWithJwtAuthnFilter builds and appends the Jwt Filter to the HTTP +// Connection Manager if applicable, and it does not already exist. +func patchHCMWithJwtAuthnFilter(mgr *hcm.HttpConnectionManager, irListener *ir.HTTPListener) error { + if mgr == nil { + return errors.New("hcm is nil") + } + + if irListener == nil { + return errors.New("ir listener is nil") + } + + if len(irListener.Routes) == 0 { + return errors.New("ir listener contains no routes") + } + + if !listenerContainsJwtAuthn(irListener) { + return nil + } + + // Return early if filter already exists. + for _, httpFilter := range mgr.HttpFilters { + if httpFilter.Name == jwtAuthenFilter { + return nil + } + } + + jwtFilter, err := buildHCMJwtFilter(irListener) + if err != nil { + return err + } + + // Ensure the authn filter is the first and the terminal filter is the last in the chain. + mgr.HttpFilters = append([]*hcm.HttpFilter{jwtFilter}, mgr.HttpFilters...) + + return nil +} + +// buildHCMJwtFilter returns a JWT authn HTTP filter from the provided IR listener. +func buildHCMJwtFilter(irListener *ir.HTTPListener) (*hcm.HttpFilter, error) { + jwtAuthnProto, err := buildJwtAuthn(irListener) + if err != nil { + return nil, err + } + + if err := jwtAuthnProto.ValidateAll(); err != nil { + return nil, err + } + + jwtAuthnAny, err := anypb.New(jwtAuthnProto) + if err != nil { + return nil, err + } + + return &hcm.HttpFilter{ + Name: jwtAuthenFilter, + ConfigType: &hcm.HttpFilter_TypedConfig{ + TypedConfig: jwtAuthnAny, + }, + }, nil +} + +// buildJwtAuthn returns a JwtAuthentication based on the provided IR HTTPListener. +func buildJwtAuthn(irListener *ir.HTTPListener) (*jwtext.JwtAuthentication, error) { + jwtProviders := make(map[string]*jwtext.JwtProvider) + reqMap := make(map[string]*jwtext.JwtRequirement) + + for _, route := range irListener.Routes { + if route != nil && routeContainsJwtAuthn(route) { + var reqs []*jwtext.JwtRequirement + for i := range route.RequestAuthentication.JWT.Providers { + irProvider := route.RequestAuthentication.JWT.Providers[i] + // Create the cluster for the remote jwks, if it doesn't exist. + clusterName, err := getJwksClusterName(&irProvider) + if err != nil { + return nil, err + } + + remote := &jwtext.JwtProvider_RemoteJwks{ + RemoteJwks: &jwtext.RemoteJwks{ + HttpUri: &core.HttpUri{ + Uri: irProvider.RemoteJWKS.URI, + HttpUpstreamType: &core.HttpUri_Cluster{ + Cluster: clusterName, + }, + Timeout: &durationpb.Duration{Seconds: 5}, + }, + CacheDuration: &durationpb.Duration{Seconds: 5 * 60}, + }, + } + + jwtProvider := &jwtext.JwtProvider{ + Issuer: irProvider.Issuer, + Audiences: irProvider.Audiences, + JwksSourceSpecifier: remote, + PayloadInMetadata: irProvider.Issuer, + } + + providerKey := fmt.Sprintf("%s-%s", route.Name, irProvider.Name) + jwtProviders[providerKey] = jwtProvider + reqs = append(reqs, &jwtext.JwtRequirement{ + RequiresType: &jwtext.JwtRequirement_ProviderName{ + ProviderName: providerKey, + }, + }) + } + if len(reqs) == 1 { + reqMap[route.Name] = reqs[0] + } else { + orListReqs := &jwtext.JwtRequirement{ + RequiresType: &jwtext.JwtRequirement_RequiresAny{ + RequiresAny: &jwtext.JwtRequirementOrList{ + Requirements: reqs, + }, + }, + } + reqMap[route.Name] = orListReqs + } + } + } + + return &jwtext.JwtAuthentication{ + RequirementMap: reqMap, + Providers: jwtProviders, + }, nil +} + +// getJwksClusterName returns a JWKS cluster name from the provided provider. +func getJwksClusterName(provider *v1alpha1.JwtAuthenticationFilterProvider) (string, error) { + if provider == nil { + return "", errors.New("nil provider") + } + + u, err := url.Parse(provider.RemoteJWKS.URI) + if err != nil { + return "", err + } + + var strPort string + switch u.Scheme { + case "https": + strPort = "443" + default: + return "", fmt.Errorf("unsupported JWKS URI scheme %s", u.Scheme) + } + + if u.Port() != "" { + strPort = u.Port() + } + + return fmt.Sprintf("%s_%s", strings.ReplaceAll(u.Hostname(), ".", "_"), strPort), nil +} + +// buildClusterFromJwks creates an xDS Cluster from the provided jwks. +func buildClusterFromJwks(jwks *jwksCluster) (*cluster.Cluster, error) { + if jwks == nil { + return nil, errors.New("jwks is nil") + } + + endpoints, err := jwks.toLbEndpoints() + if err != nil { + return nil, err + } + + tSocket, err := buildXdsUpstreamTLSSocket() + if err != nil { + return nil, err + } + + return &cluster.Cluster{ + Name: jwks.name, + ClusterDiscoveryType: &cluster.Cluster_Type{Type: cluster.Cluster_STATIC}, + ConnectTimeout: durationpb.New(10 * time.Second), + LbPolicy: cluster.Cluster_RANDOM, + LoadAssignment: &endpoint.ClusterLoadAssignment{ + ClusterName: jwks.name, + Endpoints: []*endpoint.LocalityLbEndpoints{ + { + LbEndpoints: endpoints, + }, + }, + }, + Http2ProtocolOptions: &core.Http2ProtocolOptions{}, + DnsRefreshRate: durationpb.New(30 * time.Second), + RespectDnsTtl: true, + DnsLookupFamily: cluster.Cluster_V4_ONLY, + TransportSocket: tSocket, + }, nil +} + +// buildXdsUpstreamTLSSocket returns an xDS TransportSocket that uses envoyTrustBundle +// as the CA to authenticate server certificates. +func buildXdsUpstreamTLSSocket() (*core.TransportSocket, error) { + tlsCtxProto := &tls.UpstreamTlsContext{ + CommonTlsContext: &tls.CommonTlsContext{ + ValidationContextType: &tls.CommonTlsContext_ValidationContext{ + ValidationContext: &tls.CertificateValidationContext{ + TrustedCa: &core.DataSource{ + Specifier: &core.DataSource_Filename{ + Filename: envoyTrustBundle, + }, + }, + }, + }, + }, + } + + tlsCtxAny, err := anypb.New(tlsCtxProto) + if err != nil { + return nil, err + } + + return &core.TransportSocket{ + Name: wellknown.TransportSocketTls, + ConfigType: &core.TransportSocket_TypedConfig{ + TypedConfig: tlsCtxAny, + }, + }, nil +} + +// patchRouteWithJwtConfig patches the provided route with a JWT PerRouteConfig, if the +// route doesn't contain it. The listener is used to create the PerRouteConfig JWT +// requirement. +func patchRouteWithJwtConfig(route *routev3.Route, irRoute *ir.HTTPRoute, listener *listener.Listener) error { //nolint:unparam + if route == nil { + return errors.New("xds route is nil") + } + if irRoute == nil { + return errors.New("ir route is nil") + } + if listener == nil { + return errors.New("listener is nil") + } + + filterCfg := route.GetTypedPerFilterConfig() + if _, ok := filterCfg[jwtAuthenFilter]; !ok { + if !routeContainsJwtAuthn(irRoute) { + return nil + } + + routeCfgProto, err := buildJwtPerRouteConfig(irRoute, listener) + if err != nil { + return fmt.Errorf("failed to build per route config for ir route %s", irRoute.Name) + } + + routeCfgAny, err := anypb.New(routeCfgProto) + if err != nil { + return err + } + + if filterCfg == nil { + route.TypedPerFilterConfig = make(map[string]*anypb.Any) + } + + route.TypedPerFilterConfig[jwtAuthenFilter] = routeCfgAny + } + + return nil +} + +// buildJwtPerRouteConfig returns a JWT PerRouteConfig based on the provided IR route and HCM. +func buildJwtPerRouteConfig(irRoute *ir.HTTPRoute, listener *listener.Listener) (*jwtext.PerRouteConfig, error) { + if irRoute == nil { + return nil, errors.New("ir route is nil") + } + if irRoute == nil { + return nil, errors.New("ir route does not contain jwt authn") + } + if listener == nil { + return nil, errors.New("listener is nil") + } + + filterCh := listener.GetDefaultFilterChain() + if filterCh == nil { + return nil, fmt.Errorf("listener %s does not contain the default filterchain", listener.Name) + } + + for _, filter := range filterCh.Filters { + if filter.Name == wkt.HTTPConnectionManager { + // Unmarshal the filter to a jwt authn config and validate it. + hcmProto := new(hcm.HttpConnectionManager) + hcmAny := filter.GetTypedConfig() + if err := hcmAny.UnmarshalTo(hcmProto); err != nil { + return nil, err + } + if err := hcmProto.ValidateAll(); err != nil { + return nil, err + } + // + req, err := getJwtRequirement(hcmProto.GetHttpFilters(), irRoute.Name) + if err != nil { + return nil, err + } + + return &jwtext.PerRouteConfig{ + RequirementSpecifier: req, + }, nil + } + } + + return nil, errors.New("failed to find HTTP connection manager filter") +} + +// getJwtRequirement iterates through the provided filters, returning a JWT requirement +// name if one exists. +func getJwtRequirement(filters []*hcm.HttpFilter, name string) (*jwtext.PerRouteConfig_RequirementName, error) { + if len(filters) == 0 { + return nil, errors.New("no hcm http filters") + } + + for _, filter := range filters { + if filter.Name == jwtAuthenFilter { + // Unmarshal the filter to a jwt authn config and validate it. + jwtAuthnProto := new(jwtext.JwtAuthentication) + jwtAuthnAny := filter.GetTypedConfig() + if err := jwtAuthnAny.UnmarshalTo(jwtAuthnProto); err != nil { + return nil, err + } + if err := jwtAuthnProto.ValidateAll(); err != nil { + return nil, err + } + // Return the requirement name if it's found. + if _, found := jwtAuthnProto.RequirementMap[name]; found { + return &jwtext.PerRouteConfig_RequirementName{RequirementName: name}, nil + } + return nil, fmt.Errorf("failed to find jwt requirement %s", name) + } + } + + return nil, errors.New("failed to find jwt authn filter") +} + +type jwksCluster struct { + name string + addresses []string + port int +} + +// createJwksClusters creates JWKS clusters from the provided routes, if needed. +func createJwksClusters(tCtx *types.ResourceVersionTable, routes []*ir.HTTPRoute) error { + if tCtx == nil || + tCtx.XdsResources == nil || + tCtx.XdsResources[resource.ClusterType] == nil || + len(routes) == 0 { + return nil + } + + for _, route := range routes { + if route.RequestAuthentication != nil && + route.RequestAuthentication.JWT != nil && + len(route.RequestAuthentication.JWT.Providers) > 0 && + routeContainsJwtAuthn(route) { + for i := range route.RequestAuthentication.JWT.Providers { + provider := route.RequestAuthentication.JWT.Providers[i] + jwks, err := newJwksCluster(&provider) + if err != nil { + return err + } + if existingCluster := findXdsCluster(tCtx, jwks.name); existingCluster == nil { + cluster, buildErr := buildClusterFromJwks(jwks) + if buildErr != nil { + return buildErr + } + tCtx.AddXdsResource(resource.ClusterType, cluster) + } + } + } + } + + return nil +} + +// newJwksCluster returns a jwksCluster from the provided provider. +func newJwksCluster(provider *v1alpha1.JwtAuthenticationFilterProvider) (*jwksCluster, error) { + if provider == nil { + return nil, errors.New("nil provider") + } + + u, err := url.Parse(provider.RemoteJWKS.URI) + if err != nil { + return nil, err + } + + var strPort string + switch u.Scheme { + case "https": + strPort = "443" + default: + return nil, fmt.Errorf("unsupported JWKS URI scheme %s", u.Scheme) + } + + if u.Port() != "" { + strPort = u.Port() + } + + addrs, err := resolveHostname(u.Hostname()) + if err != nil { + return nil, err + } + + name := fmt.Sprintf("%s_%s", strings.ReplaceAll(u.Hostname(), ".", "_"), strPort) + + port, err := strconv.Atoi(strPort) + if err != nil { + return nil, err + } + + return &jwksCluster{ + name: name, + addresses: addrs, + port: port, + }, nil +} + +// toLbEndpoints returns load balancer endpoints for the jwksCluster. +func (j *jwksCluster) toLbEndpoints() ([]*endpoint.LbEndpoint, error) { + var endpoints []*endpoint.LbEndpoint + + if j == nil { + return endpoints, errors.New("nil jwks cluster") + } + + for _, addr := range j.addresses { + ep := &endpoint.LbEndpoint{ + HostIdentifier: &endpoint.LbEndpoint_Endpoint{ + Endpoint: &endpoint.Endpoint{ + Address: &core.Address{ + Address: &core.Address_SocketAddress{ + SocketAddress: &core.SocketAddress{ + Address: addr, + PortSpecifier: &core.SocketAddress_PortValue{PortValue: uint32(j.port)}, + }, + }, + }, + }, + }, + } + endpoints = append(endpoints, ep) + } + + return endpoints, nil +} + +// resolveHostname looks up the provided hostname using the local resolver, returning the +// resolved IP addresses. If the hostname can't be resolved, hostname will be parsed as an +// IP address +func resolveHostname(hostname string) ([]string, error) { + ips, err := net.LookupIP(hostname) + if err != nil { + // Check if hostname is an IPv4 address. + if ip := net.ParseIP(hostname); ip != nil { + if v4 := ip.To4(); v4 != nil { + return []string{v4.String()}, nil + } + } + // Not an IP address or a hostname that can be resolved. + return nil, fmt.Errorf("failed to parse hostname: %v", err) + } + + // Only return the hostname's IPv4 addresses. + var ret []string + for i := range ips { + ip := ips[i] + if v4 := ip.To4(); v4 != nil { + ret = append(ret, ip.String()) + } + } + + if ret == nil { + return nil, fmt.Errorf("hostname %s does not resolve to an IPv4 address", hostname) + } + + return ret, nil +} + +// listenerContainsJwtAuthn returns true if JWT authentication exists for the +// provided listener. +func listenerContainsJwtAuthn(irListener *ir.HTTPListener) bool { + if irListener == nil { + return false + } + + for _, route := range irListener.Routes { + if routeContainsJwtAuthn(route) { + return true + } + } + + return false +} + +// routeContainsJwtAuthn returns true if JWT authentication exists for the +// provided route. +func routeContainsJwtAuthn(irRoute *ir.HTTPRoute) bool { + if irRoute == nil { + return false + } + + if irRoute != nil && + irRoute.RequestAuthentication != nil && + irRoute.RequestAuthentication.JWT != nil && + irRoute.RequestAuthentication.JWT.Providers != nil { + return true + } + + return false +} diff --git a/internal/xds/translator/listener.go b/internal/xds/translator/listener.go index 7f29065a6f..2a5707843e 100644 --- a/internal/xds/translator/listener.go +++ b/internal/xds/translator/listener.go @@ -120,7 +120,11 @@ func addXdsHTTPFilterChain(xdsListener *listener.Listener, irListener *ir.HTTPLi } // TODO: Make this a generic interface for all API Gateway features. - if err := patchHCMWithRateLimit(mgr, irListener); err != nil { + // https://github.com/envoyproxy/gateway/issues/882 + patchHCMWithRateLimit(mgr, irListener) + + // Add the jwt authn filter, if needed. + if err := patchHCMWithJwtAuthnFilter(mgr, irListener); err != nil { return err } diff --git a/internal/xds/translator/ratelimit.go b/internal/xds/translator/ratelimit.go index 4edf1e2006..ebd7b05c15 100644 --- a/internal/xds/translator/ratelimit.go +++ b/internal/xds/translator/ratelimit.go @@ -29,23 +29,22 @@ import ( // patchHCMWithRateLimit builds and appends the Rate Limit Filter to the HTTP connection manager // if applicable and it does not already exist. -func patchHCMWithRateLimit(mgr *hcm.HttpConnectionManager, irListener *ir.HTTPListener) error { +func patchHCMWithRateLimit(mgr *hcm.HttpConnectionManager, irListener *ir.HTTPListener) { // Return early if rate limits dont exist if !isRateLimitPresent(irListener) { - return nil + return } // Return early if filter already exists. for _, httpFilter := range mgr.HttpFilters { if httpFilter.Name == wkt.HTTPRateLimit { - return nil + return } } rateLimitFilter := buildRateLimitFilter(irListener) - // Make sure the router filter is the terminal filter in the chain + // Make sure the router filter is the terminal filter in the chain. mgr.HttpFilters = append([]*hcm.HttpFilter{rateLimitFilter}, mgr.HttpFilters...) - return nil } // isRateLimitPresent returns true if rate limit config exists for the listener. diff --git a/internal/xds/translator/route.go b/internal/xds/translator/route.go index 2000259a55..b4f603e0b8 100644 --- a/internal/xds/translator/route.go +++ b/internal/xds/translator/route.go @@ -9,6 +9,7 @@ import ( "fmt" core "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" + listener "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3" routev3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3" matcher "github.com/envoyproxy/go-control-plane/envoy/type/matcher/v3" "google.golang.org/protobuf/types/known/wrapperspb" @@ -16,7 +17,7 @@ import ( "github.com/envoyproxy/gateway/internal/ir" ) -func buildXdsRoute(httpRoute *ir.HTTPRoute) *routev3.Route { +func buildXdsRoute(httpRoute *ir.HTTPRoute, listener *listener.Listener) *routev3.Route { router := &routev3.Route{ Match: buildXdsRouteMatch(httpRoute.PathMatch, httpRoute.HeaderMatches, httpRoute.QueryParamMatches), } @@ -64,11 +65,17 @@ func buildXdsRoute(httpRoute *ir.HTTPRoute) *routev3.Route { } } - // TODO: convert this into a generic interface for API Gateway features + // TODO: Convert this into a generic interface for API Gateway features. + // https://github.com/envoyproxy/gateway/issues/882 if err := patchRouteWithRateLimit(router.GetRoute(), httpRoute); err != nil { return nil } + // Add the jwt per route config to the route, if needed. + if err := patchRouteWithJwtConfig(router, httpRoute, listener); err != nil { + return nil + } + return router } diff --git a/internal/xds/translator/testdata/in/xds-ir/authn-multi-route-multi-provider.yaml b/internal/xds/translator/testdata/in/xds-ir/authn-multi-route-multi-provider.yaml new file mode 100644 index 0000000000..71b1c275db --- /dev/null +++ b/internal/xds/translator/testdata/in/xds-ir/authn-multi-route-multi-provider.yaml @@ -0,0 +1,51 @@ +http: +- name: "first-listener" + address: "0.0.0.0" + port: 10080 + hostnames: + - "*" + routes: + - name: "first-route-www.test.com" + pathMatch: + exact: "foo/bar" + requestAuthentication: + jwt: + providers: + - name: example + issuer: https://www.example.com + audiences: + - foo.com + remoteJWKS: + uri: https://localhost/jwt/public-key/jwks.json + - name: example2 + issuer: https://www.two.example.com + audiences: + - one.foo.com + - two.foo.com + remoteJWKS: + uri: https://localhost:8080/jwt/public-key/jwks.json + destinations: + - host: "1.2.3.4" + port: 50000 + - name: "second-route-www.test.com" + pathMatch: + exact: "foo/baz" + requestAuthentication: + jwt: + providers: + - name: example + issuer: https://www.example.com + audiences: + - foo.com + remoteJWKS: + uri: https://localhost/jwt/public-key/jwks.json + - name: example2 + issuer: https://www.two.example.com + audiences: + - one.foo.com + - two.foo.com + remoteJWKS: + uri: https://localhost:8080/jwt/public-key/jwks.json + destinations: + - host: "5.6.7.8" + port: 50000 diff --git a/internal/xds/translator/testdata/in/xds-ir/authn-multi-route-single-provider.yaml b/internal/xds/translator/testdata/in/xds-ir/authn-multi-route-single-provider.yaml new file mode 100644 index 0000000000..85e0ec8cb5 --- /dev/null +++ b/internal/xds/translator/testdata/in/xds-ir/authn-multi-route-single-provider.yaml @@ -0,0 +1,37 @@ +http: +- name: "first-listener" + address: "0.0.0.0" + port: 10080 + hostnames: + - "*" + routes: + - name: "first-route" + pathMatch: + exact: "foo/bar" + requestAuthentication: + jwt: + providers: + - name: example + issuer: https://www.example.com + audiences: + - foo.com + remoteJWKS: + uri: https://localhost/jwt/public-key/jwks.json + destinations: + - host: "1.2.3.4" + port: 50000 + - name: "second-route" + pathMatch: + exact: "foo/baz" + requestAuthentication: + jwt: + providers: + - name: example + issuer: https://www.example.com + audiences: + - foo.com + remoteJWKS: + uri: https://localhost/jwt/public-key/jwks.json + destinations: + - host: "5.6.7.8" + port: 50000 diff --git a/internal/xds/translator/testdata/in/xds-ir/authn-ratelimit.yaml b/internal/xds/translator/testdata/in/xds-ir/authn-ratelimit.yaml new file mode 100644 index 0000000000..16f070de91 --- /dev/null +++ b/internal/xds/translator/testdata/in/xds-ir/authn-ratelimit.yaml @@ -0,0 +1,58 @@ +http: +- name: "first-listener" + address: "0.0.0.0" + port: 10080 + hostnames: + - "*" + routes: + - name: "first-route" + rateLimit: + global: + rules: + - headerMatches: + - name: "x-user-id" + exact: "one" + limit: + requests: 5 + unit: second + pathMatch: + exact: "foo/bar" + destinations: + - host: "1.2.3.4" + port: 50000 + requestAuthentication: + jwt: + providers: + - name: example + issuer: https://www.example.com + audiences: + - foo.com + remoteJWKS: + uri: https://localhost/jwt/public-key/jwks.json + - name: "second-route" + rateLimit: + global: + rules: + - headerMatches: + - name: "x-user-id" + distinct: true + limit: + requests: 5 + unit: second + pathMatch: + exact: "example" + destinations: + - host: "1.2.3.4" + port: 50000 + - name: "third-route" + rateLimit: + global: + rules: + - limit: + requests: 5 + unit: second + pathMatch: + exact: "test" + destinations: + - host: "1.2.3.4" + port: 50000 diff --git a/internal/xds/translator/testdata/in/xds-ir/authn-single-route-single-match.yaml b/internal/xds/translator/testdata/in/xds-ir/authn-single-route-single-match.yaml new file mode 100644 index 0000000000..4b2bcac6b1 --- /dev/null +++ b/internal/xds/translator/testdata/in/xds-ir/authn-single-route-single-match.yaml @@ -0,0 +1,22 @@ +http: +- name: "first-listener" + address: "0.0.0.0" + port: 10080 + hostnames: + - "*" + routes: + - name: "first-route" + pathMatch: + exact: "foo/bar" + requestAuthentication: + jwt: + providers: + - name: example + issuer: https://www.example.com + audiences: + - foo.com + remoteJWKS: + uri: https://localhost/jwt/public-key/jwks.json + destinations: + - host: "1.2.3.4" + port: 50000 diff --git a/internal/xds/translator/testdata/out/xds-ir/authn-multi-route-multi-provider.clusters.yaml b/internal/xds/translator/testdata/out/xds-ir/authn-multi-route-multi-provider.clusters.yaml new file mode 100644 index 0000000000..094af93ce6 --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/authn-multi-route-multi-provider.clusters.yaml @@ -0,0 +1,86 @@ +- commonLbConfig: + localityWeightedLbConfig: {} + connectTimeout: 5s + dnsLookupFamily: V4_ONLY + loadAssignment: + clusterName: first-route-www.test.com + endpoints: + - lbEndpoints: + - endpoint: + address: + socketAddress: + address: 1.2.3.4 + portValue: 50000 + loadBalancingWeight: 1 + locality: {} + name: first-route-www.test.com + outlierDetection: {} + type: STATIC +- commonLbConfig: + localityWeightedLbConfig: {} + connectTimeout: 5s + dnsLookupFamily: V4_ONLY + loadAssignment: + clusterName: second-route-www.test.com + endpoints: + - lbEndpoints: + - endpoint: + address: + socketAddress: + address: 5.6.7.8 + portValue: 50000 + loadBalancingWeight: 1 + locality: {} + name: second-route-www.test.com + outlierDetection: {} + type: STATIC +- connectTimeout: 10s + dnsLookupFamily: V4_ONLY + dnsRefreshRate: 30s + http2ProtocolOptions: {} + lbPolicy: RANDOM + loadAssignment: + clusterName: localhost_443 + endpoints: + - lbEndpoints: + - endpoint: + address: + socketAddress: + address: 127.0.0.1 + portValue: 443 + name: localhost_443 + respectDnsTtl: true + transportSocket: + name: envoy.transport_sockets.tls + typedConfig: + '@type': type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext + commonTlsContext: + validationContext: + trustedCa: + filename: /etc/ssl/certs/ca-certificates.crt + type: STATIC +- connectTimeout: 10s + dnsLookupFamily: V4_ONLY + dnsRefreshRate: 30s + http2ProtocolOptions: {} + lbPolicy: RANDOM + loadAssignment: + clusterName: localhost_8080 + endpoints: + - lbEndpoints: + - endpoint: + address: + socketAddress: + address: 127.0.0.1 + portValue: 8080 + name: localhost_8080 + respectDnsTtl: true + transportSocket: + name: envoy.transport_sockets.tls + typedConfig: + '@type': type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext + commonTlsContext: + validationContext: + trustedCa: + filename: /etc/ssl/certs/ca-certificates.crt + type: STATIC diff --git a/internal/xds/translator/testdata/out/xds-ir/authn-multi-route-multi-provider.listeners.yaml b/internal/xds/translator/testdata/out/xds-ir/authn-multi-route-multi-provider.listeners.yaml new file mode 100644 index 0000000000..24ab256417 --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/authn-multi-route-multi-provider.listeners.yaml @@ -0,0 +1,103 @@ +- accessLog: + - filter: + responseFlagFilter: + flags: + - NR + name: envoy.access_loggers.file + typedConfig: + '@type': type.googleapis.com/envoy.extensions.access_loggers.file.v3.FileAccessLog + path: /dev/stdout + address: + socketAddress: + address: 0.0.0.0 + portValue: 10080 + defaultFilterChain: + filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + accessLog: + - name: envoy.access_loggers.file + typedConfig: + '@type': type.googleapis.com/envoy.extensions.access_loggers.file.v3.FileAccessLog + path: /dev/stdout + httpFilters: + - name: envoy.filters.http.jwt_authn + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.jwt_authn.v3.JwtAuthentication + providers: + first-route-www.test.com-example: + audiences: + - foo.com + issuer: https://www.example.com + payloadInMetadata: https://www.example.com + remoteJwks: + cacheDuration: 300s + httpUri: + cluster: localhost_443 + timeout: 5s + uri: https://localhost/jwt/public-key/jwks.json + first-route-www.test.com-example2: + audiences: + - one.foo.com + - two.foo.com + issuer: https://www.two.example.com + payloadInMetadata: https://www.two.example.com + remoteJwks: + cacheDuration: 300s + httpUri: + cluster: localhost_8080 + timeout: 5s + uri: https://localhost:8080/jwt/public-key/jwks.json + second-route-www.test.com-example: + audiences: + - foo.com + issuer: https://www.example.com + payloadInMetadata: https://www.example.com + remoteJwks: + cacheDuration: 300s + httpUri: + cluster: localhost_443 + timeout: 5s + uri: https://localhost/jwt/public-key/jwks.json + second-route-www.test.com-example2: + audiences: + - one.foo.com + - two.foo.com + issuer: https://www.two.example.com + payloadInMetadata: https://www.two.example.com + remoteJwks: + cacheDuration: 300s + httpUri: + cluster: localhost_8080 + timeout: 5s + uri: https://localhost:8080/jwt/public-key/jwks.json + requirementMap: + first-route-www.test.com: + requiresAny: + requirements: + - providerName: first-route-www.test.com-example + - providerName: first-route-www.test.com-example2 + second-route-www.test.com: + requiresAny: + requirements: + - providerName: second-route-www.test.com-example + - providerName: second-route-www.test.com-example2 + - name: envoy.filters.http.router + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + rds: + configSource: + apiConfigSource: + apiType: DELTA_GRPC + grpcServices: + - envoyGrpc: + clusterName: xds_cluster + setNodeOnFirstMessageOnly: true + transportApiVersion: V3 + resourceApiVersion: V3 + routeConfigName: first-listener + statPrefix: http + upgradeConfigs: + - upgradeType: websocket + name: first-listener diff --git a/internal/xds/translator/testdata/out/xds-ir/authn-multi-route-multi-provider.routes.yaml b/internal/xds/translator/testdata/out/xds-ir/authn-multi-route-multi-provider.routes.yaml new file mode 100644 index 0000000000..38a8ceb30b --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/authn-multi-route-multi-provider.routes.yaml @@ -0,0 +1,22 @@ +- name: first-listener + virtualHosts: + - domains: + - '*' + name: first-listener + routes: + - match: + path: foo/bar + route: + cluster: first-route-www.test.com + typedPerFilterConfig: + envoy.filters.http.jwt_authn: + '@type': type.googleapis.com/envoy.extensions.filters.http.jwt_authn.v3.PerRouteConfig + requirementName: first-route-www.test.com + - match: + path: foo/baz + route: + cluster: second-route-www.test.com + typedPerFilterConfig: + envoy.filters.http.jwt_authn: + '@type': type.googleapis.com/envoy.extensions.filters.http.jwt_authn.v3.PerRouteConfig + requirementName: second-route-www.test.com diff --git a/internal/xds/translator/testdata/out/xds-ir/authn-multi-route-single-provider.clusters.yaml b/internal/xds/translator/testdata/out/xds-ir/authn-multi-route-single-provider.clusters.yaml new file mode 100644 index 0000000000..7514a02fd4 --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/authn-multi-route-single-provider.clusters.yaml @@ -0,0 +1,61 @@ +- commonLbConfig: + localityWeightedLbConfig: {} + connectTimeout: 5s + dnsLookupFamily: V4_ONLY + loadAssignment: + clusterName: first-route + endpoints: + - lbEndpoints: + - endpoint: + address: + socketAddress: + address: 1.2.3.4 + portValue: 50000 + loadBalancingWeight: 1 + locality: {} + name: first-route + outlierDetection: {} + type: STATIC +- commonLbConfig: + localityWeightedLbConfig: {} + connectTimeout: 5s + dnsLookupFamily: V4_ONLY + loadAssignment: + clusterName: second-route + endpoints: + - lbEndpoints: + - endpoint: + address: + socketAddress: + address: 5.6.7.8 + portValue: 50000 + loadBalancingWeight: 1 + locality: {} + name: second-route + outlierDetection: {} + type: STATIC +- connectTimeout: 10s + dnsLookupFamily: V4_ONLY + dnsRefreshRate: 30s + http2ProtocolOptions: {} + lbPolicy: RANDOM + loadAssignment: + clusterName: localhost_443 + endpoints: + - lbEndpoints: + - endpoint: + address: + socketAddress: + address: 127.0.0.1 + portValue: 443 + name: localhost_443 + respectDnsTtl: true + transportSocket: + name: envoy.transport_sockets.tls + typedConfig: + '@type': type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext + commonTlsContext: + validationContext: + trustedCa: + filename: /etc/ssl/certs/ca-certificates.crt + type: STATIC diff --git a/internal/xds/translator/testdata/out/xds-ir/authn-multi-route-single-provider.listeners.yaml b/internal/xds/translator/testdata/out/xds-ir/authn-multi-route-single-provider.listeners.yaml new file mode 100644 index 0000000000..0f7d3db6e8 --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/authn-multi-route-single-provider.listeners.yaml @@ -0,0 +1,73 @@ +- accessLog: + - filter: + responseFlagFilter: + flags: + - NR + name: envoy.access_loggers.file + typedConfig: + '@type': type.googleapis.com/envoy.extensions.access_loggers.file.v3.FileAccessLog + path: /dev/stdout + address: + socketAddress: + address: 0.0.0.0 + portValue: 10080 + defaultFilterChain: + filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + accessLog: + - name: envoy.access_loggers.file + typedConfig: + '@type': type.googleapis.com/envoy.extensions.access_loggers.file.v3.FileAccessLog + path: /dev/stdout + httpFilters: + - name: envoy.filters.http.jwt_authn + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.jwt_authn.v3.JwtAuthentication + providers: + first-route-example: + audiences: + - foo.com + issuer: https://www.example.com + payloadInMetadata: https://www.example.com + remoteJwks: + cacheDuration: 300s + httpUri: + cluster: localhost_443 + timeout: 5s + uri: https://localhost/jwt/public-key/jwks.json + second-route-example: + audiences: + - foo.com + issuer: https://www.example.com + payloadInMetadata: https://www.example.com + remoteJwks: + cacheDuration: 300s + httpUri: + cluster: localhost_443 + timeout: 5s + uri: https://localhost/jwt/public-key/jwks.json + requirementMap: + first-route: + providerName: first-route-example + second-route: + providerName: second-route-example + - name: envoy.filters.http.router + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + rds: + configSource: + apiConfigSource: + apiType: DELTA_GRPC + grpcServices: + - envoyGrpc: + clusterName: xds_cluster + setNodeOnFirstMessageOnly: true + transportApiVersion: V3 + resourceApiVersion: V3 + routeConfigName: first-listener + statPrefix: http + upgradeConfigs: + - upgradeType: websocket + name: first-listener diff --git a/internal/xds/translator/testdata/out/xds-ir/authn-multi-route-single-provider.routes.yaml b/internal/xds/translator/testdata/out/xds-ir/authn-multi-route-single-provider.routes.yaml new file mode 100644 index 0000000000..21d936ee9d --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/authn-multi-route-single-provider.routes.yaml @@ -0,0 +1,22 @@ +- name: first-listener + virtualHosts: + - domains: + - '*' + name: first-listener + routes: + - match: + path: foo/bar + route: + cluster: first-route + typedPerFilterConfig: + envoy.filters.http.jwt_authn: + '@type': type.googleapis.com/envoy.extensions.filters.http.jwt_authn.v3.PerRouteConfig + requirementName: first-route + - match: + path: foo/baz + route: + cluster: second-route + typedPerFilterConfig: + envoy.filters.http.jwt_authn: + '@type': type.googleapis.com/envoy.extensions.filters.http.jwt_authn.v3.PerRouteConfig + requirementName: second-route diff --git a/internal/xds/translator/testdata/out/xds-ir/authn-ratelimit.clusters.yaml b/internal/xds/translator/testdata/out/xds-ir/authn-ratelimit.clusters.yaml new file mode 100644 index 0000000000..c693e62844 --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/authn-ratelimit.clusters.yaml @@ -0,0 +1,96 @@ +- commonLbConfig: + localityWeightedLbConfig: {} + connectTimeout: 5s + dnsLookupFamily: V4_ONLY + loadAssignment: + clusterName: first-route + endpoints: + - lbEndpoints: + - endpoint: + address: + socketAddress: + address: 1.2.3.4 + portValue: 50000 + loadBalancingWeight: 1 + locality: {} + name: first-route + outlierDetection: {} + type: STATIC +- commonLbConfig: + localityWeightedLbConfig: {} + connectTimeout: 5s + dnsLookupFamily: V4_ONLY + loadAssignment: + clusterName: second-route + endpoints: + - lbEndpoints: + - endpoint: + address: + socketAddress: + address: 1.2.3.4 + portValue: 50000 + loadBalancingWeight: 1 + locality: {} + name: second-route + outlierDetection: {} + type: STATIC +- commonLbConfig: + localityWeightedLbConfig: {} + connectTimeout: 5s + dnsLookupFamily: V4_ONLY + loadAssignment: + clusterName: third-route + endpoints: + - lbEndpoints: + - endpoint: + address: + socketAddress: + address: 1.2.3.4 + portValue: 50000 + loadBalancingWeight: 1 + locality: {} + name: third-route + outlierDetection: {} + type: STATIC +- connectTimeout: 10s + dnsLookupFamily: V4_ONLY + dnsRefreshRate: 30s + http2ProtocolOptions: {} + lbPolicy: RANDOM + loadAssignment: + clusterName: ratelimit_cluster + endpoints: + - lbEndpoints: + - endpoint: + address: + socketAddress: + address: TODO + portValue: 0 + name: ratelimit_cluster + respectDnsTtl: true + type: STRICT_DNS +- connectTimeout: 10s + dnsLookupFamily: V4_ONLY + dnsRefreshRate: 30s + http2ProtocolOptions: {} + lbPolicy: RANDOM + loadAssignment: + clusterName: localhost_443 + endpoints: + - lbEndpoints: + - endpoint: + address: + socketAddress: + address: 127.0.0.1 + portValue: 443 + name: localhost_443 + respectDnsTtl: true + transportSocket: + name: envoy.transport_sockets.tls + typedConfig: + '@type': type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext + commonTlsContext: + validationContext: + trustedCa: + filename: /etc/ssl/certs/ca-certificates.crt + type: STATIC diff --git a/internal/xds/translator/testdata/out/xds-ir/authn-ratelimit.listeners.yaml b/internal/xds/translator/testdata/out/xds-ir/authn-ratelimit.listeners.yaml new file mode 100644 index 0000000000..e996d4dbda --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/authn-ratelimit.listeners.yaml @@ -0,0 +1,69 @@ +- accessLog: + - filter: + responseFlagFilter: + flags: + - NR + name: envoy.access_loggers.file + typedConfig: + '@type': type.googleapis.com/envoy.extensions.access_loggers.file.v3.FileAccessLog + path: /dev/stdout + address: + socketAddress: + address: 0.0.0.0 + portValue: 10080 + defaultFilterChain: + filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + accessLog: + - name: envoy.access_loggers.file + typedConfig: + '@type': type.googleapis.com/envoy.extensions.access_loggers.file.v3.FileAccessLog + path: /dev/stdout + httpFilters: + - name: envoy.filters.http.jwt_authn + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.jwt_authn.v3.JwtAuthentication + providers: + first-route-example: + audiences: + - foo.com + issuer: https://www.example.com + payloadInMetadata: https://www.example.com + remoteJwks: + cacheDuration: 300s + httpUri: + cluster: localhost_443 + timeout: 5s + uri: https://localhost/jwt/public-key/jwks.json + requirementMap: + first-route: + providerName: first-route-example + - name: envoy.filters.http.ratelimit + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.ratelimit.v3.RateLimit + domain: first-listener + rateLimitService: + grpcService: + envoyGrpc: + clusterName: ratelimit_cluster + transportApiVersion: V3 + - name: envoy.filters.http.router + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + rds: + configSource: + apiConfigSource: + apiType: DELTA_GRPC + grpcServices: + - envoyGrpc: + clusterName: xds_cluster + setNodeOnFirstMessageOnly: true + transportApiVersion: V3 + resourceApiVersion: V3 + routeConfigName: first-listener + statPrefix: http + upgradeConfigs: + - upgradeType: websocket + name: first-listener diff --git a/internal/xds/translator/testdata/out/xds-ir/authn-ratelimit.routes.yaml b/internal/xds/translator/testdata/out/xds-ir/authn-ratelimit.routes.yaml new file mode 100644 index 0000000000..dbab81f3b5 --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/authn-ratelimit.routes.yaml @@ -0,0 +1,42 @@ +- name: first-listener + virtualHosts: + - domains: + - '*' + name: first-listener + routes: + - match: + path: foo/bar + route: + cluster: first-route + rateLimits: + - actions: + - headerValueMatch: + descriptorKey: first-route-key-rule-0-match-0 + descriptorValue: first-route-value-rule-0-match-0 + expectMatch: true + headers: + - name: x-user-id + stringMatch: + exact: one + typedPerFilterConfig: + envoy.filters.http.jwt_authn: + '@type': type.googleapis.com/envoy.extensions.filters.http.jwt_authn.v3.PerRouteConfig + requirementName: first-route + - match: + path: example + route: + cluster: second-route + rateLimits: + - actions: + - requestHeaders: + descriptorKey: second-route-key-rule-0-match-0 + headerName: x-user-id + - match: + path: test + route: + cluster: third-route + rateLimits: + - actions: + - genericKey: + descriptorKey: third-route-key-rule-0-match--1 + descriptorValue: third-route-value-rule-0-match--1 diff --git a/internal/xds/translator/testdata/out/xds-ir/authn-single-route-single-match.clusters.yaml b/internal/xds/translator/testdata/out/xds-ir/authn-single-route-single-match.clusters.yaml new file mode 100644 index 0000000000..5027864f4f --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/authn-single-route-single-match.clusters.yaml @@ -0,0 +1,43 @@ +- commonLbConfig: + localityWeightedLbConfig: {} + connectTimeout: 5s + dnsLookupFamily: V4_ONLY + loadAssignment: + clusterName: first-route + endpoints: + - lbEndpoints: + - endpoint: + address: + socketAddress: + address: 1.2.3.4 + portValue: 50000 + loadBalancingWeight: 1 + locality: {} + name: first-route + outlierDetection: {} + type: STATIC +- connectTimeout: 10s + dnsLookupFamily: V4_ONLY + dnsRefreshRate: 30s + http2ProtocolOptions: {} + lbPolicy: RANDOM + loadAssignment: + clusterName: localhost_443 + endpoints: + - lbEndpoints: + - endpoint: + address: + socketAddress: + address: 127.0.0.1 + portValue: 443 + name: localhost_443 + respectDnsTtl: true + transportSocket: + name: envoy.transport_sockets.tls + typedConfig: + '@type': type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext + commonTlsContext: + validationContext: + trustedCa: + filename: /etc/ssl/certs/ca-certificates.crt + type: STATIC diff --git a/internal/xds/translator/testdata/out/xds-ir/authn-single-route-single-match.listeners.yaml b/internal/xds/translator/testdata/out/xds-ir/authn-single-route-single-match.listeners.yaml new file mode 100644 index 0000000000..fd70d14169 --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/authn-single-route-single-match.listeners.yaml @@ -0,0 +1,60 @@ +- accessLog: + - filter: + responseFlagFilter: + flags: + - NR + name: envoy.access_loggers.file + typedConfig: + '@type': type.googleapis.com/envoy.extensions.access_loggers.file.v3.FileAccessLog + path: /dev/stdout + address: + socketAddress: + address: 0.0.0.0 + portValue: 10080 + defaultFilterChain: + filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + accessLog: + - name: envoy.access_loggers.file + typedConfig: + '@type': type.googleapis.com/envoy.extensions.access_loggers.file.v3.FileAccessLog + path: /dev/stdout + httpFilters: + - name: envoy.filters.http.jwt_authn + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.jwt_authn.v3.JwtAuthentication + providers: + first-route-example: + audiences: + - foo.com + issuer: https://www.example.com + payloadInMetadata: https://www.example.com + remoteJwks: + cacheDuration: 300s + httpUri: + cluster: localhost_443 + timeout: 5s + uri: https://localhost/jwt/public-key/jwks.json + requirementMap: + first-route: + providerName: first-route-example + - name: envoy.filters.http.router + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + rds: + configSource: + apiConfigSource: + apiType: DELTA_GRPC + grpcServices: + - envoyGrpc: + clusterName: xds_cluster + setNodeOnFirstMessageOnly: true + transportApiVersion: V3 + resourceApiVersion: V3 + routeConfigName: first-listener + statPrefix: http + upgradeConfigs: + - upgradeType: websocket + name: first-listener diff --git a/internal/xds/translator/testdata/out/xds-ir/authn-single-route-single-match.routes.yaml b/internal/xds/translator/testdata/out/xds-ir/authn-single-route-single-match.routes.yaml new file mode 100644 index 0000000000..43197fcb93 --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/authn-single-route-single-match.routes.yaml @@ -0,0 +1,14 @@ +- name: first-listener + virtualHosts: + - domains: + - '*' + name: first-listener + routes: + - match: + path: foo/bar + route: + cluster: first-route + typedPerFilterConfig: + envoy.filters.http.jwt_authn: + '@type': type.googleapis.com/envoy.extensions.filters.http.jwt_authn.v3.PerRouteConfig + requirementName: first-route diff --git a/internal/xds/translator/translator.go b/internal/xds/translator/translator.go index 0645c81446..f6d44e745d 100644 --- a/internal/xds/translator/translator.go +++ b/internal/xds/translator/translator.go @@ -98,7 +98,7 @@ func processHTTPListenerXdsTranslation(tCtx *types.ResourceVersionTable, httpLis for _, httpRoute := range httpListener.Routes { // 1:1 between IR HTTPRoute and xDS config.route.v3.Route - xdsRoute := buildXdsRoute(httpRoute) + xdsRoute := buildXdsRoute(httpRoute, xdsListener) vHost.Routes = append(vHost.Routes, xdsRoute) // Skip trying to build an IR cluster if the httpRoute only has invalid backends @@ -121,7 +121,8 @@ func processHTTPListenerXdsTranslation(tCtx *types.ResourceVersionTable, httpLis xdsRouteCfg.VirtualHosts = append(xdsRouteCfg.VirtualHosts, vHost) - // TODO: Make this into a generic interface for API Gateway features + // TODO: Make this into a generic interface for API Gateway features. + // https://github.com/envoyproxy/gateway/issues/882 // Check if a ratelimit cluster exists, if not, add it, if its needed. // This is current O(n) right now, but it also leverages an existing // object without allocating new memory. Consider improving it in the future. @@ -132,6 +133,11 @@ func processHTTPListenerXdsTranslation(tCtx *types.ResourceVersionTable, httpLis tCtx.AddXdsResource(resource.ClusterType, rlCluster) } } + + // Create authn jwks clusters, if needed. + if err := createJwksClusters(tCtx, httpListener.Routes); err != nil { + return err + } } return nil } diff --git a/internal/xds/translator/translator_test.go b/internal/xds/translator/translator_test.go index 013db91854..90935e6ce6 100644 --- a/internal/xds/translator/translator_test.go +++ b/internal/xds/translator/translator_test.go @@ -108,6 +108,18 @@ func TestTranslateXds(t *testing.T) { { name: "ratelimit", }, + { + name: "authn-single-route-single-match", + }, + { + name: "authn-multi-route-single-provider", + }, + { + name: "authn-multi-route-multi-provider", + }, + { + name: "authn-ratelimit", + }, } for _, tc := range testCases {