diff --git a/cluster/saltbase/salt/kube-controller-manager/kube-controller-manager.manifest b/cluster/saltbase/salt/kube-controller-manager/kube-controller-manager.manifest index a75185890c9..1c216219f3e 100644 --- a/cluster/saltbase/salt/kube-controller-manager/kube-controller-manager.manifest +++ b/cluster/saltbase/salt/kube-controller-manager/kube-controller-manager.manifest @@ -24,6 +24,8 @@ {% if grains.cloud is defined -%} {% set cloud_provider = "--cloud_provider=" + grains.cloud -%} +{% set service_account_key = " --service_account_private_key_file=/srv/kubernetes/server.key " -%} + {% if grains.cloud == 'gce' -%} {% if grains.cloud_config is defined -%} {% set cloud_config = "--cloud_config=" + grains.cloud_config -%} @@ -55,7 +57,7 @@ {% endif -%} {% endif -%} -{% set params = "--master=127.0.0.1:8080" + " " + machines + " " + cluster_name + " " + cluster_cidr + " " + allocate_node_cidrs + " " + minion_regexp + " " + cloud_provider + " " + sync_nodes + " " + cloud_config + " " + pillar['log_level'] -%} +{% set params = "--master=127.0.0.1:8080" + " " + machines + " " + cluster_name + " " + cluster_cidr + " " + allocate_node_cidrs + " " + minion_regexp + " " + cloud_provider + " " + sync_nodes + " " + cloud_config + service_account_key + pillar['log_level'] -%} { "apiVersion": "v1beta3", diff --git a/cmd/kube-apiserver/app/server.go b/cmd/kube-apiserver/app/server.go index 5cf1d9a578f..0b4ace4bd29 100644 --- a/cmd/kube-apiserver/app/server.go +++ b/cmd/kube-apiserver/app/server.go @@ -67,6 +67,8 @@ type APIServer struct { BasicAuthFile string ClientCAFile string TokenAuthFile string + ServiceAccountKeyFile string + ServiceAccountLookup bool AuthorizationMode string AuthorizationPolicyFile string AdmissionControl string @@ -162,6 +164,8 @@ func (s *APIServer) AddFlags(fs *pflag.FlagSet) { fs.StringVar(&s.BasicAuthFile, "basic-auth-file", s.BasicAuthFile, "If set, the file that will be used to admit requests to the secure port of the API server via http basic authentication.") fs.StringVar(&s.ClientCAFile, "client-ca-file", s.ClientCAFile, "If set, any request presenting a client certificate signed by one of the authorities in the client-ca-file is authenticated with an identity corresponding to the CommonName of the client certificate.") fs.StringVar(&s.TokenAuthFile, "token-auth-file", s.TokenAuthFile, "If set, the file that will be used to secure the secure port of the API server via token authentication.") + fs.StringVar(&s.ServiceAccountKeyFile, "service-account-key-file", s.ServiceAccountKeyFile, "File containing PEM-encoded x509 RSA private or public key, used to verify ServiceAccount tokens. If unspecified, --tls-private-key-file is used.") + fs.BoolVar(&s.ServiceAccountLookup, "service-account-lookup", s.ServiceAccountLookup, "If true, validate ServiceAccount tokens exist in etcd as part of authentication.") fs.StringVar(&s.AuthorizationMode, "authorization-mode", s.AuthorizationMode, "Selects how to do authorization on the secure port. One of: "+strings.Join(apiserver.AuthorizationModeChoices, ",")) fs.StringVar(&s.AuthorizationPolicyFile, "authorization-policy-file", s.AuthorizationPolicyFile, "File with authorization policy in csv format, used with --authorization-mode=ABAC, on the secure port.") fs.StringVar(&s.AdmissionControl, "admission-control", s.AdmissionControl, "Ordered list of plug-ins to do admission control of resources into cluster. Comma-delimited list of: "+strings.Join(admission.GetPlugins(), ", ")) @@ -267,7 +271,11 @@ func (s *APIServer) Run(_ []string) error { n := net.IPNet(s.PortalNet) - authenticator, err := apiserver.NewAuthenticator(s.BasicAuthFile, s.ClientCAFile, s.TokenAuthFile) + // Default to the private server key for service account token signing + if s.ServiceAccountKeyFile == "" && s.TLSPrivateKeyFile != "" { + s.ServiceAccountKeyFile = s.TLSPrivateKeyFile + } + authenticator, err := apiserver.NewAuthenticator(s.BasicAuthFile, s.ClientCAFile, s.TokenAuthFile, s.ServiceAccountKeyFile, s.ServiceAccountLookup, client) if err != nil { glog.Fatalf("Invalid Authentication Config: %v", err) } diff --git a/cmd/kube-controller-manager/app/controllermanager.go b/cmd/kube-controller-manager/app/controllermanager.go index 5b1c429667d..bb4e2f62a67 100644 --- a/cmd/kube-controller-manager/app/controllermanager.go +++ b/cmd/kube-controller-manager/app/controllermanager.go @@ -20,7 +20,6 @@ limitations under the License. package app import ( - "fmt" "net" "net/http" "net/http/pprof" @@ -75,6 +74,7 @@ type CMServer struct { PodEvictionTimeout time.Duration DeletingPodsQps float32 DeletingPodsBurst int + ServiceAccountKeyFile string // TODO: Discover these by pinging the host machines, and rip out these params. NodeMilliCPU int64 @@ -143,6 +143,7 @@ func (s *CMServer) AddFlags(fs *pflag.FlagSet) { "Amount of time which we allow starting Node to be unresponsive before marking it unhealty.") fs.DurationVar(&s.NodeMonitorPeriod, "node-monitor-period", 5*time.Second, "The period for syncing NodeStatus in NodeController.") + fs.StringVar(&s.ServiceAccountKeyFile, "service-account-private-key-file", s.ServiceAccountKeyFile, "Filename containing a PEM-encoded private RSA key used to sign service account tokens.") // TODO: Discover these by pinging the host machines, and rip out these flags. // TODO: in the meantime, use resource.QuantityFlag() instead of these fs.Int64Var(&s.NodeMilliCPU, "node-milli-cpu", s.NodeMilliCPU, "The amount of MilliCPU provisioned on each node") @@ -251,12 +252,24 @@ func (s *CMServer) Run(_ []string) error { pvclaimBinder.Run() } - // TODO: generate signed token - tokenGenerator := serviceaccount.TokenGeneratorFunc(func(serviceAccount api.ServiceAccount, secret api.Secret) (string, error) { - return fmt.Sprintf("serviceaccount:%s:%s:%s:%s", serviceAccount.Namespace, serviceAccount.Name, serviceAccount.UID, secret.Name), nil - }) - serviceaccount.NewTokensController(kubeClient, serviceaccount.DefaultTokenControllerOptions(tokenGenerator)).Run() - serviceaccount.NewServiceAccountsController(kubeClient, serviceaccount.DefaultServiceAccountControllerOptions()).Run() + if len(s.ServiceAccountKeyFile) > 0 { + privateKey, err := serviceaccount.ReadPrivateKey(s.ServiceAccountKeyFile) + if err != nil { + glog.Errorf("Error reading key for service account token controller: %v", err) + } else { + serviceaccount.NewTokensController( + kubeClient, + serviceaccount.DefaultTokenControllerOptions( + serviceaccount.JWTTokenGenerator(privateKey), + ), + ).Run() + } + } + + serviceaccount.NewServiceAccountsController( + kubeClient, + serviceaccount.DefaultServiceAccountControllerOptions(), + ).Run() select {} return nil diff --git a/hack/local-up-cluster.sh b/hack/local-up-cluster.sh index 946cbb97717..e4268bd9847 100755 --- a/hack/local-up-cluster.sh +++ b/hack/local-up-cluster.sh @@ -132,12 +132,21 @@ trap cleanup EXIT echo "Starting etcd" kube::etcd::start +SERVICE_ACCOUNT_LOOKUP=${SERVICE_ACCOUNT_LOOKUP:-false} +SERVICE_ACCOUNT_KEY=${SERVICE_ACCOUNT_KEY:-"/var/run/kubernetes/serviceaccount.key"} +# Generate ServiceAccount key if needed +if [[ ! -f "${SERVICE_ACCOUNT_KEY}" ]]; then + openssl genrsa -out "${SERVICE_ACCOUNT_KEY}" 2048 2>/dev/null +fi + # Admission Controllers to invoke prior to persisting objects in cluster ADMISSION_CONTROL=NamespaceLifecycle,NamespaceAutoProvision,LimitRanger,SecurityContextDeny,ResourceQuota APISERVER_LOG=/tmp/kube-apiserver.log sudo -E "${GO_OUT}/kube-apiserver" \ --v=${LOG_LEVEL} \ + --service_account_key_file="${SERVICE_ACCOUNT_KEY}" \ + --service_account_lookup="${SERVICE_ACCOUNT_LOOKUP}" \ --admission_control="${ADMISSION_CONTROL}" \ --address="${API_HOST}" \ --port="${API_PORT}" \ @@ -155,6 +164,7 @@ CTLRMGR_LOG=/tmp/kube-controller-manager.log sudo -E "${GO_OUT}/kube-controller-manager" \ --v=${LOG_LEVEL} \ --machines="127.0.0.1" \ + --service_account_private_key_file="${SERVICE_ACCOUNT_KEY}" \ --master="${API_HOST}:${API_PORT}" >"${CTLRMGR_LOG}" 2>&1 & CTLRMGR_PID=$! diff --git a/pkg/apiserver/authn.go b/pkg/apiserver/authn.go index 9182f796343..3114cced406 100644 --- a/pkg/apiserver/authn.go +++ b/pkg/apiserver/authn.go @@ -17,8 +17,12 @@ limitations under the License. package apiserver import ( + "crypto/rsa" + "github.com/GoogleCloudPlatform/kubernetes/pkg/auth/authenticator" "github.com/GoogleCloudPlatform/kubernetes/pkg/auth/authenticator/bearertoken" + "github.com/GoogleCloudPlatform/kubernetes/pkg/client" + "github.com/GoogleCloudPlatform/kubernetes/pkg/serviceaccount" "github.com/GoogleCloudPlatform/kubernetes/pkg/util" "github.com/GoogleCloudPlatform/kubernetes/plugin/pkg/auth/authenticator/password/passwordfile" "github.com/GoogleCloudPlatform/kubernetes/plugin/pkg/auth/authenticator/request/basicauth" @@ -28,7 +32,7 @@ import ( ) // NewAuthenticator returns an authenticator.Request or an error -func NewAuthenticator(basicAuthFile, clientCAFile, tokenFile string) (authenticator.Request, error) { +func NewAuthenticator(basicAuthFile, clientCAFile, tokenFile, serviceAccountKeyFile string, serviceAccountLookup bool, client client.Interface) (authenticator.Request, error) { var authenticators []authenticator.Request if len(basicAuthFile) > 0 { @@ -55,6 +59,14 @@ func NewAuthenticator(basicAuthFile, clientCAFile, tokenFile string) (authentica authenticators = append(authenticators, tokenAuth) } + if len(serviceAccountKeyFile) > 0 { + serviceAccountAuth, err := newServiceAccountAuthenticator(serviceAccountKeyFile, serviceAccountLookup, client) + if err != nil { + return nil, err + } + authenticators = append(authenticators, serviceAccountAuth) + } + switch len(authenticators) { case 0: return nil, nil @@ -85,6 +97,16 @@ func newAuthenticatorFromTokenFile(tokenAuthFile string) (authenticator.Request, return bearertoken.New(tokenAuthenticator), nil } +// newServiceAccountAuthenticator returns an authenticator.Request or an error +func newServiceAccountAuthenticator(keyfile string, lookup bool, client client.Interface) (authenticator.Request, error) { + publicKey, err := serviceaccount.ReadPublicKey(keyfile) + if err != nil { + return nil, err + } + tokenAuthenticator := serviceaccount.JWTTokenAuthenticator([]*rsa.PublicKey{publicKey}, lookup, client) + return bearertoken.New(tokenAuthenticator), nil +} + // newAuthenticatorFromClientCAFile returns an authenticator.Request or an error func newAuthenticatorFromClientCAFile(clientCAFile string) (authenticator.Request, error) { roots, err := util.CertPoolFromFile(clientCAFile) diff --git a/pkg/serviceaccount/jwt.go b/pkg/serviceaccount/jwt.go new file mode 100644 index 00000000000..5a40c30cdad --- /dev/null +++ b/pkg/serviceaccount/jwt.go @@ -0,0 +1,228 @@ +/* +Copyright 2014 The Kubernetes Authors All rights reserved. + +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 ( + "bytes" + "crypto/rsa" + "errors" + "fmt" + "io/ioutil" + "strings" + + jwt "github.com/dgrijalva/jwt-go" + + "github.com/GoogleCloudPlatform/kubernetes/pkg/api" + "github.com/GoogleCloudPlatform/kubernetes/pkg/auth/authenticator" + "github.com/GoogleCloudPlatform/kubernetes/pkg/auth/user" + "github.com/GoogleCloudPlatform/kubernetes/pkg/client" +) + +const ( + ServiceAccountUsernamePrefix = "serviceaccount" + ServiceAccountUsernameSeparator = ":" + + Issuer = "kubernetes/serviceaccount" + + SubjectClaim = "sub" + IssuerClaim = "iss" + ServiceAccountNameClaim = "kubernetes.io/serviceaccount/service-account.name" + ServiceAccountUIDClaim = "kubernetes.io/serviceaccount/service-account.uid" + SecretNameClaim = "kubernetes.io/serviceaccount/secret.name" + NamespaceClaim = "kubernetes.io/serviceaccount/namespace" +) + +type TokenGenerator interface { + // GenerateToken generates a token which will identify the given ServiceAccount. + // The returned token will be stored in the given (and yet-unpersisted) Secret. + GenerateToken(serviceAccount api.ServiceAccount, secret api.Secret) (string, error) +} + +// ReadPrivateKey is a helper function for reading an rsa.PrivateKey from a PEM-encoded file +func ReadPrivateKey(file string) (*rsa.PrivateKey, error) { + data, err := ioutil.ReadFile(file) + if err != nil { + return nil, err + } + return jwt.ParseRSAPrivateKeyFromPEM(data) +} + +// ReadPublicKey is a helper function for reading an rsa.PublicKey from a PEM-encoded file +// Reads public keys from both public and private key files +func ReadPublicKey(file string) (*rsa.PublicKey, error) { + data, err := ioutil.ReadFile(file) + if err != nil { + return nil, err + } + + if privateKey, err := jwt.ParseRSAPrivateKeyFromPEM(data); err == nil { + return &privateKey.PublicKey, nil + } + + return jwt.ParseRSAPublicKeyFromPEM(data) +} + +// MakeUsername generates a username from the given namespace and ServiceAccount name. +// The resulting username can be passed to SplitUsername to extract the original namespace and ServiceAccount name. +func MakeUsername(namespace, name string) string { + return strings.Join([]string{ServiceAccountUsernamePrefix, namespace, name}, ServiceAccountUsernameSeparator) +} + +// SplitUsername returns the namespace and ServiceAccount name embedded in the given username, +// or an error if the username is not a valid name produced by MakeUsername +func SplitUsername(username string) (string, string, error) { + parts := strings.Split(username, ServiceAccountUsernameSeparator) + if len(parts) != 3 || parts[0] != ServiceAccountUsernamePrefix || len(parts[1]) == 0 || len(parts[2]) == 0 { + return "", "", fmt.Errorf("Username must be in the form %s", MakeUsername("namespace", "name")) + } + return parts[1], parts[2], nil +} + +// JWTTokenGenerator returns a TokenGenerator that generates signed JWT tokens, using the given privateKey. +// privateKey is a PEM-encoded byte array of a private RSA key. +// JWTTokenAuthenticator() +func JWTTokenGenerator(key *rsa.PrivateKey) TokenGenerator { + return &jwtTokenGenerator{key} +} + +type jwtTokenGenerator struct { + key *rsa.PrivateKey +} + +func (j *jwtTokenGenerator) GenerateToken(serviceAccount api.ServiceAccount, secret api.Secret) (string, error) { + token := jwt.New(jwt.SigningMethodRS256) + + // Identify the issuer + token.Claims[IssuerClaim] = Issuer + + // Username: `serviceaccount::` + token.Claims[SubjectClaim] = MakeUsername(serviceAccount.Namespace, serviceAccount.Name) + + // Persist enough structured info for the authenticator to be able to look up the service account and secret + token.Claims[NamespaceClaim] = serviceAccount.Namespace + token.Claims[ServiceAccountNameClaim] = serviceAccount.Name + token.Claims[ServiceAccountUIDClaim] = serviceAccount.UID + token.Claims[SecretNameClaim] = secret.Name + + // Sign and get the complete encoded token as a string + return token.SignedString(j.key) +} + +// JWTTokenAuthenticator authenticates tokens as JWT tokens produced by JWTTokenGenerator +// Token signatures are verified using each of the given public keys until one works (allowing key rotation) +// If lookup is true, the service account and secret referenced as claims inside the token are retrieved and verified using the given client +func JWTTokenAuthenticator(keys []*rsa.PublicKey, lookup bool, client client.Interface) authenticator.Token { + return &jwtTokenAuthenticator{keys, lookup, client} +} + +type jwtTokenAuthenticator struct { + keys []*rsa.PublicKey + lookup bool + client client.Interface +} + +func (j *jwtTokenAuthenticator) AuthenticateToken(token string) (user.Info, bool, error) { + var validationError error + + for _, key := range j.keys { + // Attempt to verify with each key until we find one that works + parsedToken, err := jwt.Parse(token, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { + return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) + } + return key, nil + }) + + if err != nil { + switch err := err.(type) { + case *jwt.ValidationError: + if (err.Errors & jwt.ValidationErrorMalformed) != 0 { + // Not a JWT, no point in continuing + return nil, false, nil + } + + if (err.Errors & jwt.ValidationErrorSignatureInvalid) != 0 { + // Signature error, perhaps one of the other keys will verify the signature + // If not, we want to return this error + validationError = err + continue + } + } + + // Other errors should just return as errors + return nil, false, err + } + + // If we get here, we have a token with a recognized signature + + // Make sure we issued the token + iss, _ := parsedToken.Claims[IssuerClaim].(string) + if iss != Issuer { + return nil, false, nil + } + + // Make sure the claims we need exist + sub, _ := parsedToken.Claims[SubjectClaim].(string) + if len(sub) == 0 { + return nil, false, errors.New("sub claim is missing") + } + namespace, _ := parsedToken.Claims[NamespaceClaim].(string) + if len(namespace) == 0 { + return nil, false, errors.New("namespace claim is missing") + } + secretName, _ := parsedToken.Claims[SecretNameClaim].(string) + if len(namespace) == 0 { + return nil, false, errors.New("secretName claim is missing") + } + serviceAccountName, _ := parsedToken.Claims[ServiceAccountNameClaim].(string) + if len(serviceAccountName) == 0 { + return nil, false, errors.New("serviceAccountName claim is missing") + } + serviceAccountUID, _ := parsedToken.Claims[ServiceAccountUIDClaim].(string) + if len(serviceAccountUID) == 0 { + return nil, false, errors.New("serviceAccountUID claim is missing") + } + + if j.lookup { + // Make sure token hasn't been invalidated by deletion of the secret + secret, err := j.client.Secrets(namespace).Get(secretName) + if err != nil { + return nil, false, errors.New("Token has been invalidated") + } + if bytes.Compare(secret.Data[api.ServiceAccountTokenKey], []byte(token)) != 0 { + return nil, false, errors.New("Token does not match server's copy") + } + + // Make sure service account still exists (name and UID) + serviceAccount, err := j.client.ServiceAccounts(namespace).Get(serviceAccountName) + if err != nil { + return nil, false, err + } + if string(serviceAccount.UID) != serviceAccountUID { + return nil, false, fmt.Errorf("ServiceAccount UID (%s) does not match claim (%s)", serviceAccount.UID, serviceAccountUID) + } + } + + return &user.DefaultInfo{ + Name: sub, + UID: serviceAccountUID, + Groups: []string{}, + }, true, nil + } + + return nil, false, validationError +} diff --git a/pkg/serviceaccount/jwt_test.go b/pkg/serviceaccount/jwt_test.go new file mode 100644 index 00000000000..c0de29f08a5 --- /dev/null +++ b/pkg/serviceaccount/jwt_test.go @@ -0,0 +1,243 @@ +/* +Copyright 2014 The Kubernetes Authors All rights reserved. + +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/rsa" + "io/ioutil" + "os" + "testing" + + "github.com/GoogleCloudPlatform/kubernetes/pkg/api" + "github.com/GoogleCloudPlatform/kubernetes/pkg/client" + "github.com/GoogleCloudPlatform/kubernetes/pkg/client/testclient" + "github.com/dgrijalva/jwt-go" +) + +const otherPublicKey = `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArXz0QkIG1B5Bj2/W69GH +rsm5e+RC3kE+VTgocge0atqlLBek35tRqLgUi3AcIrBZ/0YctMSWDVcRt5fkhWwe +Lqjj6qvAyNyOkrkBi1NFDpJBjYJtuKHgRhNxXbOzTSNpdSKXTfOkzqv56MwHOP25 +yP/NNAODUtr92D5ySI5QX8RbXW+uDn+ixul286PBW/BCrE4tuS88dA0tYJPf8LCu +sqQOwlXYH/rNUg4Pyl9xxhR5DIJR0OzNNfChjw60zieRIt2LfM83fXhwk8IxRGkc +gPZm7ZsipmfbZK2Tkhnpsa4QxDg7zHJPMsB5kxRXW0cQipXcC3baDyN9KBApNXa0 +PwIDAQAB +-----END PUBLIC KEY-----` + +const publicKey = `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA249XwEo9k4tM8fMxV7zx +OhcrP+WvXn917koM5Qr2ZXs4vo26e4ytdlrV0bQ9SlcLpQVSYjIxNfhTZdDt+ecI +zshKuv1gKIxbbLQMOuK1eA/4HALyEkFgmS/tleLJrhc65tKPMGD+pKQ/xhmzRuCG +51RoiMgbQxaCyYxGfNLpLAZK9L0Tctv9a0mJmGIYnIOQM4kC1A1I1n3EsXMWmeJU +j7OTh/AjjCnMnkgvKT2tpKxYQ59PgDgU8Ssc7RDSmSkLxnrv+OrN80j6xrw0OjEi +B4Ycr0PqfzZcvy8efTtFQ/Jnc4Bp1zUtFXt7+QeevePtQ2EcyELXE0i63T1CujRM +WwIDAQAB +-----END PUBLIC KEY----- +` + +const privateKey = `-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEA249XwEo9k4tM8fMxV7zxOhcrP+WvXn917koM5Qr2ZXs4vo26 +e4ytdlrV0bQ9SlcLpQVSYjIxNfhTZdDt+ecIzshKuv1gKIxbbLQMOuK1eA/4HALy +EkFgmS/tleLJrhc65tKPMGD+pKQ/xhmzRuCG51RoiMgbQxaCyYxGfNLpLAZK9L0T +ctv9a0mJmGIYnIOQM4kC1A1I1n3EsXMWmeJUj7OTh/AjjCnMnkgvKT2tpKxYQ59P +gDgU8Ssc7RDSmSkLxnrv+OrN80j6xrw0OjEiB4Ycr0PqfzZcvy8efTtFQ/Jnc4Bp +1zUtFXt7+QeevePtQ2EcyELXE0i63T1CujRMWwIDAQABAoIBAHJx8GqyCBDNbqk7 +e7/hI9iE1S10Wwol5GH2RWxqX28cYMKq+8aE2LI1vPiXO89xOgelk4DN6urX6xjK +ZBF8RRIMQy/e/O2F4+3wl+Nl4vOXV1u6iVXMsD6JRg137mqJf1Fr9elg1bsaRofL +Q7CxPoB8dhS+Qb+hj0DhlqhgA9zG345CQCAds0ZYAZe8fP7bkwrLqZpMn7Dz9WVm +++YgYYKjuE95kPuup/LtWfA9rJyE/Fws8/jGvRSpVn1XglMLSMKhLd27sE8ZUSV0 +2KUzbfRGE0+AnRULRrjpYaPu0XQ2JjdNvtkjBnv27RB89W9Gklxq821eH1Y8got8 +FZodjxECgYEA93pz7AQZ2xDs67d1XLCzpX84GxKzttirmyj3OIlxgzVHjEMsvw8v +sjFiBU5xEEQDosrBdSknnlJqyiq1YwWG/WDckr13d8G2RQWoySN7JVmTQfXcLoTu +YGRiiTuoEi3ab3ZqrgGrFgX7T/cHuasbYvzCvhM2b4VIR3aSxU2DTUMCgYEA4x7J +T/ErP6GkU5nKstu/mIXwNzayEO1BJvPYsy7i7EsxTm3xe/b8/6cYOz5fvJLGH5mT +Q8YvuLqBcMwZardrYcwokD55UvNLOyfADDFZ6l3WntIqbA640Ok2g1X4U8J09xIq +ZLIWK1yWbbvi4QCeN5hvWq47e8sIj5QHjIIjRwkCgYEAyNqjltxFN9zmzPDa2d24 +EAvOt3pYTYBQ1t9KtqImdL0bUqV6fZ6PsWoPCgt+DBuHb+prVPGP7Bkr/uTmznU/ ++AlTO+12NsYLbr2HHagkXE31DEXE7CSLa8RNjN/UKtz4Ohq7vnowJvG35FCz/mb3 +FUHbtHTXa2+bGBUOTf/5Hw0CgYBxw0r9EwUhw1qnUYJ5op7OzFAtp+T7m4ul8kCa +SCL8TxGsgl+SQ34opE775dtYfoBk9a0RJqVit3D8yg71KFjOTNAIqHJm/Vyyjc+h +i9rJDSXiuczsAVfLtPVMRfS0J9QkqeG4PIfkQmVLI/CZ2ZBmsqEcX+eFs4ZfPLun +Qsxe2QKBgGuPilIbLeIBDIaPiUI0FwU8v2j8CEQBYvoQn34c95hVQsig/o5z7zlo +UsO0wlTngXKlWdOcCs1kqEhTLrstf48djDxAYAxkw40nzeJOt7q52ib/fvf4/UBy +X024wzbiw1q07jFCyfQmODzURAx1VNT7QVUMdz/N8vy47/H40AZJ +-----END RSA PRIVATE KEY----- +` + +func getPrivateKey(data string) *rsa.PrivateKey { + key, _ := jwt.ParseRSAPrivateKeyFromPEM([]byte(data)) + return key +} + +func getPublicKey(data string) *rsa.PublicKey { + key, _ := jwt.ParseRSAPublicKeyFromPEM([]byte(data)) + return key +} + +func TestReadPrivateKey(t *testing.T) { + f, err := ioutil.TempFile("", "") + if err != nil { + t.Fatalf("error creating tmpfile: %v", err) + } + defer os.Remove(f.Name()) + + if err := ioutil.WriteFile(f.Name(), []byte(privateKey), os.FileMode(0600)); err != nil { + t.Fatalf("error creating tmpfile: %v", err) + } + + if _, err := ReadPrivateKey(f.Name()); err != nil { + t.Fatalf("error reading key: %v", err) + } +} + +func TestReadPublicKey(t *testing.T) { + f, err := ioutil.TempFile("", "") + if err != nil { + t.Fatalf("error creating tmpfile: %v", err) + } + defer os.Remove(f.Name()) + + if err := ioutil.WriteFile(f.Name(), []byte(publicKey), os.FileMode(0600)); err != nil { + t.Fatalf("error creating tmpfile: %v", err) + } + + if _, err := ReadPublicKey(f.Name()); err != nil { + t.Fatalf("error reading key: %v", err) + } +} + +func TestTokenGenerateAndValidate(t *testing.T) { + expectedUserName := "serviceaccount:test:my-service-account" + expectedUserUID := "12345" + + // Related API objects + serviceAccount := &api.ServiceAccount{ + ObjectMeta: api.ObjectMeta{ + Name: "my-service-account", + UID: "12345", + Namespace: "test", + }, + } + secret := &api.Secret{ + ObjectMeta: api.ObjectMeta{ + Name: "my-secret", + Namespace: "test", + }, + } + + // Generate the token + generator := JWTTokenGenerator(getPrivateKey(privateKey)) + token, err := generator.GenerateToken(*serviceAccount, *secret) + if err != nil { + t.Fatalf("error generating token: %v", err) + } + if len(token) == 0 { + t.Fatalf("no token generated") + } + + // "Save" the token + secret.Data = map[string][]byte{ + "token": []byte(token), + } + + testCases := map[string]struct { + Client client.Interface + Keys []*rsa.PublicKey + + ExpectedErr bool + ExpectedOK bool + ExpectedUserName string + ExpectedUserUID string + }{ + "no keys": { + Client: nil, + Keys: []*rsa.PublicKey{}, + ExpectedErr: false, + ExpectedOK: false, + }, + "invalid keys": { + Client: nil, + Keys: []*rsa.PublicKey{getPublicKey(otherPublicKey)}, + ExpectedErr: true, + ExpectedOK: false, + }, + "valid key": { + Client: nil, + Keys: []*rsa.PublicKey{getPublicKey(publicKey)}, + ExpectedErr: false, + ExpectedOK: true, + ExpectedUserName: expectedUserName, + ExpectedUserUID: expectedUserUID, + }, + "rotated keys": { + Client: nil, + Keys: []*rsa.PublicKey{getPublicKey(otherPublicKey), getPublicKey(publicKey)}, + ExpectedErr: false, + ExpectedOK: true, + ExpectedUserName: expectedUserName, + ExpectedUserUID: expectedUserUID, + }, + "valid lookup": { + Client: testclient.NewSimpleFake(serviceAccount, secret), + Keys: []*rsa.PublicKey{getPublicKey(publicKey)}, + ExpectedErr: false, + ExpectedOK: true, + ExpectedUserName: expectedUserName, + ExpectedUserUID: expectedUserUID, + }, + "invalid secret lookup": { + Client: testclient.NewSimpleFake(serviceAccount), + Keys: []*rsa.PublicKey{getPublicKey(publicKey)}, + ExpectedErr: true, + ExpectedOK: false, + }, + "invalid serviceaccount lookup": { + Client: testclient.NewSimpleFake(secret), + Keys: []*rsa.PublicKey{getPublicKey(publicKey)}, + ExpectedErr: true, + ExpectedOK: false, + }, + } + + for k, tc := range testCases { + authenticator := JWTTokenAuthenticator(tc.Keys, tc.Client != nil, tc.Client) + + user, ok, err := authenticator.AuthenticateToken(token) + if (err != nil) != tc.ExpectedErr { + t.Errorf("%s: Expected error=%v, got %v", k, tc.ExpectedErr, err) + continue + } + + if ok != tc.ExpectedOK { + t.Errorf("%s: Expected ok=%v, got %v", k, tc.ExpectedOK, ok) + continue + } + + if err != nil || !ok { + continue + } + + if user.GetName() != tc.ExpectedUserName { + t.Errorf("%s: Expected username=%v, got %v", k, tc.ExpectedUserName, user.GetName()) + continue + } + if user.GetUID() != tc.ExpectedUserUID { + t.Errorf("%s: Expected userUID=%v, got %v", k, tc.ExpectedUserUID, user.GetUID()) + continue + } + } +}