diff --git a/cmd/kube-apiserver/app/server.go b/cmd/kube-apiserver/app/server.go index 12ad9f4d46de8..6436528c9bbbc 100644 --- a/cmd/kube-apiserver/app/server.go +++ b/cmd/kube-apiserver/app/server.go @@ -381,6 +381,22 @@ func CreateKubeAPIServerConfig( config.ExtraConfig.KubeletClientConfig.Lookup = config.GenericConfig.EgressSelector.Lookup } + if utilfeature.DefaultFeatureGate.Enabled(features.ServiceAccountIssuerDiscovery) { + // Load the public keys. + var pubKeys []interface{} + for _, f := range s.Authentication.ServiceAccounts.KeyFiles { + keys, err := keyutil.PublicKeysFromFile(f) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to parse key file %q: %v", f, err) + } + pubKeys = append(pubKeys, keys...) + } + // Plumb the required metadata through ExtraConfig. + config.ExtraConfig.ServiceAccountIssuerURL = s.Authentication.ServiceAccounts.Issuer + config.ExtraConfig.ServiceAccountJWKSURI = s.Authentication.ServiceAccounts.JWKSURI + config.ExtraConfig.ServiceAccountPublicKeys = pubKeys + } + return config, insecureServingInfo, serviceResolver, pluginInitializers, nil } diff --git a/pkg/features/kube_features.go b/pkg/features/kube_features.go index 2983bd39fc833..b14d12663f220 100644 --- a/pkg/features/kube_features.go +++ b/pkg/features/kube_features.go @@ -239,6 +239,15 @@ const ( // to the API server. BoundServiceAccountTokenVolume featuregate.Feature = "BoundServiceAccountTokenVolume" + // owner: @mtaufen + // alpha: v1.18 + // + // Enable OIDC discovery endpoints (issuer and JWKS URLs) for the service + // account issuer in the API server. + // Note these endpoints serve minimally-compliant discovery docs that are + // intended to be used for service account token verification. + ServiceAccountIssuerDiscovery featuregate.Feature = "ServiceAccountIssuerDiscovery" + // owner: @Random-Liu // beta: v1.11 // @@ -573,6 +582,7 @@ var defaultKubernetesFeatureGates = map[featuregate.Feature]featuregate.FeatureS TokenRequest: {Default: true, PreRelease: featuregate.Beta}, TokenRequestProjection: {Default: true, PreRelease: featuregate.Beta}, BoundServiceAccountTokenVolume: {Default: false, PreRelease: featuregate.Alpha}, + ServiceAccountIssuerDiscovery: {Default: false, PreRelease: featuregate.Alpha}, CRIContainerLogRotation: {Default: true, PreRelease: featuregate.Beta}, CSIMigration: {Default: true, PreRelease: featuregate.Beta}, CSIMigrationGCE: {Default: false, PreRelease: featuregate.Beta}, // Off by default (requires GCE PD CSI Driver) diff --git a/pkg/kubeapiserver/options/authentication.go b/pkg/kubeapiserver/options/authentication.go index 267dbfa0c43be..c7b4ac5bdb569 100644 --- a/pkg/kubeapiserver/options/authentication.go +++ b/pkg/kubeapiserver/options/authentication.go @@ -81,6 +81,7 @@ type ServiceAccountAuthenticationOptions struct { KeyFiles []string Lookup bool Issuer string + JWKSURI string MaxExpiration time.Duration } @@ -188,6 +189,22 @@ func (s *BuiltInAuthenticationOptions) Validate() []error { } } + if s.ServiceAccounts != nil { + if utilfeature.DefaultFeatureGate.Enabled(features.ServiceAccountIssuerDiscovery) { + // Validate the JWKS URI when it is explicitly set. + // When unset, it is later derived from ExternalHost. + if s.ServiceAccounts.JWKSURI != "" { + if u, err := url.Parse(s.ServiceAccounts.JWKSURI); err != nil { + allErrors = append(allErrors, fmt.Errorf("service-account-jwks-uri must be a valid URL: %v", err)) + } else if u.Scheme != "https" { + allErrors = append(allErrors, fmt.Errorf("service-account-jwks-uri requires https scheme, parsed as: %v", u.String())) + } + } + } else if len(s.ServiceAccounts.JWKSURI) > 0 { + allErrors = append(allErrors, fmt.Errorf("service-account-jwks-uri may only be set when the ServiceAccountIssuerDiscovery feature gate is enabled")) + } + } + return allErrors } @@ -281,7 +298,20 @@ func (s *BuiltInAuthenticationOptions) AddFlags(fs *pflag.FlagSet) { fs.StringVar(&s.ServiceAccounts.Issuer, "service-account-issuer", s.ServiceAccounts.Issuer, ""+ "Identifier of the service account token issuer. The issuer will assert this identifier "+ - "in \"iss\" claim of issued tokens. This value is a string or URI.") + "in \"iss\" claim of issued tokens. This value is a string or URI. If this option is not "+ + "a valid URI per the OpenID Discovery 1.0 spec, the ServiceAccountIssuerDiscovery feature "+ + "will remain disabled, even if the feature gate is set to true. It is highly recommended "+ + "that this value comply with the OpenID spec: https://openid.net/specs/openid-connect-discovery-1_0.html. "+ + "In practice, this means that service-account-issuer must be an https URL. It is also highly "+ + "recommended that this URL be capable of serving OpenID discovery documents at "+ + "`{service-account-issuer}/.well-known/openid-configuration`.") + + fs.StringVar(&s.ServiceAccounts.JWKSURI, "service-account-jwks-uri", s.ServiceAccounts.JWKSURI, ""+ + "Overrides the URI for the JSON Web Key Set in the discovery doc served at "+ + "/.well-known/openid-configuration. This flag is useful if the discovery doc"+ + "and key set are served to relying parties from a URL other than the "+ + "API server's external (as auto-detected or overridden with external-hostname). "+ + "Only valid if the ServiceAccountIssuerDiscovery feature gate is enabled.") // Deprecated in 1.13 fs.StringSliceVar(&s.APIAudiences, "service-account-api-audiences", s.APIAudiences, ""+ diff --git a/pkg/master/master.go b/pkg/master/master.go index a5849d0b8c9ec..cf74eeb6b5200 100644 --- a/pkg/master/master.go +++ b/pkg/master/master.go @@ -191,6 +191,11 @@ type ExtraConfig struct { ServiceAccountIssuer serviceaccount.TokenGenerator ServiceAccountMaxExpiration time.Duration + // ServiceAccountIssuerDiscovery + ServiceAccountIssuerURL string + ServiceAccountJWKSURI string + ServiceAccountPublicKeys []interface{} + VersionedInformers informers.SharedInformerFactory } @@ -342,6 +347,39 @@ func (c completedConfig) New(delegationTarget genericapiserver.DelegationTarget) routes.Logs{}.Install(s.Handler.GoRestfulContainer) } + if utilfeature.DefaultFeatureGate.Enabled(features.ServiceAccountIssuerDiscovery) { + // Metadata and keys are expected to only change across restarts at present, + // so we just marshal immediately and serve the cached JSON bytes. + md, err := serviceaccount.NewOpenIDMetadata( + c.ExtraConfig.ServiceAccountIssuerURL, + c.ExtraConfig.ServiceAccountJWKSURI, + c.GenericConfig.ExternalAddress, + c.ExtraConfig.ServiceAccountPublicKeys, + ) + if err != nil { + // If there was an error, skip installing the endpoints and log the + // error, but continue on. We don't return the error because the + // metadata responses require additional, backwards incompatible + // validation of command-line options. + msg := fmt.Sprintf("Could not construct pre-rendered responses for"+ + " ServiceAccountIssuerDiscovery endpoints. Endpoints will not be"+ + " enabled. Error: %v", err) + if c.ExtraConfig.ServiceAccountIssuerURL != "" { + // The user likely expects this feature to be enabled if issuer URL is + // set and the feature gate is enabled. In the future, if there is no + // longer a feature gate and issuer URL is not set, the user may not + // expect this feature to be enabled. We log the former case as an Error + // and the latter case as an Info. + klog.Error(msg) + } else { + klog.Info(msg) + } + } else { + routes.NewOpenIDMetadataServer(md.ConfigJSON, md.PublicKeysetJSON). + Install(s.Handler.GoRestfulContainer) + } + } + m := &Master{ GenericAPIServer: s, ClusterAuthenticationInfo: c.ExtraConfig.ClusterAuthenticationInfo, diff --git a/pkg/routes/BUILD b/pkg/routes/BUILD index 7d7d36902509e..ef8c18fc19d85 100644 --- a/pkg/routes/BUILD +++ b/pkg/routes/BUILD @@ -10,9 +10,14 @@ go_library( srcs = [ "doc.go", "logs.go", + "openidmetadata.go", ], importpath = "k8s.io/kubernetes/pkg/routes", - deps = ["//vendor/github.com/emicklei/go-restful:go_default_library"], + deps = [ + "//pkg/serviceaccount:go_default_library", + "//vendor/github.com/emicklei/go-restful:go_default_library", + "//vendor/k8s.io/klog:go_default_library", + ], ) filegroup( diff --git a/pkg/routes/openidmetadata.go b/pkg/routes/openidmetadata.go new file mode 100644 index 0000000000000..64038a5ce9477 --- /dev/null +++ b/pkg/routes/openidmetadata.go @@ -0,0 +1,114 @@ +/* +Copyright 2019 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package routes + +import ( + "net/http" + + restful "github.com/emicklei/go-restful" + + "k8s.io/klog" + "k8s.io/kubernetes/pkg/serviceaccount" +) + +// This code is in package routes because many controllers import +// pkg/serviceaccount, but are not allowed by import-boss to depend on +// go-restful. All logic that deals with keys is kept in pkg/serviceaccount, +// and only the rendered JSON is passed into this server. + +const ( + // cacheControl is the value of the Cache-Control header. Overrides the + // global `private, no-cache` setting. + headerCacheControl = "Cache-Control" + cacheControl = "public, max-age=3600" // 1 hour + + // mimeJWKS is the content type of the keyset response + mimeJWKS = "application/jwk-set+json" +) + +// OpenIDMetadataServer is an HTTP server for metadata of the KSA token issuer. +type OpenIDMetadataServer struct { + configJSON []byte + keysetJSON []byte +} + +// NewOpenIDMetadataServer creates a new OpenIDMetadataServer. +// The issuer is the OIDC issuer; keys are the keys that may be used to sign +// KSA tokens. +func NewOpenIDMetadataServer(configJSON, keysetJSON []byte) *OpenIDMetadataServer { + return &OpenIDMetadataServer{ + configJSON: configJSON, + keysetJSON: keysetJSON, + } +} + +// Install adds this server to the request router c. +func (s *OpenIDMetadataServer) Install(c *restful.Container) { + // Configuration WebService + // Container.Add "will detect duplicate root paths and exit in that case", + // so we need a root for /.well-known/openid-configuration to avoid conflicts. + cfg := new(restful.WebService). + Produces(restful.MIME_JSON) + + cfg.Path(serviceaccount.OpenIDConfigPath).Route( + cfg.GET(""). + To(fromStandard(s.serveConfiguration)). + Doc("get service account issuer OpenID configuration, also known as the 'OIDC discovery doc'"). + Operation("getServiceAccountIssuerOpenIDConfiguration"). + // Just include the OK, doesn't look like we include Internal Error in our openapi-spec. + Returns(http.StatusOK, "OK", "")) + c.Add(cfg) + + // JWKS WebService + jwks := new(restful.WebService). + Produces(mimeJWKS) + + jwks.Path(serviceaccount.JWKSPath).Route( + jwks.GET(""). + To(fromStandard(s.serveKeys)). + Doc("get service account issuer OpenID JSON Web Key Set (contains public token verification keys)"). + Operation("getServiceAccountIssuerOpenIDKeyset"). + // Just include the OK, doesn't look like we include Internal Error in our openapi-spec. + Returns(http.StatusOK, "OK", "")) + c.Add(jwks) +} + +// fromStandard provides compatibility between the standard (net/http) handler signature and the restful signature. +func fromStandard(h http.HandlerFunc) restful.RouteFunction { + return func(req *restful.Request, resp *restful.Response) { + h(resp, req.Request) + } +} + +func (s *OpenIDMetadataServer) serveConfiguration(w http.ResponseWriter, req *http.Request) { + w.Header().Set(restful.HEADER_ContentType, restful.MIME_JSON) + w.Header().Set(headerCacheControl, cacheControl) + if _, err := w.Write(s.configJSON); err != nil { + klog.Errorf("failed to write service account issuer metadata response: %v", err) + return + } +} + +func (s *OpenIDMetadataServer) serveKeys(w http.ResponseWriter, req *http.Request) { + // Per RFC7517 : https://tools.ietf.org/html/rfc7517#section-8.5.1 + w.Header().Set(restful.HEADER_ContentType, mimeJWKS) + w.Header().Set(headerCacheControl, cacheControl) + if _, err := w.Write(s.keysetJSON); err != nil { + klog.Errorf("failed to write service account issuer JWKS response: %v", err) + return + } +} diff --git a/pkg/serviceaccount/BUILD b/pkg/serviceaccount/BUILD index bd314ae6a2689..d28601f8f660a 100644 --- a/pkg/serviceaccount/BUILD +++ b/pkg/serviceaccount/BUILD @@ -12,6 +12,7 @@ go_library( "claims.go", "jwt.go", "legacy.go", + "openidmetadata.go", "util.go", ], importpath = "k8s.io/kubernetes/pkg/serviceaccount", @@ -19,6 +20,7 @@ go_library( "//pkg/apis/core:go_default_library", "//staging/src/k8s.io/api/core/v1:go_default_library", "//staging/src/k8s.io/apimachinery/pkg/util/errors:go_default_library", + "//staging/src/k8s.io/apimachinery/pkg/util/sets:go_default_library", "//staging/src/k8s.io/apiserver/pkg/authentication/authenticator:go_default_library", "//staging/src/k8s.io/apiserver/pkg/authentication/serviceaccount:go_default_library", "//staging/src/k8s.io/apiserver/pkg/authentication/user:go_default_library", @@ -46,12 +48,14 @@ go_test( srcs = [ "claims_test.go", "jwt_test.go", + "openidmetadata_test.go", "util_test.go", ], embed = [":go_default_library"], deps = [ "//pkg/apis/core:go_default_library", "//pkg/controller/serviceaccount:go_default_library", + "//pkg/routes:go_default_library", "//staging/src/k8s.io/api/core/v1:go_default_library", "//staging/src/k8s.io/apimachinery/pkg/apis/meta/v1:go_default_library", "//staging/src/k8s.io/apiserver/pkg/authentication/authenticator:go_default_library", @@ -60,6 +64,8 @@ go_test( "//staging/src/k8s.io/client-go/listers/core/v1:go_default_library", "//staging/src/k8s.io/client-go/tools/cache:go_default_library", "//staging/src/k8s.io/client-go/util/keyutil:go_default_library", + "//vendor/github.com/emicklei/go-restful:go_default_library", + "//vendor/github.com/google/go-cmp/cmp:go_default_library", "//vendor/gopkg.in/square/go-jose.v2:go_default_library", "//vendor/gopkg.in/square/go-jose.v2/jwt:go_default_library", ], diff --git a/pkg/serviceaccount/jwt.go b/pkg/serviceaccount/jwt.go index ba03a22233dc2..24be9629d3113 100644 --- a/pkg/serviceaccount/jwt.go +++ b/pkg/serviceaccount/jwt.go @@ -114,6 +114,10 @@ func signerFromRSAPrivateKey(keyPair *rsa.PrivateKey) (jose.Signer, error) { return nil, fmt.Errorf("failed to derive keyID: %v", err) } + // IMPORTANT: If this function is updated to support additional key sizes, + // algorithmForPublicKey in serviceaccount/openidmetadata.go must also be + // updated to support the same key sizes. Today we only support RS256. + // Wrap the RSA keypair in a JOSE JWK with the designated key ID. privateJWK := &jose.JSONWebKey{ Algorithm: string(jose.RS256), diff --git a/pkg/serviceaccount/jwt_test.go b/pkg/serviceaccount/jwt_test.go index 6852015d0e8d1..aecfb23a48d0c 100644 --- a/pkg/serviceaccount/jwt_test.go +++ b/pkg/serviceaccount/jwt_test.go @@ -18,6 +18,7 @@ package serviceaccount_test import ( "context" + "fmt" "reflect" "strings" "testing" @@ -116,12 +117,18 @@ X2i8uIp/C/ASqiIGUeeKQtX0/IR3qCXyThP/dbCiHrF3v1cuhBOHY8CLVg== const ecdsaKeyID = "SoABiieYuNx4UdqYvZRVeuC6SihxgLrhLy9peHMHpTc" func getPrivateKey(data string) interface{} { - key, _ := keyutil.ParsePrivateKeyPEM([]byte(data)) + key, err := keyutil.ParsePrivateKeyPEM([]byte(data)) + if err != nil { + panic(fmt.Errorf("unexpected error parsing private key: %v", err)) + } return key } func getPublicKey(data string) interface{} { - keys, _ := keyutil.ParsePublicKeysPEM([]byte(data)) + keys, err := keyutil.ParsePublicKeysPEM([]byte(data)) + if err != nil { + panic(fmt.Errorf("unexpected error parsing public key: %v", err)) + } return keys[0] } diff --git a/pkg/serviceaccount/openidmetadata.go b/pkg/serviceaccount/openidmetadata.go new file mode 100644 index 0000000000000..56ec23d118a39 --- /dev/null +++ b/pkg/serviceaccount/openidmetadata.go @@ -0,0 +1,295 @@ +/* +Copyright 2019 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package serviceaccount + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rsa" + "encoding/json" + "fmt" + "net/url" + + jose "gopkg.in/square/go-jose.v2" + + "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/apimachinery/pkg/util/sets" +) + +const ( + // OpenIDConfigPath is the URL path at which the API server serves + // an OIDC Provider Configuration Information document, corresponding + // to the Kubernetes Service Account key issuer. + // https://openid.net/specs/openid-connect-discovery-1_0.html + OpenIDConfigPath = "/.well-known/openid-configuration" + + // JWKSPath is the URL path at which the API server serves a JWKS + // containing the public keys that may be used to sign Kubernetes + // Service Account keys. + JWKSPath = "/openid/v1/jwks" +) + +// OpenIDMetadata contains the pre-rendered responses for OIDC discovery endpoints. +type OpenIDMetadata struct { + ConfigJSON []byte + PublicKeysetJSON []byte +} + +// NewOpenIDMetadata returns the pre-rendered JSON responses for the OIDC discovery +// endpoints, or an error if they could not be constructed. Callers should note +// that this function may perform additional validation on inputs that is not +// backwards-compatible with all command-line validation. The recommendation is +// to log the error and skip installing the OIDC discovery endpoints. +func NewOpenIDMetadata(issuerURL, jwksURI, defaultExternalAddress string, pubKeys []interface{}) (*OpenIDMetadata, error) { + if issuerURL == "" { + return nil, fmt.Errorf("empty issuer URL") + } + if jwksURI == "" && defaultExternalAddress == "" { + return nil, fmt.Errorf("either the JWKS URI or the default external address, or both, must be set") + } + if len(pubKeys) == 0 { + return nil, fmt.Errorf("no keys provided for validating keyset") + } + + // Ensure the issuer URL meets the OIDC spec (this is the additional + // validation the doc comment warns about). + // https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata + iss, err := url.Parse(issuerURL) + if err != nil { + return nil, err + } + if iss.Scheme != "https" { + return nil, fmt.Errorf("issuer URL must use https scheme, got: %s", issuerURL) + } + if iss.RawQuery != "" { + return nil, fmt.Errorf("issuer URL may not include a query, got: %s", issuerURL) + } + if iss.Fragment != "" { + return nil, fmt.Errorf("issuer URL may not include a fragment, got: %s", issuerURL) + } + + // Either use the provided JWKS URI or default to ExternalAddress plus + // the JWKS path. + if jwksURI == "" { + const msg = "attempted to build jwks_uri from external " + + "address %s, but could not construct a valid URL. Error: %v" + + if defaultExternalAddress == "" { + return nil, fmt.Errorf(msg, defaultExternalAddress, + fmt.Errorf("empty address")) + } + + u := &url.URL{ + Scheme: "https", + Host: defaultExternalAddress, + Path: JWKSPath, + } + jwksURI = u.String() + + // TODO(mtaufen): I think we can probably expect ExternalAddress is + // at most just host + port and skip the sanity check, but want to be + // careful until that is confirmed. + + // Sanity check that the jwksURI we produced is the valid URL we expect. + // This is just in case ExternalAddress came in as something weird, + // like a scheme + host + port, instead of just host + port. + parsed, err := url.Parse(jwksURI) + if err != nil { + return nil, fmt.Errorf(msg, defaultExternalAddress, err) + } else if u.Scheme != parsed.Scheme || + u.Host != parsed.Host || + u.Path != parsed.Path { + return nil, fmt.Errorf(msg, defaultExternalAddress, + fmt.Errorf("got %v, expected %v", parsed, u)) + } + } else { + // Double-check that jwksURI is an https URL + if u, err := url.Parse(jwksURI); err != nil { + return nil, err + } else if u.Scheme != "https" { + return nil, fmt.Errorf("jwksURI requires https scheme, parsed as: %v", u.String()) + } + } + + configJSON, err := openIDConfigJSON(issuerURL, jwksURI, pubKeys) + if err != nil { + return nil, fmt.Errorf("could not marshal issuer discovery JSON, error: %v", err) + } + + keysetJSON, err := openIDKeysetJSON(pubKeys) + if err != nil { + return nil, fmt.Errorf("could not marshal issuer keys JSON, error: %v", err) + } + + return &OpenIDMetadata{ + ConfigJSON: configJSON, + PublicKeysetJSON: keysetJSON, + }, nil +} + +// openIDMetadata provides a minimal subset of OIDC provider metadata: +// https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata +type openIDMetadata struct { + Issuer string `json:"issuer"` // REQUIRED in OIDC; meaningful to relying parties. + // TODO(mtaufen): Since our goal is compatibility for relying parties that + // need to validate ID tokens, but do not need to initiate login flows, + // and since we aren't sure what to put in authorization_endpoint yet, + // we will omit this field until someone files a bug. + // AuthzEndpoint string `json:"authorization_endpoint"` // REQUIRED in OIDC; but useless to relying parties. + JWKSURI string `json:"jwks_uri"` // REQUIRED in OIDC; meaningful to relying parties. + ResponseTypes []string `json:"response_types_supported"` // REQUIRED in OIDC + SubjectTypes []string `json:"subject_types_supported"` // REQUIRED in OIDC + SigningAlgs []string `json:"id_token_signing_alg_values_supported"` // REQUIRED in OIDC +} + +// openIDConfigJSON returns the JSON OIDC Discovery Doc for the service +// account issuer. +func openIDConfigJSON(iss, jwksURI string, keys []interface{}) ([]byte, error) { + keyset, errs := publicJWKSFromKeys(keys) + if errs != nil { + return nil, errs + } + + metadata := openIDMetadata{ + Issuer: iss, + JWKSURI: jwksURI, + ResponseTypes: []string{"id_token"}, // Kubernetes only produces ID tokens + SubjectTypes: []string{"public"}, // https://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes + SigningAlgs: getAlgs(keyset), // REQUIRED by OIDC + } + + metadataJSON, err := json.Marshal(metadata) + if err != nil { + return nil, fmt.Errorf("failed to marshal service account issuer metadata: %v", err) + } + + return metadataJSON, nil +} + +// openIDKeysetJSON returns the JSON Web Key Set for the service account +// issuer's keys. +func openIDKeysetJSON(keys []interface{}) ([]byte, error) { + keyset, errs := publicJWKSFromKeys(keys) + if errs != nil { + return nil, errs + } + + keysetJSON, err := json.Marshal(keyset) + if err != nil { + return nil, fmt.Errorf("failed to marshal service account issuer JWKS: %v", err) + } + + return keysetJSON, nil +} + +func getAlgs(keys *jose.JSONWebKeySet) []string { + algs := sets.NewString() + for _, k := range keys.Keys { + algs.Insert(k.Algorithm) + } + // Note: List returns a sorted slice. + return algs.List() +} + +type publicKeyGetter interface { + Public() crypto.PublicKey +} + +// publicJWKSFromKeys constructs a JSONWebKeySet from a list of keys. The key +// set will only contain the public keys associated with the input keys. +func publicJWKSFromKeys(in []interface{}) (*jose.JSONWebKeySet, errors.Aggregate) { + // Decode keys into a JWKS. + var keys jose.JSONWebKeySet + var errs []error + for i, key := range in { + var pubkey *jose.JSONWebKey + var err error + + switch k := key.(type) { + case publicKeyGetter: + // This is a private key. Get its public key + pubkey, err = jwkFromPublicKey(k.Public()) + default: + pubkey, err = jwkFromPublicKey(k) + } + if err != nil { + errs = append(errs, fmt.Errorf("error constructing JWK for key #%d: %v", i, err)) + continue + } + + if !pubkey.Valid() { + errs = append(errs, fmt.Errorf("key #%d not valid", i)) + continue + } + keys.Keys = append(keys.Keys, *pubkey) + } + if len(errs) != 0 { + return nil, errors.NewAggregate(errs) + } + return &keys, nil +} + +func jwkFromPublicKey(publicKey crypto.PublicKey) (*jose.JSONWebKey, error) { + alg, err := algorithmFromPublicKey(publicKey) + if err != nil { + return nil, err + } + + keyID, err := keyIDFromPublicKey(publicKey) + if err != nil { + return nil, err + } + + jwk := &jose.JSONWebKey{ + Algorithm: string(alg), + Key: publicKey, + KeyID: keyID, + Use: "sig", + } + + if !jwk.IsPublic() { + return nil, fmt.Errorf("JWK was not a public key! JWK: %v", jwk) + } + + return jwk, nil +} + +func algorithmFromPublicKey(publicKey crypto.PublicKey) (jose.SignatureAlgorithm, error) { + switch pk := publicKey.(type) { + case *rsa.PublicKey: + // IMPORTANT: If this function is updated to support additional key sizes, + // signerFromRSAPrivateKey in serviceaccount/jwt.go must also be + // updated to support the same key sizes. Today we only support RS256. + return jose.RS256, nil + case *ecdsa.PublicKey: + switch pk.Curve { + case elliptic.P256(): + return jose.ES256, nil + case elliptic.P384(): + return jose.ES384, nil + case elliptic.P521(): + return jose.ES512, nil + default: + return "", fmt.Errorf("unknown private key curve, must be 256, 384, or 521") + } + case jose.OpaqueSigner: + return jose.SignatureAlgorithm(pk.Public().Algorithm), nil + default: + return "", fmt.Errorf("unknown public key type, must be *rsa.PublicKey, *ecdsa.PublicKey, or jose.OpaqueSigner") + } +} diff --git a/pkg/serviceaccount/openidmetadata_test.go b/pkg/serviceaccount/openidmetadata_test.go new file mode 100644 index 0000000000000..1e878edbf1c2a --- /dev/null +++ b/pkg/serviceaccount/openidmetadata_test.go @@ -0,0 +1,395 @@ +/* +Copyright 2019 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package serviceaccount_test + +import ( + "crypto/ecdsa" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + restful "github.com/emicklei/go-restful" + "github.com/google/go-cmp/cmp" + jose "gopkg.in/square/go-jose.v2" + + "k8s.io/kubernetes/pkg/routes" + "k8s.io/kubernetes/pkg/serviceaccount" +) + +const ( + exampleIssuer = "https://issuer.example.com" +) + +func setupServer(t *testing.T, iss string, keys []interface{}) (*httptest.Server, string) { + t.Helper() + + c := restful.NewContainer() + s := httptest.NewServer(c) + + // JWKS needs to be https, so swap that for the test + jwksURI, err := url.Parse(s.URL) + if err != nil { + t.Fatal(err) + } + jwksURI.Scheme = "https" + jwksURI.Path = serviceaccount.JWKSPath + + md, err := serviceaccount.NewOpenIDMetadata( + iss, jwksURI.String(), "", keys) + if err != nil { + t.Fatal(err) + } + + srv := routes.NewOpenIDMetadataServer(md.ConfigJSON, md.PublicKeysetJSON) + srv.Install(c) + + return s, jwksURI.String() +} + +var defaultKeys = []interface{}{getPublicKey(rsaPublicKey), getPublicKey(ecdsaPublicKey)} + +// Configuration is an OIDC configuration, including most but not all required fields. +// https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata +type Configuration struct { + Issuer string `json:"issuer"` + JWKSURI string `json:"jwks_uri"` + ResponseTypes []string `json:"response_types_supported"` + SigningAlgs []string `json:"id_token_signing_alg_values_supported"` + SubjectTypes []string `json:"subject_types_supported"` +} + +func TestServeConfiguration(t *testing.T) { + s, jwksURI := setupServer(t, exampleIssuer, defaultKeys) + defer s.Close() + + want := Configuration{ + Issuer: exampleIssuer, + JWKSURI: jwksURI, + ResponseTypes: []string{"id_token"}, + SubjectTypes: []string{"public"}, + SigningAlgs: []string{"ES256", "RS256"}, + } + + reqURL := s.URL + "/.well-known/openid-configuration" + + resp, err := http.Get(reqURL) + if err != nil { + t.Fatalf("Get(%s) = %v, %v want: , ", reqURL, resp, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("Get(%s) = %v, _ want: %v, _", reqURL, resp.StatusCode, http.StatusOK) + } + + if got, want := resp.Header.Get("Content-Type"), "application/json"; got != want { + t.Errorf("Get(%s) Content-Type = %q, _ want: %q, _", reqURL, got, want) + } + if got, want := resp.Header.Get("Cache-Control"), "public, max-age=3600"; got != want { + t.Errorf("Get(%s) Cache-Control = %q, _ want: %q, _", reqURL, got, want) + } + + var got Configuration + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + t.Fatalf("Decode(_) = %v, want: ", err) + } + + if !cmp.Equal(want, got) { + t.Errorf("unexpected diff in received configuration (-want, +got):%s", + cmp.Diff(want, got)) + } +} + +func TestServeKeys(t *testing.T) { + wantPubRSA := getPublicKey(rsaPublicKey).(*rsa.PublicKey) + wantPubECDSA := getPublicKey(ecdsaPublicKey).(*ecdsa.PublicKey) + var serveKeysTests = []struct { + Name string + Keys []interface{} + WantKeys []jose.JSONWebKey + }{ + { + Name: "configured public keys", + Keys: []interface{}{ + getPublicKey(rsaPublicKey), + getPublicKey(ecdsaPublicKey), + }, + WantKeys: []jose.JSONWebKey{ + { + Algorithm: "RS256", + Key: wantPubRSA, + KeyID: rsaKeyID, + Use: "sig", + Certificates: []*x509.Certificate{}, + }, + { + Algorithm: "ES256", + Key: wantPubECDSA, + KeyID: ecdsaKeyID, + Use: "sig", + Certificates: []*x509.Certificate{}, + }, + }, + }, + { + Name: "only publishes public keys", + Keys: []interface{}{ + getPrivateKey(rsaPrivateKey), + getPrivateKey(ecdsaPrivateKey), + }, + WantKeys: []jose.JSONWebKey{ + { + Algorithm: "RS256", + Key: wantPubRSA, + KeyID: rsaKeyID, + Use: "sig", + Certificates: []*x509.Certificate{}, + }, + { + Algorithm: "ES256", + Key: wantPubECDSA, + KeyID: ecdsaKeyID, + Use: "sig", + Certificates: []*x509.Certificate{}, + }, + }, + }, + } + + for _, tt := range serveKeysTests { + t.Run(tt.Name, func(t *testing.T) { + s, _ := setupServer(t, exampleIssuer, tt.Keys) + defer s.Close() + + reqURL := s.URL + "/openid/v1/jwks" + + resp, err := http.Get(reqURL) + if err != nil { + t.Fatalf("Get(%s) = %v, %v want: , ", reqURL, resp, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("Get(%s) = %v, _ want: %v, _", reqURL, resp.StatusCode, http.StatusOK) + } + + if got, want := resp.Header.Get("Content-Type"), "application/jwk-set+json"; got != want { + t.Errorf("Get(%s) Content-Type = %q, _ want: %q, _", reqURL, got, want) + } + if got, want := resp.Header.Get("Cache-Control"), "public, max-age=3600"; got != want { + t.Errorf("Get(%s) Cache-Control = %q, _ want: %q, _", reqURL, got, want) + } + + ks := &jose.JSONWebKeySet{} + if err := json.NewDecoder(resp.Body).Decode(ks); err != nil { + t.Fatalf("Decode(_) = %v, want: ", err) + } + + bigIntComparer := cmp.Comparer( + func(x, y *big.Int) bool { + return x.Cmp(y) == 0 + }) + if !cmp.Equal(tt.WantKeys, ks.Keys, bigIntComparer) { + t.Errorf("unexpected diff in JWKS keys (-want, +got): %v", + cmp.Diff(tt.WantKeys, ks.Keys, bigIntComparer)) + } + }) + } +} + +func TestURLBoundaries(t *testing.T) { + s, _ := setupServer(t, exampleIssuer, defaultKeys) + defer s.Close() + + for _, tt := range []struct { + Name string + Path string + WantOK bool + }{ + {"OIDC config path", "/.well-known/openid-configuration", true}, + {"JWKS path", "/openid/v1/jwks", true}, + {"well-known", "/.well-known", false}, + {"subpath", "/openid/v1/jwks/foo", false}, + {"query", "/openid/v1/jwks?format=yaml", true}, + {"fragment", "/openid/v1/jwks#issuer", true}, + } { + t.Run(tt.Name, func(t *testing.T) { + resp, err := http.Get(s.URL + tt.Path) + if err != nil { + t.Fatal(err) + } + + if tt.WantOK && (resp.StatusCode != http.StatusOK) { + t.Errorf("Get(%v)= %v, want %v", tt.Path, resp.StatusCode, http.StatusOK) + } + if !tt.WantOK && (resp.StatusCode != http.StatusNotFound) { + t.Errorf("Get(%v)= %v, want %v", tt.Path, resp.StatusCode, http.StatusNotFound) + } + }) + } +} + +func TestNewOpenIDMetadata(t *testing.T) { + cases := []struct { + name string + issuerURL string + jwksURI string + externalAddress string + keys []interface{} + wantConfig string + wantKeyset string + err bool + }{ + { + name: "valid inputs", + issuerURL: exampleIssuer, + jwksURI: exampleIssuer + serviceaccount.JWKSPath, + keys: defaultKeys, + wantConfig: `{"issuer":"https://issuer.example.com","jwks_uri":"https://issuer.example.com/openid/v1/jwks","response_types_supported":["id_token"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["ES256","RS256"]}`, + wantKeyset: `{"keys":[{"use":"sig","kty":"RSA","kid":"JHJehTTTZlsspKHT-GaJxK7Kd1NQgZJu3fyK6K_QDYU","alg":"RS256","n":"249XwEo9k4tM8fMxV7zxOhcrP-WvXn917koM5Qr2ZXs4vo26e4ytdlrV0bQ9SlcLpQVSYjIxNfhTZdDt-ecIzshKuv1gKIxbbLQMOuK1eA_4HALyEkFgmS_tleLJrhc65tKPMGD-pKQ_xhmzRuCG51RoiMgbQxaCyYxGfNLpLAZK9L0Tctv9a0mJmGIYnIOQM4kC1A1I1n3EsXMWmeJUj7OTh_AjjCnMnkgvKT2tpKxYQ59PgDgU8Ssc7RDSmSkLxnrv-OrN80j6xrw0OjEiB4Ycr0PqfzZcvy8efTtFQ_Jnc4Bp1zUtFXt7-QeevePtQ2EcyELXE0i63T1CujRMWw","e":"AQAB"},{"use":"sig","kty":"EC","kid":"SoABiieYuNx4UdqYvZRVeuC6SihxgLrhLy9peHMHpTc","crv":"P-256","alg":"ES256","x":"H6cuzP8XuD5wal6wf9M6xDljTOPLX2i8uIp_C_ASqiI","y":"BlHnikLV9PyEd6gl8k4T_3Wwoh6xd79XLoQTh2PAi1Y"}]}`, + }, + { + name: "valid inputs, default JWKSURI to external address", + issuerURL: exampleIssuer, + jwksURI: "", + // We expect host + port, no scheme, when API server calculates ExternalAddress. + externalAddress: "192.0.2.1:80", + keys: defaultKeys, + wantConfig: `{"issuer":"https://issuer.example.com","jwks_uri":"https://192.0.2.1:80/openid/v1/jwks","response_types_supported":["id_token"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["ES256","RS256"]}`, + wantKeyset: `{"keys":[{"use":"sig","kty":"RSA","kid":"JHJehTTTZlsspKHT-GaJxK7Kd1NQgZJu3fyK6K_QDYU","alg":"RS256","n":"249XwEo9k4tM8fMxV7zxOhcrP-WvXn917koM5Qr2ZXs4vo26e4ytdlrV0bQ9SlcLpQVSYjIxNfhTZdDt-ecIzshKuv1gKIxbbLQMOuK1eA_4HALyEkFgmS_tleLJrhc65tKPMGD-pKQ_xhmzRuCG51RoiMgbQxaCyYxGfNLpLAZK9L0Tctv9a0mJmGIYnIOQM4kC1A1I1n3EsXMWmeJUj7OTh_AjjCnMnkgvKT2tpKxYQ59PgDgU8Ssc7RDSmSkLxnrv-OrN80j6xrw0OjEiB4Ycr0PqfzZcvy8efTtFQ_Jnc4Bp1zUtFXt7-QeevePtQ2EcyELXE0i63T1CujRMWw","e":"AQAB"},{"use":"sig","kty":"EC","kid":"SoABiieYuNx4UdqYvZRVeuC6SihxgLrhLy9peHMHpTc","crv":"P-256","alg":"ES256","x":"H6cuzP8XuD5wal6wf9M6xDljTOPLX2i8uIp_C_ASqiI","y":"BlHnikLV9PyEd6gl8k4T_3Wwoh6xd79XLoQTh2PAi1Y"}]}`, + }, + { + name: "valid inputs, IP addresses instead of domains", + issuerURL: "https://192.0.2.1:80", + jwksURI: "https://192.0.2.1:80" + serviceaccount.JWKSPath, + keys: defaultKeys, + wantConfig: `{"issuer":"https://192.0.2.1:80","jwks_uri":"https://192.0.2.1:80/openid/v1/jwks","response_types_supported":["id_token"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["ES256","RS256"]}`, + wantKeyset: `{"keys":[{"use":"sig","kty":"RSA","kid":"JHJehTTTZlsspKHT-GaJxK7Kd1NQgZJu3fyK6K_QDYU","alg":"RS256","n":"249XwEo9k4tM8fMxV7zxOhcrP-WvXn917koM5Qr2ZXs4vo26e4ytdlrV0bQ9SlcLpQVSYjIxNfhTZdDt-ecIzshKuv1gKIxbbLQMOuK1eA_4HALyEkFgmS_tleLJrhc65tKPMGD-pKQ_xhmzRuCG51RoiMgbQxaCyYxGfNLpLAZK9L0Tctv9a0mJmGIYnIOQM4kC1A1I1n3EsXMWmeJUj7OTh_AjjCnMnkgvKT2tpKxYQ59PgDgU8Ssc7RDSmSkLxnrv-OrN80j6xrw0OjEiB4Ycr0PqfzZcvy8efTtFQ_Jnc4Bp1zUtFXt7-QeevePtQ2EcyELXE0i63T1CujRMWw","e":"AQAB"},{"use":"sig","kty":"EC","kid":"SoABiieYuNx4UdqYvZRVeuC6SihxgLrhLy9peHMHpTc","crv":"P-256","alg":"ES256","x":"H6cuzP8XuD5wal6wf9M6xDljTOPLX2i8uIp_C_ASqiI","y":"BlHnikLV9PyEd6gl8k4T_3Wwoh6xd79XLoQTh2PAi1Y"}]}`, + }, + { + name: "response only contains public keys, even when private keys are provided", + issuerURL: exampleIssuer, + jwksURI: exampleIssuer + serviceaccount.JWKSPath, + keys: []interface{}{getPrivateKey(rsaPrivateKey), getPrivateKey(ecdsaPrivateKey)}, + wantConfig: `{"issuer":"https://issuer.example.com","jwks_uri":"https://issuer.example.com/openid/v1/jwks","response_types_supported":["id_token"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["ES256","RS256"]}`, + wantKeyset: `{"keys":[{"use":"sig","kty":"RSA","kid":"JHJehTTTZlsspKHT-GaJxK7Kd1NQgZJu3fyK6K_QDYU","alg":"RS256","n":"249XwEo9k4tM8fMxV7zxOhcrP-WvXn917koM5Qr2ZXs4vo26e4ytdlrV0bQ9SlcLpQVSYjIxNfhTZdDt-ecIzshKuv1gKIxbbLQMOuK1eA_4HALyEkFgmS_tleLJrhc65tKPMGD-pKQ_xhmzRuCG51RoiMgbQxaCyYxGfNLpLAZK9L0Tctv9a0mJmGIYnIOQM4kC1A1I1n3EsXMWmeJUj7OTh_AjjCnMnkgvKT2tpKxYQ59PgDgU8Ssc7RDSmSkLxnrv-OrN80j6xrw0OjEiB4Ycr0PqfzZcvy8efTtFQ_Jnc4Bp1zUtFXt7-QeevePtQ2EcyELXE0i63T1CujRMWw","e":"AQAB"},{"use":"sig","kty":"EC","kid":"SoABiieYuNx4UdqYvZRVeuC6SihxgLrhLy9peHMHpTc","crv":"P-256","alg":"ES256","x":"H6cuzP8XuD5wal6wf9M6xDljTOPLX2i8uIp_C_ASqiI","y":"BlHnikLV9PyEd6gl8k4T_3Wwoh6xd79XLoQTh2PAi1Y"}]}`, + }, + { + name: "issuer missing https", + issuerURL: "http://issuer.example.com", + jwksURI: exampleIssuer + serviceaccount.JWKSPath, + keys: defaultKeys, + err: true, + }, + { + name: "issuer missing scheme", + issuerURL: "issuer.example.com", + jwksURI: exampleIssuer + serviceaccount.JWKSPath, + keys: defaultKeys, + err: true, + }, + { + name: "issuer includes query", + issuerURL: "https://issuer.example.com?foo=bar", + jwksURI: exampleIssuer + serviceaccount.JWKSPath, + keys: defaultKeys, + err: true, + }, + { + name: "issuer includes fragment", + issuerURL: "https://issuer.example.com#baz", + jwksURI: exampleIssuer + serviceaccount.JWKSPath, + keys: defaultKeys, + err: true, + }, + { + name: "issuer includes query and fragment", + issuerURL: "https://issuer.example.com?foo=bar#baz", + jwksURI: exampleIssuer + serviceaccount.JWKSPath, + keys: defaultKeys, + err: true, + }, + { + name: "issuer is not a valid URL", + issuerURL: "issuer", + jwksURI: exampleIssuer + serviceaccount.JWKSPath, + keys: defaultKeys, + err: true, + }, + { + name: "jwks missing https", + issuerURL: exampleIssuer, + jwksURI: "http://issuer.example.com" + serviceaccount.JWKSPath, + keys: defaultKeys, + err: true, + }, + { + name: "jwks missing scheme", + issuerURL: exampleIssuer, + jwksURI: "issuer.example.com" + serviceaccount.JWKSPath, + keys: defaultKeys, + err: true, + }, + { + name: "jwks is not a valid URL", + issuerURL: exampleIssuer, + jwksURI: "issuer" + serviceaccount.JWKSPath, + keys: defaultKeys, + err: true, + }, + { + name: "external address also has a scheme", + issuerURL: exampleIssuer, + externalAddress: "https://192.0.2.1:80", + keys: defaultKeys, + err: true, + }, + { + name: "missing external address and jwks", + issuerURL: exampleIssuer, + keys: defaultKeys, + err: true, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + md, err := serviceaccount.NewOpenIDMetadata(tc.issuerURL, tc.jwksURI, tc.externalAddress, tc.keys) + if tc.err { + if err == nil { + t.Fatalf("got , want error") + } + return + } else if !tc.err && err != nil { + t.Fatalf("got error %v, want ", err) + } + + config := string(md.ConfigJSON) + keyset := string(md.PublicKeysetJSON) + if config != tc.wantConfig { + t.Errorf("got metadata %s, want %s", config, tc.wantConfig) + } + if keyset != tc.wantKeyset { + t.Errorf("got keyset %s, want %s", keyset, tc.wantKeyset) + } + }) + } +} diff --git a/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/policy.go b/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/policy.go index 7e17f31dd5e14..bb1c4f8153576 100644 --- a/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/policy.go +++ b/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/policy.go @@ -458,6 +458,21 @@ func ClusterRoles() []rbacv1.ClusterRole { }, } + if utilfeature.DefaultFeatureGate.Enabled(features.ServiceAccountIssuerDiscovery) { + // Add the cluster role for reading the ServiceAccountIssuerDiscovery endpoints + // but do not bind it explicitly. Leave the decision of who can read it up + // to cluster admins. + roles = append(roles, rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{Name: "system:service-account-issuer-discovery"}, + Rules: []rbacv1.PolicyRule{ + rbacv1helpers.NewRule("get").URLs( + "/.well-known/openid-configuration", + "/openid/v1/jwks", + ).RuleOrDie(), + }, + }) + } + // node-proxier role is used by kube-proxy. nodeProxierRules := []rbacv1.PolicyRule{ rbacv1helpers.NewRule("list", "watch").Groups(legacyGroup).Resources("services", "endpoints").RuleOrDie(), diff --git a/staging/src/k8s.io/apiserver/pkg/server/options/server_run_options.go b/staging/src/k8s.io/apiserver/pkg/server/options/server_run_options.go index 490a6c91ffe1b..0d4560690008d 100644 --- a/staging/src/k8s.io/apiserver/pkg/server/options/server_run_options.go +++ b/staging/src/k8s.io/apiserver/pkg/server/options/server_run_options.go @@ -180,7 +180,7 @@ func (s *ServerRunOptions) AddUniversalFlags(fs *pflag.FlagSet) { "Memory limit for apiserver in MB (used to configure sizes of caches, etc.)") fs.StringVar(&s.ExternalHost, "external-hostname", s.ExternalHost, - "The hostname to use when generating externalized URLs for this master (e.g. Swagger API Docs).") + "The hostname to use when generating externalized URLs for this master (e.g. Swagger API Docs or OpenID Discovery).") deprecatedMasterServiceNamespace := metav1.NamespaceDefault fs.StringVar(&deprecatedMasterServiceNamespace, "master-service-namespace", deprecatedMasterServiceNamespace, ""+ diff --git a/test/integration/auth/BUILD b/test/integration/auth/BUILD index 0f2afcbabccd8..70a0c7010f3bc 100644 --- a/test/integration/auth/BUILD +++ b/test/integration/auth/BUILD @@ -88,6 +88,7 @@ go_test( "//test/e2e/lifecycle/bootstrap:go_default_library", "//test/integration:go_default_library", "//test/integration/framework:go_default_library", + "//vendor/gopkg.in/square/go-jose.v2:go_default_library", "//vendor/gopkg.in/square/go-jose.v2/jwt:go_default_library", "//vendor/k8s.io/klog:go_default_library", "//vendor/k8s.io/utils/pointer:go_default_library", diff --git a/test/integration/auth/svcaccttoken_test.go b/test/integration/auth/svcaccttoken_test.go index a50cedd1375b8..882d69b002373 100644 --- a/test/integration/auth/svcaccttoken_test.go +++ b/test/integration/auth/svcaccttoken_test.go @@ -17,16 +17,21 @@ limitations under the License. package auth import ( + "bytes" "context" "crypto/ecdsa" "encoding/base64" "encoding/json" "fmt" + "io/ioutil" + "net/http" + "net/url" "reflect" "strings" "testing" "time" + jose "gopkg.in/square/go-jose.v2" "gopkg.in/square/go-jose.v2/jwt" authenticationv1 "k8s.io/api/authentication/v1" @@ -40,6 +45,7 @@ import ( utilfeature "k8s.io/apiserver/pkg/util/feature" clientset "k8s.io/client-go/kubernetes" v1listers "k8s.io/client-go/listers/core/v1" + "k8s.io/client-go/rest" "k8s.io/client-go/tools/cache" "k8s.io/client-go/util/keyutil" featuregatetesting "k8s.io/component-base/featuregate/testing" @@ -58,12 +64,14 @@ AwEHoUQDQgAEH6cuzP8XuD5wal6wf9M6xDljTOPLX2i8uIp/C/ASqiIGUeeKQtX0 func TestServiceAccountTokenCreate(t *testing.T) { defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.TokenRequest, true)() + defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.ServiceAccountIssuerDiscovery, true)() // Build client config, clientset, and informers sk, err := keyutil.ParsePrivateKeyPEM([]byte(ecdsaPrivateKey)) if err != nil { t.Fatalf("err: %v", err) } + pk := sk.(*ecdsa.PrivateKey).PublicKey const iss = "https://foo.bar.example.com" @@ -100,6 +108,7 @@ func TestServiceAccountTokenCreate(t *testing.T) { )), ), ) + tokenGenerator, err := serviceaccount.JWTTokenGenerator(iss, sk) if err != nil { t.Fatalf("err: %v", err) @@ -108,6 +117,10 @@ func TestServiceAccountTokenCreate(t *testing.T) { masterConfig.ExtraConfig.ServiceAccountMaxExpiration = maxExpirationDuration masterConfig.GenericConfig.Authentication.APIAudiences = aud + masterConfig.ExtraConfig.ServiceAccountIssuerURL = iss + masterConfig.ExtraConfig.ServiceAccountJWKSURI = "" + masterConfig.ExtraConfig.ServiceAccountPublicKeys = []interface{}{&pk} + master, _, closeFn := framework.RunAMaster(masterConfig) defer closeFn() @@ -117,6 +130,11 @@ func TestServiceAccountTokenCreate(t *testing.T) { } *gcs = *cs + rc, err := rest.UnversionedRESTClientFor(master.GenericAPIServer.LoopbackClientConfig) + if err != nil { + t.Fatal(err) + } + var ( sa = &v1.ServiceAccount{ ObjectMeta: metav1.ObjectMeta{ @@ -568,6 +586,137 @@ func TestServiceAccountTokenCreate(t *testing.T) { doTokenReview(t, cs, treq, true) }) + + t.Run("a token is valid against the HTTP-provided service account issuer metadata", func(t *testing.T) { + sa, del := createDeleteSvcAcct(t, cs, sa) + defer del() + + t.Log("get token") + tokenRequest, err := cs.CoreV1().ServiceAccounts(sa.Namespace).CreateToken( + context.TODO(), + sa.Name, + &authenticationv1.TokenRequest{ + Spec: authenticationv1.TokenRequestSpec{ + Audiences: []string{"api"}, + }, + }, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("unexpected error creating token: %v", err) + } + token := tokenRequest.Status.Token + if token == "" { + t.Fatal("no token") + } + + t.Log("get discovery doc") + discoveryDoc := struct { + Issuer string `json:"issuer"` + JWKS string `json:"jwks_uri"` + }{} + + // A little convoluted, but the base path is hidden inside the RESTClient. + // We can't just use the RESTClient, because it throws away the headers + // before returning a result, and we need to check the headers. + discoveryURL := rc.Get().AbsPath("/.well-known/openid-configuration").URL().String() + resp, err := rc.Client.Get(discoveryURL) + if err != nil { + t.Fatalf("error getting metadata: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("got status: %v, want: %v", resp.StatusCode, http.StatusOK) + } + if got, want := resp.Header.Get("Content-Type"), "application/json"; got != want { + t.Errorf("got Content-Type: %v, want: %v", got, want) + } + if got, want := resp.Header.Get("Cache-Control"), "public, max-age=3600"; got != want { + t.Errorf("got Cache-Control: %v, want: %v", got, want) + } + + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + md := bytes.NewBuffer(b) + t.Logf("raw discovery doc response:\n---%s\n---", md.String()) + if md.Len() == 0 { + t.Fatal("empty response for discovery doc") + } + + if err := json.NewDecoder(md).Decode(&discoveryDoc); err != nil { + t.Fatalf("could not decode metadata: %v", err) + } + if discoveryDoc.Issuer != iss { + t.Fatalf("invalid issuer in discovery doc: got %s, want %s", + discoveryDoc.Issuer, iss) + } + // Parse the JWKSURI see if the path is what we expect. Since the + // integration test framework hardcodes 192.168.10.4 as the PublicAddress, + // which results in the same for ExternalAddress, we expect the JWKS URI + // to be 192.168.10.4:443, even if that's not necessarily the external + // IP of the test machine. + expectJWKSURI := (&url.URL{ + Scheme: "https", + Host: "192.168.10.4:443", + Path: serviceaccount.JWKSPath, + }).String() + if discoveryDoc.JWKS != expectJWKSURI { + t.Fatalf("unexpected jwks_uri in discovery doc: got %s, want %s", + discoveryDoc.JWKS, expectJWKSURI) + } + + // Since the test framework hardcodes the host, we combine our client's + // scheme and host with serviceaccount.JWKSPath. We know that this is what was + // in the discovery doc because we checked that it matched above. + jwksURI := rc.Get().AbsPath(serviceaccount.JWKSPath).URL().String() + t.Log("get jwks from", jwksURI) + resp, err = rc.Client.Get(jwksURI) + if err != nil { + t.Fatalf("error getting jwks: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("got status: %v, want: %v", resp.StatusCode, http.StatusOK) + } + if got, want := resp.Header.Get("Content-Type"), "application/jwk-set+json"; got != want { + t.Errorf("got Content-Type: %v, want: %v", got, want) + } + if got, want := resp.Header.Get("Cache-Control"), "public, max-age=3600"; got != want { + t.Errorf("got Cache-Control: %v, want: %v", got, want) + } + + b, err = ioutil.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + ks := bytes.NewBuffer(b) + if ks.Len() == 0 { + t.Fatal("empty jwks") + } + t.Logf("raw JWKS: \n---\n%s\n---", ks.String()) + + jwks := jose.JSONWebKeySet{} + if err := json.NewDecoder(ks).Decode(&jwks); err != nil { + t.Fatalf("could not decode JWKS: %v", err) + } + if len(jwks.Keys) != 1 { + t.Fatalf("len(jwks.Keys) = %d, want 1", len(jwks.Keys)) + } + key := jwks.Keys[0] + tok, err := jwt.ParseSigned(token) + if err != nil { + t.Fatalf("could not parse token %q: %v", token, err) + } + var claims jwt.Claims + if err := tok.Claims(key, &claims); err != nil { + t.Fatalf("could not validate claims on token: %v", err) + } + if err := claims.Validate(jwt.Expected{Issuer: discoveryDoc.Issuer}); err != nil { + t.Fatalf("invalid claims: %v", err) + } + }) } func doTokenReview(t *testing.T, cs clientset.Interface, treq *authenticationv1.TokenRequest, expectErr bool) authenticationv1.UserInfo {