diff --git a/agent/consul/state/config_entry.go b/agent/consul/state/config_entry.go index 66a8bf03c..a9f0040ff 100644 --- a/agent/consul/state/config_entry.go +++ b/agent/consul/state/config_entry.go @@ -600,6 +600,7 @@ func validateProposedConfigEntryInGraph( case structs.HTTPRoute: case structs.TCPRoute: case structs.RateLimitIPConfig: + case structs.JWTProvider: default: return fmt.Errorf("unhandled kind %q during validation of %q", kindName.Kind, kindName.Name) } diff --git a/agent/consul/usagemetrics/usagemetrics_oss_test.go b/agent/consul/usagemetrics/usagemetrics_oss_test.go index 450c23a35..1781dca4c 100644 --- a/agent/consul/usagemetrics/usagemetrics_oss_test.go +++ b/agent/consul/usagemetrics/usagemetrics_oss_test.go @@ -456,6 +456,22 @@ var baseCases = map[string]testCase{ {Name: "kind", Value: "tcp-route"}, }, }, + "consul.usage.test.consul.state.config_entries;datacenter=dc1;kind=jwt-provider": { // Legacy + Name: "consul.usage.test.consul.state.config_entries", + Value: 0, + Labels: []metrics.Label{ + {Name: "datacenter", Value: "dc1"}, + {Name: "kind", Value: "jwt-provider"}, + }, + }, + "consul.usage.test.state.config_entries;datacenter=dc1;kind=jwt-provider": { + Name: "consul.usage.test.state.config_entries", + Value: 0, + Labels: []metrics.Label{ + {Name: "datacenter", Value: "dc1"}, + {Name: "kind", Value: "jwt-provider"}, + }, + }, "consul.usage.test.consul.state.config_entries;datacenter=dc1;kind=control-plane-request-limit": { Name: "consul.usage.test.consul.state.config_entries", Value: 0, @@ -915,6 +931,22 @@ var baseCases = map[string]testCase{ {Name: "kind", Value: "tcp-route"}, }, }, + "consul.usage.test.consul.state.config_entries;datacenter=dc1;kind=jwt-provider": { // Legacy + Name: "consul.usage.test.consul.state.config_entries", + Value: 0, + Labels: []metrics.Label{ + {Name: "datacenter", Value: "dc1"}, + {Name: "kind", Value: "jwt-provider"}, + }, + }, + "consul.usage.test.state.config_entries;datacenter=dc1;kind=jwt-provider": { + Name: "consul.usage.test.state.config_entries", + Value: 0, + Labels: []metrics.Label{ + {Name: "datacenter", Value: "dc1"}, + {Name: "kind", Value: "jwt-provider"}, + }, + }, "consul.usage.test.consul.state.config_entries;datacenter=dc1;kind=control-plane-request-limit": { Name: "consul.usage.test.consul.state.config_entries", Value: 0, diff --git a/agent/structs/config_entry.go b/agent/structs/config_entry.go index 17f85934a..663dde6e0 100644 --- a/agent/structs/config_entry.go +++ b/agent/structs/config_entry.go @@ -45,6 +45,7 @@ const ( TCPRoute string = "tcp-route" // TODO: decide if we want to highlight 'ip' keyword in the name of RateLimitIPConfig RateLimitIPConfig string = "control-plane-request-limit" + JWTProvider string = "jwt-provider" ProxyConfigGlobal string = "global" MeshConfigMesh string = "mesh" @@ -72,6 +73,7 @@ var AllConfigEntryKinds = []string{ TCPRoute, InlineCertificate, RateLimitIPConfig, + JWTProvider, } // ConfigEntry is the interface for centralized configuration stored in Raft. @@ -730,6 +732,8 @@ func MakeConfigEntry(kind, name string) (ConfigEntry, error) { return &HTTPRouteConfigEntry{Name: name}, nil case TCPRoute: return &TCPRouteConfigEntry{Name: name}, nil + case JWTProvider: + return &JWTProviderConfigEntry{Name: name}, nil default: return nil, fmt.Errorf("invalid config entry kind: %s", kind) } diff --git a/agent/structs/config_entry_jwt_provider.go b/agent/structs/config_entry_jwt_provider.go new file mode 100644 index 000000000..a1e9120ea --- /dev/null +++ b/agent/structs/config_entry_jwt_provider.go @@ -0,0 +1,414 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package structs + +import ( + "encoding/base64" + "fmt" + "net/url" + "time" + + "github.com/hashicorp/consul/acl" + "github.com/hashicorp/go-multierror" +) + +const ( + DefaultClockSkewSeconds = 30 +) + +type JWTProviderConfigEntry struct { + // Kind is the kind of configuration entry and must be "jwt-provider". + Kind string `json:",omitempty"` + + // Name is the name of the provider being configured. + Name string `json:",omitempty"` + + // JSONWebKeySet defines a JSON Web Key Set, its location on disk, or the + // means with which to fetch a key set from a remote server. + JSONWebKeySet *JSONWebKeySet `json:",omitempty" alias:"json_web_key_set"` + + // Issuer is the entity that must have issued the JWT. + // This value must match the "iss" claim of the token. + Issuer string `json:",omitempty"` + + // Audiences is the set of audiences the JWT is allowed to access. + // If specified, all JWTs verified with this provider must address + // at least one of these to be considered valid. + Audiences []string `json:",omitempty"` + + // Locations where the JWT will be present in requests. + // Envoy will check all of these locations to extract a JWT. + // If no locations are specified Envoy will default to: + // 1. Authorization header with Bearer schema: + // "Authorization: Bearer " + // 2. access_token query parameter. + Locations []*JWTLocation `json:",omitempty"` + + // Forwarding defines rules for forwarding verified JWTs to the backend. + Forwarding *JWTForwardingConfig `json:",omitempty"` + + // ClockSkewSeconds specifies the maximum allowable time difference + // from clock skew when validating the "exp" (Expiration) and "nbf" + // (Not Before) claims. + // + // Default value is 30 seconds. + ClockSkewSeconds int `json:",omitempty" alias:"clock_skew_seconds"` + + // CacheConfig defines configuration for caching the validation + // result for previously seen JWTs. Caching results can speed up + // verification when individual tokens are expected to be handled + // multiple times. + CacheConfig *JWTCacheConfig `json:",omitempty" alias:"cache_config"` + + Meta map[string]string `json:",omitempty"` + acl.EnterpriseMeta `hcl:",squash" mapstructure:",squash"` + RaftIndex +} + +// JWTLocation is a location where the JWT could be present in requests. +// +// Only one of Header, QueryParam, or Cookie can be specified. +type JWTLocation struct { + // Header defines how to extract a JWT from an HTTP request header. + Header *JWTLocationHeader `json:",omitempty"` + + // QueryParam defines how to extract a JWT from an HTTP request + // query parameter. + QueryParam *JWTLocationQueryParam `json:",omitempty" alias:"query_param"` + + // Cookie defines how to extract a JWT from an HTTP request cookie. + Cookie *JWTLocationCookie `json:",omitempty"` +} + +func countTrue(vals ...bool) int { + var result int + for _, v := range vals { + if v { + result++ + } + } + return result +} + +func (location *JWTLocation) Validate() error { + hasHeader := location.Header != nil + hasQueryParam := location.QueryParam != nil + hasCookie := location.Cookie != nil + + if countTrue(hasHeader, hasQueryParam, hasCookie) != 1 { + return fmt.Errorf("Must set exactly one of: JWT location header, query param or cookie") + } + + if hasHeader { + return location.Header.Validate() + } + + if hasCookie { + return location.Cookie.Validate() + } + + return location.QueryParam.Validate() +} + +// JWTLocationHeader defines how to extract a JWT from an HTTP +// request header. +type JWTLocationHeader struct { + // Name is the name of the header containing the token. + Name string `json:",omitempty"` + + // ValuePrefix is an optional prefix that precedes the token in the + // header value. + // For example, "Bearer " is a standard value prefix for a header named + // "Authorization", but the prefix is not part of the token itself: + // "Authorization: Bearer " + ValuePrefix string `json:",omitempty" alias:"value_prefix"` + + // Forward defines whether the header with the JWT should be + // forwarded after the token has been verified. If false, the + // header will not be forwarded to the backend. + // + // Default value is false. + Forward bool `json:",omitempty"` +} + +// JWTLocationQueryParam defines how to extract a JWT from an HTTP request query parameter. +type JWTLocationQueryParam struct { + // Name is the name of the query param containing the token. + Name string `json:",omitempty"` +} + +func (qp *JWTLocationQueryParam) Validate() error { + if qp.Name == "" { + return fmt.Errorf("JWT location query param name must be specified") + } + return nil +} + +// JWTLocationCookie defines how to extract a JWT from an HTTP request cookie. +type JWTLocationCookie struct { + // Name is the name of the cookie containing the token. + Name string `json:",omitempty"` +} + +type JWTForwardingConfig struct { + // HeaderName is a header name to use when forwarding a verified + // JWT to the backend. The verified JWT could have been extracted + // from any location (query param, header, or cookie). + // + // The header value will be base64-URL-encoded, and will not be + // padded unless PadForwardPayloadHeader is true. + HeaderName string `json:",omitempty" alias:"header_name"` + + // PadForwardPayloadHeader determines whether padding should be added + // to the base64 encoded token forwarded with ForwardPayloadHeader. + // + // Default value is false. + PadForwardPayloadHeader bool `alias:"pad_forward_payload_header"` +} + +func (fc *JWTForwardingConfig) Validate() error { + if fc.HeaderName == "" { + return fmt.Errorf("Header name required for forwarding config") + } + + return nil +} + +// JSONWebKeySet defines a key set, its location on disk, or the +// means with which to fetch a key set from a remote server. +// +// Exactly one of Local or Remote must be specified. +type JSONWebKeySet struct { + // Local specifies a local source for the key set. + Local *LocalJWKS `json:",omitempty"` + + // Remote specifies how to fetch a key set from a remote server. + Remote *RemoteJWKS `json:",omitempty"` +} + +// LocalJWKS specifies a location for a local JWKS. +// +// Only one of String and Filename can be specified. +type LocalJWKS struct { + // JWKS contains a base64 encoded JWKS. + JWKS string `json:",omitempty"` + + // Filename configures a location on disk where the JWKS can be + // found. If specified, the file must be present on the disk of ALL + // proxies with intentions referencing this provider. + Filename string `json:",omitempty"` +} + +func (ks *LocalJWKS) Validate() error { + hasFilename := ks.Filename != "" + hasJWKS := ks.JWKS != "" + + if countTrue(hasFilename, hasJWKS) != 1 { + return fmt.Errorf("Must specify exactly one of String or filename for local keyset") + } + + if hasJWKS { + if _, err := base64.StdEncoding.DecodeString(ks.JWKS); err != nil { + return fmt.Errorf("JWKS must be valid base64 encoded string") + } + } + + return nil +} + +// RemoteJWKS specifies how to fetch a JWKS from a remote server. +type RemoteJWKS struct { + // URI is the URI of the server to query for the JWKS. + URI string `json:",omitempty"` + + // RequestTimeoutMs is the number of milliseconds to + // time out when making a request for the JWKS. + RequestTimeoutMs int `json:",omitempty" alias:"request_timeout_ms"` + + // CacheDuration is the duration after which cached keys + // should be expired. + // + // Default value from envoy is 10 minutes. + CacheDuration time.Duration `json:",omitempty" alias:"cache_duration"` + + // FetchAsynchronously indicates that the JWKS should be fetched + // when a client request arrives. Client requests will be paused + // until the JWKS is fetched. + // If false, the proxy listener will wait for the JWKS to be + // fetched before being activated. + // + // Default value is false. + FetchAsynchronously bool `json:",omitempty" alias:"fetch_asynchronously"` + + // RetryPolicy defines a retry policy for fetching JWKS. + // + // There is no retry by default. + RetryPolicy *JWKSRetryPolicy `json:",omitempty" alias:"retry_policy"` +} + +func (ks *RemoteJWKS) Validate() error { + if ks.URI == "" { + return fmt.Errorf("Remote JWKS URI is required") + } + + if _, err := url.ParseRequestURI(ks.URI); err != nil { + return fmt.Errorf("Remote JWKS URI is invalid: %w, uri: %s", err, ks.URI) + } + + if ks.RetryPolicy != nil && ks.RetryPolicy.RetryPolicyBackOff != nil { + return ks.RetryPolicy.RetryPolicyBackOff.Validate() + } + + return nil +} + +type JWKSRetryPolicy struct { + // NumRetries is the number of times to retry fetching the JWKS. + // The retry strategy uses jittered exponential backoff with + // a base interval of 1s and max of 10s. + // + // Default value is 0. + NumRetries int `json:",omitempty" alias:"num_retries"` + + // Backoff policy + // + // Defaults to envoy's backoff policy + RetryPolicyBackOff *RetryPolicyBackOff `json:",omitempty" alias:"retry_policy_back_off"` +} + +type RetryPolicyBackOff struct { + // BaseInterval to be used for the next back off computation + // + // The default value from envoy is 1s + BaseInterval time.Duration `json:",omitempty" alias:"base_interval"` + + // MaxInternal to be used to specify the maximum interval between retries. + // Optional but should be greater or equal to BaseInterval. + // + // Defaults to 10 times BaseInterval + MaxInterval time.Duration `json:",omitempty" alias:"max_interval"` +} + +func (r *RetryPolicyBackOff) Validate() error { + + if (r.MaxInterval != 0) && (r.BaseInterval > r.MaxInterval) { + return fmt.Errorf("Retry policy backoff's MaxInterval should be greater or equal to BaseInterval") + } + + return nil +} + +type JWTCacheConfig struct { + // Size specifies the maximum number of JWT verification + // results to cache. + // + // Defaults to 0, meaning that JWT caching is disabled. + Size int `json:",omitempty"` +} + +func (e *JWTProviderConfigEntry) GetKind() string { return JWTProvider } +func (e *JWTProviderConfigEntry) GetName() string { return e.Name } +func (e *JWTProviderConfigEntry) GetMeta() map[string]string { return e.Meta } +func (e *JWTProviderConfigEntry) GetEnterpriseMeta() *acl.EnterpriseMeta { return &e.EnterpriseMeta } +func (e *JWTProviderConfigEntry) GetRaftIndex() *RaftIndex { return &e.RaftIndex } + +func (e *JWTProviderConfigEntry) CanRead(authz acl.Authorizer) error { + var authzContext acl.AuthorizerContext + e.FillAuthzContext(&authzContext) + return authz.ToAllowAuthorizer().MeshReadAllowed(&authzContext) +} + +func (e *JWTProviderConfigEntry) CanWrite(authz acl.Authorizer) error { + var authzContext acl.AuthorizerContext + e.FillAuthzContext(&authzContext) + return authz.ToAllowAuthorizer().MeshWriteAllowed(&authzContext) +} + +func (jwks *JSONWebKeySet) Validate() error { + hasLocalKeySet := jwks.Local != nil + hasRemoteKeySet := jwks.Remote != nil + + if countTrue(hasLocalKeySet, hasRemoteKeySet) != 1 { + return fmt.Errorf("Must specify exactly one of Local or Remote JSON Web key set") + } + + if hasRemoteKeySet { + return jwks.Remote.Validate() + } + + return jwks.Local.Validate() +} + +func (lh *JWTLocationHeader) Validate() error { + if lh.Name == "" { + return fmt.Errorf("JWT location header name must be specified") + } + return nil +} + +func (lc *JWTLocationCookie) Validate() error { + if lc.Name == "" { + return fmt.Errorf("JWT location cookie name must be specified") + } + return nil +} + +func validateLocations(locations []*JWTLocation) error { + var result error + for _, location := range locations { + if err := location.Validate(); err != nil { + result = multierror.Append(result, err) + } + } + return result +} + +func (e *JWTProviderConfigEntry) Validate() error { + if e.Name == "" { + return fmt.Errorf("Name is required") + } + + if err := validateConfigEntryMeta(e.Meta); err != nil { + return err + } + + if err := e.validatePartition(); err != nil { + return err + } + + if e.JSONWebKeySet == nil { + return fmt.Errorf("JSONWebKeySet is required") + } + + if err := e.JSONWebKeySet.Validate(); err != nil { + return err + } + + if err := validateLocations(e.Locations); err != nil { + return err + } + + if e.Forwarding != nil { + if err := e.Forwarding.Validate(); err != nil { + return err + } + } + + return nil +} + +func (e *JWTProviderConfigEntry) Normalize() error { + if e == nil { + return fmt.Errorf("Config entry is nil") + } + + e.Kind = JWTProvider + e.EnterpriseMeta.Normalize() + + if e.ClockSkewSeconds == 0 { + e.ClockSkewSeconds = DefaultClockSkewSeconds + } + + return nil +} diff --git a/agent/structs/config_entry_jwt_provider_oss.go b/agent/structs/config_entry_jwt_provider_oss.go new file mode 100644 index 000000000..2152f139f --- /dev/null +++ b/agent/structs/config_entry_jwt_provider_oss.go @@ -0,0 +1,20 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +//go:build !consulent +// +build !consulent + +package structs + +import ( + "fmt" + + "github.com/hashicorp/consul/acl" +) + +func (e *JWTProviderConfigEntry) validatePartition() error { + if !acl.IsDefaultPartition(e.PartitionOrDefault()) { + return fmt.Errorf("Partitions are an enterprise only feature") + } + return nil +} diff --git a/agent/structs/config_entry_jwt_provider_test.go b/agent/structs/config_entry_jwt_provider_test.go new file mode 100644 index 000000000..814a15257 --- /dev/null +++ b/agent/structs/config_entry_jwt_provider_test.go @@ -0,0 +1,369 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package structs + +import ( + "testing" + "time" + + "github.com/hashicorp/consul/acl" + "github.com/stretchr/testify/require" +) + +func newTestAuthz(t *testing.T, src string) acl.Authorizer { + policy, err := acl.NewPolicyFromSource(src, nil, nil) + require.NoError(t, err) + + authorizer, err := acl.NewPolicyAuthorizerWithDefaults(acl.DenyAll(), []*acl.Policy{policy}, nil) + require.NoError(t, err) + return authorizer +} + +var tenSeconds time.Duration = 10 * time.Second +var hundredSeconds time.Duration = 100 * time.Second + +func TestJWTProviderConfigEntry_ValidateAndNormalize(t *testing.T) { + defaultMeta := DefaultEnterpriseMetaInDefaultPartition() + + cases := map[string]configEntryTestcase{ + "valid jwt-provider - local jwks": { + entry: &JWTProviderConfigEntry{ + Kind: JWTProvider, + Name: "test-jwt-provider", + JSONWebKeySet: &JSONWebKeySet{ + Local: &LocalJWKS{ + Filename: "jwks.txt", + }, + }, + }, + expected: &JWTProviderConfigEntry{ + Kind: JWTProvider, + Name: "test-jwt-provider", + JSONWebKeySet: &JSONWebKeySet{ + Local: &LocalJWKS{ + Filename: "jwks.txt", + }, + }, + ClockSkewSeconds: DefaultClockSkewSeconds, + EnterpriseMeta: *defaultMeta, + }, + }, + "valid jwt-provider - remote jwks defaults": { + entry: &JWTProviderConfigEntry{ + Kind: JWTProvider, + Name: "test-jwt-provider", + JSONWebKeySet: &JSONWebKeySet{ + Remote: &RemoteJWKS{ + FetchAsynchronously: true, + URI: "https://example.com/.well-known/jwks.json", + }, + }, + Locations: []*JWTLocation{ + { + Header: &JWTLocationHeader{ + Name: "Authorization", + }, + }, + }, + Forwarding: &JWTForwardingConfig{ + HeaderName: "Some-Header", + }, + }, + expected: &JWTProviderConfigEntry{ + Kind: JWTProvider, + Name: "test-jwt-provider", + JSONWebKeySet: &JSONWebKeySet{ + Remote: &RemoteJWKS{ + FetchAsynchronously: true, + URI: "https://example.com/.well-known/jwks.json", + }, + }, + Forwarding: &JWTForwardingConfig{ + HeaderName: "Some-Header", + }, + Locations: []*JWTLocation{ + { + Header: &JWTLocationHeader{ + Name: "Authorization", + }, + }, + }, + ClockSkewSeconds: DefaultClockSkewSeconds, + EnterpriseMeta: *defaultMeta, + }, + }, + "invalid jwt-provider - no name": { + entry: &JWTProviderConfigEntry{ + Kind: JWTProvider, + Name: "", + }, + validateErr: "Name is required", + }, + "invalid jwt-provider - no jwks": { + entry: &JWTProviderConfigEntry{ + Kind: JWTProvider, + Name: "okta", + }, + validateErr: "JSONWebKeySet is required", + }, + "invalid jwt-provider - no jwks local or remote set": { + entry: &JWTProviderConfigEntry{ + Kind: JWTProvider, + Name: "okta", + JSONWebKeySet: &JSONWebKeySet{}, + }, + validateErr: "Must specify exactly one of Local or Remote JSON Web key set", + }, + "invalid jwt-provider - local jwks with non-encoded base64 jwks": { + entry: &JWTProviderConfigEntry{ + Kind: JWTProvider, + Name: "okta", + JSONWebKeySet: &JSONWebKeySet{ + Local: &LocalJWKS{ + JWKS: "not base64 encoded", + }, + }, + }, + validateErr: "JWKS must be valid base64 encoded string", + }, + "invalid jwt-provider - both jwks local and remote set": { + entry: &JWTProviderConfigEntry{ + Kind: JWTProvider, + Name: "okta", + JSONWebKeySet: &JSONWebKeySet{ + Local: &LocalJWKS{ + Filename: "jwks.txt", + }, + Remote: &RemoteJWKS{}, + }, + }, + validateErr: "Must specify exactly one of Local or Remote JSON Web key set", + }, + "invalid jwt-provider - local jwks string and filename both set": { + entry: &JWTProviderConfigEntry{ + Kind: JWTProvider, + Name: "okta", + JSONWebKeySet: &JSONWebKeySet{ + Local: &LocalJWKS{ + Filename: "jwks.txt", + JWKS: "d2VhcmV0ZXN0aW5n", + }, + }, + }, + validateErr: "Must specify exactly one of String or filename for local keyset", + }, + "invalid jwt-provider - remote jwks missing uri": { + entry: &JWTProviderConfigEntry{ + Kind: JWTProvider, + Name: "okta", + JSONWebKeySet: &JSONWebKeySet{ + Remote: &RemoteJWKS{ + FetchAsynchronously: true, + }, + }, + }, + validateErr: "Remote JWKS URI is required", + }, + "invalid jwt-provider - remote jwks invalid uri": { + entry: &JWTProviderConfigEntry{ + Kind: JWTProvider, + Name: "okta", + JSONWebKeySet: &JSONWebKeySet{ + Remote: &RemoteJWKS{ + FetchAsynchronously: true, + URI: "jibberishUrl", + }, + }, + }, + validateErr: "Remote JWKS URI is invalid", + }, + "invalid jwt-provider - JWT location with all fields": { + entry: &JWTProviderConfigEntry{ + Kind: JWTProvider, + Name: "okta", + JSONWebKeySet: &JSONWebKeySet{ + Remote: &RemoteJWKS{ + FetchAsynchronously: true, + URI: "https://example.com/.well-known/jwks.json", + }, + }, + Locations: []*JWTLocation{ + { + Header: &JWTLocationHeader{ + Name: "Authorization", + }, + QueryParam: &JWTLocationQueryParam{ + Name: "TOKEN-QUERY", + }, + Cookie: &JWTLocationCookie{ + Name: "SomeCookie", + }, + }, + }, + }, + validateErr: "Must set exactly one of: JWT location header, query param or cookie", + }, + "invalid jwt-provider - Remote JWKS retry policy maxinterval < baseInterval": { + entry: &JWTProviderConfigEntry{ + Kind: JWTProvider, + Name: "okta", + JSONWebKeySet: &JSONWebKeySet{ + Remote: &RemoteJWKS{ + FetchAsynchronously: true, + URI: "https://example.com/.well-known/jwks.json", + RetryPolicy: &JWKSRetryPolicy{ + RetryPolicyBackOff: &RetryPolicyBackOff{ + BaseInterval: hundredSeconds, + MaxInterval: tenSeconds, + }, + }, + }, + }, + }, + validateErr: "Retry policy backoff's MaxInterval should be greater or equal to BaseInterval", + }, + "invalid jwt-provider - JWT location with 2 fields": { + entry: &JWTProviderConfigEntry{ + Kind: JWTProvider, + Name: "okta", + JSONWebKeySet: &JSONWebKeySet{ + Remote: &RemoteJWKS{ + FetchAsynchronously: true, + URI: "https://example.com/.well-known/jwks.json", + }, + }, + Locations: []*JWTLocation{ + { + Header: &JWTLocationHeader{ + Name: "Authorization", + }, + QueryParam: &JWTLocationQueryParam{ + Name: "TOKEN-QUERY", + }, + }, + }, + }, + validateErr: "Must set exactly one of: JWT location header, query param or cookie", + }, + "valid jwt-provider - with all possible fields": { + entry: &JWTProviderConfigEntry{ + Kind: JWTProvider, + Name: "test-jwt-provider", + Issuer: "iss", + Audiences: []string{"api", "web"}, + CacheConfig: &JWTCacheConfig{ + Size: 30, + }, + JSONWebKeySet: &JSONWebKeySet{ + Remote: &RemoteJWKS{ + FetchAsynchronously: true, + URI: "https://example.com/.well-known/jwks.json", + RetryPolicy: &JWKSRetryPolicy{ + RetryPolicyBackOff: &RetryPolicyBackOff{ + BaseInterval: tenSeconds, + MaxInterval: hundredSeconds, + }, + }, + }, + }, + Forwarding: &JWTForwardingConfig{ + HeaderName: "Some-Header", + }, + Locations: []*JWTLocation{ + { + Cookie: &JWTLocationCookie{ + Name: "SomeCookie", + }, + }, + }, + ClockSkewSeconds: 20, + }, + expected: &JWTProviderConfigEntry{ + Kind: JWTProvider, + Name: "test-jwt-provider", + Issuer: "iss", + Audiences: []string{"api", "web"}, + CacheConfig: &JWTCacheConfig{ + Size: 30, + }, + JSONWebKeySet: &JSONWebKeySet{ + Remote: &RemoteJWKS{ + FetchAsynchronously: true, + URI: "https://example.com/.well-known/jwks.json", + RetryPolicy: &JWKSRetryPolicy{ + RetryPolicyBackOff: &RetryPolicyBackOff{ + BaseInterval: tenSeconds, + MaxInterval: hundredSeconds, + }, + }, + }, + }, + Forwarding: &JWTForwardingConfig{ + HeaderName: "Some-Header", + }, + Locations: []*JWTLocation{ + { + Cookie: &JWTLocationCookie{ + Name: "SomeCookie", + }, + }, + }, + ClockSkewSeconds: 20, + EnterpriseMeta: *defaultMeta, + }, + }, + } + + testConfigEntryNormalizeAndValidate(t, cases) +} + +func TestJWTProviderConfigEntry_ACLs(t *testing.T) { + cases := []configEntryACLTestCase{ + { + name: "jwt-provider", + entry: &JWTProviderConfigEntry{ + Kind: JWTProvider, + Name: "test-provider", + JSONWebKeySet: &JSONWebKeySet{ + Local: &LocalJWKS{ + Filename: "jwks.txt", + }, + }, + }, + expectACLs: []configEntryTestACL{ + { + name: "no-authz", + authorizer: newTestAuthz(t, ``), + canRead: false, + canWrite: false, + }, + { + name: "jwt-provider: mesh read", + authorizer: newTestAuthz(t, `mesh = "read"`), + canRead: true, + canWrite: false, + }, + { + name: "jwt-provider: mesh write", + authorizer: newTestAuthz(t, `mesh = "write"`), + canRead: true, + canWrite: true, + }, + { + name: "jwt-provider: operator read", + authorizer: newTestAuthz(t, `operator = "read"`), + canRead: true, + canWrite: false, + }, + { + name: "jwt-provider: operator write", + authorizer: newTestAuthz(t, `operator = "write"`), + canRead: true, + canWrite: true, + }, + }, + }, + } + testConfigEntries_ListRelatedServices_AndACLs(t, cases) +} diff --git a/api/config_entry.go b/api/config_entry.go index 6cf21e0b7..fe735a03f 100644 --- a/api/config_entry.go +++ b/api/config_entry.go @@ -35,6 +35,7 @@ const ( TCPRoute string = "tcp-route" InlineCertificate string = "inline-certificate" HTTPRoute string = "http-route" + JWTProvider string = "jwt-provider" ) const ( @@ -392,6 +393,8 @@ func makeConfigEntry(kind, name string) (ConfigEntry, error) { return &HTTPRouteConfigEntry{Kind: kind, Name: name}, nil case RateLimitIPConfig: return &RateLimitIPConfigEntry{Kind: kind, Name: name}, nil + case JWTProvider: + return &JWTProviderConfigEntry{Kind: kind, Name: name}, nil default: return nil, fmt.Errorf("invalid config entry kind: %s", kind) } diff --git a/api/config_entry_jwt_provider.go b/api/config_entry_jwt_provider.go new file mode 100644 index 000000000..e27974af3 --- /dev/null +++ b/api/config_entry_jwt_provider.go @@ -0,0 +1,237 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package api + +import ( + "time" +) + +type JWTProviderConfigEntry struct { + // Kind is the kind of configuration entry and must be "jwt-provider". + Kind string `json:",omitempty"` + + // Name is the name of the provider being configured. + Name string `json:",omitempty"` + + // JSONWebKeySet defines a JSON Web Key Set, its location on disk, or the + // means with which to fetch a key set from a remote server. + JSONWebKeySet *JSONWebKeySet `json:",omitempty" alias:"json_web_key_set"` + + // Issuer is the entity that must have issued the JWT. + // This value must match the "iss" claim of the token. + Issuer string `json:",omitempty"` + + // Audiences is the set of audiences the JWT is allowed to access. + // If specified, all JWTs verified with this provider must address + // at least one of these to be considered valid. + Audiences []string `json:",omitempty"` + + // Locations where the JWT will be present in requests. + // Envoy will check all of these locations to extract a JWT. + // If no locations are specified Envoy will default to: + // 1. Authorization header with Bearer schema: + // "Authorization: Bearer " + // 2. access_token query parameter. + Locations []*JWTLocation `json:",omitempty"` + + // Forwarding defines rules for forwarding verified JWTs to the backend. + Forwarding *JWTForwardingConfig `json:",omitempty"` + + // ClockSkewSeconds specifies the maximum allowable time difference + // from clock skew when validating the "exp" (Expiration) and "nbf" + // (Not Before) claims. + // + // Default value is 30 seconds. + ClockSkewSeconds int `json:",omitempty" alias:"clock_skew_seconds"` + + // CacheConfig defines configuration for caching the validation + // result for previously seen JWTs. Caching results can speed up + // verification when individual tokens are expected to be handled + // multiple times. + CacheConfig *JWTCacheConfig `json:",omitempty" alias:"cache_config"` + + Meta map[string]string `json:",omitempty"` + + // CreateIndex is the Raft index this entry was created at. This is a + // read-only field. + CreateIndex uint64 `json:",omitempty"` + + // ModifyIndex is used for the Check-And-Set operations and can also be fed + // back into the WaitIndex of the QueryOptions in order to perform blocking + // queries. + ModifyIndex uint64 `json:",omitempty"` + + // Partition is the partition the JWTProviderConfigEntry applies to. + // Partitioning is a Consul Enterprise feature. + Partition string `json:",omitempty"` + + // Namespace is the namespace the JWTProviderConfigEntry applies to. + // Namespacing is a Consul Enterprise feature. + Namespace string `json:",omitempty"` +} + +// JWTLocation is a location where the JWT could be present in requests. +// +// Only one of Header, QueryParam, or Cookie can be specified. +type JWTLocation struct { + // Header defines how to extract a JWT from an HTTP request header. + Header *JWTLocationHeader `json:",omitempty"` + + // QueryParam defines how to extract a JWT from an HTTP request + // query parameter. + QueryParam *JWTLocationQueryParam `json:",omitempty" alias:"query_param"` + + // Cookie defines how to extract a JWT from an HTTP request cookie. + Cookie *JWTLocationCookie `json:",omitempty"` +} + +// JWTLocationHeader defines how to extract a JWT from an HTTP +// request header. +type JWTLocationHeader struct { + // Name is the name of the header containing the token. + Name string `json:",omitempty"` + + // ValuePrefix is an optional prefix that precedes the token in the + // header value. + // For example, "Bearer " is a standard value prefix for a header named + // "Authorization", but the prefix is not part of the token itself: + // "Authorization: Bearer " + ValuePrefix string `json:",omitempty" alias:"value_prefix"` + + // Forward defines whether the header with the JWT should be + // forwarded after the token has been verified. If false, the + // header will not be forwarded to the backend. + // + // Default value is false. + Forward bool `json:",omitempty"` +} + +// JWTLocationQueryParam defines how to extract a JWT from an HTTP request query parameter. +type JWTLocationQueryParam struct { + // Name is the name of the query param containing the token. + Name string `json:",omitempty"` +} + +// JWTLocationCookie defines how to extract a JWT from an HTTP request cookie. +type JWTLocationCookie struct { + // Name is the name of the cookie containing the token. + Name string `json:",omitempty"` +} + +type JWTForwardingConfig struct { + // HeaderName is a header name to use when forwarding a verified + // JWT to the backend. The verified JWT could have been extracted + // from any location (query param, header, or cookie). + // + // The header value will be base64-URL-encoded, and will not be + // padded unless PadForwardPayloadHeader is true. + HeaderName string `json:",omitempty" alias:"header_name"` + + // PadForwardPayloadHeader determines whether padding should be added + // to the base64 encoded token forwarded with ForwardPayloadHeader. + // + // Default value is false. + PadForwardPayloadHeader bool `json:",omitempty" alias:"pad_forward_payload_header"` +} + +// JSONWebKeySet defines a key set, its location on disk, or the +// means with which to fetch a key set from a remote server. +// +// Exactly one of Local or Remote must be specified. +type JSONWebKeySet struct { + // Local specifies a local source for the key set. + Local *LocalJWKS `json:",omitempty"` + + // Remote specifies how to fetch a key set from a remote server. + Remote *RemoteJWKS `json:",omitempty"` +} + +// LocalJWKS specifies a location for a local JWKS. +// +// Only one of String and Filename can be specified. +type LocalJWKS struct { + // JWKS contains a base64 encoded JWKS. + JWKS string `json:",omitempty"` + + // Filename configures a location on disk where the JWKS can be + // found. If specified, the file must be present on the disk of ALL + // proxies with intentions referencing this provider. + Filename string `json:",omitempty"` +} + +// RemoteJWKS specifies how to fetch a JWKS from a remote server. +type RemoteJWKS struct { + // URI is the URI of the server to query for the JWKS. + URI string `json:",omitempty"` + + // RequestTimeoutMs is the number of milliseconds to + // time out when making a request for the JWKS. + RequestTimeoutMs int `json:",omitempty" alias:"request_timeout_ms"` + + // CacheDuration is the duration after which cached keys + // should be expired. + // + // Default value is 5 minutes. + CacheDuration time.Duration `json:",omitempty" alias:"cache_duration"` + + // FetchAsynchronously indicates that the JWKS should be fetched + // when a client request arrives. Client requests will be paused + // until the JWKS is fetched. + // If false, the proxy listener will wait for the JWKS to be + // fetched before being activated. + // + // Default value is false. + FetchAsynchronously bool `json:",omitempty" alias:"fetch_asynchronously"` + + // RetryPolicy defines a retry policy for fetching JWKS. + // + // There is no retry by default. + RetryPolicy *JWKSRetryPolicy `json:",omitempty" alias:"retry_policy"` +} + +type JWKSRetryPolicy struct { + // NumRetries is the number of times to retry fetching the JWKS. + // The retry strategy uses jittered exponential backoff with + // a base interval of 1s and max of 10s. + // + // Default value is 0. + NumRetries int `json:",omitempty" alias:"num_retries"` + + // Backoff policy + // + // Defaults to Envoy's backoff policy + RetryPolicyBackOff *RetryPolicyBackOff `json:",omitempty" alias:"retry_policy_back_off"` +} + +type RetryPolicyBackOff struct { + // BaseInterval to be used for the next back off computation + // + // The default value from envoy is 1s + BaseInterval time.Duration `json:",omitempty" alias:"base_interval"` + + // MaxInternal to be used to specify the maximum interval between retries. + // Optional but should be greater or equal to BaseInterval. + // + // Defaults to 10 times BaseInterval + MaxInterval time.Duration `json:",omitempty" alias:"max_interval"` +} + +type JWTCacheConfig struct { + // Size specifies the maximum number of JWT verification + // results to cache. + // + // Defaults to 0, meaning that JWT caching is disabled. + Size int `json:",omitempty"` +} + +func (e *JWTProviderConfigEntry) GetKind() string { + return JWTProvider +} + +func (e *JWTProviderConfigEntry) GetName() string { return e.Name } +func (e *JWTProviderConfigEntry) GetMeta() map[string]string { return e.Meta } +func (e *JWTProviderConfigEntry) GetCreateIndex() uint64 { return e.CreateIndex } +func (e *JWTProviderConfigEntry) GetModifyIndex() uint64 { return e.ModifyIndex } +func (e *JWTProviderConfigEntry) GetPartition() string { return e.Partition } +func (e *JWTProviderConfigEntry) GetNamespace() string { return e.Namespace } diff --git a/api/config_entry_jwt_provider_test.go b/api/config_entry_jwt_provider_test.go new file mode 100644 index 000000000..69efa39c9 --- /dev/null +++ b/api/config_entry_jwt_provider_test.go @@ -0,0 +1,49 @@ +// Copyright (c) HashiCorp, Inc. + +package api + +import ( + "testing" + + "github.com/hashicorp/consul/sdk/testutil" + "github.com/stretchr/testify/require" +) + +func TestAPI_ConfigEntries_JWTProvider(t *testing.T) { + t.Parallel() + c, s := makeClient(t) + defer s.Stop() + + entries := c.ConfigEntries() + + testutil.RunStep(t, "set and get", func(t *testing.T) { + jwtProvider := &JWTProviderConfigEntry{ + Name: "okta", + Kind: JWTProvider, + JSONWebKeySet: &JSONWebKeySet{ + Local: &LocalJWKS{ + Filename: "test.txt", + }, + }, + Meta: map[string]string{ + "gir": "zim", + }, + } + + _, wm, err := entries.Set(jwtProvider, nil) + require.NoError(t, err) + require.NotNil(t, wm) + require.NotEqual(t, 0, wm.RequestTime) + + entry, qm, err := entries.Get(JWTProvider, "okta", nil) + require.NoError(t, err) + require.NotNil(t, qm) + require.NotEqual(t, 0, qm.RequestTime) + + result, ok := entry.(*JWTProviderConfigEntry) + require.True(t, ok) + + require.Equal(t, jwtProvider.Name, result.Name) + require.Equal(t, jwtProvider.JSONWebKeySet, result.JSONWebKeySet) + }) +}