Add plugin and key-cache for ExternalJWTSigner integration

This commit is contained in:
Harshal Neelkamal
2024-10-18 19:31:35 +00:00
parent 4c487b00af
commit 6fdacf0411
62 changed files with 4542 additions and 145 deletions

View File

@@ -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

View File

@@ -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()

View File

@@ -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
View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -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"

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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,
},

View File

@@ -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)
}

View File

@@ -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.

View File

@@ -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)
}
})
}
}

View File

@@ -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
}

View File

@@ -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)
}
})
}
}

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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() {

View File

@@ -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},

View File

@@ -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
}

View File

@@ -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{}
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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,
}
}

View File

@@ -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
}

View File

@@ -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

View 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{}

View 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
}

View 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)
}
})
}
}

View 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
}

View 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)
}
}

View 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{} }

View 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
}

View File

@@ -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: &timestamppb.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)
}

View File

@@ -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()
}

View File

@@ -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))
}

View File

@@ -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")
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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

View 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.

View 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

View 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.

View 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

View 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).

View 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

View 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

View 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",
}

View 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;
}

View 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)

View 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"

View 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
View 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=

View File

@@ -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

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View 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
View File

@@ -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