mirror of
https://github.com/optim-enterprises-bv/kubernetes.git
synced 2025-11-01 10:48:15 +00:00
Add plugin and key-cache for ExternalJWTSigner integration
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
2
go.mod
2
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
|
||||
|
||||
1
go.work
1
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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,36 +241,9 @@ func (o *Options) Complete(alternateDNS []string, alternateIPs []net.IP) (Comple
|
||||
// adjust authentication for completed authorization
|
||||
completed.Authentication.ApplyAuthorization(completed.Authorization)
|
||||
|
||||
// verify and adjust ServiceAccountTokenMaxExpiration
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
completed.ServiceAccountTokenMaxExpiration = completed.Authentication.ServiceAccounts.MaxExpiration
|
||||
|
||||
if len(completed.Authentication.ServiceAccounts.Issuers) != 0 && completed.Authentication.ServiceAccounts.Issuers[0] != "" {
|
||||
if completed.ServiceAccountSigningKeyFile != "" {
|
||||
sk, err := keyutil.PrivateKeyFromFile(completed.ServiceAccountSigningKeyFile)
|
||||
err := o.completeServiceAccountOptions(ctx, &completed)
|
||||
if err != nil {
|
||||
return CompletedOptions{}, 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 CompletedOptions{}, err
|
||||
}
|
||||
|
||||
for key, value := range completed.APIEnablement.RuntimeConfig {
|
||||
@@ -281,6 +262,72 @@ func (o *Options) Complete(alternateDNS []string, alternateIPs []net.IP) (Comple
|
||||
}, 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 fmt.Errorf("the service-account-max-token-expiration must be between 1 hour and 2^32 seconds")
|
||||
}
|
||||
}
|
||||
|
||||
if len(completed.Authentication.ServiceAccounts.Issuers) != 0 && completed.Authentication.ServiceAccounts.Issuers[0] != "" {
|
||||
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 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 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
|
||||
}
|
||||
}
|
||||
|
||||
// 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 completed.Authentication.ServiceAccounts.MaxExpiration < serviceaccount.ExpirationExtensionSeconds*time.Second {
|
||||
klog.Warningf(expExtensionWarningFmt, serviceaccount.ExpirationExtensionSeconds, completed.Authentication.ServiceAccounts.MaxExpiration)
|
||||
}
|
||||
}
|
||||
completed.ServiceAccountTokenMaxExpiration = completed.Authentication.ServiceAccounts.MaxExpiration
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ServiceIPRange checks if the serviceClusterIPRange flag is nil, raising a warning if so and
|
||||
// setting service ip range to the default value in kubeoptions.DefaultServiceIPCIDR
|
||||
// for now until the default is removed per the deprecation timeline guidelines.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -133,9 +133,13 @@ type ServiceAccountAuthenticationOptions struct {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{} },
|
||||
@@ -70,6 +70,7 @@ func NewREST(optsGetter generic.RESTOptionsGetter, issuer token.TokenGenerator,
|
||||
audsSet: sets.NewString(auds...),
|
||||
maxExpirationSeconds: int64(max.Seconds()),
|
||||
extendExpiration: extendExpiration,
|
||||
isTokenSignerExternal: isTokenSignerExternal,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ type TokenREST struct {
|
||||
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
|
||||
// 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
|
||||
|
||||
202
pkg/registry/core/serviceaccount/storage/token_test.go
Normal file
202
pkg/registry/core/serviceaccount/storage/token_test.go
Normal file
@@ -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{}
|
||||
148
pkg/serviceaccount/externaljwt/metrics/metrics.go
Normal file
148
pkg/serviceaccount/externaljwt/metrics/metrics.go
Normal file
@@ -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
|
||||
}
|
||||
244
pkg/serviceaccount/externaljwt/metrics/metrics_test.go
Normal file
244
pkg/serviceaccount/externaljwt/metrics/metrics_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
236
pkg/serviceaccount/externaljwt/plugin/keycache.go
Normal file
236
pkg/serviceaccount/externaljwt/plugin/keycache.go
Normal file
@@ -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
|
||||
}
|
||||
407
pkg/serviceaccount/externaljwt/plugin/keycache_test.go
Normal file
407
pkg/serviceaccount/externaljwt/plugin/keycache_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
218
pkg/serviceaccount/externaljwt/plugin/plugin.go
Normal file
218
pkg/serviceaccount/externaljwt/plugin/plugin.go
Normal file
@@ -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{} }
|
||||
450
pkg/serviceaccount/externaljwt/plugin/plugin_test.go
Normal file
450
pkg/serviceaccount/externaljwt/plugin/plugin_test.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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{}
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
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)
|
||||
|
||||
if tt.updatedKeysGetter != nil {
|
||||
// modify the underlying keys, expect the same response
|
||||
keysGetter.PublicKeysGetter = updatedKeysGetter
|
||||
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()
|
||||
}
|
||||
expectKeys(t, reqURL, []jose.JSONWebKey{{
|
||||
Algorithm: "RS256",
|
||||
Key: wantPubRSA,
|
||||
KeyID: rsaKeyID,
|
||||
Use: "sig",
|
||||
Certificates: []*x509.Certificate{},
|
||||
CertificateThumbprintSHA1: []uint8{},
|
||||
CertificateThumbprintSHA256: []uint8{},
|
||||
}})
|
||||
expectKeys(t, reqURL, tt.WantKeysPostUpdate)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
2
staging/src/k8s.io/externaljwt/.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
2
staging/src/k8s.io/externaljwt/.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -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.
|
||||
7
staging/src/k8s.io/externaljwt/CONTRIBUTING.md
Normal file
7
staging/src/k8s.io/externaljwt/CONTRIBUTING.md
Normal file
@@ -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
|
||||
201
staging/src/k8s.io/externaljwt/LICENSE
Normal file
201
staging/src/k8s.io/externaljwt/LICENSE
Normal file
@@ -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.
|
||||
8
staging/src/k8s.io/externaljwt/OWNERS
Normal file
8
staging/src/k8s.io/externaljwt/OWNERS
Normal file
@@ -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
|
||||
20
staging/src/k8s.io/externaljwt/README.md
Normal file
20
staging/src/k8s.io/externaljwt/README.md
Normal file
@@ -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).
|
||||
15
staging/src/k8s.io/externaljwt/SECURITY_CONTACTS
Normal file
15
staging/src/k8s.io/externaljwt/SECURITY_CONTACTS
Normal file
@@ -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
|
||||
9
staging/src/k8s.io/externaljwt/apis/OWNERS
Normal file
9
staging/src/k8s.io/externaljwt/apis/OWNERS
Normal file
@@ -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
|
||||
591
staging/src/k8s.io/externaljwt/apis/v1alpha1/api.pb.go
Normal file
591
staging/src/k8s.io/externaljwt/apis/v1alpha1/api.pb.go
Normal file
@@ -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",
|
||||
}
|
||||
113
staging/src/k8s.io/externaljwt/apis/v1alpha1/api.proto
Normal file
113
staging/src/k8s.io/externaljwt/apis/v1alpha1/api.proto
Normal file
@@ -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;
|
||||
}
|
||||
3
staging/src/k8s.io/externaljwt/code-of-conduct.md
Normal file
3
staging/src/k8s.io/externaljwt/code-of-conduct.md
Normal file
@@ -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)
|
||||
18
staging/src/k8s.io/externaljwt/docs.go
Normal file
18
staging/src/k8s.io/externaljwt/docs.go
Normal file
@@ -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"
|
||||
20
staging/src/k8s.io/externaljwt/go.mod
Normal file
20
staging/src/k8s.io/externaljwt/go.mod
Normal file
@@ -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
|
||||
)
|
||||
62
staging/src/k8s.io/externaljwt/go.sum
generated
Normal file
62
staging/src/k8s.io/externaljwt/go.sum
generated
Normal file
@@ -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=
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
305
test/integration/serviceaccount/external_jwt_signer_test.go
Normal file
305
test/integration/serviceaccount/external_jwt_signer_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
2
vendor/modules.txt
vendored
2
vendor/modules.txt
vendored
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user