Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add first integration test for jwt auth with intentions #18005

Merged
merged 1 commit into from
Jul 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions agent/xds/listeners.go
Original file line number Diff line number Diff line change
Expand Up @@ -1381,12 +1381,11 @@ func (s *ResourceGenerator) makeInboundListener(cfgSnap *proxycfg.ConfigSnapshot
if err != nil {
return nil, err
}

filterOpts.httpAuthzFilters = []*envoy_http_v3.HttpFilter{rbacFilter}

filterOpts.httpAuthzFilters = []*envoy_http_v3.HttpFilter{}
if jwtFilter != nil {
filterOpts.httpAuthzFilters = append(filterOpts.httpAuthzFilters, jwtFilter)
}
filterOpts.httpAuthzFilters = append(filterOpts.httpAuthzFilters, rbacFilter)

meshConfig := cfgSnap.MeshConfig()
includeXFCC := meshConfig == nil || meshConfig.HTTP == nil || !meshConfig.HTTP.SanitizeXForwardedClientCert
Expand Down
2 changes: 2 additions & 0 deletions test/integration/consul-container/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ require (
github.com/avast/retry-go v3.0.0+incompatible
github.com/docker/docker v23.0.6+incompatible
github.com/docker/go-connections v0.4.0
github.com/go-jose/go-jose/v3 v3.0.0
github.com/hashicorp/consul v0.0.0-00010101000000-000000000000
github.com/hashicorp/consul/api v1.22.0-rc1
github.com/hashicorp/consul/envoyextensions v0.3.0-rc1
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions test/integration/consul-container/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w=
github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg=
github.com/go-jose/go-jose/v3 v3.0.0 h1:s6rrhirfEP/CGIoc6p+PZAeogN2SxKav6Wp7+dyMWVo=
github.com/go-jose/go-jose/v3 v3.0.0/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
Expand All @@ -101,6 +103,7 @@ github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
Expand Down Expand Up @@ -286,6 +289,7 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
Expand All @@ -303,10 +307,12 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY=
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=
Expand Down
104 changes: 104 additions & 0 deletions test/integration/consul-container/libs/utils/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@
package utils

import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/pem"
"fmt"

"github.com/go-jose/go-jose/v3"
"github.com/go-jose/go-jose/v3/jwt"
"github.com/hashicorp/consul/api"
)

Expand All @@ -18,3 +27,98 @@ 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) {
roncodingenthusiast marked this conversation as resolved.
Show resolved Hide resolved
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)

if err != nil {
return "", "", fmt.Errorf("error generating private key: %w", err)
}

{
derBytes, err := x509.MarshalECPrivateKey(privateKey)
if err != nil {
return "", "", fmt.Errorf("error marshaling private key: %w", err)
}
priv = string(pem.EncodeToMemory(&pem.Block{
Type: "EC PRIVATE KEY",
Bytes: derBytes,
}))
}
{
derBytes, err := x509.MarshalPKIXPublicKey(privateKey.Public())
if err != nil {
return "", "", fmt.Errorf("error marshaling public key: %w", err)
}
pub = string(pem.EncodeToMemory(&pem.Block{
Type: "PUBLIC KEY",
Bytes: derBytes,
}))
}

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) {
var err error
if privKey == "" {
_, privKey, err = GenerateKey()
if err != nil {
return "", err
}
}
var key *ecdsa.PrivateKey
block, _ := pem.Decode([]byte(privKey))
if block != nil {
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 || block.Type != "PUBLIC KEY" {
return nil, fmt.Errorf("unable to decode public key")
}

pub, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, err
}
return &jose.JSONWebKeySet{
Keys: []jose.JSONWebKey{
{
Key: pub,
},
},
}, nil
}
215 changes: 215 additions & 0 deletions test/integration/consul-container/test/jwtauth/jwt_auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
// 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"

"github.com/go-jose/go-jose/v3/jwt"
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"
"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) {
roncodingenthusiast marked this conversation as resolved.
Show resolved Hide resolved
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) {
client := cluster.Agents[0].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) {
client := cluster.Agents[0].GetClient()

ok, _, err := client.ConfigEntries().Set(&api.JWTProviderConfigEntry{
Kind: api.JWTProvider,
Name: "test-jwt",
JSONWebKeySet: &api.JSONWebKeySet{
Local: &api.LocalJWKS{
JWKS: base64.StdEncoding.EncodeToString([]byte(jwks)),
},
},
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) {
client := cluster.Agents[0].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)
}

func doRequest(t *testing.T, url string, expStatus int, jwt string) {
roncodingenthusiast marked this conversation as resolved.
Show resolved Hide resolved
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)
})
}