From 6fdacf04117cef54a0babd0945e8ef87d0f9461d Mon Sep 17 00:00:00 2001 From: Harshal Neelkamal Date: Fri, 18 Oct 2024 19:31:35 +0000 Subject: [PATCH] Add plugin and key-cache for ExternalJWTSigner integration --- cmd/kube-apiserver/app/options/completion.go | 15 +- cmd/kube-apiserver/app/server.go | 7 +- cmd/kube-apiserver/app/testing/testserver.go | 2 +- go.mod | 2 + go.work | 1 + hack/unwanted-dependencies.json | 1 + hack/update-codegen.sh | 2 + .../authentication/validation/validation.go | 7 +- .../serviceaccount/tokens_controller.go | 4 +- .../serviceaccount/tokens_controller_test.go | 3 +- pkg/controlplane/apiserver/apis.go | 1 + pkg/controlplane/apiserver/config.go | 2 + pkg/controlplane/apiserver/config_test.go | 3 +- pkg/controlplane/apiserver/options/options.go | 97 ++- .../apiserver/options/options_test.go | 203 ++++++ .../apiserver/options/validation.go | 25 + .../apiserver/options/validation_test.go | 185 ++++++ .../samples/generic/server/server.go | 6 +- .../generic/server/testing/testserver.go | 2 +- pkg/features/kube_features.go | 7 + pkg/features/versioned_kube_features.go | 4 + pkg/kubeapiserver/options/authentication.go | 29 +- .../options/authentication_test.go | 176 +++++- pkg/registry/core/rest/storage_core.go | 2 +- .../core/rest/storage_core_generic.go | 5 +- .../core/serviceaccount/storage/storage.go | 21 +- .../serviceaccount/storage/storage_test.go | 9 +- .../core/serviceaccount/storage/token.go | 31 +- .../core/serviceaccount/storage/token_test.go | 202 ++++++ .../externaljwt/metrics/metrics.go | 148 +++++ .../externaljwt/metrics/metrics_test.go | 244 ++++++++ .../externaljwt/plugin/keycache.go | 236 +++++++ .../externaljwt/plugin/keycache_test.go | 407 ++++++++++++ .../externaljwt/plugin/plugin.go | 218 +++++++ .../externaljwt/plugin/plugin_test.go | 450 +++++++++++++ .../testing/v1alpha1/externalsigner_mock.go | 279 +++++++++ pkg/serviceaccount/jwt.go | 46 +- pkg/serviceaccount/jwt_test.go | 29 +- pkg/serviceaccount/openidmetadata.go | 13 +- pkg/serviceaccount/openidmetadata_test.go | 150 ++++- staging/publishing/import-restrictions.yaml | 4 + staging/publishing/rules.yaml | 7 + .../.github/PULL_REQUEST_TEMPLATE.md | 2 + .../src/k8s.io/externaljwt/CONTRIBUTING.md | 7 + staging/src/k8s.io/externaljwt/LICENSE | 201 ++++++ staging/src/k8s.io/externaljwt/OWNERS | 8 + staging/src/k8s.io/externaljwt/README.md | 20 + .../src/k8s.io/externaljwt/SECURITY_CONTACTS | 15 + staging/src/k8s.io/externaljwt/apis/OWNERS | 9 + .../externaljwt/apis/v1alpha1/api.pb.go | 591 ++++++++++++++++++ .../externaljwt/apis/v1alpha1/api.proto | 113 ++++ .../src/k8s.io/externaljwt/code-of-conduct.md | 3 + staging/src/k8s.io/externaljwt/docs.go | 18 + staging/src/k8s.io/externaljwt/go.mod | 20 + staging/src/k8s.io/externaljwt/go.sum | 62 ++ test/e2e_node/services/apiserver.go | 2 +- .../test_data/versioned_feature_list.yaml | 6 + test/integration/auth/svcaccttoken_test.go | 2 +- test/integration/etcd/server.go | 2 +- test/integration/framework/test_server.go | 2 +- .../external_jwt_signer_test.go | 305 +++++++++ vendor/modules.txt | 2 + 62 files changed, 4536 insertions(+), 139 deletions(-) create mode 100644 pkg/registry/core/serviceaccount/storage/token_test.go create mode 100644 pkg/serviceaccount/externaljwt/metrics/metrics.go create mode 100644 pkg/serviceaccount/externaljwt/metrics/metrics_test.go create mode 100644 pkg/serviceaccount/externaljwt/plugin/keycache.go create mode 100644 pkg/serviceaccount/externaljwt/plugin/keycache_test.go create mode 100644 pkg/serviceaccount/externaljwt/plugin/plugin.go create mode 100644 pkg/serviceaccount/externaljwt/plugin/plugin_test.go create mode 100644 pkg/serviceaccount/externaljwt/plugin/testing/v1alpha1/externalsigner_mock.go create mode 100644 staging/src/k8s.io/externaljwt/.github/PULL_REQUEST_TEMPLATE.md create mode 100644 staging/src/k8s.io/externaljwt/CONTRIBUTING.md create mode 100644 staging/src/k8s.io/externaljwt/LICENSE create mode 100644 staging/src/k8s.io/externaljwt/OWNERS create mode 100644 staging/src/k8s.io/externaljwt/README.md create mode 100644 staging/src/k8s.io/externaljwt/SECURITY_CONTACTS create mode 100644 staging/src/k8s.io/externaljwt/apis/OWNERS create mode 100644 staging/src/k8s.io/externaljwt/apis/v1alpha1/api.pb.go create mode 100644 staging/src/k8s.io/externaljwt/apis/v1alpha1/api.proto create mode 100644 staging/src/k8s.io/externaljwt/code-of-conduct.md create mode 100644 staging/src/k8s.io/externaljwt/docs.go create mode 100644 staging/src/k8s.io/externaljwt/go.mod create mode 100644 staging/src/k8s.io/externaljwt/go.sum create mode 100644 test/integration/serviceaccount/external_jwt_signer_test.go diff --git a/cmd/kube-apiserver/app/options/completion.go b/cmd/kube-apiserver/app/options/completion.go index 32018eda75f70..554a4b70e0402 100644 --- a/cmd/kube-apiserver/app/options/completion.go +++ b/cmd/kube-apiserver/app/options/completion.go @@ -17,6 +17,7 @@ limitations under the License. package options import ( + "context" "fmt" "net" "strings" @@ -45,27 +46,27 @@ type CompletedOptions struct { // Complete set default ServerRunOptions. // Should be called after kube-apiserver flags parsed. -func (opts *ServerRunOptions) Complete() (CompletedOptions, error) { - if opts == nil { +func (s *ServerRunOptions) Complete(ctx context.Context) (CompletedOptions, error) { + if s == nil { return CompletedOptions{completedOptions: &completedOptions{}}, nil } - // process opts.ServiceClusterIPRange from list to Primary and Secondary + // process s.ServiceClusterIPRange from list to Primary and Secondary // we process secondary only if provided by user - apiServerServiceIP, primaryServiceIPRange, secondaryServiceIPRange, err := getServiceIPAndRanges(opts.ServiceClusterIPRanges) + apiServerServiceIP, primaryServiceIPRange, secondaryServiceIPRange, err := getServiceIPAndRanges(s.ServiceClusterIPRanges) if err != nil { return CompletedOptions{}, err } - controlplane, err := opts.Options.Complete([]string{"kubernetes.default.svc", "kubernetes.default", "kubernetes"}, []net.IP{apiServerServiceIP}) + controlplane, err := s.Options.Complete(ctx, []string{"kubernetes.default.svc", "kubernetes.default", "kubernetes"}, []net.IP{apiServerServiceIP}) if err != nil { return CompletedOptions{}, err } completed := completedOptions{ CompletedOptions: controlplane, - CloudProvider: opts.CloudProvider, + CloudProvider: s.CloudProvider, - Extra: opts.Extra, + Extra: s.Extra, } completed.PrimaryServiceClusterIPRange = primaryServiceIPRange diff --git a/cmd/kube-apiserver/app/server.go b/cmd/kube-apiserver/app/server.go index b78c8cefe6b0c..8aa05a4d8f891 100644 --- a/cmd/kube-apiserver/app/server.go +++ b/cmd/kube-apiserver/app/server.go @@ -67,6 +67,7 @@ func NewAPIServerCommand() *cobra.Command { _, featureGate := featuregate.DefaultComponentGlobalsRegistry.ComponentGlobalsOrRegister( featuregate.DefaultKubeComponent, utilversion.DefaultBuildEffectiveVersion(), utilfeature.DefaultMutableFeatureGate) s := options.NewServerRunOptions() + ctx := genericapiserver.SetupSignalContext() cmd := &cobra.Command{ Use: "kube-apiserver", @@ -97,7 +98,7 @@ cluster's shared state through which all other components interact.`, cliflag.PrintFlags(fs) // set default options - completedOptions, err := s.Complete() + completedOptions, err := s.Complete(ctx) if err != nil { return err } @@ -108,7 +109,7 @@ cluster's shared state through which all other components interact.`, } // add feature enablement metrics featureGate.AddMetrics() - return Run(cmd.Context(), completedOptions) + return Run(ctx, completedOptions) }, Args: func(cmd *cobra.Command, args []string) error { for _, arg := range args { @@ -119,7 +120,7 @@ cluster's shared state through which all other components interact.`, return nil }, } - cmd.SetContext(genericapiserver.SetupSignalContext()) + cmd.SetContext(ctx) fs := cmd.Flags() namedFlagSets := s.Flags() diff --git a/cmd/kube-apiserver/app/testing/testserver.go b/cmd/kube-apiserver/app/testing/testserver.go index b8a2673521c56..685047ac10eda 100644 --- a/cmd/kube-apiserver/app/testing/testserver.go +++ b/cmd/kube-apiserver/app/testing/testserver.go @@ -390,7 +390,7 @@ func StartTestServer(t ktesting.TB, instanceOptions *TestServerInstanceOptions, s.Authentication.ServiceAccounts.Issuers = []string{"https://foo.bar.example.com"} s.Authentication.ServiceAccounts.KeyFiles = []string{saSigningKeyFile.Name()} - completedOptions, err := s.Complete() + completedOptions, err := s.Complete(tCtx) if err != nil { return result, fmt.Errorf("failed to set default ServerRunOptions: %v", err) } diff --git a/go.mod b/go.mod index e6c225aba340c..0dae2b65a8229 100644 --- a/go.mod +++ b/go.mod @@ -103,6 +103,7 @@ require ( k8s.io/csi-translation-lib v0.0.0 k8s.io/dynamic-resource-allocation v0.0.0 k8s.io/endpointslice v0.0.0 + k8s.io/externaljwt v0.0.0 k8s.io/klog/v2 v2.130.1 k8s.io/kms v0.0.0 k8s.io/kube-aggregator v0.0.0 @@ -239,6 +240,7 @@ replace ( k8s.io/csi-translation-lib => ./staging/src/k8s.io/csi-translation-lib k8s.io/dynamic-resource-allocation => ./staging/src/k8s.io/dynamic-resource-allocation k8s.io/endpointslice => ./staging/src/k8s.io/endpointslice + k8s.io/externaljwt => ./staging/src/k8s.io/externaljwt k8s.io/kms => ./staging/src/k8s.io/kms k8s.io/kube-aggregator => ./staging/src/k8s.io/kube-aggregator k8s.io/kube-controller-manager => ./staging/src/k8s.io/kube-controller-manager diff --git a/go.work b/go.work index e6eec2e4a0a26..d48c8b6b2716d 100644 --- a/go.work +++ b/go.work @@ -23,6 +23,7 @@ use ( ./staging/src/k8s.io/csi-translation-lib ./staging/src/k8s.io/dynamic-resource-allocation ./staging/src/k8s.io/endpointslice + ./staging/src/k8s.io/externaljwt ./staging/src/k8s.io/kms ./staging/src/k8s.io/kube-aggregator ./staging/src/k8s.io/kube-controller-manager diff --git a/hack/unwanted-dependencies.json b/hack/unwanted-dependencies.json index 560f6d47ccb5f..fe82b5db17050 100644 --- a/hack/unwanted-dependencies.json +++ b/hack/unwanted-dependencies.json @@ -111,6 +111,7 @@ "k8s.io/client-go", "k8s.io/code-generator", "k8s.io/cri-api", + "k8s.io/externaljwt", "k8s.io/kms", "k8s.io/kube-aggregator", "k8s.io/kubelet", diff --git a/hack/update-codegen.sh b/hack/update-codegen.sh index 3f146f18f2260..281a6f2749a98 100755 --- a/hack/update-codegen.sh +++ b/hack/update-codegen.sh @@ -785,6 +785,8 @@ function codegen::protobindings() { "staging/src/k8s.io/kubelet/pkg/apis/pluginregistration" "pkg/kubelet/pluginmanager/pluginwatcher/example_plugin_apis" + + "staging/src/k8s.io/externaljwt/apis" ) kube::log::status "Generating protobuf bindings for ${#apis[@]} targets" diff --git a/pkg/apis/authentication/validation/validation.go b/pkg/apis/authentication/validation/validation.go index b174ddfc1e937..844758f852228 100644 --- a/pkg/apis/authentication/validation/validation.go +++ b/pkg/apis/authentication/validation/validation.go @@ -19,19 +19,18 @@ limitations under the License. package validation import ( - "time" - "k8s.io/apimachinery/pkg/util/validation/field" "k8s.io/kubernetes/pkg/apis/authentication" ) +const MinTokenAgeSec = 10 * 60 // 10 minutes + // ValidateTokenRequest validates a TokenRequest. func ValidateTokenRequest(tr *authentication.TokenRequest) field.ErrorList { allErrs := field.ErrorList{} specPath := field.NewPath("spec") - const min = 10 * time.Minute - if tr.Spec.ExpirationSeconds < int64(min.Seconds()) { + if tr.Spec.ExpirationSeconds < MinTokenAgeSec { allErrs = append(allErrs, field.Invalid(specPath.Child("expirationSeconds"), tr.Spec.ExpirationSeconds, "may not specify a duration less than 10 minutes")) } if tr.Spec.ExpirationSeconds > 1<<32 { diff --git a/pkg/controller/serviceaccount/tokens_controller.go b/pkg/controller/serviceaccount/tokens_controller.go index dfacea8acfaf7..eb430b2d723f9 100644 --- a/pkg/controller/serviceaccount/tokens_controller.go +++ b/pkg/controller/serviceaccount/tokens_controller.go @@ -409,7 +409,9 @@ func (e *TokensController) generateTokenIfNeeded(logger klog.Logger, serviceAcco // Generate the token if needsToken { - token, err := e.token.GenerateToken(serviceaccount.LegacyClaims(*serviceAccount, *liveSecret)) + c, pc := serviceaccount.LegacyClaims(*serviceAccount, *liveSecret) + // TODO: need to plumb context if using external signer ever becomes a posibility. + token, err := e.token.GenerateToken(context.TODO(), c, pc) if err != nil { return false, err } diff --git a/pkg/controller/serviceaccount/tokens_controller_test.go b/pkg/controller/serviceaccount/tokens_controller_test.go index dd1eb2610c8a4..c8854801c71f7 100644 --- a/pkg/controller/serviceaccount/tokens_controller_test.go +++ b/pkg/controller/serviceaccount/tokens_controller_test.go @@ -17,6 +17,7 @@ limitations under the License. package serviceaccount import ( + "context" "reflect" "testing" "time" @@ -40,7 +41,7 @@ type testGenerator struct { Err error } -func (t *testGenerator) GenerateToken(sc *jwt.Claims, pc interface{}) (string, error) { +func (t *testGenerator) GenerateToken(ctx context.Context, sc *jwt.Claims, pc interface{}) (string, error) { return t.Token, t.Err } diff --git a/pkg/controlplane/apiserver/apis.go b/pkg/controlplane/apiserver/apis.go index 9ebb7e8f4fe1d..7ed3a58489b0c 100644 --- a/pkg/controlplane/apiserver/apis.go +++ b/pkg/controlplane/apiserver/apis.go @@ -52,6 +52,7 @@ func (c *CompletedConfig) NewCoreGenericConfig() *corerest.GenericConfig { LoopbackClientConfig: c.Generic.LoopbackClientConfig, ServiceAccountIssuer: c.Extra.ServiceAccountIssuer, ExtendExpiration: c.Extra.ExtendExpiration, + IsTokenSignerExternal: c.Extra.IsTokenSignerExternal, ServiceAccountMaxExpiration: c.Extra.ServiceAccountMaxExpiration, APIAudiences: c.Generic.Authentication.APIAudiences, Informers: c.Extra.VersionedInformers, diff --git a/pkg/controlplane/apiserver/config.go b/pkg/controlplane/apiserver/config.go index fe99c7d4362b1..8cf724ff53987 100644 --- a/pkg/controlplane/apiserver/config.go +++ b/pkg/controlplane/apiserver/config.go @@ -92,6 +92,7 @@ type Extra struct { ServiceAccountIssuer serviceaccount.TokenGenerator ServiceAccountMaxExpiration time.Duration ExtendExpiration bool + IsTokenSignerExternal bool // ServiceAccountIssuerDiscovery ServiceAccountIssuerURL string @@ -300,6 +301,7 @@ func CreateConfig( ServiceAccountIssuer: opts.ServiceAccountIssuer, ServiceAccountMaxExpiration: opts.ServiceAccountTokenMaxExpiration, ExtendExpiration: opts.Authentication.ServiceAccounts.ExtendExpiration, + IsTokenSignerExternal: opts.Authentication.ServiceAccounts.IsTokenSignerExternal, VersionedInformers: versionedInformers, }, diff --git a/pkg/controlplane/apiserver/config_test.go b/pkg/controlplane/apiserver/config_test.go index 0e6516e62cadb..b6d4f9799273d 100644 --- a/pkg/controlplane/apiserver/config_test.go +++ b/pkg/controlplane/apiserver/config_test.go @@ -17,6 +17,7 @@ limitations under the License. package apiserver import ( + "context" "net" "testing" @@ -45,7 +46,7 @@ func TestBuildGenericConfig(t *testing.T) { s.BindPort = ln.Addr().(*net.TCPAddr).Port opts.SecureServing = s - completedOptions, err := opts.Complete(nil, nil) + completedOptions, err := opts.Complete(context.TODO(), nil, nil) if err != nil { t.Fatalf("Failed to complete apiserver options: %v", err) } diff --git a/pkg/controlplane/apiserver/options/options.go b/pkg/controlplane/apiserver/options/options.go index f21faca2a27f3..133a1294bb1aa 100644 --- a/pkg/controlplane/apiserver/options/options.go +++ b/pkg/controlplane/apiserver/options/options.go @@ -18,6 +18,7 @@ limitations under the License. package options import ( + "context" "fmt" "net" "os" @@ -36,9 +37,11 @@ import ( "k8s.io/klog/v2" netutil "k8s.io/utils/net" + "k8s.io/kubernetes/pkg/apis/authentication/validation" _ "k8s.io/kubernetes/pkg/features" kubeoptions "k8s.io/kubernetes/pkg/kubeapiserver/options" "k8s.io/kubernetes/pkg/serviceaccount" + "k8s.io/kubernetes/pkg/serviceaccount/externaljwt/plugin" ) // Options define the flags and validation for a generic controlplane. If the @@ -85,6 +88,8 @@ type Options struct { ShowHiddenMetricsForVersion string SystemNamespaces []string + + ServiceAccountSigningEndpoint string } // completedServerRunOptions is a private wrapper that enforces a call of Complete() before Run can be invoked. @@ -191,9 +196,12 @@ func (s *Options) AddFlags(fss *cliflag.NamedFlagSets) { fs.StringVar(&s.ServiceAccountSigningKeyFile, "service-account-signing-key-file", s.ServiceAccountSigningKeyFile, ""+ "Path to the file that contains the current private key of the service account token issuer. The issuer will sign issued ID tokens with this private key.") + + fs.StringVar(&s.ServiceAccountSigningEndpoint, "service-account-signing-endpoint", s.ServiceAccountSigningEndpoint, ""+ + "Path to socket where a external JWT signer is listening. This flag is mutually exclusive with --service-account-signing-key-file and --service-account-key-file. Requires enabling feature gate (ExternalServiceAccountTokenSigner)") } -func (o *Options) Complete(alternateDNS []string, alternateIPs []net.IP) (CompletedOptions, error) { +func (o *Options) Complete(ctx context.Context, alternateDNS []string, alternateIPs []net.IP) (CompletedOptions, error) { if o == nil { return CompletedOptions{completedOptions: &completedOptions{}}, nil } @@ -233,52 +241,91 @@ func (o *Options) Complete(alternateDNS []string, alternateIPs []net.IP) (Comple // adjust authentication for completed authorization completed.Authentication.ApplyAuthorization(completed.Authorization) - // verify and adjust ServiceAccountTokenMaxExpiration + err := o.completeServiceAccountOptions(ctx, &completed) + if err != nil { + return CompletedOptions{}, err + } + + for key, value := range completed.APIEnablement.RuntimeConfig { + if key == "v1" || strings.HasPrefix(key, "v1/") || + key == "api/v1" || strings.HasPrefix(key, "api/v1/") { + delete(completed.APIEnablement.RuntimeConfig, key) + completed.APIEnablement.RuntimeConfig["/v1"] = value + } + if key == "api/legacy" { + delete(completed.APIEnablement.RuntimeConfig, key) + } + } + + return CompletedOptions{ + completedOptions: &completed, + }, nil +} + +func (o *Options) completeServiceAccountOptions(ctx context.Context, completed *completedOptions) error { + transitionWarningFmt := "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 %d seconds (currently %s)" + expExtensionWarningFmt := "service-account-extend-token-expiration is true, enabling tokens valid up to %d seconds, which is longer than service-account-max-token-expiration set to %s" + // verify service-account-max-token-expiration if completed.Authentication.ServiceAccounts.MaxExpiration != 0 { lowBound := time.Hour upBound := time.Duration(1<<32) * time.Second if completed.Authentication.ServiceAccounts.MaxExpiration < lowBound || completed.Authentication.ServiceAccounts.MaxExpiration > upBound { - return CompletedOptions{}, fmt.Errorf("the service-account-max-token-expiration must be between 1 hour and 2^32 seconds") - } - if completed.Authentication.ServiceAccounts.ExtendExpiration { - if completed.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 %d seconds (currently %s)", serviceaccount.WarnOnlyBoundTokenExpirationSeconds, completed.Authentication.ServiceAccounts.MaxExpiration) - } - if completed.Authentication.ServiceAccounts.MaxExpiration < serviceaccount.ExpirationExtensionSeconds*time.Second { - klog.Warningf("service-account-extend-token-expiration is true, enabling tokens valid up to %d seconds, which is longer than service-account-max-token-expiration set to %s seconds", serviceaccount.ExpirationExtensionSeconds, completed.Authentication.ServiceAccounts.MaxExpiration) - } + return fmt.Errorf("the service-account-max-token-expiration must be between 1 hour and 2^32 seconds") } } - completed.ServiceAccountTokenMaxExpiration = completed.Authentication.ServiceAccounts.MaxExpiration if len(completed.Authentication.ServiceAccounts.Issuers) != 0 && completed.Authentication.ServiceAccounts.Issuers[0] != "" { - if completed.ServiceAccountSigningKeyFile != "" { + switch { + case completed.ServiceAccountSigningEndpoint != "" && completed.ServiceAccountSigningKeyFile != "": + return fmt.Errorf("service-account-signing-key-file and service-account-signing-endpoint are mutually exclusive and cannot be set at the same time") + case completed.ServiceAccountSigningKeyFile != "": sk, err := keyutil.PrivateKeyFromFile(completed.ServiceAccountSigningKeyFile) if err != nil { - return CompletedOptions{}, fmt.Errorf("failed to parse service-account-issuer-key-file: %w", err) + return fmt.Errorf("failed to parse service-account-issuer-key-file: %w", err) } completed.ServiceAccountIssuer, err = serviceaccount.JWTTokenGenerator(completed.Authentication.ServiceAccounts.Issuers[0], sk) if err != nil { - return CompletedOptions{}, fmt.Errorf("failed to build token generator: %w", err) + return fmt.Errorf("failed to build token generator: %w", err) + } + case completed.ServiceAccountSigningEndpoint != "": + plugin, cache, err := plugin.New(ctx, completed.Authentication.ServiceAccounts.Issuers[0], completed.ServiceAccountSigningEndpoint, 60*time.Second, false) + if err != nil { + return fmt.Errorf("while setting up external-jwt-signer: %w", err) } + timedContext, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + metadata, err := plugin.GetServiceMetadata(timedContext) + if err != nil { + return fmt.Errorf("while setting up external-jwt-signer: %w", err) + } + if metadata.MaxTokenExpirationSeconds < validation.MinTokenAgeSec { + return fmt.Errorf("max token life supported by external-jwt-signer (%ds) is less than acceptable (min %ds)", metadata.MaxTokenExpirationSeconds, validation.MinTokenAgeSec) + } + if completed.Authentication.ServiceAccounts.MaxExpiration != 0 { + return fmt.Errorf("service-account-max-token-expiration and service-account-signing-endpoint are mutually exclusive and cannot be set at the same time") + } + transitionWarningFmt = "service-account-extend-token-expiration is true, in order to correctly trigger safe transition logic, token lifetime supported by external-jwt-signer must be longer than %d seconds (currently %s)" + expExtensionWarningFmt = "service-account-extend-token-expiration is true, tokens validity will be caped at the smaller of %d seconds and maximum token lifetime supported by external-jwt-signer (%s)" + completed.ServiceAccountIssuer = plugin + completed.Authentication.ServiceAccounts.ExternalPublicKeysGetter = cache + completed.Authentication.ServiceAccounts.MaxExpiration = time.Duration(metadata.MaxTokenExpirationSeconds) * time.Second + completed.Authentication.ServiceAccounts.IsTokenSignerExternal = true } } - for key, value := range completed.APIEnablement.RuntimeConfig { - if key == "v1" || strings.HasPrefix(key, "v1/") || - key == "api/v1" || strings.HasPrefix(key, "api/v1/") { - delete(completed.APIEnablement.RuntimeConfig, key) - completed.APIEnablement.RuntimeConfig["/v1"] = value + // Set Max expiration and warn on conflicting configuration. + if completed.Authentication.ServiceAccounts.ExtendExpiration && completed.Authentication.ServiceAccounts.MaxExpiration != 0 { + if completed.Authentication.ServiceAccounts.MaxExpiration < serviceaccount.WarnOnlyBoundTokenExpirationSeconds*time.Second { + klog.Warningf(transitionWarningFmt, serviceaccount.WarnOnlyBoundTokenExpirationSeconds, completed.Authentication.ServiceAccounts.MaxExpiration) } - if key == "api/legacy" { - delete(completed.APIEnablement.RuntimeConfig, key) + if completed.Authentication.ServiceAccounts.MaxExpiration < serviceaccount.ExpirationExtensionSeconds*time.Second { + klog.Warningf(expExtensionWarningFmt, serviceaccount.ExpirationExtensionSeconds, completed.Authentication.ServiceAccounts.MaxExpiration) } } + completed.ServiceAccountTokenMaxExpiration = completed.Authentication.ServiceAccounts.MaxExpiration - return CompletedOptions{ - completedOptions: &completed, - }, nil + return nil } // ServiceIPRange checks if the serviceClusterIPRange flag is nil, raising a warning if so and diff --git a/pkg/controlplane/apiserver/options/options_test.go b/pkg/controlplane/apiserver/options/options_test.go index 79d48570b2f8b..0d4eecb741b4a 100644 --- a/pkg/controlplane/apiserver/options/options_test.go +++ b/pkg/controlplane/apiserver/options/options_test.go @@ -17,6 +17,13 @@ limitations under the License. package options import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "os" "reflect" "testing" "time" @@ -39,6 +46,7 @@ import ( "k8s.io/component-base/metrics" utilversion "k8s.io/component-base/version" kubeoptions "k8s.io/kubernetes/pkg/kubeapiserver/options" + v1alpha1testing "k8s.io/kubernetes/pkg/serviceaccount/externaljwt/plugin/testing/v1alpha1" netutils "k8s.io/utils/net" ) @@ -308,3 +316,198 @@ func TestAddFlags(t *testing.T) { t.Errorf("Got emulation version %s, wanted %s", testEffectiveVersion.EmulationVersion().String(), "1.31") } } + +func TestCompleteForServiceAccount(t *testing.T) { + + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + panic("Error while generating first RSA key") + } + + // Marshal the private key into PEM format + privateKeyPEM := &pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + } + + // Open a file to write the private key + privateKeyFile, err := os.Create("private_key.pem") + if err != nil { + t.Fatalf("Failed to create private key file: %v", err) + } + t.Cleanup(func() { + _ = privateKeyFile.Close() + _ = os.Remove("private_key.pem") + }) + + // Write the PEM-encoded private key to the file + if err := pem.Encode(privateKeyFile, privateKeyPEM); err != nil { + t.Fatalf("Failed to encode private key: %v", err) + } + + // create and start mock signer. + socketPath := "@mock-external-jwt-signer.sock" + mockSigner := v1alpha1testing.NewMockSigner(t, socketPath) + defer mockSigner.CleanUp() + + testCases := []struct { + desc string + issuers []string + signingEndpoint string + signingKeyFiles string + maxExpiration time.Duration + externalMaxExpirationSec int64 + fetchError error + metadataError error + + wantError error + expectedMaxtokenExp time.Duration + expectedIsExternalSigner bool + externalPublicKeyGetterPresent bool + }{ + { + desc: "no endpoint or key file", + issuers: []string{ + "iss", + }, + signingEndpoint: socketPath, + signingKeyFiles: "private_key.pem", + maxExpiration: time.Second * 3600, + + wantError: fmt.Errorf("service-account-signing-key-file and service-account-signing-endpoint are mutually exclusive and cannot be set at the same time"), + }, + { + desc: "max token expiration breaching accepteable values", + issuers: []string{ + "iss", + }, + signingEndpoint: socketPath, + signingKeyFiles: "private_key.pem", + maxExpiration: time.Second * 10, + + wantError: fmt.Errorf("the service-account-max-token-expiration must be between 1 hour and 2^32 seconds"), + }, + { + desc: "path to a signing key provided", + issuers: []string{ + "iss", + }, + signingEndpoint: "", + signingKeyFiles: "private_key.pem", + maxExpiration: time.Second * 3600, + + expectedIsExternalSigner: false, + externalPublicKeyGetterPresent: false, + expectedMaxtokenExp: time.Second * 3600, + }, + { + desc: "signing endpoint provided", + issuers: []string{ + "iss", + }, + signingEndpoint: socketPath, + signingKeyFiles: "", + maxExpiration: 0, + externalMaxExpirationSec: 600, // 10m + + expectedIsExternalSigner: true, + externalPublicKeyGetterPresent: true, + expectedMaxtokenExp: time.Second * 600, // 10m + }, + { + desc: "signing endpoint provided and max token expiration set", + issuers: []string{ + "iss", + }, + signingEndpoint: socketPath, + signingKeyFiles: "", + maxExpiration: time.Second * 3600, + externalMaxExpirationSec: 600, // 10m + + wantError: fmt.Errorf("service-account-max-token-expiration and service-account-signing-endpoint are mutually exclusive and cannot be set at the same time"), + }, + { + desc: "signing endpoint provided but return smaller than accaptable max token exp", + issuers: []string{ + "iss", + }, + signingEndpoint: socketPath, + signingKeyFiles: "", + maxExpiration: 0, + externalMaxExpirationSec: 300, // 5m + + wantError: fmt.Errorf("max token life supported by external-jwt-signer (300s) is less than acceptable (min 600s)"), + }, + { + desc: "signing endpoint provided and error when getting metadata", + issuers: []string{ + "iss", + }, + signingEndpoint: socketPath, + signingKeyFiles: "", + maxExpiration: 0, + externalMaxExpirationSec: 900, // 15m + metadataError: fmt.Errorf("metadata error"), + + wantError: fmt.Errorf("while setting up external-jwt-signer: rpc error: code = Unknown desc = metadata error"), + }, + { + desc: "signing endpoint provided and error when creating plugin (during initial fetch)", + issuers: []string{ + "iss", + }, + signingEndpoint: socketPath, + signingKeyFiles: "", + maxExpiration: 0, + externalMaxExpirationSec: 900, // 15m + fetchError: fmt.Errorf("keys fetch error"), + + wantError: fmt.Errorf("while setting up external-jwt-signer: while initially filling key cache: while performing initial cache fill: while fetching token verification keys: while getting externally supported jwt signing keys: rpc error: code = Unknown desc = keys fetch error"), + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + options := NewOptions() + options.ServiceAccountSigningEndpoint = tc.signingEndpoint + options.ServiceAccountSigningKeyFile = tc.signingKeyFiles + options.Authentication = &kubeoptions.BuiltInAuthenticationOptions{ + ServiceAccounts: &kubeoptions.ServiceAccountAuthenticationOptions{ + Issuers: tc.issuers, + MaxExpiration: tc.maxExpiration, + }, + } + + _ = mockSigner.Reset() + mockSigner.MaxTokenExpirationSeconds = tc.externalMaxExpirationSec + mockSigner.MetadataError = tc.metadataError + mockSigner.FetchError = tc.fetchError + + co := completedOptions{ + Options: *options, + } + + err := options.completeServiceAccountOptions(context.Background(), &co) + + if tc.wantError != nil { + if err == nil || tc.wantError.Error() != err.Error() { + t.Errorf("Expected error: %v, got: %v", tc.wantError, err) + } + return + } + + if err != nil { + t.Errorf("Didn't expect any error but got: %v", err) + } + if tc.externalPublicKeyGetterPresent != (co.Authentication.ServiceAccounts.ExternalPublicKeysGetter != nil) { + t.Errorf("Unexpected value of ExternalPublicKeysGetter: %v", co.Authentication.ServiceAccounts.ExternalPublicKeysGetter) + } + if tc.expectedIsExternalSigner != co.Authentication.ServiceAccounts.IsTokenSignerExternal { + t.Errorf("Expected IsTokenSignerExternal %v, found %v", tc.expectedIsExternalSigner, co.Authentication.ServiceAccounts.IsTokenSignerExternal) + } + if tc.expectedMaxtokenExp.Seconds() != co.Authentication.ServiceAccounts.MaxExpiration.Seconds() { + t.Errorf("Expected MaxExpiration to be %v, found %v", tc.expectedMaxtokenExp, co.Authentication.ServiceAccounts.MaxExpiration) + } + }) + } +} diff --git a/pkg/controlplane/apiserver/options/validation.go b/pkg/controlplane/apiserver/options/validation.go index 4838d1f29effb..36e34d13b4030 100644 --- a/pkg/controlplane/apiserver/options/validation.go +++ b/pkg/controlplane/apiserver/options/validation.go @@ -19,6 +19,7 @@ package options import ( "errors" "fmt" + "regexp" "strings" apiextensionsapiserver "k8s.io/apiextensions-apiserver/pkg/apiserver" @@ -103,6 +104,29 @@ func validateUnknownVersionInteroperabilityProxyFlags(options *Options) []error return err } +var pathOrSocket = regexp.MustCompile(`(^(/[^/ ]*)+/?$)|(^@([a-zA-Z0-9_-]+\.)*[a-zA-Z0-9_-]+$)`) + +func validateServiceAccountTokenSigningConfig(options *Options) []error { + if len(options.ServiceAccountSigningEndpoint) == 0 { + return nil + } + + errors := []error{} + + if len(options.ServiceAccountSigningKeyFile) != 0 || len(options.Authentication.ServiceAccounts.KeyFiles) != 0 { + errors = append(errors, fmt.Errorf("can't set `--service-account-signing-key-file` and/or `--service-account-key-file` with `--service-account-signing-endpoint` (They are mutually exclusive)")) + } + if !utilfeature.DefaultFeatureGate.Enabled(features.ExternalServiceAccountTokenSigner) { + errors = append(errors, fmt.Errorf("setting `--service-account-signing-endpoint` requires enabling ExternalServiceAccountTokenSigner feature gate")) + } + // Check if ServiceAccountSigningEndpoint is a linux file path or an abstract socket name. + if !pathOrSocket.MatchString(options.ServiceAccountSigningEndpoint) { + errors = append(errors, fmt.Errorf("invalid value %q passed for `--service-account-signing-endpoint`, should be a valid location on the filesystem or must be prefixed with @ to name UDS in abstract namespace", options.ServiceAccountSigningEndpoint)) + } + + return errors +} + // Validate checks Options and return a slice of found errs. func (s *Options) Validate() []error { var errs []error @@ -121,6 +145,7 @@ func (s *Options) Validate() []error { errs = append(errs, validateUnknownVersionInteroperabilityProxyFeature()...) errs = append(errs, validateUnknownVersionInteroperabilityProxyFlags(s)...) errs = append(errs, validateNodeSelectorAuthorizationFeature()...) + errs = append(errs, validateServiceAccountTokenSigningConfig(s)...) return errs } diff --git a/pkg/controlplane/apiserver/options/validation_test.go b/pkg/controlplane/apiserver/options/validation_test.go index ebb1c7bba7690..3ce550eb49887 100644 --- a/pkg/controlplane/apiserver/options/validation_test.go +++ b/pkg/controlplane/apiserver/options/validation_test.go @@ -17,6 +17,8 @@ limitations under the License. package options import ( + "fmt" + "reflect" "strings" "testing" @@ -262,3 +264,186 @@ func TestValidateOptions(t *testing.T) { }) } } + +func TestValidateServcieAccountTokenSigningConfig(t *testing.T) { + tests := []struct { + name string + featureEnabled bool + options *Options + expectedErrors []error + }{ + { + name: "Signing keys file provided while external signer endpoint is provided", + featureEnabled: true, + expectedErrors: []error{ + fmt.Errorf("can't set `--service-account-signing-key-file` and/or `--service-account-key-file` with `--service-account-signing-endpoint` (They are mutually exclusive)"), + }, + options: &Options{ + ServiceAccountSigningEndpoint: "@ebc.eng.hij", + ServiceAccountSigningKeyFile: "/abc/efg", + }, + }, + { + name: "Verification keys file provided while external signer endpoint is provided", + featureEnabled: true, + expectedErrors: []error{ + fmt.Errorf("can't set `--service-account-signing-key-file` and/or `--service-account-key-file` with `--service-account-signing-endpoint` (They are mutually exclusive)"), + }, + options: &Options{ + ServiceAccountSigningEndpoint: "@ebc.eng.hij", + Authentication: &kubeoptions.BuiltInAuthenticationOptions{ + ServiceAccounts: &kubeoptions.ServiceAccountAuthenticationOptions{ + KeyFiles: []string{ + "abc", + "efg", + }, + }, + }, + }, + }, + { + name: "Verification key and signing key file provided while external signer endpoint is provided", + featureEnabled: true, + expectedErrors: []error{ + fmt.Errorf("can't set `--service-account-signing-key-file` and/or `--service-account-key-file` with `--service-account-signing-endpoint` (They are mutually exclusive)"), + }, + options: &Options{ + ServiceAccountSigningEndpoint: "@ebc.eng.hij", + ServiceAccountSigningKeyFile: "/abc/efg", + Authentication: &kubeoptions.BuiltInAuthenticationOptions{ + ServiceAccounts: &kubeoptions.ServiceAccountAuthenticationOptions{ + KeyFiles: []string{ + "/abc/efg", + "/abc/xyz", + }, + }, + }, + }, + }, + { + name: "feature disabled and external signer endpoint is provided", + featureEnabled: false, + expectedErrors: []error{ + fmt.Errorf("setting `--service-account-signing-endpoint` requires enabling ExternalServiceAccountTokenSigner feature gate"), + }, + options: &Options{ + ServiceAccountSigningEndpoint: "@ebc.eng.hij", + }, + }, + { + name: "invalid external signer endpoint provided - 1", + featureEnabled: true, + expectedErrors: []error{ + fmt.Errorf("invalid value \"abc\" passed for `--service-account-signing-endpoint`, should be a valid location on the filesystem or must be prefixed with @ to name UDS in abstract namespace"), + }, + options: &Options{ + ServiceAccountSigningEndpoint: "abc", + }, + }, + { + name: "invalid external signer endpoint provided - 2", + featureEnabled: true, + expectedErrors: []error{ + fmt.Errorf("invalid value \"@abc@\" passed for `--service-account-signing-endpoint`, should be a valid location on the filesystem or must be prefixed with @ to name UDS in abstract namespace"), + }, + options: &Options{ + ServiceAccountSigningEndpoint: "@abc@", + }, + }, + { + name: "invalid external signer endpoint provided - 3", + featureEnabled: true, + expectedErrors: []error{ + fmt.Errorf("invalid value \"@abc.abc .ae\" passed for `--service-account-signing-endpoint`, should be a valid location on the filesystem or must be prefixed with @ to name UDS in abstract namespace"), + }, + options: &Options{ + ServiceAccountSigningEndpoint: "@abc.abc .ae", + }, + }, + { + name: "invalid external signer endpoint provided - 4", + featureEnabled: true, + expectedErrors: []error{ + fmt.Errorf("invalid value \"/@e_adnb/xyz /efg\" passed for `--service-account-signing-endpoint`, should be a valid location on the filesystem or must be prefixed with @ to name UDS in abstract namespace"), + }, + options: &Options{ + ServiceAccountSigningEndpoint: "/@e_adnb/xyz /efg", + }, + }, + { + name: "invalid external signer endpoint provided - 5", + featureEnabled: true, + expectedErrors: []error{ + fmt.Errorf("invalid value \"/e /xyz /efg\" passed for `--service-account-signing-endpoint`, should be a valid location on the filesystem or must be prefixed with @ to name UDS in abstract namespace"), + }, + options: &Options{ + ServiceAccountSigningEndpoint: "/e /xyz /efg", + }, + }, + { + name: "valid external signer endpoint provided - 1", + featureEnabled: true, + expectedErrors: []error{}, + options: &Options{ + ServiceAccountSigningEndpoint: "/e/an_b-d/efg", + }, + }, + { + name: "valid external signer endpoint provided - 2", + featureEnabled: true, + expectedErrors: []error{}, + options: &Options{ + ServiceAccountSigningEndpoint: "@ebc.sock", + }, + }, + { + name: "valid external signer endpoint provided - 3", + featureEnabled: true, + expectedErrors: []error{}, + options: &Options{ + ServiceAccountSigningEndpoint: "@ebc.eng.hij", + }, + }, + { + name: "All errors at once", + featureEnabled: false, + expectedErrors: []error{ + fmt.Errorf("can't set `--service-account-signing-key-file` and/or `--service-account-key-file` with `--service-account-signing-endpoint` (They are mutually exclusive)"), + fmt.Errorf("setting `--service-account-signing-endpoint` requires enabling ExternalServiceAccountTokenSigner feature gate"), + fmt.Errorf("invalid value \"/e /xyz /efg\" passed for `--service-account-signing-endpoint`, should be a valid location on the filesystem or must be prefixed with @ to name UDS in abstract namespace"), + }, + options: &Options{ + ServiceAccountSigningEndpoint: "/e /xyz /efg", + ServiceAccountSigningKeyFile: "/abc/efg", + Authentication: &kubeoptions.BuiltInAuthenticationOptions{ + ServiceAccounts: &kubeoptions.ServiceAccountAuthenticationOptions{ + KeyFiles: []string{ + "/abc/xyz", + }, + }, + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + + if test.options.Authentication == nil { + test.options.Authentication = &kubeoptions.BuiltInAuthenticationOptions{ + ServiceAccounts: &kubeoptions.ServiceAccountAuthenticationOptions{ + KeyFiles: []string{}, + }, + } + } + + if test.featureEnabled { + featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.ExternalServiceAccountTokenSigner, true) + } + errs := validateServiceAccountTokenSigningConfig(test.options) + if !reflect.DeepEqual(errs, test.expectedErrors) { + t.Errorf("Expected errors message: %v \n but got: %v", test.expectedErrors, errs) + } + }) + } +} diff --git a/pkg/controlplane/apiserver/samples/generic/server/server.go b/pkg/controlplane/apiserver/samples/generic/server/server.go index 7f0533951cb65..be6af3f94d471 100644 --- a/pkg/controlplane/apiserver/samples/generic/server/server.go +++ b/pkg/controlplane/apiserver/samples/generic/server/server.go @@ -83,7 +83,9 @@ APIs.`, } cliflag.PrintFlags(fs) - completedOptions, err := s.Complete([]string{}, []net.IP{}) + ctx := genericapiserver.SetupSignalContext() + + completedOptions, err := s.Complete(ctx, []string{}, []net.IP{}) if err != nil { return err } @@ -94,7 +96,7 @@ APIs.`, // add feature enablement metrics utilfeature.DefaultMutableFeatureGate.AddMetrics() - ctx := genericapiserver.SetupSignalContext() + return Run(ctx, completedOptions) }, Args: func(cmd *cobra.Command, args []string) error { diff --git a/pkg/controlplane/apiserver/samples/generic/server/testing/testserver.go b/pkg/controlplane/apiserver/samples/generic/server/testing/testserver.go index d5e196c0b6d7b..d369abeb5cf66 100644 --- a/pkg/controlplane/apiserver/samples/generic/server/testing/testserver.go +++ b/pkg/controlplane/apiserver/samples/generic/server/testing/testserver.go @@ -164,7 +164,7 @@ func StartTestServer(t ktesting.TB, instanceOptions *TestServerInstanceOptions, o.Authentication.ServiceAccounts.Issuers = []string{"https://foo.bar.example.com"} o.Authentication.ServiceAccounts.KeyFiles = []string{saSigningKeyFile.Name()} - completedOptions, err := o.Complete(nil, nil) + completedOptions, err := o.Complete(tCtx, nil, nil) if err != nil { return result, fmt.Errorf("failed to set default ServerRunOptions: %w", err) } diff --git a/pkg/features/kube_features.go b/pkg/features/kube_features.go index a16463529de29..aca2150f4adae 100644 --- a/pkg/features/kube_features.go +++ b/pkg/features/kube_features.go @@ -819,6 +819,13 @@ const ( // instead of changing each file on the volumes recursively. // Enables the SELinuxChangePolicy field in PodSecurityContext before SELinuxMount featgure gate is enabled. SELinuxChangePolicy featuregate.Feature = "SELinuxChangePolicy" + + // owner: @HarshalNeelkamal + // alpha: v1.32 + // + // Enables external service account JWT signing and key management. + // If enabled, it allows passing --service-account-signing-endpoint flag to configure external signer. + ExternalServiceAccountTokenSigner featuregate.Feature = "ExternalServiceAccountTokenSigner" ) func init() { diff --git a/pkg/features/versioned_kube_features.go b/pkg/features/versioned_kube_features.go index 500e21b767647..4e306502e968a 100644 --- a/pkg/features/versioned_kube_features.go +++ b/pkg/features/versioned_kube_features.go @@ -195,6 +195,10 @@ var defaultVersionedKubernetesFeatureGates = map[featuregate.Feature]featuregate {Version: version.MustParse("1.20"), Default: true, PreRelease: featuregate.GA}, // lock to default and remove after v1.22 based on KEP #1972 update }, + ExternalServiceAccountTokenSigner: { + {Version: version.MustParse("1.32"), Default: false, PreRelease: featuregate.Alpha}, + }, + genericfeatures.AdmissionWebhookMatchConditions: { {Version: version.MustParse("1.27"), Default: false, PreRelease: featuregate.Alpha}, {Version: version.MustParse("1.28"), Default: true, PreRelease: featuregate.Beta}, diff --git a/pkg/kubeapiserver/options/authentication.go b/pkg/kubeapiserver/options/authentication.go index 8df383adeb44c..e2b4ea38782a4 100644 --- a/pkg/kubeapiserver/options/authentication.go +++ b/pkg/kubeapiserver/options/authentication.go @@ -127,15 +127,19 @@ type OIDCAuthenticationOptions struct { // ServiceAccountAuthenticationOptions contains service account authentication options for API Server type ServiceAccountAuthenticationOptions struct { - KeyFiles []string - Lookup bool - Issuers []string - JWKSURI string - MaxExpiration time.Duration - ExtendExpiration bool + KeyFiles []string + Lookup bool + Issuers []string + JWKSURI string + MaxExpiration time.Duration + ExtendExpiration bool + IsTokenSignerExternal bool // OptionalTokenGetter is a function that returns a service account token getter. // If not set, the default token getter will be used. OptionalTokenGetter func(factory informers.SharedInformerFactory) serviceaccount.ServiceAccountTokenGetter + // ExternalPublicKeysGetter gets set if `--service-account-signing-endpoint` is passed. + // ExternalPublicKeysGetter is mutually exclusive with KeyFiles. + ExternalPublicKeysGetter serviceaccount.PublicKeysGetter } // TokenFileAuthenticationOptions contains token file authentication options for API Server @@ -270,8 +274,8 @@ func (o *BuiltInAuthenticationOptions) Validate() []error { if len(o.ServiceAccounts.Issuers) == 0 { allErrors = append(allErrors, errors.New("service-account-issuer is a required flag")) } - if len(o.ServiceAccounts.KeyFiles) == 0 { - allErrors = append(allErrors, errors.New("service-account-key-file is a required flag")) + if len(o.ServiceAccounts.KeyFiles) == 0 && o.ServiceAccounts.ExternalPublicKeysGetter == nil { + allErrors = append(allErrors, errors.New("either `--service-account-key-file` or `--service-account-signing-endpoint` must be set")) } // Validate the JWKS URI when it is explicitly set. @@ -592,7 +596,11 @@ func (o *BuiltInAuthenticationOptions) ToAuthenticationConfig() (kubeauthenticat if len(o.ServiceAccounts.Issuers) != 0 && len(o.APIAudiences) == 0 { ret.APIAudiences = authenticator.Audiences(o.ServiceAccounts.Issuers) } - if len(o.ServiceAccounts.KeyFiles) > 0 { + + switch { + case len(o.ServiceAccounts.KeyFiles) > 0 && o.ServiceAccounts.ExternalPublicKeysGetter != nil: + return kubeauthenticator.Config{}, fmt.Errorf("cannot set mutually exclusive flags `--service-account-key-file` and `--service-account-signing-endpoint` at the same time") + case len(o.ServiceAccounts.KeyFiles) > 0: allPublicKeys := []interface{}{} for _, keyfile := range o.ServiceAccounts.KeyFiles { publicKeys, err := keyutil.PublicKeysFromFile(keyfile) @@ -606,7 +614,10 @@ func (o *BuiltInAuthenticationOptions) ToAuthenticationConfig() (kubeauthenticat return kubeauthenticator.Config{}, fmt.Errorf("failed to set up public service account keys: %w", err) } ret.ServiceAccountPublicKeysGetter = keysGetter + case o.ServiceAccounts.ExternalPublicKeysGetter != nil: + ret.ServiceAccountPublicKeysGetter = o.ServiceAccounts.ExternalPublicKeysGetter } + ret.ServiceAccountIssuers = o.ServiceAccounts.Issuers ret.ServiceAccountLookup = o.ServiceAccounts.Lookup } diff --git a/pkg/kubeapiserver/options/authentication_test.go b/pkg/kubeapiserver/options/authentication_test.go index 842f4dea41485..4842104ef003e 100644 --- a/pkg/kubeapiserver/options/authentication_test.go +++ b/pkg/kubeapiserver/options/authentication_test.go @@ -18,6 +18,11 @@ package options import ( "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" "os" "reflect" "strings" @@ -45,6 +50,7 @@ import ( openapicommon "k8s.io/kube-openapi/pkg/common" kubeauthenticator "k8s.io/kubernetes/pkg/kubeapiserver/authenticator" "k8s.io/kubernetes/pkg/serviceaccount" + "k8s.io/utils/pointer" ) @@ -102,7 +108,7 @@ func TestAuthenticationValidate(t *testing.T) { testSA: &ServiceAccountAuthenticationOptions{ Issuers: []string{"http://foo.bar.com"}, }, - expectErr: "service-account-key-file is a required flag", + expectErr: "either `--service-account-key-file` or `--service-account-signing-endpoint` must be set", }, { name: "test when ServiceAccounts doesn't have issuer", @@ -1511,3 +1517,171 @@ func errString(err error) string { } return err.Error() } + +func TestToAuthenticationConfigForServiceAccount(t *testing.T) { + + dummyExternalGetter := &dummyPublicKeyGetter{} + keyFileName := "public_key.pem" + + key1, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + panic("Error while generating first RSA key") + } + pubKey1Bytes, err := x509.MarshalPKIXPublicKey(&key1.PublicKey) + if err != nil { + panic("Error while marshaling first public key") + } + + publicKeyBlock := &pem.Block{ + Type: "PUBLIC KEY", + Bytes: pubKey1Bytes, + } + + publicKeyFile, err := os.Create(keyFileName) + if err != nil { + fmt.Println("Error creating public key file:", err) + return + } + t.Cleanup(func() { + // An open file cannot be removed on Windows. Close it first. + if err := publicKeyFile.Close(); err != nil { + t.Fatal(err) + } + if err := os.Remove(publicKeyFile.Name()); err != nil { + t.Fatal(err) + } + }) + + if err := pem.Encode(publicKeyFile, publicKeyBlock); err != nil { + fmt.Println("Error encoding public key:", err) + return + } + + testCases := []struct { + desc string + options *BuiltInAuthenticationOptions + expectConfig kubeauthenticator.Config + expectedErr error + expectedExternalGetter bool + expectedStaticGetter bool + }{ + { + desc: "neither key file nor external getter configured", + options: &BuiltInAuthenticationOptions{ + ServiceAccounts: &ServiceAccountAuthenticationOptions{ + Lookup: true, + Issuers: []string{"http://foo.bar.com"}, + KeyFiles: []string{}, + ExternalPublicKeysGetter: nil, + }, + }, + expectConfig: kubeauthenticator.Config{ + APIAudiences: authenticator.Audiences{"http://foo.bar.com"}, + ServiceAccountLookup: true, + ServiceAccountIssuers: []string{"http://foo.bar.com"}, + }, + expectedErr: nil, + }, + { + desc: "both key file and external getter configured", + options: &BuiltInAuthenticationOptions{ + ServiceAccounts: &ServiceAccountAuthenticationOptions{ + Lookup: true, + Issuers: []string{"http://foo.bar.com"}, + KeyFiles: []string{keyFileName}, + ExternalPublicKeysGetter: dummyExternalGetter, + }, + }, + expectConfig: kubeauthenticator.Config{ + APIAudiences: authenticator.Audiences{"http://foo.bar.com"}, + ServiceAccountLookup: true, + ServiceAccountIssuers: []string{"http://foo.bar.com"}, + }, + expectedErr: fmt.Errorf("cannot set mutually exclusive flags `--service-account-key-file` and `--service-account-signing-endpoint` at the same time"), + }, + { + desc: "external getter configured", + options: &BuiltInAuthenticationOptions{ + ServiceAccounts: &ServiceAccountAuthenticationOptions{ + Lookup: true, + Issuers: []string{"http://foo.bar.com"}, + KeyFiles: []string{}, + ExternalPublicKeysGetter: dummyExternalGetter, + }, + }, + expectConfig: kubeauthenticator.Config{ + APIAudiences: authenticator.Audiences{"http://foo.bar.com"}, + ServiceAccountLookup: true, + ServiceAccountIssuers: []string{"http://foo.bar.com"}, + }, + expectedErr: nil, + expectedExternalGetter: true, + }, + { + desc: "external getter configured", + options: &BuiltInAuthenticationOptions{ + ServiceAccounts: &ServiceAccountAuthenticationOptions{ + Lookup: true, + Issuers: []string{"http://foo.bar.com"}, + KeyFiles: []string{keyFileName}, + ExternalPublicKeysGetter: nil, + }, + }, + expectConfig: kubeauthenticator.Config{ + APIAudiences: authenticator.Audiences{"http://foo.bar.com"}, + ServiceAccountLookup: true, + ServiceAccountIssuers: []string{"http://foo.bar.com"}, + }, + expectedErr: nil, + expectedStaticGetter: true, + }, + } + + for _, tc := range testCases { + resultConfig, err := tc.options.ToAuthenticationConfig() + if tc.expectedErr != nil { + if err == nil || tc.expectedErr.Error() != err.Error() { + t.Fatalf("Expected error: %v and got: %v", tc.expectedErr, err) + } + return + } + + // make out of scope fields nil + resultConfig.AuthenticationConfig = nil + + if tc.expectedExternalGetter { + if resultConfig.ServiceAccountPublicKeysGetter == nil { + t.Fatalf("Expected external getter but none") + } else if resultConfig.ServiceAccountPublicKeysGetter != dummyExternalGetter { + t.Fatalf("Expected external getter but found someting else") + } + resultConfig.ServiceAccountPublicKeysGetter = nil + } + + if tc.expectedStaticGetter { + if resultConfig.ServiceAccountPublicKeysGetter == nil { + t.Fatalf("Expected static getter but none") + } else if resultConfig.ServiceAccountPublicKeysGetter == dummyExternalGetter { + t.Fatalf("Expected static getter but found external getter") + } + resultConfig.ServiceAccountPublicKeysGetter = nil + } + + if !reflect.DeepEqual(resultConfig, tc.expectConfig) { + t.Error(cmp.Diff(resultConfig, tc.expectConfig)) + } + } +} + +type dummyPublicKeyGetter struct { +} + +func (d *dummyPublicKeyGetter) AddListener(listener serviceaccount.Listener) {} + +func (d *dummyPublicKeyGetter) GetCacheAgeMaxSeconds() int { + return 10 +} + +func (d *dummyPublicKeyGetter) GetPublicKeys(ctx context.Context, keyIDHint string) []serviceaccount.PublicKey { + return []serviceaccount.PublicKey{} +} diff --git a/pkg/registry/core/rest/storage_core.go b/pkg/registry/core/rest/storage_core.go index b5c4e39ec9561..69329088a0cb4 100644 --- a/pkg/registry/core/rest/storage_core.go +++ b/pkg/registry/core/rest/storage_core.go @@ -223,7 +223,7 @@ func (p *legacyProvider) NewRESTStorage(apiResourceConfigSource serverstorage.AP utilfeature.DefaultFeatureGate.Enabled(features.ServiceAccountTokenPodNodeInfo) { nodeGetter = nodeStorage.Node.Store } - serviceAccountStorage, err = serviceaccountstore.NewREST(restOptionsGetter, p.ServiceAccountIssuer, p.APIAudiences, p.ServiceAccountMaxExpiration, podStorage.Pod.Store, storage["secrets"].(rest.Getter), nodeGetter, p.ExtendExpiration) + serviceAccountStorage, err = serviceaccountstore.NewREST(restOptionsGetter, p.ServiceAccountIssuer, p.APIAudiences, p.ServiceAccountMaxExpiration, podStorage.Pod.Store, storage["secrets"].(rest.Getter), nodeGetter, p.ExtendExpiration, p.IsTokenSignerExternal) if err != nil { return genericapiserver.APIGroupInfo{}, err } diff --git a/pkg/registry/core/rest/storage_core_generic.go b/pkg/registry/core/rest/storage_core_generic.go index a4fe9f007fcf1..664724f3bc021 100644 --- a/pkg/registry/core/rest/storage_core_generic.go +++ b/pkg/registry/core/rest/storage_core_generic.go @@ -57,6 +57,7 @@ type GenericConfig struct { ServiceAccountIssuer serviceaccount.TokenGenerator ServiceAccountMaxExpiration time.Duration ExtendExpiration bool + IsTokenSignerExternal bool APIAudiences authenticator.Audiences @@ -102,9 +103,9 @@ func (c *GenericConfig) NewRESTStorage(apiResourceConfigSource serverstorage.API var serviceAccountStorage *serviceaccountstore.REST if c.ServiceAccountIssuer != nil { - serviceAccountStorage, err = serviceaccountstore.NewREST(restOptionsGetter, c.ServiceAccountIssuer, c.APIAudiences, c.ServiceAccountMaxExpiration, newNotFoundGetter(schema.GroupResource{Resource: "pods"}), secretStorage.Store, newNotFoundGetter(schema.GroupResource{Resource: "nodes"}), c.ExtendExpiration) + serviceAccountStorage, err = serviceaccountstore.NewREST(restOptionsGetter, c.ServiceAccountIssuer, c.APIAudiences, c.ServiceAccountMaxExpiration, newNotFoundGetter(schema.GroupResource{Resource: "pods"}), secretStorage.Store, newNotFoundGetter(schema.GroupResource{Resource: "nodes"}), c.ExtendExpiration, c.IsTokenSignerExternal) } else { - serviceAccountStorage, err = serviceaccountstore.NewREST(restOptionsGetter, nil, nil, 0, newNotFoundGetter(schema.GroupResource{Resource: "pods"}), newNotFoundGetter(schema.GroupResource{Resource: "secrets"}), newNotFoundGetter(schema.GroupResource{Resource: "nodes"}), false) + serviceAccountStorage, err = serviceaccountstore.NewREST(restOptionsGetter, nil, nil, 0, newNotFoundGetter(schema.GroupResource{Resource: "pods"}), newNotFoundGetter(schema.GroupResource{Resource: "secrets"}), newNotFoundGetter(schema.GroupResource{Resource: "nodes"}), false, c.IsTokenSignerExternal) } if err != nil { return genericapiserver.APIGroupInfo{}, err diff --git a/pkg/registry/core/serviceaccount/storage/storage.go b/pkg/registry/core/serviceaccount/storage/storage.go index f6082bcefff6f..d6bbaf5c33d2a 100644 --- a/pkg/registry/core/serviceaccount/storage/storage.go +++ b/pkg/registry/core/serviceaccount/storage/storage.go @@ -39,7 +39,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, nodeStorage rest.Getter, extendExpiration bool) (*REST, error) { +func NewREST(optsGetter generic.RESTOptionsGetter, issuer token.TokenGenerator, auds authenticator.Audiences, max time.Duration, podStorage, secretStorage, nodeStorage rest.Getter, extendExpiration bool, isTokenSignerExternal bool) (*REST, error) { store := &genericregistry.Store{ NewFunc: func() runtime.Object { return &api.ServiceAccount{} }, NewListFunc: func() runtime.Object { return &api.ServiceAccountList{} }, @@ -61,15 +61,16 @@ func NewREST(optsGetter generic.RESTOptionsGetter, issuer token.TokenGenerator, var trest *TokenREST if issuer != nil && podStorage != nil && secretStorage != nil { trest = &TokenREST{ - svcaccts: store, - pods: podStorage, - secrets: secretStorage, - nodes: nodeStorage, - issuer: issuer, - auds: auds, - audsSet: sets.NewString(auds...), - maxExpirationSeconds: int64(max.Seconds()), - extendExpiration: extendExpiration, + svcaccts: store, + pods: podStorage, + secrets: secretStorage, + nodes: nodeStorage, + issuer: issuer, + auds: auds, + audsSet: sets.NewString(auds...), + maxExpirationSeconds: int64(max.Seconds()), + extendExpiration: extendExpiration, + isTokenSignerExternal: isTokenSignerExternal, } } diff --git a/pkg/registry/core/serviceaccount/storage/storage_test.go b/pkg/registry/core/serviceaccount/storage/storage_test.go index ecf13431f5ca2..31c94d21b4227 100644 --- a/pkg/registry/core/serviceaccount/storage/storage_test.go +++ b/pkg/registry/core/serviceaccount/storage/storage_test.go @@ -27,6 +27,7 @@ import ( "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apiserver/pkg/audit" + "k8s.io/apiserver/pkg/authentication/authenticator" "k8s.io/apiserver/pkg/endpoints/request" "k8s.io/apiserver/pkg/registry/generic" genericregistrytest "k8s.io/apiserver/pkg/registry/generic/testing" @@ -42,6 +43,10 @@ import ( ) func newStorage(t *testing.T) (*REST, *etcd3testing.EtcdTestServer) { + return newTokenStorage(t, fakeTokenGenerator{"fake"}, nil, panicGetter{}, panicGetter{}, nil) +} + +func newTokenStorage(t *testing.T, issuer token.TokenGenerator, auds authenticator.Audiences, podStorage, secretStorage, nodeStorage rest.Getter) (*REST, *etcd3testing.EtcdTestServer) { etcdStorage, server := registrytest.NewEtcdStorage(t, "") restOptions := generic.RESTOptions{ StorageConfig: etcdStorage, @@ -50,7 +55,7 @@ func newStorage(t *testing.T) (*REST, *etcd3testing.EtcdTestServer) { ResourcePrefix: "serviceaccounts", } // set issuer, podStore and secretStore to allow the token endpoint to be initialised - rest, err := NewREST(restOptions, fakeTokenGenerator{"fake"}, nil, 0, panicGetter{}, panicGetter{}, nil, false) + rest, err := NewREST(restOptions, issuer, auds, 0, podStorage, secretStorage, nodeStorage, false, false) if err != nil { t.Fatalf("unexpected error from REST storage: %v", err) } @@ -62,7 +67,7 @@ type fakeTokenGenerator struct { staticToken string } -func (f fakeTokenGenerator) GenerateToken(claims *jwt.Claims, privateClaims interface{}) (string, error) { +func (f fakeTokenGenerator) GenerateToken(ctx context.Context, claims *jwt.Claims, privateClaims interface{}) (string, error) { return f.staticToken, nil } diff --git a/pkg/registry/core/serviceaccount/storage/token.go b/pkg/registry/core/serviceaccount/storage/token.go index 0c5f0cb1325c3..2cf899203db9e 100644 --- a/pkg/registry/core/serviceaccount/storage/token.go +++ b/pkg/registry/core/serviceaccount/storage/token.go @@ -56,15 +56,16 @@ func (r *TokenREST) Destroy() { } type TokenREST struct { - svcaccts rest.Getter - pods rest.Getter - secrets rest.Getter - nodes rest.Getter - issuer token.TokenGenerator - auds authenticator.Audiences - audsSet sets.String - maxExpirationSeconds int64 - extendExpiration bool + svcaccts rest.Getter + pods rest.Getter + secrets rest.Getter + nodes rest.Getter + issuer token.TokenGenerator + auds authenticator.Audiences + audsSet sets.String + maxExpirationSeconds int64 + extendExpiration bool + isTokenSignerExternal bool } var _ = rest.NamedCreater(&TokenREST{}) @@ -217,16 +218,22 @@ func (r *TokenREST) Create(ctx context.Context, name string, obj runtime.Object, exp := req.Spec.ExpirationSeconds if r.extendExpiration && pod != nil && req.Spec.ExpirationSeconds == token.WarnOnlyBoundTokenExpirationSeconds && r.isKubeAudiences(req.Spec.Audiences) { warnAfter = exp - exp = token.ExpirationExtensionSeconds + // If token issuer is external-jwt-signer, then choose the smaller of + // ExpirationExtensionSeconds and max token lifetime supported by external signer. + if r.isTokenSignerExternal { + exp = min(r.maxExpirationSeconds, token.ExpirationExtensionSeconds) + } else { + exp = token.ExpirationExtensionSeconds + } } sc, pc, err := token.Claims(*svcacct, pod, secret, node, exp, warnAfter, req.Spec.Audiences) if err != nil { return nil, err } - tokdata, err := r.issuer.GenerateToken(sc, pc) + tokdata, err := r.issuer.GenerateToken(ctx, sc, pc) if err != nil { - return nil, fmt.Errorf("failed to generate token: %v", err) + return nil, errors.NewInternalError(fmt.Errorf("failed to generate token: %v", err)) } // populate status diff --git a/pkg/registry/core/serviceaccount/storage/token_test.go b/pkg/registry/core/serviceaccount/storage/token_test.go new file mode 100644 index 0000000000000..1e28759126458 --- /dev/null +++ b/pkg/registry/core/serviceaccount/storage/token_test.go @@ -0,0 +1,202 @@ +/* +Copyright 2024 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 storage + +import ( + "context" + "encoding/base64" + "encoding/json" + "strings" + "testing" + "time" + + "gopkg.in/square/go-jose.v2/jwt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/authentication/authenticator" + "k8s.io/apiserver/pkg/endpoints/request" + "k8s.io/apiserver/pkg/registry/rest" + utilfeature "k8s.io/apiserver/pkg/util/feature" + featuregatetesting "k8s.io/component-base/featuregate/testing" + authenticationapi "k8s.io/kubernetes/pkg/apis/authentication" + api "k8s.io/kubernetes/pkg/apis/core" + "k8s.io/kubernetes/pkg/features" + token "k8s.io/kubernetes/pkg/serviceaccount" +) + +func TestCreate_Token_WithExpiryCap(t *testing.T) { + + testcases := []struct { + desc string + extendExpiration bool + maxExpirationSeconds int + expectedTokenAgeSec int + isExternal bool + }{ + { + desc: "maxExpirationSeconds honoured", + extendExpiration: true, + maxExpirationSeconds: 5 * 60 * 60, // 5h + expectedTokenAgeSec: 5 * 60 * 60, // 5h + isExternal: true, + }, + { + desc: "ExpirationExtensionSeconds used for exp", + extendExpiration: true, + maxExpirationSeconds: 2 * 365 * 24 * 60 * 60, // 2 years + expectedTokenAgeSec: token.ExpirationExtensionSeconds, // 1y + isExternal: true, + }, + { + desc: "ExpirationExtensionSeconds used for exp", + extendExpiration: true, + maxExpirationSeconds: 5 * 60 * 60, // 5h + expectedTokenAgeSec: token.ExpirationExtensionSeconds, // 1y + isExternal: false, + }, + { + desc: "requested time use with extension disabled", + extendExpiration: false, + maxExpirationSeconds: 5 * 60 * 60, // 5h + expectedTokenAgeSec: 3607, // 1h + isExternal: true, + }, + { + desc: "maxExpirationSeconds honoured with extension disabled", + extendExpiration: false, + maxExpirationSeconds: 30 * 60, // 30m + expectedTokenAgeSec: 30 * 60, // 30m + isExternal: true, + }, + } + + // Create a test service account + serviceAccount := validNewServiceAccount("foo") + + // Create a new pod + pod := &api.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: serviceAccount.Namespace, + }, + Spec: api.PodSpec{ + ServiceAccountName: serviceAccount.Name, + }, + } + podGetter := &objectGetter{obj: pod} + aud := authenticator.Audiences{ + "aud-1", + "aud-2", + } + + for _, tc := range testcases { + t.Run(tc.desc, func(t *testing.T) { + storage, server := newTokenStorage(t, testTokenGenerator{"fake"}, aud, podGetter, panicGetter{}, nil) + defer server.Terminate(t) + defer storage.Store.DestroyFunc() + + ctx := context.Background() + // add the namespace to the context as it is required + ctx = request.WithNamespace(ctx, serviceAccount.Namespace) + + // Enable ExternalServiceAccountTokenSigner feature + featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.ExternalServiceAccountTokenSigner, true) + + // record namespace in the store. + _, err := storage.Store.Create(ctx, serviceAccount, rest.ValidateAllObjectFunc, &metav1.CreateOptions{}) + if err != nil { + t.Fatalf("failed creating test service account: %v", err) + } + + // add the namespace to the context as it is required + ctx = request.WithNamespace(ctx, serviceAccount.Namespace) + storage.Token.extendExpiration = tc.extendExpiration + storage.Token.maxExpirationSeconds = int64(tc.maxExpirationSeconds) + storage.Token.isTokenSignerExternal = tc.isExternal + + tokenReqTimeStamp := time.Now() + out, err := storage.Token.Create(ctx, serviceAccount.Name, &authenticationapi.TokenRequest{ + ObjectMeta: metav1.ObjectMeta{ + Name: serviceAccount.Name, + Namespace: serviceAccount.Namespace, + }, + Spec: authenticationapi.TokenRequestSpec{ + ExpirationSeconds: 3607, + BoundObjectRef: &authenticationapi.BoundObjectReference{ + Name: pod.Name, + Kind: "Pod", + APIVersion: "v1", + }, + Audiences: aud, + }, + }, rest.ValidateAllObjectFunc, &metav1.CreateOptions{}) + if err != nil { + t.Fatalf("failed calling /token endpoint for service account: %v", err) + } + + tokenReq := out.(*authenticationapi.TokenRequest) + payload := strings.Split(tokenReq.Status.Token, ".")[1] + claims, err := base64.RawURLEncoding.DecodeString(payload) + if err != nil { + t.Fatalf("failed when decoding payload: %v", err) + } + structuredClaim := jwt.Claims{} + err = json.Unmarshal(claims, &structuredClaim) + if err != nil { + t.Fatalf("Error unmarshalling Claims: %v", err) + } + structuredClaim.Expiry.Time() + upperBound := tokenReqTimeStamp.Add(time.Duration(tc.expectedTokenAgeSec+10) * time.Second) + lowerBound := tokenReqTimeStamp.Add(time.Duration(tc.expectedTokenAgeSec-10) * time.Second) + + // check for token expiration with a toleration of +/-10s after tokenReqTimeStamp to make for latencies. + if structuredClaim.Expiry.Time().After(upperBound) || + structuredClaim.Expiry.Time().Before(lowerBound) { + t.Fatalf("expected token expiration to be between %v to %v\n was %v", upperBound, lowerBound, structuredClaim.Expiry.Time()) + } + + }) + + } +} + +type objectGetter struct { + obj runtime.Object + err error +} + +func (f objectGetter) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { + return f.obj, f.err +} + +var _ rest.Getter = objectGetter{} + +// A basic fake token generator which always returns a static string +type testTokenGenerator struct { + staticToken string +} + +func (f testTokenGenerator) GenerateToken(ctx context.Context, claims *jwt.Claims, privateClaims interface{}) (string, error) { + c, err := json.Marshal(claims) + if err != nil { + return "", err + } + return f.staticToken + "." + base64.RawURLEncoding.EncodeToString(c) + "." + f.staticToken, nil +} + +var _ token.TokenGenerator = testTokenGenerator{} diff --git a/pkg/serviceaccount/externaljwt/metrics/metrics.go b/pkg/serviceaccount/externaljwt/metrics/metrics.go new file mode 100644 index 0000000000000..d20e7893be7e4 --- /dev/null +++ b/pkg/serviceaccount/externaljwt/metrics/metrics.go @@ -0,0 +1,148 @@ +/* +Copyright 2024 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 metrics + +import ( + "context" + "errors" + "sync" + "time" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "k8s.io/component-base/metrics" + "k8s.io/component-base/metrics/legacyregistry" +) + +const ( + namespace = "apiserver" + subsystem = "externaljwt" +) + +var ( + lastKeyFetchTimeStamp = metrics.NewGaugeVec( + &metrics.GaugeOpts{ + Namespace: namespace, + Subsystem: subsystem, + Name: "fetch_keys_success_timestamp", + Help: "Unix Timestamp in seconds of the last successful FetchKeys request", + StabilityLevel: metrics.ALPHA, + }, + nil, + ) + + dataTimeStamp = metrics.NewGaugeVec( + &metrics.GaugeOpts{ + Namespace: namespace, + Subsystem: subsystem, + Name: "fetch_keys_data_timestamp", + Help: "Unix Timestamp in seconds of the last successful FetchKeys data_timestamp value returned by the external signer", + StabilityLevel: metrics.ALPHA, + }, + nil, + ) + + totalKeyFetch = metrics.NewCounterVec( + &metrics.CounterOpts{ + Namespace: namespace, + Subsystem: subsystem, + Name: "fetch_keys_request_total", + Help: "Total attempts at syncing supported JWKs", + StabilityLevel: metrics.ALPHA, + }, + []string{"code"}, + ) + + tokenGenReqTotal = metrics.NewCounterVec( + &metrics.CounterOpts{ + Namespace: namespace, + Subsystem: subsystem, + Name: "sign_request_total", + Help: "Total attempts at signing JWT", + StabilityLevel: metrics.ALPHA, + }, + []string{"code"}, + ) + + requestDurationSeconds = metrics.NewHistogramVec( + &metrics.HistogramOpts{ + Namespace: namespace, + Subsystem: subsystem, + Name: "request_duration_seconds", + Help: "Request duration and time for calls to external-jwt-signer", + Buckets: []float64{.001, .005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10, 30, 60}, + StabilityLevel: metrics.ALPHA, + }, + []string{"method", "code"}, + ) +) + +var registerMetrics sync.Once + +func RegisterMetrics() { + registerMetrics.Do(func() { + legacyregistry.MustRegister(lastKeyFetchTimeStamp) + legacyregistry.MustRegister(dataTimeStamp) + legacyregistry.MustRegister(totalKeyFetch) + legacyregistry.MustRegister(tokenGenReqTotal) + legacyregistry.MustRegister(requestDurationSeconds) + }) +} + +func RecordFetchKeysAttempt(err error) { + totalKeyFetch.WithLabelValues(getErrorCode(err)).Inc() + if err == nil { + lastKeyFetchTimeStamp.WithLabelValues().SetToCurrentTime() + } +} + +func RecordTokenGenAttempt(err error) { + tokenGenReqTotal.WithLabelValues(getErrorCode(err)).Inc() +} + +func RecordKeyDataTimeStamp(timestamp int64) { + dataTimeStamp.WithLabelValues().Set(float64(timestamp)) +} + +type gRPCError interface { + GRPCStatus() *status.Status +} + +func getErrorCode(err error) string { + if err == nil { + return codes.OK.String() + } + + // handle errors wrapped with fmt.Errorf and similar + var s gRPCError + if errors.As(err, &s) { + return s.GRPCStatus().Code().String() + } + + // This is not gRPC error. The operation must have failed before gRPC + // method was called, otherwise we would get gRPC error. + return "unknown-non-grpc" +} + +func OuboundRequestMetricsInterceptor(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { + start := time.Now() + err := invoker(ctx, method, req, reply, cc, opts...) + requestDurationSeconds.WithLabelValues(method, getErrorCode(err)).Observe(time.Since(start).Seconds()) + return err +} diff --git a/pkg/serviceaccount/externaljwt/metrics/metrics_test.go b/pkg/serviceaccount/externaljwt/metrics/metrics_test.go new file mode 100644 index 0000000000000..026237502668f --- /dev/null +++ b/pkg/serviceaccount/externaljwt/metrics/metrics_test.go @@ -0,0 +1,244 @@ +/* +Copyright 2024 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 metrics + +import ( + "fmt" + "strings" + "testing" + "time" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "k8s.io/component-base/metrics/legacyregistry" + "k8s.io/component-base/metrics/testutil" +) + +func TestFetchMetrics(t *testing.T) { + testCases := []struct { + desc string + metrics []string + want string + emit func() + }{ + { + desc: "basic test", + metrics: []string{ + "apiserver_externaljwt_fetch_keys_request_total", + }, + emit: func() { + RecordFetchKeysAttempt(status.New(codes.Internal, "error ocured").Err()) + }, + want: fmt.Sprintf(` + # HELP apiserver_externaljwt_fetch_keys_request_total [ALPHA] Total attempts at syncing supported JWKs + # TYPE apiserver_externaljwt_fetch_keys_request_total counter + apiserver_externaljwt_fetch_keys_request_total{code="%s"} 1 + `, codes.Internal.String()), + }, + { + desc: "wrapped error code", + metrics: []string{ + "apiserver_externaljwt_fetch_keys_request_total", + }, + emit: func() { + RecordFetchKeysAttempt(fmt.Errorf("some error %w", status.New(codes.Canceled, "error ocured").Err())) + }, + want: fmt.Sprintf(` + # HELP apiserver_externaljwt_fetch_keys_request_total [ALPHA] Total attempts at syncing supported JWKs + # TYPE apiserver_externaljwt_fetch_keys_request_total counter + apiserver_externaljwt_fetch_keys_request_total{code="%s"} 1 + apiserver_externaljwt_fetch_keys_request_total{code="%s"} 1 + `, codes.Internal.String(), codes.Canceled.String()), + }, + { + desc: "success count appears", + metrics: []string{ + "apiserver_externaljwt_fetch_keys_request_total", + }, + emit: func() { + RecordFetchKeysAttempt(nil) + }, + want: fmt.Sprintf(` + # HELP apiserver_externaljwt_fetch_keys_request_total [ALPHA] Total attempts at syncing supported JWKs + # TYPE apiserver_externaljwt_fetch_keys_request_total counter + apiserver_externaljwt_fetch_keys_request_total{code="%s"} 1 + apiserver_externaljwt_fetch_keys_request_total{code="%s"} 1 + apiserver_externaljwt_fetch_keys_request_total{code="%s"} 1 + `, codes.Internal.String(), codes.Canceled.String(), codes.OK.String()), + }, + { + desc: "success count increments", + metrics: []string{ + "apiserver_externaljwt_fetch_keys_request_total", + }, + emit: func() { + RecordFetchKeysAttempt(nil) + }, + want: fmt.Sprintf(` + # HELP apiserver_externaljwt_fetch_keys_request_total [ALPHA] Total attempts at syncing supported JWKs + # TYPE apiserver_externaljwt_fetch_keys_request_total counter + apiserver_externaljwt_fetch_keys_request_total{code="%s"} 1 + apiserver_externaljwt_fetch_keys_request_total{code="%s"} 1 + apiserver_externaljwt_fetch_keys_request_total{code="%s"} 2 + `, codes.Internal.String(), codes.Canceled.String(), codes.OK.String()), + }, + } + + RegisterMetrics() + + for _, tt := range testCases { + t.Run(tt.desc, func(t *testing.T) { + tt.emit() + if err := testutil.GatherAndCompare(legacyregistry.DefaultGatherer, strings.NewReader(tt.want), tt.metrics...); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestTokenGenMetrics(t *testing.T) { + testCases := []struct { + desc string + metrics []string + want string + emit func() + }{ + { + desc: "basic test", + metrics: []string{ + "apiserver_externaljwt_sign_request_total", + }, + emit: func() { + RecordTokenGenAttempt(fmt.Errorf("some error %w", status.New(codes.Internal, "error ocured").Err())) + }, + want: fmt.Sprintf(` + # HELP apiserver_externaljwt_sign_request_total [ALPHA] Total attempts at signing JWT + # TYPE apiserver_externaljwt_sign_request_total counter + apiserver_externaljwt_sign_request_total{code="%s"} 1 + `, codes.Internal.String()), + }, + { + desc: "wrapped error code", + metrics: []string{ + "apiserver_externaljwt_sign_request_total", + }, + emit: func() { + RecordTokenGenAttempt(fmt.Errorf("some error %w", fmt.Errorf("some error %w", status.New(codes.Canceled, "error ocured").Err()))) + }, + want: fmt.Sprintf(` + # HELP apiserver_externaljwt_sign_request_total [ALPHA] Total attempts at signing JWT + # TYPE apiserver_externaljwt_sign_request_total counter + apiserver_externaljwt_sign_request_total{code="%s"} 1 + apiserver_externaljwt_sign_request_total{code="%s"} 1 + `, codes.Internal.String(), codes.Canceled.String()), + }, + { + desc: "success count appears", + metrics: []string{ + "apiserver_externaljwt_sign_request_total", + }, + emit: func() { + RecordTokenGenAttempt(nil) + }, + want: fmt.Sprintf(` + # HELP apiserver_externaljwt_sign_request_total [ALPHA] Total attempts at signing JWT + # TYPE apiserver_externaljwt_sign_request_total counter + apiserver_externaljwt_sign_request_total{code="%s"} 1 + apiserver_externaljwt_sign_request_total{code="%s"} 1 + apiserver_externaljwt_sign_request_total{code="%s"} 1 + `, codes.Internal.String(), codes.Canceled.String(), codes.OK.String()), + }, + { + desc: "success count increments", + metrics: []string{ + "apiserver_externaljwt_sign_request_total", + }, + emit: func() { + RecordTokenGenAttempt(nil) + }, + want: fmt.Sprintf(` + # HELP apiserver_externaljwt_sign_request_total [ALPHA] Total attempts at signing JWT + # TYPE apiserver_externaljwt_sign_request_total counter + apiserver_externaljwt_sign_request_total{code="%s"} 1 + apiserver_externaljwt_sign_request_total{code="%s"} 1 + apiserver_externaljwt_sign_request_total{code="%s"} 2 + `, codes.Internal.String(), codes.Canceled.String(), codes.OK.String()), + }, + } + + RegisterMetrics() + + for _, tt := range testCases { + t.Run(tt.desc, func(t *testing.T) { + tt.emit() + if err := testutil.GatherAndCompare(legacyregistry.DefaultGatherer, strings.NewReader(tt.want), tt.metrics...); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestRecordKeyDataTimeStamp(t *testing.T) { + + dataTimeStamp1 := time.Now().Unix() + dataTimeStamp2 := time.Now().Add(time.Second * 1200).Unix() + + testCases := []struct { + desc string + metrics []string + want int64 + emit func() + }{ + { + desc: "basic test", + metrics: []string{ + "fetch_keys_data_timestamp", + }, + emit: func() { + RecordKeyDataTimeStamp(dataTimeStamp1) + }, + want: dataTimeStamp1, + }, + { + desc: "update to a new value", + metrics: []string{ + "fetch_keys_data_timestamp", + }, + emit: func() { + RecordKeyDataTimeStamp(dataTimeStamp1) + RecordKeyDataTimeStamp(dataTimeStamp2) + }, + want: dataTimeStamp2, + }, + } + + RegisterMetrics() + + for _, tt := range testCases { + t.Run(tt.desc, func(t *testing.T) { + tt.emit() + actualValue, err := testutil.GetGaugeMetricValue(dataTimeStamp.WithLabelValues()) + if err != nil { + t.Errorf("error when getting gauge value for dataTimeStamp: %v", err) + } + if actualValue != float64(tt.want) { + t.Errorf("Expected dataTimeStamp to be %v, got %v", tt.want, actualValue) + } + }) + } +} diff --git a/pkg/serviceaccount/externaljwt/plugin/keycache.go b/pkg/serviceaccount/externaljwt/plugin/keycache.go new file mode 100644 index 0000000000000..546b42f50f8a9 --- /dev/null +++ b/pkg/serviceaccount/externaljwt/plugin/keycache.go @@ -0,0 +1,236 @@ +/* +Copyright 2024 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 plugin + +import ( + "context" + "crypto/x509" + "fmt" + "sync" + "sync/atomic" + "time" + + "golang.org/x/sync/singleflight" + "k8s.io/klog/v2" + "k8s.io/kubernetes/pkg/serviceaccount" + + externaljwtv1alpha1 "k8s.io/externaljwt/apis/v1alpha1" + externaljwtmetrics "k8s.io/kubernetes/pkg/serviceaccount/externaljwt/metrics" +) + +const fallbackRefreshDuration = 10 * time.Second + +type keyCache struct { + client externaljwtv1alpha1.ExternalJWTSignerClient + + syncGroup singleflight.Group + listenersLock sync.Mutex + listeners []serviceaccount.Listener + + verificationKeys atomic.Pointer[VerificationKeys] +} + +// newKeyCache constructs an implementation of KeyCache. +func newKeyCache(client externaljwtv1alpha1.ExternalJWTSignerClient) *keyCache { + cache := &keyCache{ + client: client, + } + cache.verificationKeys.Store(&VerificationKeys{}) + return cache +} + +// InitialFill can be used to perform an initial fetch for keys get the +// refresh interval as recommended by external signer. +func (p *keyCache) initialFill(ctx context.Context) error { + if _, err := p.syncKeys(ctx); err != nil { + return fmt.Errorf("while performing initial cache fill: %w", err) + } + return nil +} + +func (p *keyCache) scheduleSync(ctx context.Context, keySyncTimeout time.Duration) { + timer := time.NewTimer(p.verificationKeys.Load().NextRefreshHint.Sub(time.Now())) + defer timer.Stop() + + var lastDataTimestamp time.Time + for { + select { + case <-ctx.Done(): + klog.InfoS("Key cache shutting down") + return + case <-timer.C: + } + + timedCtx, cancel := context.WithTimeout(ctx, keySyncTimeout) + dataTimestamp, err := p.syncKeys(timedCtx) + if err != nil { + klog.Errorf("when syncing supported public keys(Stale set of keys will be supported): %v", err) + timer.Reset(fallbackRefreshDuration) + } else { + timer.Reset(p.verificationKeys.Load().NextRefreshHint.Sub(time.Now())) + if lastDataTimestamp.IsZero() || !dataTimestamp.Equal(lastDataTimestamp) { + lastDataTimestamp = dataTimestamp + p.broadcastUpdate() + } + } + cancel() + } +} + +func (p *keyCache) AddListener(listener serviceaccount.Listener) { + p.listenersLock.Lock() + defer p.listenersLock.Unlock() + + p.listeners = append(p.listeners, listener) +} + +func (p *keyCache) GetCacheAgeMaxSeconds() int { + val := int(p.verificationKeys.Load().NextRefreshHint.Sub(time.Now()).Seconds()) + if val < 0 { + return 0 + } + return val +} + +// GetPublicKeys returns the public key corresponding to requested keyID. +// Getter is expected to return All keys for keyID "" +func (p *keyCache) GetPublicKeys(ctx context.Context, keyID string) []serviceaccount.PublicKey { + pubKeys, ok := p.findKeyForKeyID(keyID) + if ok { + return pubKeys + } + + // If we didn't find it, trigger a sync. + if _, err := p.syncKeys(ctx); err != nil { + klog.ErrorS(err, "Error while syncing keys") + return []serviceaccount.PublicKey{} + } + + pubKeys, ok = p.findKeyForKeyID(keyID) + if ok { + return pubKeys + } + + // If we still didn't find it, then it's an unknown keyID. + klog.Errorf("Key id %q not found after refresh", keyID) + return []serviceaccount.PublicKey{} +} + +func (p *keyCache) findKeyForKeyID(keyID string) ([]serviceaccount.PublicKey, bool) { + if len(p.verificationKeys.Load().Keys) == 0 { + klog.Error("No keys currently in cache. Initial fill has not completed") + return nil, false + } + + if keyID == "" { + return p.verificationKeys.Load().Keys, true + } + + keysToReturn := []serviceaccount.PublicKey{} + for _, key := range p.verificationKeys.Load().Keys { + if key.KeyID == keyID { + keysToReturn = append(keysToReturn, key) + } + } + + return keysToReturn, len(keysToReturn) > 0 +} + +// sync supported external keys. +// completely re-writes the set of supported keys. +func (p *keyCache) syncKeys(ctx context.Context) (time.Time, error) { + val, err, _ := p.syncGroup.Do("", func() (any, error) { + newPublicKeys, err := p.getTokenVerificationKeys(ctx) + externaljwtmetrics.RecordFetchKeysAttempt(err) + if err != nil { + return nil, fmt.Errorf("while fetching token verification keys: %w", err) + } + + p.verificationKeys.Store(newPublicKeys) + + externaljwtmetrics.RecordKeyDataTimeStamp(newPublicKeys.DataTimestamp.Unix()) + + return newPublicKeys, nil + }) + if err != nil { + return time.Time{}, err + } + + vk := val.(*VerificationKeys) + + return vk.DataTimestamp, nil +} + +func (p *keyCache) broadcastUpdate() { + p.listenersLock.Lock() + defer p.listenersLock.Unlock() + + for _, l := range p.listeners { + l.Enqueue() + } +} + +// GetTokenVerificationKeys returns a map of supported external keyIDs to keys +// the keys are PKIX-serialized. It calls external-jwt-signer with a timeout of keySyncTimeoutSec. +func (p *keyCache) getTokenVerificationKeys(ctx context.Context) (*VerificationKeys, error) { + req := &externaljwtv1alpha1.FetchKeysRequest{} + resp, err := p.client.FetchKeys(ctx, req) + if err != nil { + return nil, fmt.Errorf("while getting externally supported jwt signing keys: %w", err) + } + // Validate the refresh hint. + if resp.RefreshHintSeconds <= 0 { + return nil, fmt.Errorf("found invalid refresh hint (%ds)", resp.RefreshHintSeconds) + } + + if len(resp.Keys) == 0 { + return nil, fmt.Errorf("found no keys") + } + if err := resp.DataTimestamp.CheckValid(); err != nil { + return nil, fmt.Errorf("invalid data timestamp: %w", err) + } + keys := make([]serviceaccount.PublicKey, 0, len(resp.Keys)) + for _, protoKey := range resp.Keys { + if protoKey == nil { + return nil, fmt.Errorf("found nil public key") + } + if len(protoKey.KeyId) == 0 || len(protoKey.KeyId) > 1024 { + return nil, fmt.Errorf("found invalid public key id %q", protoKey.KeyId) + } + if len(protoKey.Key) == 0 { + return nil, fmt.Errorf("found empty public key") + } + parsedPublicKey, err := x509.ParsePKIXPublicKey(protoKey.Key) + if err != nil { + return nil, fmt.Errorf("while parsing external public keys: %w", err) + } + + keys = append(keys, serviceaccount.PublicKey{ + KeyID: protoKey.KeyId, + PublicKey: parsedPublicKey, + ExcludeFromOIDCDiscovery: protoKey.ExcludeFromOidcDiscovery, + }) + } + + vk := &VerificationKeys{ + Keys: keys, + DataTimestamp: resp.DataTimestamp.AsTime(), + NextRefreshHint: time.Now().Add(time.Duration(resp.RefreshHintSeconds) * time.Second), + } + + return vk, nil +} diff --git a/pkg/serviceaccount/externaljwt/plugin/keycache_test.go b/pkg/serviceaccount/externaljwt/plugin/keycache_test.go new file mode 100644 index 0000000000000..dddb4d3d44e57 --- /dev/null +++ b/pkg/serviceaccount/externaljwt/plugin/keycache_test.go @@ -0,0 +1,407 @@ +/* +Copyright 2024 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 plugin + +import ( + "context" + "fmt" + "net" + "os" + "strings" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/protobuf/types/known/timestamppb" + + externaljwtv1alpha1 "k8s.io/externaljwt/apis/v1alpha1" + "k8s.io/kubernetes/pkg/serviceaccount" +) + +func TestExternalPublicKeyGetter(t *testing.T) { + + invalidKid := string(make([]byte, 1025)) + testCases := []struct { + desc string + expectedErr error + supportedKeys map[string]supportedKeyT + wantVerificationKeys *VerificationKeys + refreshHintSec int + dataTimeStamp *timestamppb.Timestamp + supportedKeysOverride []*externaljwtv1alpha1.Key + }{ + { + desc: "single key in signer", + supportedKeys: map[string]supportedKeyT{ + "key-1": { + key: &rsaKey1.PublicKey, + }, + }, + wantVerificationKeys: &VerificationKeys{ + Keys: []serviceaccount.PublicKey{ + { + KeyID: "key-1", + PublicKey: &rsaKey1.PublicKey, + ExcludeFromOIDCDiscovery: false, + }, + }, + }, + refreshHintSec: 20, + dataTimeStamp: timestamppb.New(time.Time{}), + }, + { + desc: "multiple keys in signer", + supportedKeys: map[string]supportedKeyT{ + "key-1": { + key: &rsaKey1.PublicKey, + }, + "key-2": { + key: &rsaKey2.PublicKey, + excludeFromOidc: true, + }, + }, + wantVerificationKeys: &VerificationKeys{ + Keys: []serviceaccount.PublicKey{ + { + KeyID: "key-1", + PublicKey: &rsaKey1.PublicKey, + ExcludeFromOIDCDiscovery: false, + }, + { + KeyID: "key-2", + PublicKey: &rsaKey2.PublicKey, + ExcludeFromOIDCDiscovery: true, + }, + }, + }, + refreshHintSec: 10, + dataTimeStamp: timestamppb.New(time.Time{}), + }, + { + desc: "empty kid", + supportedKeys: map[string]supportedKeyT{ + "": { + key: &rsaKey1.PublicKey, + }, + "key-2": { + key: &rsaKey2.PublicKey, + excludeFromOidc: true, + }, + }, + expectedErr: fmt.Errorf("found invalid public key id %q", ""), + refreshHintSec: 10, + dataTimeStamp: timestamppb.New(time.Time{}), + }, + { + desc: "kid longer than 1024", + supportedKeys: map[string]supportedKeyT{ + invalidKid: { + key: &rsaKey1.PublicKey, + }, + "key-2": { + key: &rsaKey2.PublicKey, + excludeFromOidc: true, + }, + }, + expectedErr: fmt.Errorf("found invalid public key id %q", invalidKid), + refreshHintSec: 10, + dataTimeStamp: timestamppb.New(time.Time{}), + }, + { + desc: "no keys", + supportedKeys: map[string]supportedKeyT{}, + expectedErr: fmt.Errorf("found no keys"), + refreshHintSec: 10, + dataTimeStamp: timestamppb.New(time.Time{}), + }, + { + desc: "invalid data timestamp", + supportedKeys: map[string]supportedKeyT{ + "key-2": { + key: &rsaKey2.PublicKey, + excludeFromOidc: true, + }, + }, + expectedErr: fmt.Errorf("invalid data timestamp"), + refreshHintSec: 10, + dataTimeStamp: nil, + }, + { + desc: "empty public key", + expectedErr: fmt.Errorf("found empty public key"), + refreshHintSec: 10, + dataTimeStamp: timestamppb.New(time.Time{}), + supportedKeys: map[string]supportedKeyT{ + "key-2": { + key: &rsaKey2.PublicKey, + excludeFromOidc: true, + }, + }, + supportedKeysOverride: []*externaljwtv1alpha1.Key{ + { + KeyId: "kid", + Key: nil, + }, + }, + }, + } + + for i, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + ctx := context.Background() + + sockname := fmt.Sprintf("@test-external-public-key-getter-%d.sock", i) + t.Cleanup(func() { _ = os.Remove(sockname) }) + + addr := &net.UnixAddr{Name: sockname, Net: "unix"} + listener, err := net.ListenUnix(addr.Network(), addr) + if err != nil { + t.Fatalf("Failed to start fake backend: %v", err) + } + + grpcServer := grpc.NewServer() + + backend := &dummyExtrnalSigner{ + supportedKeys: tc.supportedKeys, + refreshHintSeconds: tc.refreshHintSec, + } + backend.DataTimeStamp = tc.dataTimeStamp + backend.SupportedKeysOverride = tc.supportedKeysOverride + externaljwtv1alpha1.RegisterExternalJWTSignerServer(grpcServer, backend) + + defer grpcServer.Stop() + go func() { + if err := grpcServer.Serve(listener); err != nil { + panic(fmt.Errorf("error returned from grpcServer: %w", err)) + } + }() + + clientConn, err := grpc.DialContext( + ctx, + sockname, + grpc.WithContextDialer(func(ctx context.Context, path string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "unix", path) + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + t.Fatalf("Failed to dial buffconn client: %v", err) + } + defer func() { + _ = clientConn.Close() + }() + + plugin := newPlugin("iss", clientConn, true) + + signingKeys, err := plugin.keyCache.getTokenVerificationKeys(ctx) + if err != nil { + if tc.expectedErr == nil { + t.Fatalf("error getting supported keys: %v", err) + } + if !strings.Contains(err.Error(), tc.expectedErr.Error()) { + t.Fatalf("want error: %v, got error: %v", tc.expectedErr, err) + return + } + } + + if tc.expectedErr == nil { + if diff := cmp.Diff(signingKeys.Keys, tc.wantVerificationKeys.Keys, cmpopts.SortSlices(sortPublicKeySlice)); diff != "" { + t.Fatalf("Bad result from GetTokenSigningKeys; diff (-got +want)\n%s", diff) + } + expectedRefreshHintSec := time.Now().Add(time.Duration(tc.refreshHintSec) * time.Second) + difference := signingKeys.NextRefreshHint.Sub(expectedRefreshHintSec).Seconds() + if difference > 1 || difference < -1 { // tolerate 1 sec of skew for test + t.Fatalf("refreshHint not as expected; got: %v want: %v", signingKeys.NextRefreshHint, expectedRefreshHintSec) + } + } + }) + } +} + +func TestInitialFill(t *testing.T) { + ctx := context.Background() + + sockname := "@test-initial-fill.sock" + t.Cleanup(func() { _ = os.Remove(sockname) }) + + addr := &net.UnixAddr{Name: sockname, Net: "unix"} + listener, err := net.ListenUnix(addr.Network(), addr) + if err != nil { + t.Fatalf("Failed to start fake backend: %v", err) + } + + grpcServer := grpc.NewServer() + + supportedKeys := map[string]supportedKeyT{ + "key-1": { + key: &rsaKey1.PublicKey, + }, + } + wantPubKeys := []serviceaccount.PublicKey{ + { + KeyID: "key-1", + PublicKey: &rsaKey1.PublicKey, + ExcludeFromOIDCDiscovery: false, + }, + } + + backend := &dummyExtrnalSigner{ + supportedKeys: supportedKeys, + refreshHintSeconds: 10, + DataTimeStamp: timestamppb.New(time.Time{}), + } + externaljwtv1alpha1.RegisterExternalJWTSignerServer(grpcServer, backend) + + defer grpcServer.Stop() + go func() { + if err := grpcServer.Serve(listener); err != nil { + panic(fmt.Errorf("error returned from grpcServer: %w", err)) + } + }() + + clientConn, err := grpc.DialContext( + ctx, + sockname, + grpc.WithContextDialer(func(ctx context.Context, path string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "unix", path) + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + t.Fatalf("Failed to dial buffconn client: %v", err) + } + defer func() { _ = clientConn.Close() }() + + plugin := newPlugin("iss", clientConn, true) + + if err := plugin.keyCache.initialFill(ctx); err != nil { + t.Fatalf("Error during InitialFill: %v", err) + } + + gotPubKeys := plugin.keyCache.GetPublicKeys(ctx, "") + if diff := cmp.Diff(gotPubKeys, wantPubKeys); diff != "" { + t.Fatalf("Bad public keys; diff (-got +want)\n%s", diff) + } +} + +func TestReflectChanges(t *testing.T) { + ctx := context.Background() + + sockname := "@test-reflect-changes.sock" + t.Cleanup(func() { _ = os.Remove(sockname) }) + + addr := &net.UnixAddr{Name: sockname, Net: "unix"} + listener, err := net.ListenUnix(addr.Network(), addr) + if err != nil { + t.Fatalf("Failed to start fake backend: %v", err) + } + + grpcServer := grpc.NewServer() + + supportedKeysT1 := map[string]supportedKeyT{ + "key-1": { + key: &rsaKey1.PublicKey, + }, + } + wantPubKeysT1 := []serviceaccount.PublicKey{ + { + KeyID: "key-1", + PublicKey: &rsaKey1.PublicKey, + ExcludeFromOIDCDiscovery: false, + }, + } + + backend := &dummyExtrnalSigner{ + supportedKeys: supportedKeysT1, + refreshHintSeconds: 10, + DataTimeStamp: timestamppb.New(time.Time{}), + } + externaljwtv1alpha1.RegisterExternalJWTSignerServer(grpcServer, backend) + + defer grpcServer.Stop() + go func() { + if err := grpcServer.Serve(listener); err != nil { + panic(fmt.Errorf("error returned from grpcServer: %w", err)) + } + }() + + clientConn, err := grpc.DialContext( + ctx, + sockname, + grpc.WithContextDialer(func(ctx context.Context, path string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "unix", path) + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + t.Fatalf("Failed to dial buffconn client: %v", err) + } + defer func() { _ = clientConn.Close() }() + + plugin := newPlugin("iss", clientConn, true) + + if err := plugin.keyCache.initialFill(ctx); err != nil { + t.Fatalf("Error during InitialFill: %v", err) + } + + gotPubKeysT1 := plugin.keyCache.GetPublicKeys(ctx, "") + if diff := cmp.Diff(gotPubKeysT1, wantPubKeysT1, cmpopts.SortSlices(sortPublicKeySlice)); diff != "" { + t.Fatalf("Bad public keys; diff (-got +want)\n%s", diff) + } + + if _, err := plugin.keyCache.syncKeys(ctx); err != nil { + t.Fatalf("Error while calling syncKeys: %v", err) + } + + supportedKeysT2 := map[string]supportedKeyT{ + "key-1": { + key: &rsaKey1.PublicKey, + excludeFromOidc: true, + }, + "key-2": { + key: &rsaKey2.PublicKey, + }, + } + wantPubKeysT2 := []serviceaccount.PublicKey{ + { + KeyID: "key-1", + PublicKey: &rsaKey1.PublicKey, + ExcludeFromOIDCDiscovery: true, + }, + { + KeyID: "key-2", + PublicKey: &rsaKey2.PublicKey, + ExcludeFromOIDCDiscovery: false, + }, + } + + backend.keyLock.Lock() + backend.supportedKeys = supportedKeysT2 + backend.keyLock.Unlock() + + if _, err := plugin.keyCache.syncKeys(ctx); err != nil { + t.Fatalf("Error while calling syncKeys: %v", err) + } + + gotPubKeysT2 := plugin.keyCache.GetPublicKeys(ctx, "") + if diff := cmp.Diff(gotPubKeysT2, wantPubKeysT2, cmpopts.SortSlices(sortPublicKeySlice)); diff != "" { + t.Fatalf("Bad public keys; diff (-got +want)\n%s", diff) + } +} diff --git a/pkg/serviceaccount/externaljwt/plugin/plugin.go b/pkg/serviceaccount/externaljwt/plugin/plugin.go new file mode 100644 index 0000000000000..bf6b4bf0cd355 --- /dev/null +++ b/pkg/serviceaccount/externaljwt/plugin/plugin.go @@ -0,0 +1,218 @@ +/* +Copyright 2024 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 plugin + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net" + "time" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + jose "gopkg.in/square/go-jose.v2" + "gopkg.in/square/go-jose.v2/jwt" + + externaljwtv1alpha1 "k8s.io/externaljwt/apis/v1alpha1" + "k8s.io/kubernetes/pkg/serviceaccount" + externaljwtmetrics "k8s.io/kubernetes/pkg/serviceaccount/externaljwt/metrics" +) + +func init() { + externaljwtmetrics.RegisterMetrics() +} + +type VerificationKeys struct { + Keys []serviceaccount.PublicKey + DataTimestamp time.Time + NextRefreshHint time.Time +} + +// New calls external signer to fill out supported keys. +// It also starts a periodic sync of external keys. +// In order for the key cache and external signing to work correctly, pass a context that will live as +// long as the dependent process; is used to maintain the lifetime of the connection to external signer. +func New(ctx context.Context, issuer, socketPath string, keySyncTimeout time.Duration, allowSigningWithNonOIDCKeys bool) (*Plugin, *keyCache, error) { + conn, err := grpc.Dial( + socketPath, + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithAuthority("localhost"), + grpc.WithDefaultCallOptions(grpc.WaitForReady(true)), + grpc.WithContextDialer(func(ctx context.Context, path string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "unix", path) + }), + grpc.WithChainUnaryInterceptor(externaljwtmetrics.OuboundRequestMetricsInterceptor), + ) + if err != nil { + return nil, nil, fmt.Errorf("while dialing grpc socket at %q: %w", socketPath, err) + } + + plugin := newPlugin(issuer, conn, allowSigningWithNonOIDCKeys) + + initialFillCtx, cancel := context.WithTimeout(ctx, keySyncTimeout) + defer cancel() + + if err := plugin.keyCache.initialFill(initialFillCtx); err != nil { + return nil, nil, fmt.Errorf("while initially filling key cache: %w", err) + } + go plugin.keyCache.scheduleSync(ctx, keySyncTimeout) + + go func() { + <-ctx.Done() + _ = conn.Close() + }() + + return plugin, plugin.keyCache, nil +} + +// enables plugging in an external jwt signer. +type Plugin struct { + iss string + client externaljwtv1alpha1.ExternalJWTSignerClient + keyCache *keyCache + allowSigningWithNonOIDCKeys bool +} + +// newPlugin constructs an implementation of external JWT signer plugin. +func newPlugin(iss string, conn *grpc.ClientConn, allowSigningWithNonOIDCKeys bool) *Plugin { + client := externaljwtv1alpha1.NewExternalJWTSignerClient(conn) + plugin := &Plugin{ + iss: iss, + client: client, + allowSigningWithNonOIDCKeys: allowSigningWithNonOIDCKeys, + keyCache: newKeyCache(client), + } + return plugin +} + +// GenerateToken creates a service account token with the provided claims by +// calling out to the external signer binary. +func (p *Plugin) GenerateToken(ctx context.Context, claims *jwt.Claims, privateClaims interface{}) (string, error) { + jwt, err := p.signAndAssembleJWT(ctx, claims, privateClaims) + externaljwtmetrics.RecordTokenGenAttempt(err) + return jwt, err +} + +func (p *Plugin) signAndAssembleJWT(ctx context.Context, claims *jwt.Claims, privateClaims interface{}) (string, error) { + payload, err := mergeClaims(p.iss, claims, privateClaims) + if err != nil { + return "", fmt.Errorf("while merging claims: %w", err) + } + + payloadBase64 := base64.RawURLEncoding.EncodeToString(payload) + + request := &externaljwtv1alpha1.SignJWTRequest{ + Claims: payloadBase64, + } + + response, err := p.client.Sign(ctx, request) + if err != nil { + return "", fmt.Errorf("while signing jwt: %w", err) + } + + if err := p.validateJWTHeader(ctx, response); err != nil { + return "", fmt.Errorf("while validating header: %w", err) + } + + if len(response.Signature) == 0 { + return "", fmt.Errorf("empty signature returned") + } + + return response.Header + "." + payloadBase64 + "." + response.Signature, nil +} + +// GetServiceMetadata returns metadata associated with externalJWTSigner +// It Includes details like max token lifetime supported by externalJWTSigner, etc. +func (p *Plugin) GetServiceMetadata(ctx context.Context) (*externaljwtv1alpha1.MetadataResponse, error) { + req := &externaljwtv1alpha1.MetadataRequest{} + return p.client.Metadata(ctx, req) +} + +func (p *Plugin) validateJWTHeader(ctx context.Context, response *externaljwtv1alpha1.SignJWTResponse) error { + jsonBytes, err := base64.RawURLEncoding.DecodeString(response.Header) + if err != nil { + return fmt.Errorf("while unwrapping header: %w", err) + } + + decoder := json.NewDecoder(bytes.NewBuffer(jsonBytes)) + decoder.DisallowUnknownFields() + + header := &struct { + Algorithm string `json:"alg,omitempty"` + KeyID string `json:"kid,omitempty"` + Type string `json:"typ,omitempty"` + }{} + + if err := decoder.Decode(header); err != nil { + return fmt.Errorf("while parsing header JSON: %w", err) + } + + if header.Type != "JWT" { + return fmt.Errorf("bad type") + } + if len(header.KeyID) == 0 { + return fmt.Errorf("key id missing") + } + if len(header.KeyID) > 1024 { + return fmt.Errorf("key id longer than 1 kb") + } + switch header.Algorithm { + // IMPORTANT: If this function is updated to support additional algorithms, + // JWTTokenGenerator, signerFromRSAPrivateKey, signerFromECDSAPrivateKey in + // kubernetes/pkg/serviceaccount/jwt.go must also be updated to support the same Algorithms. + case "RS256", "ES256", "ES384", "ES512": + // OK + default: + return fmt.Errorf("bad signing algorithm %q", header.Algorithm) + } + + if !p.allowSigningWithNonOIDCKeys { + publicKeys := p.keyCache.GetPublicKeys(ctx, header.KeyID) + for _, key := range publicKeys { + // Such keys shall only be used for validating formerly issued tokens. + if key.ExcludeFromOIDCDiscovery { + return fmt.Errorf("key used for signing JWT (kid: %s) is excluded from OIDC discovery docs", header.KeyID) + } + } + } + + return nil +} + +func mergeClaims(iss string, claims *jwt.Claims, privateClaims interface{}) ([]byte, error) { + var out []byte + signer := payloadGrabber(func(payload []byte) { out = payload }) + _, err := serviceaccount.GenerateToken(signer, iss, claims, privateClaims) + if len(out) == 0 { + return nil, fmt.Errorf("failed to marshal: %w", err) + } + return out, nil // error is safe to ignore as long as we have the payload bytes +} + +var _ jose.Signer = payloadGrabber(nil) + +type payloadGrabber func(payload []byte) + +func (p payloadGrabber) Sign(payload []byte) (*jose.JSONWebSignature, error) { + p(payload) + return nil, jose.ErrUnprotectedNonce // return some error to stop after we have the payload +} + +func (p payloadGrabber) Options() jose.SignerOptions { return jose.SignerOptions{} } diff --git a/pkg/serviceaccount/externaljwt/plugin/plugin_test.go b/pkg/serviceaccount/externaljwt/plugin/plugin_test.go new file mode 100644 index 0000000000000..20a4f648a4b17 --- /dev/null +++ b/pkg/serviceaccount/externaljwt/plugin/plugin_test.go @@ -0,0 +1,450 @@ +/* +Copyright 2024 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 plugin + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/json" + "fmt" + "net" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/protobuf/types/known/timestamppb" + "gopkg.in/square/go-jose.v2/jwt" + + "k8s.io/kubernetes/pkg/serviceaccount" + + externaljwtv1alpha1 "k8s.io/externaljwt/apis/v1alpha1" +) + +var ( + rsaKey1 *rsa.PrivateKey + rsaKey2 *rsa.PrivateKey +) + +func init() { + var err error + + rsaKey1, err = rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + panic("Error while generating first RSA key") + } + + rsaKey2, err = rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + panic("Error while generating second RSA key") + } +} + +func TestExternalTokenGenerator(t *testing.T) { + testCases := []struct { + desc string + + publicClaims jwt.Claims + privateClaims privateClaimsT + + iss string + backendSetKeyID string + backendSetAlgorithm string + supportedKeys map[string]supportedKeyT + allowSigningWithNonOIDCKeys bool + + wantClaims unifiedClaimsT + wantErr error + }{ + { + desc: "correct token with correct claims returned", + publicClaims: jwt.Claims{ + Subject: "some-subject", + Audience: jwt.Audience{ + "some-audience-1", + "some-audience-2", + }, + ID: "id-1", + }, + privateClaims: privateClaimsT{ + Kubernetes: kubernetesT{ + Namespace: "foo", + Svcacct: refT{ + Name: "default", + UID: "abcdef", + }, + }, + }, + iss: "some-issuer", + backendSetKeyID: "key-id-1", + backendSetAlgorithm: "RS256", + supportedKeys: map[string]supportedKeyT{ + "key-id-1": { + key: &rsaKey1.PublicKey, + }, + }, + + wantClaims: unifiedClaimsT{ + Issuer: "some-issuer", + Subject: "some-subject", + Audience: jwt.Audience{ + "some-audience-1", + "some-audience-2", + }, + ID: "id-1", + Kubernetes: kubernetesT{ + Namespace: "foo", + Svcacct: refT{ + Name: "default", + UID: "abcdef", + }, + }, + }, + }, + { + desc: "correct token with correct claims signed by key that's excluded from OIDC", + publicClaims: jwt.Claims{ + Subject: "some-subject", + Audience: jwt.Audience{ + "some-audience-1", + "some-audience-2", + }, + ID: "id-1", + }, + privateClaims: privateClaimsT{ + Kubernetes: kubernetesT{ + Namespace: "foo", + Svcacct: refT{ + Name: "default", + UID: "abcdef", + }, + }, + }, + iss: "some-issuer", + backendSetKeyID: "key-id-1", + backendSetAlgorithm: "RS256", + supportedKeys: map[string]supportedKeyT{ + "key-id-1": { + key: &rsaKey1.PublicKey, + excludeFromOidc: true, + }, + }, + + wantErr: fmt.Errorf("while validating header: key used for signing JWT (kid: key-id-1) is excluded from OIDC discovery docs"), + }, + { + desc: "token signed with key that's excluded from OIDC but validation is disabled", + publicClaims: jwt.Claims{ + Subject: "some-subject", + Audience: jwt.Audience{ + "some-audience-1", + "some-audience-2", + }, + ID: "key-id-1", + }, + privateClaims: privateClaimsT{ + Kubernetes: kubernetesT{ + Namespace: "foo", + Svcacct: refT{ + Name: "default", + UID: "abcdef", + }, + }, + }, + iss: "some-issuer", + backendSetKeyID: "key-id-1", + backendSetAlgorithm: "RS256", + supportedKeys: map[string]supportedKeyT{ + "key-id-1": { + key: &rsaKey1.PublicKey, + excludeFromOidc: true, + }, + }, + allowSigningWithNonOIDCKeys: true, + + wantClaims: unifiedClaimsT{ + Issuer: "some-issuer", + Subject: "some-subject", + Audience: jwt.Audience{ + "some-audience-1", + "some-audience-2", + }, + ID: "key-id-1", + Kubernetes: kubernetesT{ + Namespace: "foo", + Svcacct: refT{ + Name: "default", + UID: "abcdef", + }, + }, + }, + }, + { + desc: "empty key ID returned from signer", + iss: "some-issuer", + backendSetKeyID: "", + backendSetAlgorithm: "RS256", + supportedKeys: map[string]supportedKeyT{ + "key-id-1": { + key: &rsaKey1.PublicKey, + excludeFromOidc: true, + }, + }, + wantErr: fmt.Errorf("while validating header: key id missing"), + }, + { + desc: "key id longer than 1024 bytes returned from signer", + iss: "some-issuer", + backendSetKeyID: string(make([]byte, 1025)), + backendSetAlgorithm: "RS256", + supportedKeys: map[string]supportedKeyT{ + "key-id-1": { + key: &rsaKey1.PublicKey, + excludeFromOidc: true, + }, + }, + wantErr: fmt.Errorf("while validating header: key id longer than 1 kb"), + }, + { + desc: "unsupported alg returned from signer", + iss: "some-issuer", + backendSetKeyID: "key-id-1", + backendSetAlgorithm: "something-unsupported", + supportedKeys: map[string]supportedKeyT{ + "key-id-1": { + key: &rsaKey1.PublicKey, + excludeFromOidc: true, + }, + }, + wantErr: fmt.Errorf("while validating header: bad signing algorithm \"something-unsupported\""), + }, + { + desc: "empty alg returned from signer", + iss: "some-issuer", + backendSetKeyID: "key-id-1", + backendSetAlgorithm: "", + supportedKeys: map[string]supportedKeyT{ + "key-id-1": { + key: &rsaKey1.PublicKey, + excludeFromOidc: true, + }, + }, + wantErr: fmt.Errorf("while validating header: bad signing algorithm \"\""), + }, + } + + for i, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + ctx := context.Background() + + sockname := fmt.Sprintf("@test-external-token-generator-%d.sock", i) + t.Cleanup(func() { _ = os.Remove(sockname) }) + + addr := &net.UnixAddr{Name: sockname, Net: "unix"} + listener, err := net.ListenUnix(addr.Network(), addr) + if err != nil { + t.Fatalf("Failed to start fake backend: %v", err) + } + + grpcServer := grpc.NewServer() + + backend := &dummyExtrnalSigner{ + keyID: tc.backendSetKeyID, + signingAlgorithm: tc.backendSetAlgorithm, + signature: "abcdef", + supportedKeys: tc.supportedKeys, + refreshHintSeconds: 10, + DataTimeStamp: timestamppb.New(time.Time{}), + } + externaljwtv1alpha1.RegisterExternalJWTSignerServer(grpcServer, backend) + + go func() { + if err := grpcServer.Serve(listener); err != nil { + panic(fmt.Errorf("error returned from grpcServer: %w", err)) + } + }() + defer grpcServer.Stop() + + clientConn, err := grpc.DialContext( + ctx, + sockname, + grpc.WithContextDialer(func(ctx context.Context, path string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "unix", path) + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + t.Fatalf("Failed to dial buffconn client: %v", err) + } + defer func() { _ = clientConn.Close() }() + + plugin := newPlugin(tc.iss, clientConn, tc.allowSigningWithNonOIDCKeys) + err = plugin.keyCache.initialFill(ctx) + if err != nil { + t.Fatalf("initial fill failed: %v", err) + } + + gotToken, err := plugin.GenerateToken(ctx, &tc.publicClaims, tc.privateClaims) + if err != nil && tc.wantErr != nil { + if err.Error() != tc.wantErr.Error() { + t.Fatalf("want error: %v, got error: %v", tc.wantErr, err) + } + return + } else if err != nil && tc.wantErr == nil { + t.Fatalf("Unexpected error generating token: %v", err) + } else if err == nil && tc.wantErr != nil { + t.Fatalf("Wanted error %q, but got nil", tc.wantErr) + } + + tokenPieces := strings.Split(gotToken, ".") + payloadBase64 := tokenPieces[1] + + gotClaimBytes, err := base64.RawURLEncoding.DecodeString(payloadBase64) + if err != nil { + t.Fatalf("error converting received tokens to bytes: %v", err) + } + + gotClaims := unifiedClaimsT{} + if err := json.Unmarshal(gotClaimBytes, &gotClaims); err != nil { + t.Fatalf("Error while unmarshaling claims from backend: %v", err) + } + + if diff := cmp.Diff(gotClaims, tc.wantClaims); diff != "" { + t.Fatalf("Bad claims; diff (-got +want):\n%s", diff) + } + + // Don't check header or signature values since we're not testing + // our (fake) backends. + }) + } + +} + +func sortPublicKeySlice(a, b serviceaccount.PublicKey) bool { + return a.KeyID < b.KeyID +} + +type headerT struct { + Algorithm string `json:"alg"` + KeyID string `json:"kid,omitempty"` + Type string `json:"typ"` +} + +type unifiedClaimsT struct { + Issuer string `json:"iss,omitempty"` + Subject string `json:"sub,omitempty"` + Audience jwt.Audience `json:"aud,omitempty"` + Expiry *jwt.NumericDate `json:"exp,omitempty"` + NotBefore *jwt.NumericDate `json:"nbf,omitempty"` + IssuedAt *jwt.NumericDate `json:"iat,omitempty"` + ID string `json:"jti,omitempty"` + Kubernetes kubernetesT `json:"kubernetes.io,omitempty"` +} + +type privateClaimsT struct { + Kubernetes kubernetesT `json:"kubernetes.io,omitempty"` +} + +type kubernetesT struct { + Namespace string `json:"namespace,omitempty"` + Svcacct refT `json:"serviceaccount,omitempty"` + Pod *refT `json:"pod,omitempty"` + Secret *refT `json:"secret,omitempty"` + Node *refT `json:"node,omitempty"` + WarnAfter *jwt.NumericDate `json:"warnafter,omitempty"` +} + +type refT struct { + Name string `json:"name,omitempty"` + UID string `json:"uid,omitempty"` +} + +type supportedKeyT struct { + key *rsa.PublicKey + excludeFromOidc bool +} + +type dummyExtrnalSigner struct { + externaljwtv1alpha1.UnimplementedExternalJWTSignerServer + + // required for Sign() + keyID string + signingAlgorithm string + signature string + + // required for FetchKeys() + keyLock sync.Mutex + supportedKeys map[string]supportedKeyT + refreshHintSeconds int + DataTimeStamp *timestamppb.Timestamp + SupportedKeysOverride []*externaljwtv1alpha1.Key +} + +func (des *dummyExtrnalSigner) Sign(ctx context.Context, r *externaljwtv1alpha1.SignJWTRequest) (*externaljwtv1alpha1.SignJWTResponse, error) { + header := &headerT{ + Type: "JWT", + Algorithm: des.signingAlgorithm, + KeyID: des.keyID, + } + + headerJSON, err := json.Marshal(header) + if err != nil { + return nil, fmt.Errorf("failed to create header for JWT response") + } + + resp := &externaljwtv1alpha1.SignJWTResponse{ + Header: base64.RawURLEncoding.EncodeToString(headerJSON), + Signature: des.signature, + } + return resp, nil +} + +func (des *dummyExtrnalSigner) FetchKeys(ctx context.Context, r *externaljwtv1alpha1.FetchKeysRequest) (*externaljwtv1alpha1.FetchKeysResponse, error) { + des.keyLock.Lock() + defer des.keyLock.Unlock() + + pbKeys := []*externaljwtv1alpha1.Key{} + if des.SupportedKeysOverride != nil { + pbKeys = des.SupportedKeysOverride + } else { + for kid, k := range des.supportedKeys { + keyBytes, err := x509.MarshalPKIXPublicKey(k.key) + if err != nil { + return nil, fmt.Errorf("while marshaling key: %w", err) + } + pbKey := &externaljwtv1alpha1.Key{ + KeyId: kid, + Key: keyBytes, + ExcludeFromOidcDiscovery: k.excludeFromOidc, + } + pbKeys = append(pbKeys, pbKey) + } + } + + return &externaljwtv1alpha1.FetchKeysResponse{ + Keys: pbKeys, + DataTimestamp: des.DataTimeStamp, + RefreshHintSeconds: int64(des.refreshHintSeconds), + }, nil +} diff --git a/pkg/serviceaccount/externaljwt/plugin/testing/v1alpha1/externalsigner_mock.go b/pkg/serviceaccount/externaljwt/plugin/testing/v1alpha1/externalsigner_mock.go new file mode 100644 index 0000000000000..146d1c59ff440 --- /dev/null +++ b/pkg/serviceaccount/externaljwt/plugin/testing/v1alpha1/externalsigner_mock.go @@ -0,0 +1,279 @@ +/* +Copyright 2024 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 v1alpha1 + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/json" + "fmt" + "net" + "os" + "sync" + "sync/atomic" + "testing" + "time" + + "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/timestamppb" + + "k8s.io/externaljwt/apis/v1alpha1" + "k8s.io/klog/v2" +) + +type MockSigner struct { + socketPath string + server *grpc.Server + listener net.Listener + + SigningKey *rsa.PrivateKey + SigningKeyID string + SigningAlg string + TokenType string + SupportedKeys atomic.Pointer[map[string]KeyT] + AckKeyFetch chan bool + MaxTokenExpirationSeconds int64 + + FetchError error + MetadataError error + errorLock sync.RWMutex +} + +type KeyT struct { + Key []byte + ExcludeFromOidcDiscovery bool +} + +// NewMockSigner starts and returns a new MockSigner +// It servers on the provided socket. +func NewMockSigner(t *testing.T, socketPath string) *MockSigner { + server := grpc.NewServer() + + m := &MockSigner{ + socketPath: socketPath, + server: server, + AckKeyFetch: make(chan bool), + MaxTokenExpirationSeconds: 10 * 60, // 10m + } + + if err := m.Reset(); err != nil { + t.Fatalf("failed to load keys for mock signer: %v", err) + } + + v1alpha1.RegisterExternalJWTSignerServer(server, m) + if err := m.start(t); err != nil { + t.Fatalf("failed to start Mock Signer with error: %v", err) + } + + t.Cleanup(m.CleanUp) + if err := m.waitForMockServerToStart(); err != nil { + t.Fatalf("failed to start Mock Signer with error %v", err) + } + + return m +} + +func (m *MockSigner) Sign(ctx context.Context, req *v1alpha1.SignJWTRequest) (*v1alpha1.SignJWTResponse, error) { + + header := &struct { + Algorithm string `json:"alg,omitempty"` + KeyID string `json:"kid,omitempty"` + Type string `json:"typ,omitempty"` + }{ + Type: m.TokenType, + Algorithm: m.SigningAlg, + KeyID: m.SigningKeyID, + } + + headerJSON, err := json.Marshal(header) + if err != nil { + return nil, fmt.Errorf("failed to create header for JWT response") + } + + base64Header := base64.RawURLEncoding.EncodeToString(headerJSON) + + toBeSignedHash := hashBytes([]byte(base64Header + "." + req.Claims)) + + signature, err := rsa.SignPKCS1v15(rand.Reader, m.SigningKey, crypto.SHA256, toBeSignedHash) + if err != nil { + return nil, fmt.Errorf("unable to sign payload: %w", err) + } + + return &v1alpha1.SignJWTResponse{ + Header: base64Header, + Signature: base64.RawURLEncoding.EncodeToString(signature), + }, nil +} + +func (m *MockSigner) FetchKeys(ctx context.Context, req *v1alpha1.FetchKeysRequest) (*v1alpha1.FetchKeysResponse, error) { + m.errorLock.RLocker().Lock() + defer m.errorLock.RLocker().Unlock() + if m.FetchError != nil { + return nil, m.FetchError + } + + keys := []*v1alpha1.Key{} + + for id, k := range *m.SupportedKeys.Load() { + keys = append(keys, &v1alpha1.Key{ + KeyId: id, + Key: k.Key, + ExcludeFromOidcDiscovery: k.ExcludeFromOidcDiscovery, + }) + } + + select { + case <-m.AckKeyFetch: + default: + } + + return &v1alpha1.FetchKeysResponse{ + RefreshHintSeconds: 5, + DataTimestamp: ×tamppb.Timestamp{Seconds: time.Now().Unix()}, + Keys: keys, + }, nil +} + +func (m *MockSigner) Metadata(ctx context.Context, req *v1alpha1.MetadataRequest) (*v1alpha1.MetadataResponse, error) { + m.errorLock.RLocker().Lock() + defer m.errorLock.RLocker().Unlock() + if m.MetadataError != nil { + return nil, m.MetadataError + } + return &v1alpha1.MetadataResponse{ + MaxTokenExpirationSeconds: m.MaxTokenExpirationSeconds, + }, nil +} + +// Reset genrate and adds signing/supported keys to MockSigner instance. +func (m *MockSigner) Reset() error { + + priv1, pub1, err := generateKeyPair() + if err != nil { + return err + } + + _, pub2, err := generateKeyPair() + if err != nil { + return err + } + + _, pub3, err := generateKeyPair() + if err != nil { + return err + } + + m.SigningKey = priv1 + m.SigningKeyID = "kid-1" + m.SigningAlg = "RS256" + m.TokenType = "JWT" + m.SupportedKeys.Store(&map[string]KeyT{ + "kid-1": {Key: pub1}, + "kid-2": {Key: pub2}, + "kid-3": {Key: pub3}, + }) + m.errorLock.Lock() + defer m.errorLock.Unlock() + m.FetchError = nil + m.MetadataError = nil + m.MaxTokenExpirationSeconds = 10 * 60 // 10m + + return nil +} + +// start makes the gRpc MockServer listen on unix socket. +func (m *MockSigner) start(t *testing.T) error { + var err error + + m.listener, err = net.Listen("unix", m.socketPath) + if err != nil { + return fmt.Errorf("failed to listen on the unix socket, error: %w", err) + } + + klog.Infof("Starting Mock Signer at socketPath %s", m.socketPath) + go func() { + if err := m.server.Serve(m.listener); err != nil { + t.Error(err) + } + }() + klog.Infof("Mock Signer listening at socketPath %s", m.socketPath) + + return nil +} + +// waitForMockServerToStart waits until Mock signer is ready to server. +// waits for a max of 30s before failing. +func (m *MockSigner) waitForMockServerToStart() error { + var gRPCErr error + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) + defer cancel() + doneCh := ctx.Done() + + for range 30 { + select { + case <-doneCh: + return fmt.Errorf("failed to start Mock signer: %w", ctx.Err()) + default: + } + if _, gRPCErr = m.FetchKeys(context.Background(), &v1alpha1.FetchKeysRequest{}); gRPCErr == nil { + break + } + time.Sleep(time.Second) + } + + if gRPCErr != nil { + return fmt.Errorf("failed to start Mock signer, gRPC error: %w", gRPCErr) + } + + return nil +} + +// CleanUp stops gRPC server and the underlying listener. +func (m *MockSigner) CleanUp() { + m.server.Stop() + _ = m.listener.Close() + _ = os.Remove(m.socketPath) +} + +func generateKeyPair() (*rsa.PrivateKey, []byte, error) { + + // Generate a new private key + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + klog.Errorf("Error generating private key: %v", err) + return nil, nil, err + } + + publicKeyBytes, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey) + if err != nil { + klog.Errorf("Error marshaling public key: %v", err) + return nil, nil, err + } + + return privateKey, publicKeyBytes, nil +} + +func hashBytes(bytes []byte) []byte { + hasher := crypto.SHA256.New() + hasher.Write(bytes) + return hasher.Sum(nil) +} diff --git a/pkg/serviceaccount/jwt.go b/pkg/serviceaccount/jwt.go index ac075bc6c90af..cf6301b3b5563 100644 --- a/pkg/serviceaccount/jwt.go +++ b/pkg/serviceaccount/jwt.go @@ -53,7 +53,7 @@ type TokenGenerator interface { // the payload object. Public claims take precedent over private // claims i.e. if both claims and privateClaims have an "exp" field, // the value in claims will be used. - GenerateToken(claims *jwt.Claims, privateClaims interface{}) (string, error) + GenerateToken(ctx context.Context, claims *jwt.Claims, privateClaims interface{}) (string, error) } // JWTTokenGenerator returns a TokenGenerator that generates signed JWT tokens, using the given privateKey. @@ -118,8 +118,9 @@ func signerFromRSAPrivateKey(keyPair *rsa.PrivateKey) (jose.Signer, error) { } // 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. + // algorithmForPublicKey in serviceaccount/openidmetadata.go and + // validateJWTHeader in externaljwt/pkg/plugin/plugin.go must also + // be updated to support the same key sizes. Today we only support RS256. // Wrap the RSA keypair in a JOSE JWK with the designated key ID. privateJWK := &jose.JSONWebKey{ @@ -146,6 +147,11 @@ func signerFromRSAPrivateKey(keyPair *rsa.PrivateKey) (jose.Signer, error) { func signerFromECDSAPrivateKey(keyPair *ecdsa.PrivateKey) (jose.Signer, error) { var alg jose.SignatureAlgorithm + + // IMPORTANT: If this function is updated to support additional algorithms, + // validateJWTHeader in externaljwt/pkg/plugin/plugin.go must also be updated + // to support the same Algorithms. Today we only support "ES256", "ES384", "ES512". + switch keyPair.Curve { case elliptic.P256(): alg = jose.ES256 @@ -211,15 +217,8 @@ type jwtTokenGenerator struct { signer jose.Signer } -func (j *jwtTokenGenerator) GenerateToken(claims *jwt.Claims, privateClaims interface{}) (string, error) { - // claims are applied in reverse precedence - return jwt.Signed(j.signer). - Claims(privateClaims). - Claims(claims). - Claims(&jwt.Claims{ - Issuer: j.iss, - }). - CompactSerialize() +func (j *jwtTokenGenerator) GenerateToken(ctx context.Context, claims *jwt.Claims, privateClaims interface{}) (string, error) { + return GenerateToken(j.signer, j.iss, claims, privateClaims) } // JWTTokenAuthenticator authenticates tokens as JWT tokens produced by JWTTokenGenerator @@ -257,12 +256,13 @@ type PublicKeysGetter interface { // GetPublicKeys returns public keys to use for verifying a token with the given key id. // keyIDHint may be empty if the token did not have a kid header, or if all public keys are desired. - GetPublicKeys(keyIDHint string) []PublicKey + GetPublicKeys(ctx context.Context, keyIDHint string) []PublicKey } type PublicKey struct { - KeyID string - PublicKey interface{} + KeyID string + PublicKey interface{} + ExcludeFromOIDCDiscovery bool } type staticPublicKeysGetter struct { @@ -306,7 +306,7 @@ func (s staticPublicKeysGetter) GetCacheAgeMaxSeconds() int { return 3600 } -func (s staticPublicKeysGetter) GetPublicKeys(keyID string) []PublicKey { +func (s staticPublicKeysGetter) GetPublicKeys(ctx context.Context, keyID string) []PublicKey { if len(keyID) == 0 { return s.allPublicKeys } @@ -357,7 +357,7 @@ func (j *jwtTokenAuthenticator[PrivateClaims]) AuthenticateToken(ctx context.Con found bool errlist []error ) - keys := j.keysGetter.GetPublicKeys(kid) + keys := j.keysGetter.GetPublicKeys(ctx, kid) if len(keys) == 0 { return nil, false, fmt.Errorf("invalid signature, no keys found") } @@ -438,3 +438,15 @@ func (j *jwtTokenAuthenticator[PrivateClaims]) hasCorrectIssuer(tokenData string } return j.issuers[claims.Issuer] } + +// GenerateToken is shared between internal and external signer code to ensure that claim merging logic remains consistent between them. +func GenerateToken(signer jose.Signer, iss string, claims *jwt.Claims, privateClaims interface{}) (string, error) { + // claims are applied in reverse precedence + return jwt.Signed(signer). + Claims(privateClaims). + Claims(claims). + Claims(&jwt.Claims{ + Issuer: iss, + }). + CompactSerialize() +} diff --git a/pkg/serviceaccount/jwt_test.go b/pkg/serviceaccount/jwt_test.go index 446ed567fa9f6..8d0d2308b3893 100644 --- a/pkg/serviceaccount/jwt_test.go +++ b/pkg/serviceaccount/jwt_test.go @@ -174,7 +174,8 @@ func TestTokenGenerateAndValidate(t *testing.T) { if err != nil { t.Fatalf("error making generator: %v", err) } - rsaToken, err := rsaGenerator.GenerateToken(serviceaccount.LegacyClaims(*serviceAccount, *rsaSecret)) + c, pc := serviceaccount.LegacyClaims(*serviceAccount, *rsaSecret) + rsaToken, err := rsaGenerator.GenerateToken(context.TODO(), c, pc) if err != nil { t.Fatalf("error generating token: %v", err) } @@ -188,7 +189,8 @@ func TestTokenGenerateAndValidate(t *testing.T) { checkJSONWebSignatureHasKeyID(t, rsaToken, rsaKeyID) // Generate RSA token with invalidAutoSecret - invalidAutoSecretToken, err := rsaGenerator.GenerateToken(serviceaccount.LegacyClaims(*serviceAccount, *invalidAutoSecret)) + c, pc = serviceaccount.LegacyClaims(*serviceAccount, *invalidAutoSecret) + invalidAutoSecretToken, err := rsaGenerator.GenerateToken(context.TODO(), c, pc) if err != nil { t.Fatalf("error generating token: %v", err) } @@ -217,7 +219,8 @@ func TestTokenGenerateAndValidate(t *testing.T) { if err != nil { t.Fatalf("error making generator: %v", err) } - badIssuerToken, err := badIssuerGenerator.GenerateToken(serviceaccount.LegacyClaims(*serviceAccount, *rsaSecret)) + c, pc = serviceaccount.LegacyClaims(*serviceAccount, *rsaSecret) + badIssuerToken, err := badIssuerGenerator.GenerateToken(context.TODO(), c, pc) if err != nil { t.Fatalf("error generating token: %v", err) } @@ -227,7 +230,8 @@ func TestTokenGenerateAndValidate(t *testing.T) { if err != nil { t.Fatalf("error making generator: %v", err) } - differentIssuerToken, err := differentIssuerGenerator.GenerateToken(serviceaccount.LegacyClaims(*serviceAccount, *rsaSecret)) + c, pc = serviceaccount.LegacyClaims(*serviceAccount, *rsaSecret) + differentIssuerToken, err := differentIssuerGenerator.GenerateToken(context.TODO(), c, pc) if err != nil { t.Fatalf("error generating token: %v", err) } @@ -394,7 +398,7 @@ func TestTokenGenerateAndValidate(t *testing.T) { authn := serviceaccount.JWTTokenAuthenticator([]string{serviceaccount.LegacyIssuer, "bar"}, keysGetter, auds, validator) // An invalid, non-JWT token should always fail - ctx := authenticator.WithAudiences(context.Background(), auds) + ctx := authenticator.WithAudiences(context.TODO(), auds) if _, ok, err := authn.AuthenticateToken(ctx, "invalid token"); err != nil || ok { t.Errorf("%s: Expected err=nil, ok=false for non-JWT token", k) continue @@ -445,15 +449,15 @@ type keyIDPrefixer struct { keyIDPrefix string } -func (k *keyIDPrefixer) GetPublicKeys(keyIDHint string) []serviceaccount.PublicKey { +func (k *keyIDPrefixer) GetPublicKeys(ctx context.Context, keyIDHint string) []serviceaccount.PublicKey { if k.keyIDPrefix == "" { - return k.PublicKeysGetter.GetPublicKeys(keyIDHint) + return k.PublicKeysGetter.GetPublicKeys(context.TODO(), keyIDHint) } if keyIDHint != "" { keyIDHint = k.keyIDPrefix + keyIDHint } var retval []serviceaccount.PublicKey - for _, key := range k.PublicKeysGetter.GetPublicKeys(keyIDHint) { + for _, key := range k.PublicKeysGetter.GetPublicKeys(context.TODO(), keyIDHint) { key.KeyID = k.keyIDPrefix + key.KeyID retval = append(retval, key) } @@ -503,7 +507,8 @@ func generateECDSAToken(t *testing.T, iss string, serviceAccount *v1.ServiceAcco if err != nil { t.Fatalf("error making generator: %v", err) } - ecdsaToken, err := ecdsaGenerator.GenerateToken(serviceaccount.LegacyClaims(*serviceAccount, *ecdsaSecret)) + c, pc := serviceaccount.LegacyClaims(*serviceAccount, *ecdsaSecret) + ecdsaToken, err := ecdsaGenerator.GenerateToken(context.TODO(), c, pc) if err != nil { t.Fatalf("error generating token: %v", err) } @@ -590,17 +595,17 @@ func TestStaticPublicKeysGetter(t *testing.T) { t.Fatalf("unexpected construction error: %v", err) } - bogusKeys := getter.GetPublicKeys("bogus") + bogusKeys := getter.GetPublicKeys(context.TODO(), "bogus") if len(bogusKeys) != 0 { t.Fatalf("unexpected bogus keys: %#v", bogusKeys) } - allKeys := getter.GetPublicKeys("") + allKeys := getter.GetPublicKeys(context.TODO(), "") if !reflect.DeepEqual(tc.ExpectKeys, allKeys) { t.Fatalf("unexpected keys: %#v", allKeys) } for _, key := range allKeys { - keysByID := getter.GetPublicKeys(key.KeyID) + keysByID := getter.GetPublicKeys(context.TODO(), key.KeyID) if len(keysByID) != 1 { t.Fatalf("expected 1 key for id %s, got %d", key.KeyID, len(keysByID)) } diff --git a/pkg/serviceaccount/openidmetadata.go b/pkg/serviceaccount/openidmetadata.go index 9a58f967be26f..ba00987bc3299 100644 --- a/pkg/serviceaccount/openidmetadata.go +++ b/pkg/serviceaccount/openidmetadata.go @@ -17,6 +17,7 @@ limitations under the License. package serviceaccount import ( + "context" "crypto" "crypto/ecdsa" "crypto/elliptic" @@ -75,7 +76,17 @@ func (p *openidConfigProvider) Enqueue() { } } func (p *openidConfigProvider) Update() error { - pubKeys := p.pubKeyGetter.GetPublicKeys("") + pubKeys := []PublicKey{} + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + unfilteredPubKeys := p.pubKeyGetter.GetPublicKeys(ctx, "") + for _, key := range unfilteredPubKeys { + if !key.ExcludeFromOIDCDiscovery { + pubKeys = append(pubKeys, key) + } + } + if len(pubKeys) == 0 { return fmt.Errorf("no keys provided for validating keyset") } diff --git a/pkg/serviceaccount/openidmetadata_test.go b/pkg/serviceaccount/openidmetadata_test.go index 365af2c7241b4..5e39a864bc5d7 100644 --- a/pkg/serviceaccount/openidmetadata_test.go +++ b/pkg/serviceaccount/openidmetadata_test.go @@ -17,6 +17,7 @@ limitations under the License. package serviceaccount_test import ( + "context" "crypto/ecdsa" "crypto/rsa" "crypto/x509" @@ -161,10 +162,18 @@ func expectConfiguration(t *testing.T, reqURL string, want Configuration) { func TestServeKeys(t *testing.T) { wantPubRSA := getPublicKey(rsaPublicKey).(*rsa.PublicKey) wantPubECDSA := getPublicKey(ecdsaPublicKey).(*ecdsa.PublicKey) + + alternateGetter, err := serviceaccount.StaticPublicKeysGetter([]interface{}{wantPubRSA}) + if err != nil { + t.Fatal(err) + } + var serveKeysTests = []struct { - Name string - Keys []interface{} - WantKeys []jose.JSONWebKey + Name string + Keys []interface{} + WantKeys []jose.JSONWebKey + updatedKeysGetter serviceaccount.PublicKeysGetter + WantKeysPostUpdate []jose.JSONWebKey }{ { Name: "configured public keys", @@ -220,6 +229,97 @@ func TestServeKeys(t *testing.T) { }, }, }, + { + Name: "configured public keys reacting to update", + Keys: []interface{}{ + getPublicKey(rsaPublicKey), + getPublicKey(ecdsaPublicKey), + }, + WantKeys: []jose.JSONWebKey{ + { + Algorithm: "RS256", + Key: wantPubRSA, + KeyID: rsaKeyID, + Use: "sig", + Certificates: []*x509.Certificate{}, + CertificateThumbprintSHA1: []uint8{}, + CertificateThumbprintSHA256: []uint8{}, + }, + { + Algorithm: "ES256", + Key: wantPubECDSA, + KeyID: ecdsaKeyID, + Use: "sig", + Certificates: []*x509.Certificate{}, + CertificateThumbprintSHA1: []uint8{}, + CertificateThumbprintSHA256: []uint8{}, + }, + }, + updatedKeysGetter: alternateGetter, + WantKeysPostUpdate: []jose.JSONWebKey{ + { + Algorithm: "RS256", + Key: wantPubRSA, + KeyID: rsaKeyID, + Use: "sig", + Certificates: []*x509.Certificate{}, + CertificateThumbprintSHA1: []uint8{}, + CertificateThumbprintSHA256: []uint8{}, + }, + }, + }, + { + Name: "configured public keys reacting to update while excluding keys", + Keys: []interface{}{ + getPublicKey(rsaPublicKey), + getPublicKey(ecdsaPublicKey), + }, + WantKeys: []jose.JSONWebKey{ + { + Algorithm: "RS256", + Key: wantPubRSA, + KeyID: rsaKeyID, + Use: "sig", + Certificates: []*x509.Certificate{}, + CertificateThumbprintSHA1: []uint8{}, + CertificateThumbprintSHA256: []uint8{}, + }, + { + Algorithm: "ES256", + Key: wantPubECDSA, + KeyID: ecdsaKeyID, + Use: "sig", + Certificates: []*x509.Certificate{}, + CertificateThumbprintSHA1: []uint8{}, + CertificateThumbprintSHA256: []uint8{}, + }, + }, + updatedKeysGetter: dummyPublicKeyGetter{ + keys: []serviceaccount.PublicKey{ + { + KeyID: rsaKeyID, + PublicKey: wantPubRSA, + ExcludeFromOIDCDiscovery: true, + }, + { + KeyID: ecdsaKeyID, + PublicKey: wantPubECDSA, + ExcludeFromOIDCDiscovery: false, + }, + }, + }, + WantKeysPostUpdate: []jose.JSONWebKey{ + { + Algorithm: "ES256", + Key: wantPubECDSA, + KeyID: ecdsaKeyID, + Use: "sig", + Certificates: []*x509.Certificate{}, + CertificateThumbprintSHA1: []uint8{}, + CertificateThumbprintSHA256: []uint8{}, + }, + }, + }, } for _, tt := range serveKeysTests { @@ -228,10 +328,6 @@ func TestServeKeys(t *testing.T) { if err != nil { t.Fatal(err) } - updatedKeysGetter, err := serviceaccount.StaticPublicKeysGetter([]interface{}{wantPubRSA}) - if err != nil { - t.Fatal(err) - } keysGetter := &proxyKeyGetter{PublicKeysGetter: initialKeysGetter} s, _ := setupServer(t, exampleIssuer, keysGetter) defer s.Close() @@ -239,23 +335,17 @@ func TestServeKeys(t *testing.T) { reqURL := s.URL + "/openid/v1/jwks" expectKeys(t, reqURL, tt.WantKeys) - // modify the underlying keys, expect the same response - keysGetter.PublicKeysGetter = updatedKeysGetter - expectKeys(t, reqURL, tt.WantKeys) + if tt.updatedKeysGetter != nil { + // modify the underlying keys, expect the same response + keysGetter.PublicKeysGetter = tt.updatedKeysGetter + expectKeys(t, reqURL, tt.WantKeys) - // notify the metadata the keys changed, expected a modified response - for _, listener := range keysGetter.listeners { - listener.Enqueue() + // notify the metadata the keys changed, expected a modified response + for _, listener := range keysGetter.listeners { + listener.Enqueue() + } + expectKeys(t, reqURL, tt.WantKeysPostUpdate) } - expectKeys(t, reqURL, []jose.JSONWebKey{{ - Algorithm: "RS256", - Key: wantPubRSA, - KeyID: rsaKeyID, - Use: "sig", - Certificates: []*x509.Certificate{}, - CertificateThumbprintSHA1: []uint8{}, - CertificateThumbprintSHA256: []uint8{}, - }}) }) } } @@ -479,3 +569,19 @@ func TestNewOpenIDMetadata(t *testing.T) { }) } } + +type dummyPublicKeyGetter struct { + keys []serviceaccount.PublicKey +} + +func (d dummyPublicKeyGetter) AddListener(listener serviceaccount.Listener) { + // no-op +} + +func (d dummyPublicKeyGetter) GetCacheAgeMaxSeconds() int { + return 3600 +} + +func (d dummyPublicKeyGetter) GetPublicKeys(ctx context.Context, keyIDHint string) []serviceaccount.PublicKey { + return d.keys +} diff --git a/staging/publishing/import-restrictions.yaml b/staging/publishing/import-restrictions.yaml index 6d4b381e957a7..ab93947293d54 100644 --- a/staging/publishing/import-restrictions.yaml +++ b/staging/publishing/import-restrictions.yaml @@ -317,3 +317,7 @@ - k8s.io/cri-client - k8s.io/klog/v2 - k8s.io/utils + +- baseImportPath: "./staging/src/k8s.io/externaljwt" + allowedImports: + - k8s.io/externaljwt diff --git a/staging/publishing/rules.yaml b/staging/publishing/rules.yaml index 43f15bb5abdae..0a0309a917d58 100644 --- a/staging/publishing/rules.yaml +++ b/staging/publishing/rules.yaml @@ -2441,6 +2441,13 @@ rules: branch: release-1.31 dirs: - staging/src/k8s.io/endpointslice +- destination: externaljwt + branches: + - name: master + source: + branch: master + dirs: + - staging/src/k8s.io/externaljwt recursive-delete-patterns: - '*/.gitattributes' default-go-version: 1.23.2 diff --git a/staging/src/k8s.io/externaljwt/.github/PULL_REQUEST_TEMPLATE.md b/staging/src/k8s.io/externaljwt/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000000..e7e5eb834b23a --- /dev/null +++ b/staging/src/k8s.io/externaljwt/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,2 @@ +Sorry, we do not accept changes directly against this repository. Please see +CONTRIBUTING.md for information on where and how to contribute instead. diff --git a/staging/src/k8s.io/externaljwt/CONTRIBUTING.md b/staging/src/k8s.io/externaljwt/CONTRIBUTING.md new file mode 100644 index 0000000000000..523f171cc59d9 --- /dev/null +++ b/staging/src/k8s.io/externaljwt/CONTRIBUTING.md @@ -0,0 +1,7 @@ +# Contributing guidelines + +Do not open pull requests directly against this repository, they will be ignored. Instead, please open pull requests against [kubernetes/kubernetes](https://git.k8s.io/kubernetes/). Please follow the same [contributing guide](https://git.k8s.io/kubernetes/CONTRIBUTING.md) you would follow for any other pull request made to kubernetes/kubernetes. + +This repository is published from [kubernetes/kubernetes/staging/src/k8s.io/externaljwt](https://git.k8s.io/kubernetes/staging/src/k8s.io/externaljwt) by the [kubernetes publishing-bot](https://git.k8s.io/publishing-bot). + +Please see [Staging Directory and Publishing](https://git.k8s.io/community/contributors/devel/sig-architecture/staging.md) for more information \ No newline at end of file diff --git a/staging/src/k8s.io/externaljwt/LICENSE b/staging/src/k8s.io/externaljwt/LICENSE new file mode 100644 index 0000000000000..9c8f3ea0871e0 --- /dev/null +++ b/staging/src/k8s.io/externaljwt/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + 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. \ No newline at end of file diff --git a/staging/src/k8s.io/externaljwt/OWNERS b/staging/src/k8s.io/externaljwt/OWNERS new file mode 100644 index 0000000000000..e9f4e0b7241ea --- /dev/null +++ b/staging/src/k8s.io/externaljwt/OWNERS @@ -0,0 +1,8 @@ +# See the OWNERS docs at https://go.k8s.io/owners + +approvers: + - sig-auth-serviceaccounts-approvers +reviewers: + - sig-auth-serviceaccounts-reviewers +labels: + - sig/auth diff --git a/staging/src/k8s.io/externaljwt/README.md b/staging/src/k8s.io/externaljwt/README.md new file mode 100644 index 0000000000000..c0cbbadc35b39 --- /dev/null +++ b/staging/src/k8s.io/externaljwt/README.md @@ -0,0 +1,20 @@ +# ExternalJWT + +This repository contains proto APIs which enable plugging external JWT signing and key management. + +See [KEP 740](https://github.com/kubernetes/enhancements/tree/master/keps/sig-auth/740-service-account-external-signing) for more details. + +## Community, discussion, contribution, and support + +ExternalJWT a sub-project of [SIG-Auth](https://github.com/kubernetes/community/tree/master/sig-auth). + +You can reach the maintainers of this project at: + +- Slack: [#sig-auth](https://kubernetes.slack.com/messages/sig-auth) +- Mailing List: [kubernetes-sig-auth](https://groups.google.com/forum/#!forum/kubernetes-sig-auth) + +Learn how to engage with the Kubernetes community on the [community page](http://kubernetes.io/community/). + +### Code of conduct + +Participation in the Kubernetes community is governed by the [Kubernetes Code of Conduct](code-of-conduct.md). \ No newline at end of file diff --git a/staging/src/k8s.io/externaljwt/SECURITY_CONTACTS b/staging/src/k8s.io/externaljwt/SECURITY_CONTACTS new file mode 100644 index 0000000000000..46300148c103b --- /dev/null +++ b/staging/src/k8s.io/externaljwt/SECURITY_CONTACTS @@ -0,0 +1,15 @@ +# Defined below are the security contacts for this repo. +# +# They are the contact point for the Product Security Committee to reach out +# to for triaging and handling of incoming issues. +# +# The below names agree to abide by the +# [Embargo Policy](https://git.k8s.io/security/private-distributors-list.md#embargo-policy) +# and will be removed and replaced if they violate that agreement. +# +# DO NOT REPORT SECURITY VULNERABILITIES DIRECTLY TO THESE NAMES, FOLLOW THE +# INSTRUCTIONS AT https://kubernetes.io/security/ + +liggitt +enj +taahm \ No newline at end of file diff --git a/staging/src/k8s.io/externaljwt/apis/OWNERS b/staging/src/k8s.io/externaljwt/apis/OWNERS new file mode 100644 index 0000000000000..fa20d4cbebaa9 --- /dev/null +++ b/staging/src/k8s.io/externaljwt/apis/OWNERS @@ -0,0 +1,9 @@ +# See the OWNERS docs at https://go.k8s.io/owners + +# Disable inheritance as this is an api owners file +options: + no_parent_owners: true +approvers: + - api-approvers +reviewers: + - sig-auth-api-reviewers diff --git a/staging/src/k8s.io/externaljwt/apis/v1alpha1/api.pb.go b/staging/src/k8s.io/externaljwt/apis/v1alpha1/api.pb.go new file mode 100644 index 0000000000000..aafc917fc1046 --- /dev/null +++ b/staging/src/k8s.io/externaljwt/apis/v1alpha1/api.pb.go @@ -0,0 +1,591 @@ +/* +Copyright 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. +*/ + +// Code generated by protoc-gen-gogo. DO NOT EDIT. +// source: api.proto + +package v1alpha1 + +import ( + context "context" + fmt "fmt" + proto "github.com/gogo/protobuf/proto" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + math "math" +) + +// Reference imports to suppress errors if they are not otherwise used. +var _ = proto.Marshal +var _ = fmt.Errorf +var _ = math.Inf + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the proto package it is being compiled against. +// A compilation error at this line likely means your copy of the +// proto package needs to be updated. +const _ = proto.GoGoProtoPackageIsVersion3 // please upgrade the proto package + +type SignJWTRequest struct { + // URL-safe base64 wrapped payload to be signed. + // Exactly as it appears in the second segment of the JWT + Claims string `protobuf:"bytes,1,opt,name=claims,proto3" json:"claims,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *SignJWTRequest) Reset() { *m = SignJWTRequest{} } +func (m *SignJWTRequest) String() string { return proto.CompactTextString(m) } +func (*SignJWTRequest) ProtoMessage() {} +func (*SignJWTRequest) Descriptor() ([]byte, []int) { + return fileDescriptor_00212fb1f9d3bf1c, []int{0} +} +func (m *SignJWTRequest) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_SignJWTRequest.Unmarshal(m, b) +} +func (m *SignJWTRequest) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_SignJWTRequest.Marshal(b, m, deterministic) +} +func (m *SignJWTRequest) XXX_Merge(src proto.Message) { + xxx_messageInfo_SignJWTRequest.Merge(m, src) +} +func (m *SignJWTRequest) XXX_Size() int { + return xxx_messageInfo_SignJWTRequest.Size(m) +} +func (m *SignJWTRequest) XXX_DiscardUnknown() { + xxx_messageInfo_SignJWTRequest.DiscardUnknown(m) +} + +var xxx_messageInfo_SignJWTRequest proto.InternalMessageInfo + +func (m *SignJWTRequest) GetClaims() string { + if m != nil { + return m.Claims + } + return "" +} + +type SignJWTResponse struct { + // header must contain only alg, kid, typ claims. + // typ must be “JWT”. + // kid must be non-empty, <=1024 characters, and its corresponding public key should not be excluded from OIDC discovery. + // alg must be one of the algorithms supported by kube-apiserver (currently RS256, ES256, ES384, ES512). + // header cannot have any additional data that kube-apiserver does not recognize. + // Already wrapped in URL-safe base64, exactly as it appears in the first segment of the JWT. + Header string `protobuf:"bytes,1,opt,name=header,proto3" json:"header,omitempty"` + // The signature for the JWT. + // Already wrapped in URL-safe base64, exactly as it appears in the final segment of the JWT. + Signature string `protobuf:"bytes,2,opt,name=signature,proto3" json:"signature,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *SignJWTResponse) Reset() { *m = SignJWTResponse{} } +func (m *SignJWTResponse) String() string { return proto.CompactTextString(m) } +func (*SignJWTResponse) ProtoMessage() {} +func (*SignJWTResponse) Descriptor() ([]byte, []int) { + return fileDescriptor_00212fb1f9d3bf1c, []int{1} +} +func (m *SignJWTResponse) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_SignJWTResponse.Unmarshal(m, b) +} +func (m *SignJWTResponse) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_SignJWTResponse.Marshal(b, m, deterministic) +} +func (m *SignJWTResponse) XXX_Merge(src proto.Message) { + xxx_messageInfo_SignJWTResponse.Merge(m, src) +} +func (m *SignJWTResponse) XXX_Size() int { + return xxx_messageInfo_SignJWTResponse.Size(m) +} +func (m *SignJWTResponse) XXX_DiscardUnknown() { + xxx_messageInfo_SignJWTResponse.DiscardUnknown(m) +} + +var xxx_messageInfo_SignJWTResponse proto.InternalMessageInfo + +func (m *SignJWTResponse) GetHeader() string { + if m != nil { + return m.Header + } + return "" +} + +func (m *SignJWTResponse) GetSignature() string { + if m != nil { + return m.Signature + } + return "" +} + +type FetchKeysRequest struct { + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *FetchKeysRequest) Reset() { *m = FetchKeysRequest{} } +func (m *FetchKeysRequest) String() string { return proto.CompactTextString(m) } +func (*FetchKeysRequest) ProtoMessage() {} +func (*FetchKeysRequest) Descriptor() ([]byte, []int) { + return fileDescriptor_00212fb1f9d3bf1c, []int{2} +} +func (m *FetchKeysRequest) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_FetchKeysRequest.Unmarshal(m, b) +} +func (m *FetchKeysRequest) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_FetchKeysRequest.Marshal(b, m, deterministic) +} +func (m *FetchKeysRequest) XXX_Merge(src proto.Message) { + xxx_messageInfo_FetchKeysRequest.Merge(m, src) +} +func (m *FetchKeysRequest) XXX_Size() int { + return xxx_messageInfo_FetchKeysRequest.Size(m) +} +func (m *FetchKeysRequest) XXX_DiscardUnknown() { + xxx_messageInfo_FetchKeysRequest.DiscardUnknown(m) +} + +var xxx_messageInfo_FetchKeysRequest proto.InternalMessageInfo + +type FetchKeysResponse struct { + Keys []*Key `protobuf:"bytes,1,rep,name=keys,proto3" json:"keys,omitempty"` + // The timestamp when this data was pulled from the authoritative source of + // truth for verification keys. + // kube-apiserver can export this from metrics, to enable end-to-end SLOs. + DataTimestamp *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=data_timestamp,json=dataTimestamp,proto3" json:"data_timestamp,omitempty"` + // refresh interval for verification keys to pick changes if any. + // any value <= 0 is considered a misconfiguration. + RefreshHintSeconds int64 `protobuf:"varint,3,opt,name=refresh_hint_seconds,json=refreshHintSeconds,proto3" json:"refresh_hint_seconds,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *FetchKeysResponse) Reset() { *m = FetchKeysResponse{} } +func (m *FetchKeysResponse) String() string { return proto.CompactTextString(m) } +func (*FetchKeysResponse) ProtoMessage() {} +func (*FetchKeysResponse) Descriptor() ([]byte, []int) { + return fileDescriptor_00212fb1f9d3bf1c, []int{3} +} +func (m *FetchKeysResponse) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_FetchKeysResponse.Unmarshal(m, b) +} +func (m *FetchKeysResponse) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_FetchKeysResponse.Marshal(b, m, deterministic) +} +func (m *FetchKeysResponse) XXX_Merge(src proto.Message) { + xxx_messageInfo_FetchKeysResponse.Merge(m, src) +} +func (m *FetchKeysResponse) XXX_Size() int { + return xxx_messageInfo_FetchKeysResponse.Size(m) +} +func (m *FetchKeysResponse) XXX_DiscardUnknown() { + xxx_messageInfo_FetchKeysResponse.DiscardUnknown(m) +} + +var xxx_messageInfo_FetchKeysResponse proto.InternalMessageInfo + +func (m *FetchKeysResponse) GetKeys() []*Key { + if m != nil { + return m.Keys + } + return nil +} + +func (m *FetchKeysResponse) GetDataTimestamp() *timestamppb.Timestamp { + if m != nil { + return m.DataTimestamp + } + return nil +} + +func (m *FetchKeysResponse) GetRefreshHintSeconds() int64 { + if m != nil { + return m.RefreshHintSeconds + } + return 0 +} + +type Key struct { + // A unique identifier for this key. + // Length must be <=1024. + KeyId string `protobuf:"bytes,1,opt,name=key_id,json=keyId,proto3" json:"key_id,omitempty"` + // The public key, PKIX-serialized. + // must be a public key supported by kube-apiserver (currently RSA 256 or ECDSA 256/384/521) + Key []byte `protobuf:"bytes,2,opt,name=key,proto3" json:"key,omitempty"` + // Set only for keys that are not used to sign bound tokens. + // eg: supported keys for legacy tokens. + // If set, key is used for verification but excluded from OIDC discovery docs. + // if set, external signer should not use this key to sign a JWT. + ExcludeFromOidcDiscovery bool `protobuf:"varint,3,opt,name=exclude_from_oidc_discovery,json=excludeFromOidcDiscovery,proto3" json:"exclude_from_oidc_discovery,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *Key) Reset() { *m = Key{} } +func (m *Key) String() string { return proto.CompactTextString(m) } +func (*Key) ProtoMessage() {} +func (*Key) Descriptor() ([]byte, []int) { + return fileDescriptor_00212fb1f9d3bf1c, []int{4} +} +func (m *Key) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_Key.Unmarshal(m, b) +} +func (m *Key) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_Key.Marshal(b, m, deterministic) +} +func (m *Key) XXX_Merge(src proto.Message) { + xxx_messageInfo_Key.Merge(m, src) +} +func (m *Key) XXX_Size() int { + return xxx_messageInfo_Key.Size(m) +} +func (m *Key) XXX_DiscardUnknown() { + xxx_messageInfo_Key.DiscardUnknown(m) +} + +var xxx_messageInfo_Key proto.InternalMessageInfo + +func (m *Key) GetKeyId() string { + if m != nil { + return m.KeyId + } + return "" +} + +func (m *Key) GetKey() []byte { + if m != nil { + return m.Key + } + return nil +} + +func (m *Key) GetExcludeFromOidcDiscovery() bool { + if m != nil { + return m.ExcludeFromOidcDiscovery + } + return false +} + +type MetadataRequest struct { + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *MetadataRequest) Reset() { *m = MetadataRequest{} } +func (m *MetadataRequest) String() string { return proto.CompactTextString(m) } +func (*MetadataRequest) ProtoMessage() {} +func (*MetadataRequest) Descriptor() ([]byte, []int) { + return fileDescriptor_00212fb1f9d3bf1c, []int{5} +} +func (m *MetadataRequest) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_MetadataRequest.Unmarshal(m, b) +} +func (m *MetadataRequest) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_MetadataRequest.Marshal(b, m, deterministic) +} +func (m *MetadataRequest) XXX_Merge(src proto.Message) { + xxx_messageInfo_MetadataRequest.Merge(m, src) +} +func (m *MetadataRequest) XXX_Size() int { + return xxx_messageInfo_MetadataRequest.Size(m) +} +func (m *MetadataRequest) XXX_DiscardUnknown() { + xxx_messageInfo_MetadataRequest.DiscardUnknown(m) +} + +var xxx_messageInfo_MetadataRequest proto.InternalMessageInfo + +type MetadataResponse struct { + // used by kube-apiserver for defaulting/validation of JWT lifetime while accounting for configuration flag values: + // 1. `--service-account-max-token-expiration` + // 2. `--service-account-extend-token-expiration` + // + // * If `--service-account-max-token-expiration` is greater than `max_token_expiration_seconds`, kube-apiserver treats that as misconfiguration and exits. + // * If `--service-account-max-token-expiration` is not explicitly set, kube-apiserver defaults to `max_token_expiration_seconds`. + // * If `--service-account-extend-token-expiration` is true, the extended expiration is `min(1 year, max_token_expiration_seconds)`. + // + // `max_token_expiration_seconds` must be at least 600s. + MaxTokenExpirationSeconds int64 `protobuf:"varint,1,opt,name=max_token_expiration_seconds,json=maxTokenExpirationSeconds,proto3" json:"max_token_expiration_seconds,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *MetadataResponse) Reset() { *m = MetadataResponse{} } +func (m *MetadataResponse) String() string { return proto.CompactTextString(m) } +func (*MetadataResponse) ProtoMessage() {} +func (*MetadataResponse) Descriptor() ([]byte, []int) { + return fileDescriptor_00212fb1f9d3bf1c, []int{6} +} +func (m *MetadataResponse) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_MetadataResponse.Unmarshal(m, b) +} +func (m *MetadataResponse) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_MetadataResponse.Marshal(b, m, deterministic) +} +func (m *MetadataResponse) XXX_Merge(src proto.Message) { + xxx_messageInfo_MetadataResponse.Merge(m, src) +} +func (m *MetadataResponse) XXX_Size() int { + return xxx_messageInfo_MetadataResponse.Size(m) +} +func (m *MetadataResponse) XXX_DiscardUnknown() { + xxx_messageInfo_MetadataResponse.DiscardUnknown(m) +} + +var xxx_messageInfo_MetadataResponse proto.InternalMessageInfo + +func (m *MetadataResponse) GetMaxTokenExpirationSeconds() int64 { + if m != nil { + return m.MaxTokenExpirationSeconds + } + return 0 +} + +func init() { + proto.RegisterType((*SignJWTRequest)(nil), "v1alpha1.SignJWTRequest") + proto.RegisterType((*SignJWTResponse)(nil), "v1alpha1.SignJWTResponse") + proto.RegisterType((*FetchKeysRequest)(nil), "v1alpha1.FetchKeysRequest") + proto.RegisterType((*FetchKeysResponse)(nil), "v1alpha1.FetchKeysResponse") + proto.RegisterType((*Key)(nil), "v1alpha1.Key") + proto.RegisterType((*MetadataRequest)(nil), "v1alpha1.MetadataRequest") + proto.RegisterType((*MetadataResponse)(nil), "v1alpha1.MetadataResponse") +} + +func init() { proto.RegisterFile("api.proto", fileDescriptor_00212fb1f9d3bf1c) } + +var fileDescriptor_00212fb1f9d3bf1c = []byte{ + // 483 bytes of a gzipped FileDescriptorProto + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x6c, 0x92, 0xcd, 0x6e, 0xd3, 0x40, + 0x10, 0xc7, 0x31, 0x29, 0x51, 0x32, 0xa5, 0x6d, 0xb2, 0x02, 0xe4, 0xba, 0x95, 0x08, 0x3e, 0xe5, + 0x64, 0xd3, 0x70, 0xe1, 0x52, 0x21, 0x3e, 0x1a, 0xa0, 0x11, 0x42, 0x72, 0x22, 0x55, 0xe2, 0x62, + 0x6d, 0xed, 0x49, 0xbc, 0xf8, 0x63, 0xcd, 0xee, 0xa6, 0xd8, 0xcf, 0xc4, 0x43, 0xf1, 0x2a, 0xc8, + 0x1f, 0xeb, 0x94, 0x2a, 0xb7, 0xdd, 0xf9, 0xff, 0x77, 0x66, 0x7e, 0xb3, 0x03, 0x43, 0x9a, 0x33, + 0x27, 0x17, 0x5c, 0x71, 0x32, 0xb8, 0xbb, 0xa0, 0x49, 0x1e, 0xd1, 0x0b, 0xeb, 0xe5, 0x86, 0xf3, + 0x4d, 0x82, 0x6e, 0x1d, 0xbf, 0xdd, 0xae, 0x5d, 0xc5, 0x52, 0x94, 0x8a, 0xa6, 0x79, 0x63, 0xb5, + 0xa7, 0x70, 0xbc, 0x64, 0x9b, 0xec, 0xfa, 0x66, 0xe5, 0xe1, 0xaf, 0x2d, 0x4a, 0x45, 0x5e, 0x40, + 0x3f, 0x48, 0x28, 0x4b, 0xa5, 0x69, 0x4c, 0x8c, 0xe9, 0xd0, 0x6b, 0x6f, 0xf6, 0x67, 0x38, 0xe9, + 0x9c, 0x32, 0xe7, 0x99, 0xc4, 0xca, 0x1a, 0x21, 0x0d, 0x51, 0x68, 0x6b, 0x73, 0x23, 0xe7, 0x30, + 0x94, 0x6c, 0x93, 0x51, 0xb5, 0x15, 0x68, 0x3e, 0xae, 0xa5, 0x5d, 0xc0, 0x26, 0x30, 0x9a, 0xa3, + 0x0a, 0xa2, 0x05, 0x96, 0xb2, 0x2d, 0x6a, 0xff, 0x31, 0x60, 0x7c, 0x2f, 0xd8, 0xe6, 0x7f, 0x05, + 0x07, 0x31, 0x96, 0x55, 0x23, 0xbd, 0xe9, 0xe1, 0xec, 0xc8, 0xd1, 0x58, 0xce, 0x02, 0x4b, 0xaf, + 0x96, 0xc8, 0x7b, 0x38, 0x0e, 0xa9, 0xa2, 0x7e, 0xc7, 0x55, 0xd7, 0x3b, 0x9c, 0x59, 0x4e, 0x43, + 0xee, 0x68, 0x72, 0x67, 0xa5, 0x1d, 0xde, 0x51, 0xf5, 0xa2, 0xbb, 0x92, 0xd7, 0xf0, 0x4c, 0xe0, + 0x5a, 0xa0, 0x8c, 0xfc, 0x88, 0x65, 0xca, 0x97, 0x18, 0xf0, 0x2c, 0x94, 0x66, 0x6f, 0x62, 0x4c, + 0x7b, 0x1e, 0x69, 0xb5, 0x2f, 0x2c, 0x53, 0xcb, 0x46, 0xb1, 0x53, 0xe8, 0x2d, 0xb0, 0x24, 0xcf, + 0xa1, 0x1f, 0x63, 0xe9, 0xb3, 0xb0, 0xc5, 0x7f, 0x12, 0x63, 0xf9, 0x35, 0x24, 0x23, 0xe8, 0xc5, + 0x58, 0xd6, 0x7d, 0x3c, 0xf5, 0xaa, 0x23, 0xb9, 0x84, 0x33, 0x2c, 0x82, 0x64, 0x1b, 0xa2, 0xbf, + 0x16, 0x3c, 0xf5, 0x39, 0x0b, 0x03, 0x3f, 0x64, 0x32, 0xe0, 0x77, 0x28, 0xca, 0xba, 0xd0, 0xc0, + 0x33, 0x5b, 0xcb, 0x5c, 0xf0, 0xf4, 0x3b, 0x0b, 0x83, 0x4f, 0x5a, 0xb7, 0xc7, 0x70, 0xf2, 0x0d, + 0x15, 0xad, 0xba, 0xd6, 0xf3, 0x5a, 0xc2, 0x68, 0x17, 0x6a, 0xa7, 0xf5, 0x0e, 0xce, 0x53, 0x5a, + 0xf8, 0x8a, 0xc7, 0x98, 0xf9, 0x58, 0xe4, 0x4c, 0x50, 0xc5, 0x78, 0xd6, 0xf1, 0x18, 0x35, 0xcf, + 0x69, 0x4a, 0x8b, 0x55, 0x65, 0xb9, 0xea, 0x1c, 0x2d, 0xd6, 0xec, 0xaf, 0x01, 0xe3, 0xab, 0x42, + 0xa1, 0xc8, 0x68, 0x72, 0x7d, 0xb3, 0xaa, 0x7e, 0x1b, 0x05, 0xb9, 0x84, 0x83, 0xea, 0x44, 0xcc, + 0xdd, 0xf8, 0xff, 0xdf, 0x18, 0xeb, 0x74, 0x8f, 0xd2, 0xf4, 0x64, 0x3f, 0x22, 0x73, 0x18, 0x76, + 0x1f, 0x4b, 0xac, 0x9d, 0xf3, 0xe1, 0x0a, 0x58, 0x67, 0x7b, 0xb5, 0x2e, 0xcf, 0x47, 0x18, 0x68, + 0x62, 0x72, 0xaf, 0xe0, 0x83, 0xc1, 0x58, 0xd6, 0x3e, 0x49, 0x27, 0xf9, 0x60, 0xff, 0x98, 0xc4, + 0x6f, 0xa5, 0xc3, 0xb8, 0x8b, 0x2d, 0xe7, 0xcf, 0xdf, 0xca, 0xa5, 0x39, 0x93, 0xae, 0x7e, 0x76, + 0xdb, 0xaf, 0x37, 0xe6, 0xcd, 0xbf, 0x00, 0x00, 0x00, 0xff, 0xff, 0x90, 0x1b, 0xfb, 0x90, 0x50, + 0x03, 0x00, 0x00, +} + +// Reference imports to suppress errors if they are not otherwise used. +var _ context.Context +var _ grpc.ClientConn + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +const _ = grpc.SupportPackageIsVersion4 + +// ExternalJWTSignerClient is the client API for ExternalJWTSigner service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://godoc.org/google.golang.org/grpc#ClientConn.NewStream. +type ExternalJWTSignerClient interface { + // Sign takes a serialized JWT payload, and returns the serialized header and + // signature. The caller can then assemble the JWT from the header, payload, + // and signature. + // + // The plugin MUST set a key id in the returned JWT header. + Sign(ctx context.Context, in *SignJWTRequest, opts ...grpc.CallOption) (*SignJWTResponse, error) + // FetchKeys returns the set of public keys that are trusted to sign + // Kubernetes service account tokens. Kube-apiserver will call this RPC: + // + // * Every time it tries to validate a JWT from the service account issuer with an unknown key ID, and + // + // - Periodically, so it can serve reasonably-up-to-date keys from the OIDC + // JWKs endpoint. + FetchKeys(ctx context.Context, in *FetchKeysRequest, opts ...grpc.CallOption) (*FetchKeysResponse, error) + // Metadata is meant to be called once on startup. + // Enables sharing metadata with kube-apiserver (eg: the max token lifetime that signer supports) + Metadata(ctx context.Context, in *MetadataRequest, opts ...grpc.CallOption) (*MetadataResponse, error) +} + +type externalJWTSignerClient struct { + cc *grpc.ClientConn +} + +func NewExternalJWTSignerClient(cc *grpc.ClientConn) ExternalJWTSignerClient { + return &externalJWTSignerClient{cc} +} + +func (c *externalJWTSignerClient) Sign(ctx context.Context, in *SignJWTRequest, opts ...grpc.CallOption) (*SignJWTResponse, error) { + out := new(SignJWTResponse) + err := c.cc.Invoke(ctx, "/v1alpha1.ExternalJWTSigner/Sign", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *externalJWTSignerClient) FetchKeys(ctx context.Context, in *FetchKeysRequest, opts ...grpc.CallOption) (*FetchKeysResponse, error) { + out := new(FetchKeysResponse) + err := c.cc.Invoke(ctx, "/v1alpha1.ExternalJWTSigner/FetchKeys", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *externalJWTSignerClient) Metadata(ctx context.Context, in *MetadataRequest, opts ...grpc.CallOption) (*MetadataResponse, error) { + out := new(MetadataResponse) + err := c.cc.Invoke(ctx, "/v1alpha1.ExternalJWTSigner/Metadata", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +// ExternalJWTSignerServer is the server API for ExternalJWTSigner service. +type ExternalJWTSignerServer interface { + // Sign takes a serialized JWT payload, and returns the serialized header and + // signature. The caller can then assemble the JWT from the header, payload, + // and signature. + // + // The plugin MUST set a key id in the returned JWT header. + Sign(context.Context, *SignJWTRequest) (*SignJWTResponse, error) + // FetchKeys returns the set of public keys that are trusted to sign + // Kubernetes service account tokens. Kube-apiserver will call this RPC: + // + // * Every time it tries to validate a JWT from the service account issuer with an unknown key ID, and + // + // - Periodically, so it can serve reasonably-up-to-date keys from the OIDC + // JWKs endpoint. + FetchKeys(context.Context, *FetchKeysRequest) (*FetchKeysResponse, error) + // Metadata is meant to be called once on startup. + // Enables sharing metadata with kube-apiserver (eg: the max token lifetime that signer supports) + Metadata(context.Context, *MetadataRequest) (*MetadataResponse, error) +} + +// UnimplementedExternalJWTSignerServer can be embedded to have forward compatible implementations. +type UnimplementedExternalJWTSignerServer struct { +} + +func (*UnimplementedExternalJWTSignerServer) Sign(ctx context.Context, req *SignJWTRequest) (*SignJWTResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method Sign not implemented") +} +func (*UnimplementedExternalJWTSignerServer) FetchKeys(ctx context.Context, req *FetchKeysRequest) (*FetchKeysResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method FetchKeys not implemented") +} +func (*UnimplementedExternalJWTSignerServer) Metadata(ctx context.Context, req *MetadataRequest) (*MetadataResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method Metadata not implemented") +} + +func RegisterExternalJWTSignerServer(s *grpc.Server, srv ExternalJWTSignerServer) { + s.RegisterService(&_ExternalJWTSigner_serviceDesc, srv) +} + +func _ExternalJWTSigner_Sign_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SignJWTRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ExternalJWTSignerServer).Sign(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/v1alpha1.ExternalJWTSigner/Sign", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ExternalJWTSignerServer).Sign(ctx, req.(*SignJWTRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ExternalJWTSigner_FetchKeys_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(FetchKeysRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ExternalJWTSignerServer).FetchKeys(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/v1alpha1.ExternalJWTSigner/FetchKeys", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ExternalJWTSignerServer).FetchKeys(ctx, req.(*FetchKeysRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ExternalJWTSigner_Metadata_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(MetadataRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ExternalJWTSignerServer).Metadata(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/v1alpha1.ExternalJWTSigner/Metadata", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ExternalJWTSignerServer).Metadata(ctx, req.(*MetadataRequest)) + } + return interceptor(ctx, in, info, handler) +} + +var _ExternalJWTSigner_serviceDesc = grpc.ServiceDesc{ + ServiceName: "v1alpha1.ExternalJWTSigner", + HandlerType: (*ExternalJWTSignerServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Sign", + Handler: _ExternalJWTSigner_Sign_Handler, + }, + { + MethodName: "FetchKeys", + Handler: _ExternalJWTSigner_FetchKeys_Handler, + }, + { + MethodName: "Metadata", + Handler: _ExternalJWTSigner_Metadata_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "api.proto", +} diff --git a/staging/src/k8s.io/externaljwt/apis/v1alpha1/api.proto b/staging/src/k8s.io/externaljwt/apis/v1alpha1/api.proto new file mode 100644 index 0000000000000..97790479780df --- /dev/null +++ b/staging/src/k8s.io/externaljwt/apis/v1alpha1/api.proto @@ -0,0 +1,113 @@ +/* +Copyright 2024 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. +*/ + +// To regenerate api.pb.go run `hack/update-codegen.sh protobindings` +syntax = "proto3"; + +package v1alpha1; + +option go_package = "k8s.io/externaljwt/apis/v1alpha1"; + +import "google/protobuf/timestamp.proto"; + +// This service is served by a process on a local Unix Domain Socket. +service ExternalJWTSigner { + // Sign takes a serialized JWT payload, and returns the serialized header and + // signature. The caller can then assemble the JWT from the header, payload, + // and signature. + // + // The plugin MUST set a key id in the returned JWT header. + rpc Sign(SignJWTRequest) returns (SignJWTResponse) {} + + // FetchKeys returns the set of public keys that are trusted to sign + // Kubernetes service account tokens. Kube-apiserver will call this RPC: + // + // * Every time it tries to validate a JWT from the service account issuer with an unknown key ID, and + // + // * Periodically, so it can serve reasonably-up-to-date keys from the OIDC + // JWKs endpoint. + rpc FetchKeys(FetchKeysRequest) returns (FetchKeysResponse) {} + + // Metadata is meant to be called once on startup. + // Enables sharing metadata with kube-apiserver (eg: the max token lifetime that signer supports) + rpc Metadata(MetadataRequest) returns (MetadataResponse) {} + } + + message SignJWTRequest { + // URL-safe base64 wrapped payload to be signed. + // Exactly as it appears in the second segment of the JWT + string claims = 1; + } + + message SignJWTResponse { + // header must contain only alg, kid, typ claims. + // typ must be “JWT”. + // kid must be non-empty, <=1024 characters, and its corresponding public key should not be excluded from OIDC discovery. + // alg must be one of the algorithms supported by kube-apiserver (currently RS256, ES256, ES384, ES512). + // header cannot have any additional data that kube-apiserver does not recognize. + // Already wrapped in URL-safe base64, exactly as it appears in the first segment of the JWT. + string header = 1; + + // The signature for the JWT. + // Already wrapped in URL-safe base64, exactly as it appears in the final segment of the JWT. + string signature = 2; + } + + message FetchKeysRequest {} + + message FetchKeysResponse { + repeated Key keys = 1; + + // The timestamp when this data was pulled from the authoritative source of + // truth for verification keys. + // kube-apiserver can export this from metrics, to enable end-to-end SLOs. + google.protobuf.Timestamp data_timestamp = 2; + + // refresh interval for verification keys to pick changes if any. + // any value <= 0 is considered a misconfiguration. + int64 refresh_hint_seconds = 3; + } + + message Key { + // A unique identifier for this key. + // Length must be <=1024. + string key_id = 1; + + // The public key, PKIX-serialized. + // must be a public key supported by kube-apiserver (currently RSA 256 or ECDSA 256/384/521) + bytes key = 2; + + // Set only for keys that are not used to sign bound tokens. + // eg: supported keys for legacy tokens. + // If set, key is used for verification but excluded from OIDC discovery docs. + // if set, external signer should not use this key to sign a JWT. + bool exclude_from_oidc_discovery = 3; + } + + message MetadataRequest {} + + message MetadataResponse { + // used by kube-apiserver for defaulting/validation of JWT lifetime while accounting for configuration flag values: + // 1. `--service-account-max-token-expiration` + // 2. `--service-account-extend-token-expiration` + // + // * If `--service-account-max-token-expiration` is greater than `max_token_expiration_seconds`, kube-apiserver treats that as misconfiguration and exits. + // * If `--service-account-max-token-expiration` is not explicitly set, kube-apiserver defaults to `max_token_expiration_seconds`. + // * If `--service-account-extend-token-expiration` is true, the extended expiration is `min(1 year, max_token_expiration_seconds)`. + // + // `max_token_expiration_seconds` must be at least 600s. + int64 max_token_expiration_seconds = 1; + } \ No newline at end of file diff --git a/staging/src/k8s.io/externaljwt/code-of-conduct.md b/staging/src/k8s.io/externaljwt/code-of-conduct.md new file mode 100644 index 0000000000000..3cd88b27d8281 --- /dev/null +++ b/staging/src/k8s.io/externaljwt/code-of-conduct.md @@ -0,0 +1,3 @@ +# Kubernetes Community Code of Conduct + +Please refer to our [Kubernetes Community Code of Conduct](https://git.k8s.io/community/code-of-conduct.md) \ No newline at end of file diff --git a/staging/src/k8s.io/externaljwt/docs.go b/staging/src/k8s.io/externaljwt/docs.go new file mode 100644 index 0000000000000..f19d6978b3d8e --- /dev/null +++ b/staging/src/k8s.io/externaljwt/docs.go @@ -0,0 +1,18 @@ +/* +Copyright 2024 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 externaljwt contains the proto definitions for the ExternalJWTSigner. +package externaljwt // import "k8s.io/externaljwt" diff --git a/staging/src/k8s.io/externaljwt/go.mod b/staging/src/k8s.io/externaljwt/go.mod new file mode 100644 index 0000000000000..bec9fb9ac6c8c --- /dev/null +++ b/staging/src/k8s.io/externaljwt/go.mod @@ -0,0 +1,20 @@ +// This is a generated file. Do not edit directly. + +module k8s.io/externaljwt + +go 1.23.0 + +godebug default=go1.23 + +require ( + github.com/gogo/protobuf v1.3.2 + google.golang.org/grpc v1.65.0 + google.golang.org/protobuf v1.35.1 +) + +require ( + golang.org/x/net v0.30.0 // indirect + golang.org/x/sys v0.26.0 // indirect + golang.org/x/text v0.19.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7 // indirect +) diff --git a/staging/src/k8s.io/externaljwt/go.sum b/staging/src/k8s.io/externaljwt/go.sum new file mode 100644 index 0000000000000..dfcea7ccbb065 --- /dev/null +++ b/staging/src/k8s.io/externaljwt/go.sum @@ -0,0 +1,62 @@ +cel.dev/expr v0.15.0/go.mod h1:TRSuuV7DlVCE/uwv5QbAiW/v8l5O8C4eEPHeu7gf7Sg= +cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cncf/xds/go v0.0.0-20240423153145-555b57ec207b/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= +github.com/envoyproxy/go-control-plane v0.12.0/go.mod h1:ZBTaoJ23lqITozF0M6G4/IragXCQKCnYbmlmtHvwRG0= +github.com/envoyproxy/protoc-gen-validate v1.0.4/go.mod h1:qys6tmnRsYrQqIhm2bvKZH4Blx/1gTIZ2UKVY1M+Yew= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/glog v1.2.1/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= +golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= +golang.org/x/oauth2 v0.20.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157/go.mod h1:99sLkeliLXfdj2J75X3Ho+rrVCaJze0uwN7zDDkjPVU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7 h1:2035KHhUv+EpyB+hWgJnaWKJOdX1E95w2S8Rr4uWKTs= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= +google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc= +google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ= +google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= +google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= diff --git a/test/e2e_node/services/apiserver.go b/test/e2e_node/services/apiserver.go index 4488715b3bd58..d4fe88f782d7a 100644 --- a/test/e2e_node/services/apiserver.go +++ b/test/e2e_node/services/apiserver.go @@ -102,7 +102,7 @@ func (a *APIServer) Start(ctx context.Context) error { go func() { defer close(errCh) defer cancel(errors.New("shutting down")) // Calling Stop is optional, but cancel always should be invoked. - completedOptions, err := o.Complete() + completedOptions, err := o.Complete(ctx) if err != nil { errCh <- fmt.Errorf("set apiserver default options error: %w", err) return diff --git a/test/featuregates_linter/test_data/versioned_feature_list.yaml b/test/featuregates_linter/test_data/versioned_feature_list.yaml index 9ccfebc5afcb0..591cb636af2fc 100644 --- a/test/featuregates_linter/test_data/versioned_feature_list.yaml +++ b/test/featuregates_linter/test_data/versioned_feature_list.yaml @@ -452,6 +452,12 @@ lockToDefault: false preRelease: GA version: "1.20" +- name: ExternalServiceAccountTokenSigner + versionedSpecs: + - default: false + lockToDefault: false + preRelease: Alpha + version: "1.32" - name: GracefulNodeShutdown versionedSpecs: - default: false diff --git a/test/integration/auth/svcaccttoken_test.go b/test/integration/auth/svcaccttoken_test.go index ab8a643854906..656f37081157f 100644 --- a/test/integration/auth/svcaccttoken_test.go +++ b/test/integration/auth/svcaccttoken_test.go @@ -691,7 +691,7 @@ func TestServiceAccountTokenCreate(t *testing.T) { if err != nil { t.Fatalf("err calling Claims: %v", err) } - tok, err := tokenGenerator.GenerateToken(sc, pc) + tok, err := tokenGenerator.GenerateToken(context.TODO(), sc, pc) if err != nil { t.Fatalf("err signing expired token: %v", err) } diff --git a/test/integration/etcd/server.go b/test/integration/etcd/server.go index 8a9c2869b6b44..03911859aafe8 100644 --- a/test/integration/etcd/server.go +++ b/test/integration/etcd/server.go @@ -106,7 +106,7 @@ func StartRealAPIServerOrDie(t *testing.T, configFuncs ...func(*options.ServerRu for _, f := range configFuncs { f(opts) } - completedOptions, err := opts.Complete() + completedOptions, err := opts.Complete(tCtx) if err != nil { t.Fatal(err) } diff --git a/test/integration/framework/test_server.go b/test/integration/framework/test_server.go index bb43850466d9c..8afe46e9ebd4b 100644 --- a/test/integration/framework/test_server.go +++ b/test/integration/framework/test_server.go @@ -158,7 +158,7 @@ func StartTestServer(ctx context.Context, t testing.TB, setup TestServerSetup) ( setup.ModifyServerRunOptions(opts) } - completedOptions, err := opts.Complete() + completedOptions, err := opts.Complete(ctx) if err != nil { t.Fatal(err) } diff --git a/test/integration/serviceaccount/external_jwt_signer_test.go b/test/integration/serviceaccount/external_jwt_signer_test.go new file mode 100644 index 0000000000000..8feb3065091ae --- /dev/null +++ b/test/integration/serviceaccount/external_jwt_signer_test.go @@ -0,0 +1,305 @@ +/* +Copyright 2024 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 ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "fmt" + "os" + "strings" + "testing" + "time" + + "k8s.io/kubernetes/cmd/kube-apiserver/app/options" + "k8s.io/kubernetes/test/integration/framework" + "k8s.io/kubernetes/test/utils/ktesting" + + authv1 "k8s.io/api/authentication/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + utilfeature "k8s.io/apiserver/pkg/util/feature" + featuregatetesting "k8s.io/component-base/featuregate/testing" + "k8s.io/kubernetes/pkg/features" + v1alpha1testing "k8s.io/kubernetes/pkg/serviceaccount/externaljwt/plugin/testing/v1alpha1" +) + +func TestExternalJWTSigningAndAuth(t *testing.T) { + // Enable feature gate for external JWT signer. + featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.ExternalServiceAccountTokenSigner, true) + + // Prep some keys to use with test. + key1, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + panic("Error while generating first RSA key") + } + pubKey1Bytes, err := x509.MarshalPKIXPublicKey(&key1.PublicKey) + if err != nil { + panic("Error while marshaling first public key") + } + + tCtx := ktesting.Init(t) + ctx, cancel := context.WithCancel(tCtx) + defer cancel() + + // create and start mock signer. + socketPath := "@mock-external-jwt-signer.sock" + t.Cleanup(func() { _ = os.Remove(socketPath) }) + mockSigner := v1alpha1testing.NewMockSigner(t, socketPath) + defer mockSigner.CleanUp() + + // Start Api server configured with external signer. + client, _, tearDownFn := framework.StartTestServer(ctx, t, framework.TestServerSetup{ + ModifyServerRunOptions: func(opt *options.ServerRunOptions) { + opt.ServiceAccountSigningEndpoint = socketPath + opt.ServiceAccountSigningKeyFile = "" + opt.Authentication.ServiceAccounts.KeyFiles = []string{} + }, + }) + defer tearDownFn() + + // Create Namesapce (ns-1) to work with. + if _, err := client.CoreV1().Namespaces().Create(ctx, &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ns-1", + }, + }, metav1.CreateOptions{}); err != nil { + t.Fatalf("Error when creating namespace: %v", err) + } + + // Create ServiceAccount (sa-1) to work with. + if _, err := client.CoreV1().ServiceAccounts("ns-1").Create(ctx, &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sa-1", + }, + }, metav1.CreateOptions{}); err != nil { + t.Fatalf("Error when creating service-account: %v", err) + } + + testCases := []struct { + desc string + preTestSignerUpdate func() + preValidationSignerUpdate func() + wantTokenReqErr error + shouldPassAuth bool + }{ + { + desc: "signing key supported.", + preTestSignerUpdate: func() { /*no-op*/ }, + preValidationSignerUpdate: func() { /*no-op*/ }, + shouldPassAuth: true, + }, + { + desc: "signing key not among supported set", + preTestSignerUpdate: func() { + mockSigner.SigningKey = key1 + mockSigner.SigningKeyID = "updated-kid-1" + }, + preValidationSignerUpdate: func() { /*no-op*/ }, + shouldPassAuth: false, + }, + { + desc: "signing key corresponds to public key that is excluded from OIDC", + preTestSignerUpdate: func() { + mockSigner.SigningKey = key1 + mockSigner.SigningKeyID = "updated-kid-1" + + cpy := make(map[string]v1alpha1testing.KeyT) + for key, value := range *mockSigner.SupportedKeys.Load() { + cpy[key] = value + } + cpy["updated-kid-1"] = v1alpha1testing.KeyT{ + Key: pubKey1Bytes, + ExcludeFromOidcDiscovery: true, + } + mockSigner.SupportedKeys.Store(&cpy) + }, + preValidationSignerUpdate: func() { /*no-op*/ }, + wantTokenReqErr: fmt.Errorf("failed to generate token: while validating header: key used for signing JWT (kid: updated-kid-1) is excluded from OIDC discovery docs"), + }, + { + desc: "different signing and supported keys with same id", + preTestSignerUpdate: func() { + mockSigner.SigningKey = key1 + }, + preValidationSignerUpdate: func() { /*no-op*/ }, + shouldPassAuth: false, + }, + { + desc: "token gen failure with un-supported Alg type", + preTestSignerUpdate: func() { + mockSigner.SigningAlg = "ABC" + }, + preValidationSignerUpdate: func() { /*no-op*/ }, + wantTokenReqErr: fmt.Errorf("failed to generate token: while validating header: bad signing algorithm \"ABC\""), + }, + { + desc: "token gen failure with un-supported token type", + preTestSignerUpdate: func() { + mockSigner.TokenType = "ABC" + }, + preValidationSignerUpdate: func() { /*no-op*/ }, + wantTokenReqErr: fmt.Errorf("failed to generate token: while validating header: bad type"), + }, + { + desc: "change of supported keys not picked immediately", + preTestSignerUpdate: func() { + mockSigner.SigningKey = key1 + }, + preValidationSignerUpdate: func() { + mockSigner.SupportedKeys.Store(&map[string]v1alpha1testing.KeyT{}) + }, + shouldPassAuth: false, + }, + { + desc: "change of supported keys picked up after periodic sync", + preTestSignerUpdate: func() { + mockSigner.SigningKey = key1 + }, + preValidationSignerUpdate: func() { + cpy := make(map[string]v1alpha1testing.KeyT) + for key, value := range *mockSigner.SupportedKeys.Load() { + cpy[key] = value + } + cpy["kid-1"] = v1alpha1testing.KeyT{Key: pubKey1Bytes} + mockSigner.SupportedKeys.Store(&cpy) + mockSigner.AckKeyFetch <- true + }, + shouldPassAuth: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + // Start fresh + err := mockSigner.Reset() + if err != nil { + t.Fatalf("failed to reset signer for the test %q: %v", tc.desc, err) + } + mockSigner.AckKeyFetch <- true + + // Adjust parameters on mock signer for the test. + tc.preTestSignerUpdate() + + // Request a token for ns-1:sa-1. + tokenExpirationSec := int64(2 * 60 * 60) // 2h + tokenRequest, err := client.CoreV1().ServiceAccounts("ns-1").CreateToken(ctx, "sa-1", &authv1.TokenRequest{ + Spec: authv1.TokenRequestSpec{ + ExpirationSeconds: &tokenExpirationSec, + }, + }, metav1.CreateOptions{}) + if tc.wantTokenReqErr != nil { + if err == nil || !strings.Contains(err.Error(), tc.wantTokenReqErr.Error()) { + t.Fatalf("wanted error: %v, got error: %v", tc.wantTokenReqErr, err) + } + return + } else if err != nil { + t.Fatalf("Error when creating token: %v", err) + } + + // Adjust parameters on mock signer for the test. + tc.preValidationSignerUpdate() + + // Try Validating the token. + tokenReviewResult, err := client.AuthenticationV1().TokenReviews().Create(ctx, &authv1.TokenReview{ + Spec: authv1.TokenReviewSpec{ + Token: tokenRequest.Status.Token, + }, + }, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Error when validating token: %v", err) + } + + if !tokenReviewResult.Status.Authenticated && tc.shouldPassAuth { + t.Fatal("Expected Authentication to succeed") + } else if tokenReviewResult.Status.Authenticated && !tc.shouldPassAuth { + t.Fatal("Expected Authentication to fail") + } + }) + } +} + +func TestDelayedStartForSigner(t *testing.T) { + // Enable feature gate for external JWT signer. + featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.ExternalServiceAccountTokenSigner, true) + + tCtx := ktesting.Init(t) + ctx, cancel := context.WithCancel(tCtx) + defer cancel() + + // Schedule signer to start on socket after 20 sec + socketPath := "@mock-external-jwt-signer.sock" + t.Cleanup(func() { _ = os.Remove(socketPath) }) + go func() { + time.Sleep(20 * time.Second) + v1alpha1testing.NewMockSigner(t, socketPath) + }() + + // Start Api server configured with external signer. + client, _, tearDownFn := framework.StartTestServer(ctx, t, framework.TestServerSetup{ + ModifyServerRunOptions: func(opt *options.ServerRunOptions) { + opt.ServiceAccountSigningEndpoint = socketPath + opt.ServiceAccountSigningKeyFile = "" + opt.Authentication.ServiceAccounts.KeyFiles = []string{} + }, + }) + defer tearDownFn() + + // Create Namesapce (ns-1) to work with. + if _, err := client.CoreV1().Namespaces().Create(ctx, &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ns-1", + }, + }, metav1.CreateOptions{}); err != nil { + t.Fatalf("Error when creating namespace: %v", err) + } + + // Create ServiceAccount (sa-1) to work with. + if _, err := client.CoreV1().ServiceAccounts("ns-1").Create(ctx, &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sa-1", + }, + }, metav1.CreateOptions{}); err != nil { + t.Fatalf("Error when creating service-account: %v", err) + } + + // Request a token for ns-1:sa-1. + tokenExpirationSec := int64(2 * 60 * 60) // 2h + tokenRequest, err := client.CoreV1().ServiceAccounts("ns-1").CreateToken(ctx, "sa-1", &authv1.TokenRequest{ + Spec: authv1.TokenRequestSpec{ + ExpirationSeconds: &tokenExpirationSec, + }, + }, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Error when creating token: %v", err) + } + + // Try Validating the token. + tokenReviewResult, err := client.AuthenticationV1().TokenReviews().Create(ctx, &authv1.TokenReview{ + Spec: authv1.TokenReviewSpec{ + Token: tokenRequest.Status.Token, + }, + }, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Error when validating token: %v", err) + } + if !tokenReviewResult.Status.Authenticated { + t.Fatal("Expected Authentication to succeed") + } +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 3dfba18c616f8..fced924fe31bf 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -1055,6 +1055,8 @@ gopkg.in/yaml.v3 ## explicit; go 1.23.0 # k8s.io/endpointslice v0.0.0 => ./staging/src/k8s.io/endpointslice ## explicit; go 1.23.0 +# k8s.io/externaljwt v0.0.0 => ./staging/src/k8s.io/externaljwt +## explicit; go 1.23.0 # k8s.io/gengo/v2 v2.0.0-20240911193312-2b36238f13e9 ## explicit; go 1.20 k8s.io/gengo/v2