mirror of
https://github.com/k3s-io/kubernetes.git
synced 2025-07-21 02:41:25 +00:00
Merge pull request #80724 from cceckman/provider-info-e2e
Provide OIDC discovery for service account token issuer
This commit is contained in:
commit
8ca96f3e07
@ -381,6 +381,22 @@ func CreateKubeAPIServerConfig(
|
|||||||
config.ExtraConfig.KubeletClientConfig.Lookup = config.GenericConfig.EgressSelector.Lookup
|
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
|
return config, insecureServingInfo, serviceResolver, pluginInitializers, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -239,6 +239,15 @@ const (
|
|||||||
// to the API server.
|
// to the API server.
|
||||||
BoundServiceAccountTokenVolume featuregate.Feature = "BoundServiceAccountTokenVolume"
|
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
|
// owner: @Random-Liu
|
||||||
// beta: v1.11
|
// beta: v1.11
|
||||||
//
|
//
|
||||||
@ -573,6 +582,7 @@ var defaultKubernetesFeatureGates = map[featuregate.Feature]featuregate.FeatureS
|
|||||||
TokenRequest: {Default: true, PreRelease: featuregate.Beta},
|
TokenRequest: {Default: true, PreRelease: featuregate.Beta},
|
||||||
TokenRequestProjection: {Default: true, PreRelease: featuregate.Beta},
|
TokenRequestProjection: {Default: true, PreRelease: featuregate.Beta},
|
||||||
BoundServiceAccountTokenVolume: {Default: false, PreRelease: featuregate.Alpha},
|
BoundServiceAccountTokenVolume: {Default: false, PreRelease: featuregate.Alpha},
|
||||||
|
ServiceAccountIssuerDiscovery: {Default: false, PreRelease: featuregate.Alpha},
|
||||||
CRIContainerLogRotation: {Default: true, PreRelease: featuregate.Beta},
|
CRIContainerLogRotation: {Default: true, PreRelease: featuregate.Beta},
|
||||||
CSIMigration: {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)
|
CSIMigrationGCE: {Default: false, PreRelease: featuregate.Beta}, // Off by default (requires GCE PD CSI Driver)
|
||||||
|
@ -81,6 +81,7 @@ type ServiceAccountAuthenticationOptions struct {
|
|||||||
KeyFiles []string
|
KeyFiles []string
|
||||||
Lookup bool
|
Lookup bool
|
||||||
Issuer string
|
Issuer string
|
||||||
|
JWKSURI string
|
||||||
MaxExpiration time.Duration
|
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
|
return allErrors
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -281,7 +298,20 @@ func (s *BuiltInAuthenticationOptions) AddFlags(fs *pflag.FlagSet) {
|
|||||||
|
|
||||||
fs.StringVar(&s.ServiceAccounts.Issuer, "service-account-issuer", s.ServiceAccounts.Issuer, ""+
|
fs.StringVar(&s.ServiceAccounts.Issuer, "service-account-issuer", s.ServiceAccounts.Issuer, ""+
|
||||||
"Identifier of the service account token issuer. The issuer will assert this identifier "+
|
"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
|
// Deprecated in 1.13
|
||||||
fs.StringSliceVar(&s.APIAudiences, "service-account-api-audiences", s.APIAudiences, ""+
|
fs.StringSliceVar(&s.APIAudiences, "service-account-api-audiences", s.APIAudiences, ""+
|
||||||
|
@ -191,6 +191,11 @@ type ExtraConfig struct {
|
|||||||
ServiceAccountIssuer serviceaccount.TokenGenerator
|
ServiceAccountIssuer serviceaccount.TokenGenerator
|
||||||
ServiceAccountMaxExpiration time.Duration
|
ServiceAccountMaxExpiration time.Duration
|
||||||
|
|
||||||
|
// ServiceAccountIssuerDiscovery
|
||||||
|
ServiceAccountIssuerURL string
|
||||||
|
ServiceAccountJWKSURI string
|
||||||
|
ServiceAccountPublicKeys []interface{}
|
||||||
|
|
||||||
VersionedInformers informers.SharedInformerFactory
|
VersionedInformers informers.SharedInformerFactory
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -342,6 +347,39 @@ func (c completedConfig) New(delegationTarget genericapiserver.DelegationTarget)
|
|||||||
routes.Logs{}.Install(s.Handler.GoRestfulContainer)
|
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{
|
m := &Master{
|
||||||
GenericAPIServer: s,
|
GenericAPIServer: s,
|
||||||
ClusterAuthenticationInfo: c.ExtraConfig.ClusterAuthenticationInfo,
|
ClusterAuthenticationInfo: c.ExtraConfig.ClusterAuthenticationInfo,
|
||||||
|
@ -10,9 +10,14 @@ go_library(
|
|||||||
srcs = [
|
srcs = [
|
||||||
"doc.go",
|
"doc.go",
|
||||||
"logs.go",
|
"logs.go",
|
||||||
|
"openidmetadata.go",
|
||||||
],
|
],
|
||||||
importpath = "k8s.io/kubernetes/pkg/routes",
|
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(
|
filegroup(
|
||||||
|
114
pkg/routes/openidmetadata.go
Normal file
114
pkg/routes/openidmetadata.go
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
@ -12,6 +12,7 @@ go_library(
|
|||||||
"claims.go",
|
"claims.go",
|
||||||
"jwt.go",
|
"jwt.go",
|
||||||
"legacy.go",
|
"legacy.go",
|
||||||
|
"openidmetadata.go",
|
||||||
"util.go",
|
"util.go",
|
||||||
],
|
],
|
||||||
importpath = "k8s.io/kubernetes/pkg/serviceaccount",
|
importpath = "k8s.io/kubernetes/pkg/serviceaccount",
|
||||||
@ -19,6 +20,7 @@ go_library(
|
|||||||
"//pkg/apis/core:go_default_library",
|
"//pkg/apis/core:go_default_library",
|
||||||
"//staging/src/k8s.io/api/core/v1: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/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/authenticator:go_default_library",
|
||||||
"//staging/src/k8s.io/apiserver/pkg/authentication/serviceaccount: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",
|
"//staging/src/k8s.io/apiserver/pkg/authentication/user:go_default_library",
|
||||||
@ -46,12 +48,14 @@ go_test(
|
|||||||
srcs = [
|
srcs = [
|
||||||
"claims_test.go",
|
"claims_test.go",
|
||||||
"jwt_test.go",
|
"jwt_test.go",
|
||||||
|
"openidmetadata_test.go",
|
||||||
"util_test.go",
|
"util_test.go",
|
||||||
],
|
],
|
||||||
embed = [":go_default_library"],
|
embed = [":go_default_library"],
|
||||||
deps = [
|
deps = [
|
||||||
"//pkg/apis/core:go_default_library",
|
"//pkg/apis/core:go_default_library",
|
||||||
"//pkg/controller/serviceaccount: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/api/core/v1:go_default_library",
|
||||||
"//staging/src/k8s.io/apimachinery/pkg/apis/meta/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",
|
"//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/listers/core/v1:go_default_library",
|
||||||
"//staging/src/k8s.io/client-go/tools/cache: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",
|
"//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:go_default_library",
|
||||||
"//vendor/gopkg.in/square/go-jose.v2/jwt:go_default_library",
|
"//vendor/gopkg.in/square/go-jose.v2/jwt:go_default_library",
|
||||||
],
|
],
|
||||||
|
@ -114,6 +114,10 @@ func signerFromRSAPrivateKey(keyPair *rsa.PrivateKey) (jose.Signer, error) {
|
|||||||
return nil, fmt.Errorf("failed to derive keyID: %v", err)
|
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.
|
// Wrap the RSA keypair in a JOSE JWK with the designated key ID.
|
||||||
privateJWK := &jose.JSONWebKey{
|
privateJWK := &jose.JSONWebKey{
|
||||||
Algorithm: string(jose.RS256),
|
Algorithm: string(jose.RS256),
|
||||||
|
@ -18,6 +18,7 @@ package serviceaccount_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@ -116,12 +117,18 @@ X2i8uIp/C/ASqiIGUeeKQtX0/IR3qCXyThP/dbCiHrF3v1cuhBOHY8CLVg==
|
|||||||
const ecdsaKeyID = "SoABiieYuNx4UdqYvZRVeuC6SihxgLrhLy9peHMHpTc"
|
const ecdsaKeyID = "SoABiieYuNx4UdqYvZRVeuC6SihxgLrhLy9peHMHpTc"
|
||||||
|
|
||||||
func getPrivateKey(data string) interface{} {
|
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
|
return key
|
||||||
}
|
}
|
||||||
|
|
||||||
func getPublicKey(data string) interface{} {
|
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]
|
return keys[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
295
pkg/serviceaccount/openidmetadata.go
Normal file
295
pkg/serviceaccount/openidmetadata.go
Normal file
@ -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")
|
||||||
|
}
|
||||||
|
}
|
395
pkg/serviceaccount/openidmetadata_test.go
Normal file
395
pkg/serviceaccount/openidmetadata_test.go
Normal file
@ -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: <response>, <nil>", 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: <nil>", 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: <response>, <nil>", 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: <nil>", 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 <nil>, want error")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
} else if !tc.err && err != nil {
|
||||||
|
t.Fatalf("got error %v, want <nil>", 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@ -461,6 +461,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.
|
// node-proxier role is used by kube-proxy.
|
||||||
nodeProxierRules := []rbacv1.PolicyRule{
|
nodeProxierRules := []rbacv1.PolicyRule{
|
||||||
rbacv1helpers.NewRule("list", "watch").Groups(legacyGroup).Resources("services", "endpoints").RuleOrDie(),
|
rbacv1helpers.NewRule("list", "watch").Groups(legacyGroup).Resources("services", "endpoints").RuleOrDie(),
|
||||||
|
@ -180,7 +180,7 @@ func (s *ServerRunOptions) AddUniversalFlags(fs *pflag.FlagSet) {
|
|||||||
"Memory limit for apiserver in MB (used to configure sizes of caches, etc.)")
|
"Memory limit for apiserver in MB (used to configure sizes of caches, etc.)")
|
||||||
|
|
||||||
fs.StringVar(&s.ExternalHost, "external-hostname", s.ExternalHost,
|
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
|
deprecatedMasterServiceNamespace := metav1.NamespaceDefault
|
||||||
fs.StringVar(&deprecatedMasterServiceNamespace, "master-service-namespace", deprecatedMasterServiceNamespace, ""+
|
fs.StringVar(&deprecatedMasterServiceNamespace, "master-service-namespace", deprecatedMasterServiceNamespace, ""+
|
||||||
|
@ -88,6 +88,7 @@ go_test(
|
|||||||
"//test/e2e/lifecycle/bootstrap:go_default_library",
|
"//test/e2e/lifecycle/bootstrap:go_default_library",
|
||||||
"//test/integration:go_default_library",
|
"//test/integration:go_default_library",
|
||||||
"//test/integration/framework: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/gopkg.in/square/go-jose.v2/jwt:go_default_library",
|
||||||
"//vendor/k8s.io/klog:go_default_library",
|
"//vendor/k8s.io/klog:go_default_library",
|
||||||
"//vendor/k8s.io/utils/pointer:go_default_library",
|
"//vendor/k8s.io/utils/pointer:go_default_library",
|
||||||
|
@ -17,16 +17,21 @@ limitations under the License.
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/ecdsa"
|
"crypto/ecdsa"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
jose "gopkg.in/square/go-jose.v2"
|
||||||
"gopkg.in/square/go-jose.v2/jwt"
|
"gopkg.in/square/go-jose.v2/jwt"
|
||||||
|
|
||||||
authenticationv1 "k8s.io/api/authentication/v1"
|
authenticationv1 "k8s.io/api/authentication/v1"
|
||||||
@ -40,6 +45,7 @@ import (
|
|||||||
utilfeature "k8s.io/apiserver/pkg/util/feature"
|
utilfeature "k8s.io/apiserver/pkg/util/feature"
|
||||||
clientset "k8s.io/client-go/kubernetes"
|
clientset "k8s.io/client-go/kubernetes"
|
||||||
v1listers "k8s.io/client-go/listers/core/v1"
|
v1listers "k8s.io/client-go/listers/core/v1"
|
||||||
|
"k8s.io/client-go/rest"
|
||||||
"k8s.io/client-go/tools/cache"
|
"k8s.io/client-go/tools/cache"
|
||||||
"k8s.io/client-go/util/keyutil"
|
"k8s.io/client-go/util/keyutil"
|
||||||
featuregatetesting "k8s.io/component-base/featuregate/testing"
|
featuregatetesting "k8s.io/component-base/featuregate/testing"
|
||||||
@ -58,12 +64,14 @@ AwEHoUQDQgAEH6cuzP8XuD5wal6wf9M6xDljTOPLX2i8uIp/C/ASqiIGUeeKQtX0
|
|||||||
|
|
||||||
func TestServiceAccountTokenCreate(t *testing.T) {
|
func TestServiceAccountTokenCreate(t *testing.T) {
|
||||||
defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.TokenRequest, true)()
|
defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.TokenRequest, true)()
|
||||||
|
defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.ServiceAccountIssuerDiscovery, true)()
|
||||||
|
|
||||||
// Build client config, clientset, and informers
|
// Build client config, clientset, and informers
|
||||||
sk, err := keyutil.ParsePrivateKeyPEM([]byte(ecdsaPrivateKey))
|
sk, err := keyutil.ParsePrivateKeyPEM([]byte(ecdsaPrivateKey))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("err: %v", err)
|
t.Fatalf("err: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
pk := sk.(*ecdsa.PrivateKey).PublicKey
|
pk := sk.(*ecdsa.PrivateKey).PublicKey
|
||||||
|
|
||||||
const iss = "https://foo.bar.example.com"
|
const iss = "https://foo.bar.example.com"
|
||||||
@ -100,6 +108,7 @@ func TestServiceAccountTokenCreate(t *testing.T) {
|
|||||||
)),
|
)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
tokenGenerator, err := serviceaccount.JWTTokenGenerator(iss, sk)
|
tokenGenerator, err := serviceaccount.JWTTokenGenerator(iss, sk)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("err: %v", err)
|
t.Fatalf("err: %v", err)
|
||||||
@ -108,6 +117,10 @@ func TestServiceAccountTokenCreate(t *testing.T) {
|
|||||||
masterConfig.ExtraConfig.ServiceAccountMaxExpiration = maxExpirationDuration
|
masterConfig.ExtraConfig.ServiceAccountMaxExpiration = maxExpirationDuration
|
||||||
masterConfig.GenericConfig.Authentication.APIAudiences = aud
|
masterConfig.GenericConfig.Authentication.APIAudiences = aud
|
||||||
|
|
||||||
|
masterConfig.ExtraConfig.ServiceAccountIssuerURL = iss
|
||||||
|
masterConfig.ExtraConfig.ServiceAccountJWKSURI = ""
|
||||||
|
masterConfig.ExtraConfig.ServiceAccountPublicKeys = []interface{}{&pk}
|
||||||
|
|
||||||
master, _, closeFn := framework.RunAMaster(masterConfig)
|
master, _, closeFn := framework.RunAMaster(masterConfig)
|
||||||
defer closeFn()
|
defer closeFn()
|
||||||
|
|
||||||
@ -117,6 +130,11 @@ func TestServiceAccountTokenCreate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
*gcs = *cs
|
*gcs = *cs
|
||||||
|
|
||||||
|
rc, err := rest.UnversionedRESTClientFor(master.GenericAPIServer.LoopbackClientConfig)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
sa = &v1.ServiceAccount{
|
sa = &v1.ServiceAccount{
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
@ -568,6 +586,137 @@ func TestServiceAccountTokenCreate(t *testing.T) {
|
|||||||
|
|
||||||
doTokenReview(t, cs, treq, true)
|
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 {
|
func doTokenReview(t *testing.T, cs clientset.Interface, treq *authenticationv1.TokenRequest, expectErr bool) authenticationv1.UserInfo {
|
||||||
|
Loading…
Reference in New Issue
Block a user