From 7b6e28bb5f336198becbd8a1c522876f2f507ca6 Mon Sep 17 00:00:00 2001 From: Ronald Ekambi Date: Tue, 4 Jul 2023 14:13:38 -0400 Subject: [PATCH] Add first integration test for jwt auth with intention --- test/integration/consul-container/go.mod | 2 + test/integration/consul-container/go.sum | 3 + .../consul-container/libs/utils/helpers.go | 108 ++++++++ .../test/jwtauth/jwt_auth_test.go | 230 ++++++++++++++++++ 4 files changed, 343 insertions(+) create mode 100644 test/integration/consul-container/test/jwtauth/jwt_auth_test.go diff --git a/test/integration/consul-container/go.mod b/test/integration/consul-container/go.mod index 46a4d21c9b9f1..077b03f96dd51 100644 --- a/test/integration/consul-container/go.mod +++ b/test/integration/consul-container/go.mod @@ -26,6 +26,7 @@ require ( github.com/testcontainers/testcontainers-go v0.20.1 golang.org/x/mod v0.10.0 google.golang.org/grpc v1.55.0 + gopkg.in/square/go-jose.v2 v2.5.1 ) require ( @@ -83,6 +84,7 @@ require ( github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect github.com/sirupsen/logrus v1.9.0 // indirect github.com/stretchr/objx v0.5.0 // indirect + golang.org/x/crypto v0.1.0 // indirect golang.org/x/exp v0.0.0-20230321023759-10a507213a29 // indirect golang.org/x/net v0.10.0 // indirect golang.org/x/sync v0.2.0 // indirect diff --git a/test/integration/consul-container/go.sum b/test/integration/consul-container/go.sum index b7ae09743d5bd..60917babc7763 100644 --- a/test/integration/consul-container/go.sum +++ b/test/integration/consul-container/go.sum @@ -307,6 +307,7 @@ golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.1.0 h1:MDRAIl0xIo9Io2xV565hzXHw3zVseKrJKodhohM5CjU= +golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20230321023759-10a507213a29 h1:ooxPy7fPvB4kwsA2h+iBNHkAbp/4JxTSwCmvdjEYmug= golang.org/x/exp v0.0.0-20230321023759-10a507213a29/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= @@ -417,6 +418,8 @@ gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLks gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/square/go-jose.v2 v2.5.1 h1:7odma5RETjNHWJnR32wx8t+Io4djHE1PqxCFx3iiZ2w= +gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/test/integration/consul-container/libs/utils/helpers.go b/test/integration/consul-container/libs/utils/helpers.go index e18293a96ac09..a4955dbc7b902 100644 --- a/test/integration/consul-container/libs/utils/helpers.go +++ b/test/integration/consul-container/libs/utils/helpers.go @@ -4,7 +4,16 @@ package utils import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "fmt" + "github.com/hashicorp/consul/api" + "gopkg.in/square/go-jose.v2" + "gopkg.in/square/go-jose.v2/jwt" ) func ApplyDefaultProxySettings(c *api.Client) (bool, error) { @@ -18,3 +27,102 @@ func ApplyDefaultProxySettings(c *api.Client) (bool, error) { ok, _, err := c.ConfigEntries().Set(req, &api.WriteOptions{}) return ok, err } + +// Generates a private and public key pair that is for signing +// JWT +func GenerateKey() (pub, priv string, err error) { + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + + if err != nil { + return "", "", fmt.Errorf("error generating private key: %v", err) + } + + { + derBytes, err := x509.MarshalECPrivateKey(privateKey) + if err != nil { + return "", "", fmt.Errorf("error marshaling private key: %v", err) + } + pemBlock := &pem.Block{ + Type: "EC PRIVATE KEY", + Bytes: derBytes, + } + priv = string(pem.EncodeToMemory(pemBlock)) + } + { + derBytes, err := x509.MarshalPKIXPublicKey(privateKey.Public()) + if err != nil { + return "", "", fmt.Errorf("error marshaling public key: %v", err) + } + pemBlock := &pem.Block{ + Type: "PUBLIC KEY", + Bytes: derBytes, + } + pub = string(pem.EncodeToMemory(pemBlock)) + } + + return pub, priv, nil +} + +// SignJWT will bundle the provided claims into a signed JWT. The provided key +// is assumed to be ECDSA. +// +// If no private key is provided, it will generate a private key. These can +// be retrieved via the SigningKeys() method. +func SignJWT(privKey string, claims jwt.Claims, privateClaims interface{}) (string, error) { + if privKey == "" { + var err error + _, privKey, err = GenerateKey() + if err != nil { + return "", err + } + } + var key *ecdsa.PrivateKey + block, _ := pem.Decode([]byte(privKey)) + if block != nil { + var err error + key, err = x509.ParseECPrivateKey(block.Bytes) + if err != nil { + return "", err + } + } + + sig, err := jose.NewSigner( + jose.SigningKey{Algorithm: jose.ES256, Key: key}, + (&jose.SignerOptions{}).WithType("JWT"), + ) + if err != nil { + return "", err + } + + raw, err := jwt.Signed(sig). + Claims(claims). + Claims(privateClaims). + CompactSerialize() + if err != nil { + return "", err + } + + return raw, nil +} + +// newJWKS converts a pem-encoded public key into JWKS data suitable for a +// verification endpoint response +func NewJWKS(pubKey string) (*jose.JSONWebKeySet, error) { + block, _ := pem.Decode([]byte(pubKey)) + if block == nil { + return nil, fmt.Errorf("unable to decode public key") + } + input := block.Bytes + + pub, err := x509.ParsePKIXPublicKey(input) + if err != nil { + return nil, err + } + return &jose.JSONWebKeySet{ + Keys: []jose.JSONWebKey{ + { + Key: pub, + }, + }, + }, nil +} diff --git a/test/integration/consul-container/test/jwtauth/jwt_auth_test.go b/test/integration/consul-container/test/jwtauth/jwt_auth_test.go new file mode 100644 index 0000000000000..e4b97832de19d --- /dev/null +++ b/test/integration/consul-container/test/jwtauth/jwt_auth_test.go @@ -0,0 +1,230 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package jwtauth + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + + "github.com/hashicorp/consul/api" + "github.com/hashicorp/consul/sdk/testutil/retry" + "github.com/stretchr/testify/require" + + libassert "github.com/hashicorp/consul/test/integration/consul-container/libs/assert" + libcluster "github.com/hashicorp/consul/test/integration/consul-container/libs/cluster" + libservice "github.com/hashicorp/consul/test/integration/consul-container/libs/service" + libtopology "github.com/hashicorp/consul/test/integration/consul-container/libs/topology" + libutils "github.com/hashicorp/consul/test/integration/consul-container/libs/utils" + "github.com/hashicorp/go-cleanhttp" + "gopkg.in/square/go-jose.v2/jwt" + "testing" + "time" +) + +// TestJWTAuthConnectService summary +// This test ensures that when we have an intention referencing a JWT, requests +// without JWT authorization headers are denied. And requests with the correct JWT +// Authorization header are successful +// +// Steps: +// - Creates a single agent cluster +// - Creates a static-server and sidecar containers +// - Registers the created static-server and sidecar with consul +// - Create a static-client and sidecar containers +// - Registers the static-client and sidecar with consul +// - Ensure client sidecar is running as expected +// - Make a request without the JWT Authorization header and expects 401 StatusUnauthorized +// - Make a request with the JWT Authorization header and expects a 200 +func TestJWTAuthConnectService(t *testing.T) { + t.Parallel() + + cluster, _, _ := libtopology.NewCluster(t, &libtopology.ClusterConfig{ + NumServers: 1, + NumClients: 1, + ApplyDefaultProxySettings: true, + BuildOpts: &libcluster.BuildOptions{ + Datacenter: "dc1", + InjectAutoEncryption: true, + InjectGossipEncryption: true, + }, + }) + + clientService := createServices(t, cluster) + _, clientPort := clientService.GetAddr() + _, clientAdminPort := clientService.GetAdminAddr() + + libassert.AssertUpstreamEndpointStatus(t, clientAdminPort, "static-server.default", "HEALTHY", 1) + libassert.AssertContainerState(t, clientService, "running") + libassert.AssertFortioName(t, fmt.Sprintf("http://localhost:%d", clientPort), "static-server", "") + + claims := jwt.Claims{ + Subject: "r3qXcK2bix9eFECzsU3Sbmh0K16fatW6@clients", + Audience: jwt.Audience{"https://consul.test"}, + Issuer: "https://legit.issuer.internal/", + NotBefore: jwt.NewNumericDate(time.Now().Add(-5 * time.Second)), + Expiry: jwt.NewNumericDate(time.Now().Add(60 * time.Minute)), + } + + jwks, jwt := makeJWKSAndJWT(t, claims) + + // configure proxy-defaults, jwt-provider and intention + configureProxyDefaults(t, cluster) + configureJWTProvider(t, cluster, jwks, claims) + configureIntentions(t, cluster) + + baseURL := fmt.Sprintf("http://localhost:%d", clientPort) + // fails without jwt headers + doRequest(t, baseURL, http.StatusUnauthorized, "") + // succeeds with jwt + doRequest(t, baseURL, http.StatusOK, jwt) +} + +func createServices(t *testing.T, cluster *libcluster.Cluster) libservice.Service { + node := cluster.Agents[0] + client := node.GetClient() + // Create a service and proxy instance + serviceOpts := &libservice.ServiceOpts{ + Name: libservice.StaticServerServiceName, + ID: "static-server", + HTTPPort: 8080, + GRPCPort: 8079, + } + + // Create a service and proxy instance + _, _, err := libservice.CreateAndRegisterStaticServerAndSidecar(node, serviceOpts) + require.NoError(t, err) + + libassert.CatalogServiceExists(t, client, "static-server-sidecar-proxy", nil) + libassert.CatalogServiceExists(t, client, libservice.StaticServerServiceName, nil) + + // Create a client proxy instance with the server as an upstream + clientConnectProxy, err := libservice.CreateAndRegisterStaticClientSidecar(node, "", false, false) + require.NoError(t, err) + + libassert.CatalogServiceExists(t, client, "static-client-sidecar-proxy", nil) + + return clientConnectProxy +} + +// creates a JWKS and JWT that will be used for validation +func makeJWKSAndJWT(t *testing.T, claims jwt.Claims) (string, string) { + pub, priv, err := libutils.GenerateKey() + require.NoError(t, err) + + jwks, err := libutils.NewJWKS(pub) + require.NoError(t, err) + + jwksJson, err := json.Marshal(jwks) + require.NoError(t, err) + + type orgs struct { + Primary string `json:"primary"` + } + privateCl := struct { + FirstName string `json:"first_name"` + Org orgs `json:"org"` + Groups []string `json:"groups"` + }{ + FirstName: "jeff2", + Org: orgs{"engineering"}, + Groups: []string{"foo", "bar"}, + } + + jwt, err := libutils.SignJWT(priv, claims, privateCl) + require.NoError(t, err) + return string(jwksJson), jwt +} + +// configures the protocol to http as this is needed for jwt-auth +func configureProxyDefaults(t *testing.T, cluster *libcluster.Cluster) { + node := cluster.Agents[0] + client := node.GetClient() + + ok, _, err := client.ConfigEntries().Set(&api.ProxyConfigEntry{ + Kind: api.ProxyDefaults, + Name: api.ProxyConfigGlobal, + Config: map[string]interface{}{ + "protocol": "http", + }, + }, nil) + require.NoError(t, err) + require.True(t, ok) +} + +// creates a JWT local provider +func configureJWTProvider(t *testing.T, cluster *libcluster.Cluster, jwks string, claims jwt.Claims) { + node := cluster.Agents[0] + client := node.GetClient() + + jwksB64 := base64.StdEncoding.EncodeToString([]byte(jwks)) + + ok, _, err := client.ConfigEntries().Set(&api.JWTProviderConfigEntry{ + Kind: api.JWTProvider, + Name: "test-jwt", + JSONWebKeySet: &api.JSONWebKeySet{ + Local: &api.LocalJWKS{ + JWKS: jwksB64, + }, + }, + Issuer: claims.Issuer, + Audiences: claims.Audience, + }, nil) + require.NoError(t, err) + require.True(t, ok) +} + +// creates an intention referencing the jwt provider +func configureIntentions(t *testing.T, cluster *libcluster.Cluster) { + node := cluster.Agents[0] + client := node.GetClient() + + ok, _, err := client.ConfigEntries().Set(&api.ServiceIntentionsConfigEntry{ + Kind: "service-intentions", + Name: libservice.StaticServerServiceName, + Sources: []*api.SourceIntention{ + { + Name: libservice.StaticClientServiceName, + Action: api.IntentionActionAllow, + }, + }, + JWT: &api.IntentionJWTRequirement{ + Providers: []*api.IntentionJWTProvider{ + { + Name: "test-jwt", + VerifyClaims: []*api.IntentionJWTClaimVerification{}, + }, + }, + }, + }, nil) + require.NoError(t, err) + require.True(t, ok) + + entries, _, err := client.ConfigEntries().List("service-intentions", nil) + require.NoError(t, err) + t.Logf("intentions list:") + for i, e := range entries { + intention := e.(*api.ServiceIntentionsConfigEntry) + intentionJson, err := json.Marshal(intention) + require.NoError(t, err) + t.Logf("%d: %s", i, string(intentionJson)) + } +} + +func doRequest(t *testing.T, url string, expStatus int, jwt string) { + retry.RunWith(&retry.Timer{Timeout: 5 * time.Second, Wait: time.Second}, t, func(r *retry.R) { + + client := cleanhttp.DefaultClient() + + req, err := http.NewRequest("GET", url, nil) + require.NoError(r, err) + if jwt != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", jwt)) + } + resp, err := client.Do(req) + require.NoError(r, err) + require.Equal(r, expStatus, resp.StatusCode) + }) +}