From ae0e52d28cd26fcece361e14e5263a654b1c4a09 Mon Sep 17 00:00:00 2001 From: Jiajie Yang Date: Fri, 13 Mar 2020 14:49:47 -0700 Subject: [PATCH] Monitoring safe rollout of time-bound service account token. --- cmd/kube-apiserver/app/server.go | 9 +++ pkg/kubeapiserver/options/authentication.go | 17 +++-- pkg/master/master.go | 2 + pkg/registry/core/rest/storage_core.go | 5 +- .../core/serviceaccount/storage/storage.go | 3 +- .../serviceaccount/storage/storage_test.go | 2 +- .../core/serviceaccount/storage/token.go | 20 +++++- pkg/serviceaccount/BUILD | 4 ++ pkg/serviceaccount/claims.go | 44 ++++++++++--- pkg/serviceaccount/claims_test.go | 41 +++++++++--- pkg/serviceaccount/jwt.go | 7 ++- pkg/serviceaccount/legacy.go | 3 +- pkg/serviceaccount/metrics.go | 63 +++++++++++++++++++ .../pkg/admission/serviceaccount/admission.go | 2 +- .../serviceaccount/admission_test.go | 2 +- test/integration/auth/svcaccttoken_test.go | 60 +++++++++++++++++- 16 files changed, 251 insertions(+), 33 deletions(-) create mode 100644 pkg/serviceaccount/metrics.go diff --git a/cmd/kube-apiserver/app/server.go b/cmd/kube-apiserver/app/server.go index 458da8559b5..beaba5843af 100644 --- a/cmd/kube-apiserver/app/server.go +++ b/cmd/kube-apiserver/app/server.go @@ -351,6 +351,7 @@ func CreateKubeAPIServerConfig( ServiceAccountIssuer: s.ServiceAccountIssuer, ServiceAccountMaxExpiration: s.ServiceAccountTokenMaxExpiration, + ExtendExpiration: s.Authentication.ServiceAccounts.ExtendExpiration, VersionedInformers: versionedInformers, }, @@ -689,6 +690,14 @@ func Complete(s *options.ServerRunOptions) (completedServerRunOptions, error) { s.Authentication.ServiceAccounts.MaxExpiration > upBound { return options, fmt.Errorf("the serviceaccount max expiration must be between 1 hour to 2^32 seconds") } + if s.Authentication.ServiceAccounts.ExtendExpiration { + if s.Authentication.ServiceAccounts.MaxExpiration < serviceaccount.WarnOnlyBoundTokenExpirationSeconds*time.Second { + klog.Warningf("service-account-extend-token-expiration is true, in order to correctly trigger safe transition logic, service-account-max-token-expiration must be set longer than 3607 seconds (currently %s)", s.Authentication.ServiceAccounts.MaxExpiration) + } + if s.Authentication.ServiceAccounts.MaxExpiration < serviceaccount.ExpirationExtensionSeconds*time.Second { + klog.Warningf("service-account-extend-token-expiration is true, enabling tokens valid up to 1 year, which is longer than service-account-max-token-expiration set to %s", s.Authentication.ServiceAccounts.MaxExpiration) + } + } } s.ServiceAccountIssuer, err = serviceaccount.JWTTokenGenerator(s.Authentication.ServiceAccounts.Issuer, sk) diff --git a/pkg/kubeapiserver/options/authentication.go b/pkg/kubeapiserver/options/authentication.go index cdc716b5016..1a432a85c3c 100644 --- a/pkg/kubeapiserver/options/authentication.go +++ b/pkg/kubeapiserver/options/authentication.go @@ -73,11 +73,12 @@ type OIDCAuthenticationOptions struct { } type ServiceAccountAuthenticationOptions struct { - KeyFiles []string - Lookup bool - Issuer string - JWKSURI string - MaxExpiration time.Duration + KeyFiles []string + Lookup bool + Issuer string + JWKSURI string + MaxExpiration time.Duration + ExtendExpiration bool } type TokenFileAuthenticationOptions struct { @@ -304,6 +305,12 @@ func (s *BuiltInAuthenticationOptions) AddFlags(fs *pflag.FlagSet) { fs.DurationVar(&s.ServiceAccounts.MaxExpiration, "service-account-max-token-expiration", s.ServiceAccounts.MaxExpiration, ""+ "The maximum validity duration of a token created by the service account token issuer. If an otherwise valid "+ "TokenRequest with a validity duration larger than this value is requested, a token will be issued with a validity duration of this value.") + + fs.BoolVar(&s.ServiceAccounts.ExtendExpiration, "service-account-extend-token-expiration", s.ServiceAccounts.ExtendExpiration, ""+ + "Turns on projected service account expiration extension during token generation, "+ + "which helps safe transition from legacy token to bound service account token feature. "+ + "If this flag is enabled, admission injected tokens would be extended up to 1 year to "+ + "prevent unexpected failure during transition, ignoring value of service-account-max-token-expiration.") } if s.TokenFile != nil { diff --git a/pkg/master/master.go b/pkg/master/master.go index b6bce285d4e..8aeca2cc42e 100644 --- a/pkg/master/master.go +++ b/pkg/master/master.go @@ -190,6 +190,7 @@ type ExtraConfig struct { ServiceAccountIssuer serviceaccount.TokenGenerator ServiceAccountMaxExpiration time.Duration + ExtendExpiration bool // ServiceAccountIssuerDiscovery ServiceAccountIssuerURL string @@ -397,6 +398,7 @@ func (c completedConfig) New(delegationTarget genericapiserver.DelegationTarget) ServiceNodePortRange: c.ExtraConfig.ServiceNodePortRange, LoopbackClientConfig: c.GenericConfig.LoopbackClientConfig, ServiceAccountIssuer: c.ExtraConfig.ServiceAccountIssuer, + ExtendExpiration: c.ExtraConfig.ExtendExpiration, ServiceAccountMaxExpiration: c.ExtraConfig.ServiceAccountMaxExpiration, APIAudiences: c.GenericConfig.Authentication.APIAudiences, } diff --git a/pkg/registry/core/rest/storage_core.go b/pkg/registry/core/rest/storage_core.go index 2a31e98171d..b1d509ccfc6 100644 --- a/pkg/registry/core/rest/storage_core.go +++ b/pkg/registry/core/rest/storage_core.go @@ -85,6 +85,7 @@ type LegacyRESTStorageProvider struct { ServiceAccountIssuer serviceaccount.TokenGenerator ServiceAccountMaxExpiration time.Duration + ExtendExpiration bool APIAudiences authenticator.Audiences @@ -181,9 +182,9 @@ func (c LegacyRESTStorageProvider) NewLegacyRESTStorage(restOptionsGetter generi var serviceAccountStorage *serviceaccountstore.REST if c.ServiceAccountIssuer != nil && utilfeature.DefaultFeatureGate.Enabled(features.TokenRequest) { - serviceAccountStorage, err = serviceaccountstore.NewREST(restOptionsGetter, c.ServiceAccountIssuer, c.APIAudiences, c.ServiceAccountMaxExpiration, podStorage.Pod.Store, secretStorage.Store) + serviceAccountStorage, err = serviceaccountstore.NewREST(restOptionsGetter, c.ServiceAccountIssuer, c.APIAudiences, c.ServiceAccountMaxExpiration, podStorage.Pod.Store, secretStorage.Store, c.ExtendExpiration) } else { - serviceAccountStorage, err = serviceaccountstore.NewREST(restOptionsGetter, nil, nil, 0, nil, nil) + serviceAccountStorage, err = serviceaccountstore.NewREST(restOptionsGetter, nil, nil, 0, nil, nil, false) } if err != nil { return LegacyRESTStorage{}, genericapiserver.APIGroupInfo{}, err diff --git a/pkg/registry/core/serviceaccount/storage/storage.go b/pkg/registry/core/serviceaccount/storage/storage.go index 3667fcb7310..ef2b1f4f988 100644 --- a/pkg/registry/core/serviceaccount/storage/storage.go +++ b/pkg/registry/core/serviceaccount/storage/storage.go @@ -38,7 +38,7 @@ type REST struct { } // NewREST returns a RESTStorage object that will work against service accounts. -func NewREST(optsGetter generic.RESTOptionsGetter, issuer token.TokenGenerator, auds authenticator.Audiences, max time.Duration, podStorage, secretStorage *genericregistry.Store) (*REST, error) { +func NewREST(optsGetter generic.RESTOptionsGetter, issuer token.TokenGenerator, auds authenticator.Audiences, max time.Duration, podStorage, secretStorage *genericregistry.Store, extendExpiration bool) (*REST, error) { store := &genericregistry.Store{ NewFunc: func() runtime.Object { return &api.ServiceAccount{} }, NewListFunc: func() runtime.Object { return &api.ServiceAccountList{} }, @@ -65,6 +65,7 @@ func NewREST(optsGetter generic.RESTOptionsGetter, issuer token.TokenGenerator, issuer: issuer, auds: auds, maxExpirationSeconds: int64(max.Seconds()), + extendExpiration: extendExpiration, } } diff --git a/pkg/registry/core/serviceaccount/storage/storage_test.go b/pkg/registry/core/serviceaccount/storage/storage_test.go index 605f0e5ba40..220f5053047 100644 --- a/pkg/registry/core/serviceaccount/storage/storage_test.go +++ b/pkg/registry/core/serviceaccount/storage/storage_test.go @@ -38,7 +38,7 @@ func newStorage(t *testing.T) (*REST, *etcd3testing.EtcdTestServer) { DeleteCollectionWorkers: 1, ResourcePrefix: "serviceaccounts", } - rest, err := NewREST(restOptions, nil, nil, 0, nil, nil) + rest, err := NewREST(restOptions, nil, nil, 0, nil, nil, false) if err != nil { t.Fatalf("unexpected error from REST storage: %v", err) } diff --git a/pkg/registry/core/serviceaccount/storage/token.go b/pkg/registry/core/serviceaccount/storage/token.go index fe8c9bf9063..4732099fcd7 100644 --- a/pkg/registry/core/serviceaccount/storage/token.go +++ b/pkg/registry/core/serviceaccount/storage/token.go @@ -19,6 +19,7 @@ package storage import ( "context" "fmt" + "time" authenticationapiv1 "k8s.io/api/authentication/v1" "k8s.io/apimachinery/pkg/api/errors" @@ -46,6 +47,7 @@ type TokenREST struct { issuer token.TokenGenerator auds authenticator.Audiences maxExpirationSeconds int64 + extendExpiration bool } var _ = rest.NamedCreater(&TokenREST{}) @@ -121,7 +123,21 @@ func (r *TokenREST) Create(ctx context.Context, name string, obj runtime.Object, out.Spec.ExpirationSeconds = r.maxExpirationSeconds } - sc, pc := token.Claims(*svcacct, pod, secret, out.Spec.ExpirationSeconds, out.Spec.Audiences) + // Tweak expiration for safe transition of projected service account token. + // Warn (instead of fail) after requested expiration time. + // Fail after hard-coded extended expiration time. + // Only perform the extension when token is pod-bound. + var warnAfter int64 + exp := out.Spec.ExpirationSeconds + if r.extendExpiration && pod != nil && out.Spec.ExpirationSeconds == token.WarnOnlyBoundTokenExpirationSeconds { + warnAfter = exp + exp = token.ExpirationExtensionSeconds + } + + // Save current time before building the token, to make sure the expiration + // returned in TokenRequestStatus would be earlier than exp field in token. + nowTime := time.Now() + sc, pc := token.Claims(*svcacct, pod, secret, exp, warnAfter, out.Spec.Audiences) tokdata, err := r.issuer.GenerateToken(sc, pc) if err != nil { return nil, fmt.Errorf("failed to generate token: %v", err) @@ -129,7 +145,7 @@ func (r *TokenREST) Create(ctx context.Context, name string, obj runtime.Object, out.Status = authenticationapi.TokenRequestStatus{ Token: tokdata, - ExpirationTimestamp: metav1.Time{Time: sc.Expiry.Time()}, + ExpirationTimestamp: metav1.Time{Time: nowTime.Add(time.Duration(out.Spec.ExpirationSeconds) * time.Second)}, } return out, nil } diff --git a/pkg/serviceaccount/BUILD b/pkg/serviceaccount/BUILD index bfb6f959084..de1981ef869 100644 --- a/pkg/serviceaccount/BUILD +++ b/pkg/serviceaccount/BUILD @@ -12,6 +12,7 @@ go_library( "claims.go", "jwt.go", "legacy.go", + "metrics.go", "openidmetadata.go", "util.go", ], @@ -21,9 +22,12 @@ go_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/audit: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", + "//staging/src/k8s.io/component-base/metrics:go_default_library", + "//staging/src/k8s.io/component-base/metrics/legacyregistry: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", diff --git a/pkg/serviceaccount/claims.go b/pkg/serviceaccount/claims.go index 543efc20e20..150c4d9ef20 100644 --- a/pkg/serviceaccount/claims.go +++ b/pkg/serviceaccount/claims.go @@ -17,17 +17,26 @@ limitations under the License. package serviceaccount import ( + "context" "errors" "fmt" "time" "gopkg.in/square/go-jose.v2/jwt" - "k8s.io/klog" - + "k8s.io/apiserver/pkg/audit" apiserverserviceaccount "k8s.io/apiserver/pkg/authentication/serviceaccount" + "k8s.io/klog" "k8s.io/kubernetes/pkg/apis/core" ) +const ( + // Injected bound service account token expiration which triggers monitoring of its time-bound feature. + WarnOnlyBoundTokenExpirationSeconds = 60*60 + 7 + + // Extended expiration for those modifed tokens involved in safe rollout if time-bound feature. + ExpirationExtensionSeconds = 24 * 365 * 60 * 60 +) + // time.Now stubbed out to allow testing var now = time.Now @@ -36,10 +45,11 @@ type privateClaims struct { } type kubernetes struct { - Namespace string `json:"namespace,omitempty"` - Svcacct ref `json:"serviceaccount,omitempty"` - Pod *ref `json:"pod,omitempty"` - Secret *ref `json:"secret,omitempty"` + Namespace string `json:"namespace,omitempty"` + Svcacct ref `json:"serviceaccount,omitempty"` + Pod *ref `json:"pod,omitempty"` + Secret *ref `json:"secret,omitempty"` + WarnAfter jwt.NumericDate `json:"warnafter,omitempty"` } type ref struct { @@ -47,7 +57,7 @@ type ref struct { UID string `json:"uid,omitempty"` } -func Claims(sa core.ServiceAccount, pod *core.Pod, secret *core.Secret, expirationSeconds int64, audience []string) (*jwt.Claims, interface{}) { +func Claims(sa core.ServiceAccount, pod *core.Pod, secret *core.Secret, expirationSeconds, warnafter int64, audience []string) (*jwt.Claims, interface{}) { now := now() sc := &jwt.Claims{ Subject: apiserverserviceaccount.MakeUsername(sa.Namespace, sa.Name), @@ -77,6 +87,11 @@ func Claims(sa core.ServiceAccount, pod *core.Pod, secret *core.Secret, expirati UID: string(secret.UID), } } + + if warnafter != 0 { + pc.Kubernetes.WarnAfter = jwt.NewNumericDate(now.Add(time.Duration(warnafter) * time.Second)) + } + return sc, pc } @@ -92,7 +107,7 @@ type validator struct { var _ = Validator(&validator{}) -func (v *validator) Validate(_ string, public *jwt.Claims, privateObj interface{}) (*ServiceAccountInfo, error) { +func (v *validator) Validate(ctx context.Context, _ string, public *jwt.Claims, privateObj interface{}) (*ServiceAccountInfo, error) { private, ok := privateObj.(*privateClaims) if !ok { klog.Errorf("jwt validator expected private claim of type *privateClaims but got: %T", privateObj) @@ -169,6 +184,19 @@ func (v *validator) Validate(_ string, public *jwt.Claims, privateObj interface{ podUID = podref.UID } + // Check special 'warnafter' field for projected service account token transition. + warnafter := private.Kubernetes.WarnAfter + if warnafter != 0 { + if nowTime.After(warnafter.Time()) { + secondsAfterWarn := nowTime.Unix() - warnafter.Time().Unix() + auditInfo := fmt.Sprintf("subject: %s, seconds after warning threshold: %d", public.Subject, secondsAfterWarn) + audit.AddAuditAnnotation(ctx, "authentication.k8s.io/stale-token", auditInfo) + staleTokensTotal.Inc() + } else { + validTokensTotal.Inc() + } + } + return &ServiceAccountInfo{ Namespace: private.Kubernetes.Namespace, Name: private.Kubernetes.Svcacct.Name, diff --git a/pkg/serviceaccount/claims_test.go b/pkg/serviceaccount/claims_test.go index 8c5ac79a915..76e4dc2bb08 100644 --- a/pkg/serviceaccount/claims_test.go +++ b/pkg/serviceaccount/claims_test.go @@ -17,6 +17,7 @@ limitations under the License. package serviceaccount import ( + "context" "encoding/json" "fmt" "testing" @@ -62,11 +63,12 @@ func TestClaims(t *testing.T) { } cs := []struct { // input - sa core.ServiceAccount - pod *core.Pod - sec *core.Secret - exp int64 - aud []string + sa core.ServiceAccount + pod *core.Pod + sec *core.Secret + exp int64 + warnafter int64 + aud []string // desired sc *jwt.Claims pc *privateClaims @@ -161,6 +163,31 @@ func TestClaims(t *testing.T) { }, }, }, + { + // warn after provided + sa: sa, + pod: pod, + sec: sec, + exp: 60 * 60 * 24, + warnafter: 60 * 60, + // nil audience + aud: nil, + + sc: &jwt.Claims{ + Subject: "system:serviceaccount:myns:mysvcacct", + IssuedAt: jwt.NumericDate(1514764800), + NotBefore: jwt.NumericDate(1514764800), + Expiry: jwt.NumericDate(1514764800 + 60*60*24), + }, + pc: &privateClaims{ + Kubernetes: kubernetes{ + Namespace: "myns", + Svcacct: ref{Name: "mysvcacct", UID: "mysvcacct-uid"}, + Pod: &ref{Name: "mypod", UID: "mypod-uid"}, + WarnAfter: jwt.NumericDate(1514764800 + 60*60), + }, + }, + }, } for i, c := range cs { t.Run(fmt.Sprintf("case %d", i), func(t *testing.T) { @@ -175,7 +202,7 @@ func TestClaims(t *testing.T) { return string(b) } - sc, pc := Claims(c.sa, c.pod, c.sec, c.exp, c.aud) + sc, pc := Claims(c.sa, c.pod, c.sec, c.exp, c.warnafter, c.aud) if spew(sc) != spew(c.sc) { t.Errorf("standard claims differed\n\tsaw:\t%s\n\twant:\t%s", spew(sc), spew(c.sc)) } @@ -310,7 +337,7 @@ func TestValidatePrivateClaims(t *testing.T) { for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) { v := &validator{tc.getter} - _, err := v.Validate("", &jwt.Claims{Expiry: jwt.NumericDate(nowUnix)}, tc.private) + _, err := v.Validate(context.Background(), "", &jwt.Claims{Expiry: jwt.NumericDate(nowUnix)}, tc.private) if err != nil && !tc.expectErr { t.Fatal(err) } diff --git a/pkg/serviceaccount/jwt.go b/pkg/serviceaccount/jwt.go index 24be9629d31..441666ee668 100644 --- a/pkg/serviceaccount/jwt.go +++ b/pkg/serviceaccount/jwt.go @@ -33,6 +33,7 @@ import ( v1 "k8s.io/api/core/v1" utilerrors "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/apiserver/pkg/audit" "k8s.io/apiserver/pkg/authentication/authenticator" ) @@ -244,7 +245,7 @@ type Validator interface { // Validate validates a token and returns user information or an error. // Validator can assume that the issuer and signature of a token are already // verified when this function is called. - Validate(tokenData string, public *jwt.Claims, private interface{}) (*ServiceAccountInfo, error) + Validate(ctx context.Context, tokenData string, public *jwt.Claims, private interface{}) (*ServiceAccountInfo, error) // NewPrivateClaims returns a struct that the authenticator should // deserialize the JWT payload into. The authenticator may then pass this // struct back to the Validator as the 'private' argument to a Validate() @@ -287,6 +288,8 @@ func (j *jwtTokenAuthenticator) AuthenticateToken(ctx context.Context, tokenData tokenAudiences := authenticator.Audiences(public.Audience) if len(tokenAudiences) == 0 { // only apiserver audiences are allowed for legacy tokens + audit.AddAuditAnnotation(ctx, "authentication.k8s.io/legacy-token", public.Subject) + legacyTokensTotal.Inc() tokenAudiences = j.implicitAuds } @@ -303,7 +306,7 @@ func (j *jwtTokenAuthenticator) AuthenticateToken(ctx context.Context, tokenData // If we get here, we have a token with a recognized signature and // issuer string. - sa, err := j.validator.Validate(tokenData, public, private) + sa, err := j.validator.Validate(ctx, tokenData, public, private) if err != nil { return nil, false, err } diff --git a/pkg/serviceaccount/legacy.go b/pkg/serviceaccount/legacy.go index 4739493ee4b..9b3bb6db970 100644 --- a/pkg/serviceaccount/legacy.go +++ b/pkg/serviceaccount/legacy.go @@ -18,6 +18,7 @@ package serviceaccount import ( "bytes" + "context" "errors" "fmt" @@ -62,7 +63,7 @@ type legacyValidator struct { var _ = Validator(&legacyValidator{}) -func (v *legacyValidator) Validate(tokenData string, public *jwt.Claims, privateObj interface{}) (*ServiceAccountInfo, error) { +func (v *legacyValidator) Validate(ctx context.Context, tokenData string, public *jwt.Claims, privateObj interface{}) (*ServiceAccountInfo, error) { private, ok := privateObj.(*legacyPrivateClaims) if !ok { klog.Errorf("jwt validator expected private claim of type *legacyPrivateClaims but got: %T", privateObj) diff --git a/pkg/serviceaccount/metrics.go b/pkg/serviceaccount/metrics.go new file mode 100644 index 00000000000..cc1913d2f73 --- /dev/null +++ b/pkg/serviceaccount/metrics.go @@ -0,0 +1,63 @@ +/* +Copyright 2020 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 ( + "k8s.io/component-base/metrics" + "k8s.io/component-base/metrics/legacyregistry" +) + +const kubeServiceAccountSubsystem = "serviceaccount" + +var ( + // LegacyTokensTotal is the number of legacy tokens used against apiserver. + legacyTokensTotal = metrics.NewCounter( + &metrics.CounterOpts{ + Subsystem: kubeServiceAccountSubsystem, + Name: "legacy_tokens_total", + Help: "Cumulative legacy service account tokens used", + StabilityLevel: metrics.ALPHA, + }, + ) + + // StaleTokensTotal is the number of stale projected tokens not refreshed on + // client side. + staleTokensTotal = metrics.NewCounter( + &metrics.CounterOpts{ + Subsystem: kubeServiceAccountSubsystem, + Name: "stale_tokens_total", + Help: "Cumulative stale projected service account tokens used", + StabilityLevel: metrics.ALPHA, + }, + ) + + // ValidTokensTotal is the number of valid projected tokens used. + validTokensTotal = metrics.NewCounter( + &metrics.CounterOpts{ + Subsystem: kubeServiceAccountSubsystem, + Name: "valid_tokens_total", + Help: "Cumulative valid projected service account tokens used", + StabilityLevel: metrics.ALPHA, + }, + ) +) + +func init() { + legacyregistry.MustRegister(legacyTokensTotal) + legacyregistry.MustRegister(staleTokensTotal) + legacyregistry.MustRegister(validTokensTotal) +} diff --git a/plugin/pkg/admission/serviceaccount/admission.go b/plugin/pkg/admission/serviceaccount/admission.go index 4fd478cfd2e..19f633c747f 100644 --- a/plugin/pkg/admission/serviceaccount/admission.go +++ b/plugin/pkg/admission/serviceaccount/admission.go @@ -530,7 +530,7 @@ func (s *Plugin) createVolume(tokenVolumeName, secretName string) api.Volume { { ServiceAccountToken: &api.ServiceAccountTokenProjection{ Path: "token", - ExpirationSeconds: 60 * 60, + ExpirationSeconds: serviceaccount.WarnOnlyBoundTokenExpirationSeconds, }, }, { diff --git a/plugin/pkg/admission/serviceaccount/admission_test.go b/plugin/pkg/admission/serviceaccount/admission_test.go index 6f8fb4a1dc0..77ef50c6222 100644 --- a/plugin/pkg/admission/serviceaccount/admission_test.go +++ b/plugin/pkg/admission/serviceaccount/admission_test.go @@ -252,7 +252,7 @@ func TestAssignsDefaultServiceAccountAndBoundTokenWithNoSecretTokens(t *testing. VolumeSource: api.VolumeSource{ Projected: &api.ProjectedVolumeSource{ Sources: []api.VolumeProjection{ - {ServiceAccountToken: &api.ServiceAccountTokenProjection{ExpirationSeconds: 3600, Path: "token"}}, + {ServiceAccountToken: &api.ServiceAccountTokenProjection{ExpirationSeconds: 3607, Path: "token"}}, {ConfigMap: &api.ConfigMapProjection{LocalObjectReference: api.LocalObjectReference{Name: "kube-root-ca.crt"}, Items: []api.KeyToPath{{Key: "ca.crt", Path: "ca.crt"}}}}, {DownwardAPI: &api.DownwardAPIProjection{Items: []api.DownwardAPIVolumeFile{{Path: "namespace", FieldRef: &api.ObjectFieldSelector{APIVersion: "v1", FieldPath: "metadata.namespace"}}}}}, }, diff --git a/test/integration/auth/svcaccttoken_test.go b/test/integration/auth/svcaccttoken_test.go index a5099da7a5e..6494f6713d6 100644 --- a/test/integration/auth/svcaccttoken_test.go +++ b/test/integration/auth/svcaccttoken_test.go @@ -27,6 +27,7 @@ import ( "net/http" "net/url" "reflect" + "strconv" "strings" "testing" "time" @@ -77,7 +78,7 @@ func TestServiceAccountTokenCreate(t *testing.T) { const iss = "https://foo.bar.example.com" aud := authenticator.Audiences{"api"} - maxExpirationSeconds := int64(60 * 60) + maxExpirationSeconds := int64(60 * 60 * 2) maxExpirationDuration, err := time.ParseDuration(fmt.Sprintf("%ds", maxExpirationSeconds)) if err != nil { t.Fatalf("err: %v", err) @@ -116,6 +117,7 @@ func TestServiceAccountTokenCreate(t *testing.T) { masterConfig.ExtraConfig.ServiceAccountIssuer = tokenGenerator masterConfig.ExtraConfig.ServiceAccountMaxExpiration = maxExpirationDuration masterConfig.GenericConfig.Authentication.APIAudiences = aud + masterConfig.ExtraConfig.ExtendExpiration = true masterConfig.ExtraConfig.ServiceAccountIssuerURL = iss masterConfig.ExtraConfig.ServiceAccountJWKSURI = "" @@ -373,7 +375,7 @@ func TestServiceAccountTokenCreate(t *testing.T) { coresa := core.ServiceAccount{ ObjectMeta: sa.ObjectMeta, } - _, pc := serviceaccount.Claims(coresa, nil, nil, 0, nil) + _, pc := serviceaccount.Claims(coresa, nil, nil, 0, 0, nil) tok, err := masterConfig.ExtraConfig.ServiceAccountIssuer.GenerateToken(sc, pc) if err != nil { t.Fatalf("err signing expired token: %v", err) @@ -383,6 +385,60 @@ func TestServiceAccountTokenCreate(t *testing.T) { doTokenReview(t, cs, treq, true) }) + t.Run("expiration extended token", func(t *testing.T) { + var requestExp int64 = 60*60 + 7 + treq := &authenticationv1.TokenRequest{ + Spec: authenticationv1.TokenRequestSpec{ + Audiences: []string{"api"}, + ExpirationSeconds: &requestExp, + BoundObjectRef: &authenticationv1.BoundObjectReference{ + Kind: "Pod", + APIVersion: "v1", + Name: pod.Name, + }, + }, + } + + sa, del := createDeleteSvcAcct(t, cs, sa) + defer del() + pod, delPod := createDeletePod(t, cs, pod) + defer delPod() + treq.Spec.BoundObjectRef.UID = pod.UID + + treq, err = cs.CoreV1().ServiceAccounts(sa.Namespace).CreateToken(context.TODO(), sa.Name, treq, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("err: %v", err) + } + + doTokenReview(t, cs, treq, false) + + // Give some tolerance to avoid flakiness since we are using real time. + var leeway int64 = 2 + actualExpiry := jwt.NewNumericDate(time.Now().Add(time.Duration(24*365) * time.Hour)) + assumedExpiry := jwt.NewNumericDate(time.Now().Add(time.Duration(requestExp) * time.Second)) + exp, err := strconv.ParseInt(getSubObject(t, getPayload(t, treq.Status.Token), "exp"), 10, 64) + if err != nil { + t.Fatalf("error parsing exp: %v", err) + } + warnafter, err := strconv.ParseInt(getSubObject(t, getPayload(t, treq.Status.Token), "kubernetes.io", "warnafter"), 10, 64) + if err != nil { + t.Fatalf("error parsing warnafter: %v", err) + } + + if exp < int64(actualExpiry)-leeway || exp > int64(actualExpiry)+leeway { + t.Errorf("unexpected token exp %d, should within range of %d +- %d seconds", exp, actualExpiry, leeway) + } + if warnafter < int64(assumedExpiry)-leeway || warnafter > int64(assumedExpiry)+leeway { + t.Errorf("unexpected token warnafter %d, should within range of %d +- %d seconds", warnafter, assumedExpiry, leeway) + } + + checkExpiration(t, treq, requestExp) + expStatus := treq.Status.ExpirationTimestamp.Time.Unix() + if expStatus < int64(assumedExpiry)-leeway || warnafter > int64(assumedExpiry)+leeway { + t.Errorf("unexpected expiration returned in tokenrequest status %d, should within range of %d +- %d seconds", expStatus, assumedExpiry, leeway) + } + }) + t.Run("a token without an api audience is invalid", func(t *testing.T) { treq := &authenticationv1.TokenRequest{ Spec: authenticationv1.TokenRequestSpec{