389 lines
12 KiB
Go
389 lines
12 KiB
Go
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package supervisorconfig
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
|
|
"gopkg.in/square/go-jose.v2"
|
|
corev1 "k8s.io/api/core/v1"
|
|
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
|
corev1informers "k8s.io/client-go/informers/core/v1"
|
|
"k8s.io/client-go/kubernetes"
|
|
"k8s.io/client-go/util/retry"
|
|
"k8s.io/klog/v2"
|
|
|
|
configv1alpha1 "go.pinniped.dev/generated/1.19/apis/supervisor/config/v1alpha1"
|
|
pinnipedclientset "go.pinniped.dev/generated/1.19/client/supervisor/clientset/versioned"
|
|
configinformers "go.pinniped.dev/generated/1.19/client/supervisor/informers/externalversions/config/v1alpha1"
|
|
pinnipedcontroller "go.pinniped.dev/internal/controller"
|
|
"go.pinniped.dev/internal/controllerlib"
|
|
"go.pinniped.dev/internal/plog"
|
|
)
|
|
|
|
// These constants are the keys in a FederationDomain's Secret's Data map.
|
|
const (
|
|
// activeJWKKey points to the current private key used for signing tokens.
|
|
//
|
|
// Note! The value for this key will contain private key material!
|
|
activeJWKKey = "activeJWK"
|
|
// jwksKey points to the current JWKS used to verify tokens.
|
|
//
|
|
// Note! The value for this key will contain only public key material!
|
|
jwksKey = "jwks"
|
|
|
|
jwksSecretTypeValue corev1.SecretType = "secrets.pinniped.dev/federation-domain-jwks"
|
|
)
|
|
|
|
const (
|
|
federationDomainKind = "FederationDomain"
|
|
)
|
|
|
|
// generateKey is stubbed out for the purpose of testing. The default behavior is to generate an EC key.
|
|
//nolint:gochecknoglobals
|
|
var generateKey func(r io.Reader) (interface{}, error) = generateECKey
|
|
|
|
func generateECKey(r io.Reader) (interface{}, error) {
|
|
return ecdsa.GenerateKey(elliptic.P256(), r)
|
|
}
|
|
|
|
// jwkController holds the fields necessary for the JWKS controller to communicate with FederationDomains and
|
|
// secrets, both via a cache and via the API.
|
|
type jwksWriterController struct {
|
|
jwksSecretLabels map[string]string
|
|
pinnipedClient pinnipedclientset.Interface
|
|
kubeClient kubernetes.Interface
|
|
federationDomainInformer configinformers.FederationDomainInformer
|
|
secretInformer corev1informers.SecretInformer
|
|
}
|
|
|
|
// NewJWKSWriterController returns a controllerlib.Controller that ensures a FederationDomain has a corresponding
|
|
// Secret that contains a valid active JWK and JWKS.
|
|
func NewJWKSWriterController(
|
|
jwksSecretLabels map[string]string,
|
|
kubeClient kubernetes.Interface,
|
|
pinnipedClient pinnipedclientset.Interface,
|
|
secretInformer corev1informers.SecretInformer,
|
|
federationDomainInformer configinformers.FederationDomainInformer,
|
|
withInformer pinnipedcontroller.WithInformerOptionFunc,
|
|
) controllerlib.Controller {
|
|
return controllerlib.New(
|
|
controllerlib.Config{
|
|
Name: "JWKSController",
|
|
Syncer: &jwksWriterController{
|
|
jwksSecretLabels: jwksSecretLabels,
|
|
kubeClient: kubeClient,
|
|
pinnipedClient: pinnipedClient,
|
|
secretInformer: secretInformer,
|
|
federationDomainInformer: federationDomainInformer,
|
|
},
|
|
},
|
|
// We want to be notified when a FederationDomain's secret gets updated or deleted. When this happens, we
|
|
// should get notified via the corresponding FederationDomain key.
|
|
withInformer(
|
|
secretInformer,
|
|
controllerlib.FilterFuncs{
|
|
ParentFunc: func(obj metav1.Object) controllerlib.Key {
|
|
if isFederationDomainControllee(obj) {
|
|
controller := metav1.GetControllerOf(obj)
|
|
return controllerlib.Key{
|
|
Name: controller.Name,
|
|
Namespace: obj.GetNamespace(),
|
|
}
|
|
}
|
|
return controllerlib.Key{}
|
|
},
|
|
AddFunc: isFederationDomainControllee,
|
|
UpdateFunc: func(oldObj, newObj metav1.Object) bool {
|
|
return isFederationDomainControllee(oldObj) || isFederationDomainControllee(newObj)
|
|
},
|
|
DeleteFunc: isFederationDomainControllee,
|
|
},
|
|
controllerlib.InformerOption{},
|
|
),
|
|
// We want to be notified when anything happens to an FederationDomain.
|
|
withInformer(
|
|
federationDomainInformer,
|
|
pinnipedcontroller.MatchAnythingFilter(nil), // nil parent func is fine because each event is distinct
|
|
controllerlib.InformerOption{},
|
|
),
|
|
)
|
|
}
|
|
|
|
// Sync implements controllerlib.Syncer.
|
|
func (c *jwksWriterController) Sync(ctx controllerlib.Context) error {
|
|
federationDomain, err := c.federationDomainInformer.Lister().FederationDomains(ctx.Key.Namespace).Get(ctx.Key.Name)
|
|
notFound := k8serrors.IsNotFound(err)
|
|
if err != nil && !notFound {
|
|
return fmt.Errorf(
|
|
"failed to get %s/%s FederationDomain: %w",
|
|
ctx.Key.Namespace,
|
|
ctx.Key.Name,
|
|
err,
|
|
)
|
|
}
|
|
|
|
if notFound {
|
|
// The corresponding secret to this FederationDomain should have been garbage collected since it should have
|
|
// had this FederationDomain as its owner.
|
|
plog.Debug(
|
|
"FederationDomain deleted",
|
|
"federationdomain",
|
|
klog.KRef(ctx.Key.Namespace, ctx.Key.Name),
|
|
)
|
|
return nil
|
|
}
|
|
|
|
secretNeedsUpdate, err := c.secretNeedsUpdate(federationDomain)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot determine secret status: %w", err)
|
|
}
|
|
if !secretNeedsUpdate {
|
|
// Secret is up to date - we are good to go.
|
|
plog.Debug(
|
|
"secret is up to date",
|
|
"federationdomain",
|
|
klog.KRef(ctx.Key.Namespace, ctx.Key.Name),
|
|
)
|
|
return nil
|
|
}
|
|
|
|
// If the FederationDomain does not have a secret associated with it, that secret does not exist, or the secret
|
|
// is invalid, we will generate a new secret (i.e., a JWKS).
|
|
secret, err := c.generateSecret(federationDomain)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot generate secret: %w", err)
|
|
}
|
|
|
|
if err := c.createOrUpdateSecret(ctx.Context, secret); err != nil {
|
|
return fmt.Errorf("cannot create or update secret: %w", err)
|
|
}
|
|
plog.Debug("created/updated secret", "secret", klog.KObj(secret))
|
|
|
|
// Ensure that the FederationDomain points to the secret.
|
|
newFederationDomain := federationDomain.DeepCopy()
|
|
newFederationDomain.Status.Secrets.JWKS.Name = secret.Name
|
|
if err := c.updateFederationDomain(ctx.Context, newFederationDomain); err != nil {
|
|
return fmt.Errorf("cannot update FederationDomain: %w", err)
|
|
}
|
|
plog.Debug("updated FederationDomain", "federationdomain", klog.KObj(newFederationDomain))
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *jwksWriterController) secretNeedsUpdate(federationDomain *configv1alpha1.FederationDomain) (bool, error) {
|
|
if federationDomain.Status.Secrets.JWKS.Name == "" {
|
|
// If the FederationDomain says it doesn't have a secret associated with it, then let's create one.
|
|
return true, nil
|
|
}
|
|
|
|
// This FederationDomain says it has a secret associated with it. Let's try to get it from the cache.
|
|
secret, err := c.secretInformer.Lister().Secrets(federationDomain.Namespace).Get(federationDomain.Status.Secrets.JWKS.Name)
|
|
notFound := k8serrors.IsNotFound(err)
|
|
if err != nil && !notFound {
|
|
return false, fmt.Errorf("cannot get secret: %w", err)
|
|
}
|
|
if notFound {
|
|
// If we can't find the secret, let's assume we need to create it.
|
|
return true, nil
|
|
}
|
|
|
|
if !isValid(secret) {
|
|
// If this secret is invalid, we need to generate a new one.
|
|
return true, nil
|
|
}
|
|
|
|
return false, nil
|
|
}
|
|
|
|
func (c *jwksWriterController) generateSecret(federationDomain *configv1alpha1.FederationDomain) (*corev1.Secret, error) {
|
|
// Note! This is where we could potentially add more handling of FederationDomain spec fields which tell us how
|
|
// this FederationDomain should sign and verify ID tokens (e.g., hardcoded token secret, gRPC
|
|
// connection to KMS, etc).
|
|
//
|
|
// For now, we just generate an new RSA keypair and put that in the secret.
|
|
|
|
key, err := generateKey(rand.Reader)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot generate key: %w", err)
|
|
}
|
|
|
|
jwk := jose.JSONWebKey{
|
|
Key: key,
|
|
KeyID: "pinniped-supervisor-key",
|
|
Algorithm: "ES256",
|
|
Use: "sig",
|
|
}
|
|
jwkData, err := json.Marshal(jwk)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot marshal jwk: %w", err)
|
|
}
|
|
|
|
jwks := jose.JSONWebKeySet{
|
|
Keys: []jose.JSONWebKey{jwk.Public()},
|
|
}
|
|
jwksData, err := json.Marshal(jwks)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot marshal jwks: %w", err)
|
|
}
|
|
|
|
s := corev1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: federationDomain.Name + "-jwks",
|
|
Namespace: federationDomain.Namespace,
|
|
Labels: c.jwksSecretLabels,
|
|
OwnerReferences: []metav1.OwnerReference{
|
|
*metav1.NewControllerRef(federationDomain, schema.GroupVersionKind{
|
|
Group: configv1alpha1.SchemeGroupVersion.Group,
|
|
Version: configv1alpha1.SchemeGroupVersion.Version,
|
|
Kind: federationDomainKind,
|
|
}),
|
|
},
|
|
},
|
|
Data: map[string][]byte{
|
|
activeJWKKey: jwkData,
|
|
jwksKey: jwksData,
|
|
},
|
|
Type: jwksSecretTypeValue,
|
|
}
|
|
|
|
return &s, nil
|
|
}
|
|
|
|
func (c *jwksWriterController) createOrUpdateSecret(
|
|
ctx context.Context,
|
|
newSecret *corev1.Secret,
|
|
) error {
|
|
secretClient := c.kubeClient.CoreV1().Secrets(newSecret.Namespace)
|
|
return retry.RetryOnConflict(retry.DefaultRetry, func() error {
|
|
oldSecret, err := secretClient.Get(ctx, newSecret.Name, metav1.GetOptions{})
|
|
notFound := k8serrors.IsNotFound(err)
|
|
if err != nil && !notFound {
|
|
return fmt.Errorf("cannot get secret: %w", err)
|
|
}
|
|
|
|
if notFound {
|
|
// New secret doesn't exist, so create it.
|
|
_, err := secretClient.Create(ctx, newSecret, metav1.CreateOptions{})
|
|
if err != nil {
|
|
return fmt.Errorf("cannot create secret: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// New secret already exists, so ensure it is up to date.
|
|
|
|
if isValid(oldSecret) {
|
|
// If the secret already has valid JWK's, then we are good to go and we don't need an update.
|
|
return nil
|
|
}
|
|
|
|
oldSecret.Data = newSecret.Data
|
|
oldSecret.Type = jwksSecretTypeValue
|
|
_, err = secretClient.Update(ctx, oldSecret, metav1.UpdateOptions{})
|
|
return err
|
|
})
|
|
}
|
|
|
|
func (c *jwksWriterController) updateFederationDomain(
|
|
ctx context.Context,
|
|
newFederationDomain *configv1alpha1.FederationDomain,
|
|
) error {
|
|
federationDomainClient := c.pinnipedClient.ConfigV1alpha1().FederationDomains(newFederationDomain.Namespace)
|
|
return retry.RetryOnConflict(retry.DefaultRetry, func() error {
|
|
oldFederationDomain, err := federationDomainClient.Get(ctx, newFederationDomain.Name, metav1.GetOptions{})
|
|
if err != nil {
|
|
return fmt.Errorf("cannot get FederationDomain: %w", err)
|
|
}
|
|
|
|
if newFederationDomain.Status.Secrets.JWKS.Name == oldFederationDomain.Status.Secrets.JWKS.Name {
|
|
// If the existing FederationDomain is up to date, we don't need to update it.
|
|
return nil
|
|
}
|
|
|
|
oldFederationDomain.Status.Secrets.JWKS.Name = newFederationDomain.Status.Secrets.JWKS.Name
|
|
_, err = federationDomainClient.Update(ctx, oldFederationDomain, metav1.UpdateOptions{})
|
|
return err
|
|
})
|
|
}
|
|
|
|
// isFederationDomainControlle returns whether the provided obj is controlled by a FederationDomain.
|
|
func isFederationDomainControllee(obj metav1.Object) bool {
|
|
controller := metav1.GetControllerOf(obj)
|
|
return controller != nil &&
|
|
controller.APIVersion == configv1alpha1.SchemeGroupVersion.String() &&
|
|
controller.Kind == federationDomainKind
|
|
}
|
|
|
|
// isValid returns whether the provided secret contains a valid active JWK and verification JWKS.
|
|
func isValid(secret *corev1.Secret) bool {
|
|
if secret.Type != jwksSecretTypeValue {
|
|
plog.Debug("secret does not have the expected type", "expectedType", jwksSecretTypeValue, "actualType", secret.Type)
|
|
return false
|
|
}
|
|
|
|
jwkData, ok := secret.Data[activeJWKKey]
|
|
if !ok {
|
|
plog.Debug("secret does not contain active jwk")
|
|
return false
|
|
}
|
|
|
|
var activeJWK jose.JSONWebKey
|
|
if err := json.Unmarshal(jwkData, &activeJWK); err != nil {
|
|
plog.Debug("cannot unmarshal active jwk", "err", err)
|
|
return false
|
|
}
|
|
|
|
if activeJWK.IsPublic() {
|
|
plog.Debug("active jwk is public", "keyid", activeJWK.KeyID)
|
|
return false
|
|
}
|
|
|
|
if !activeJWK.Valid() {
|
|
plog.Debug("active jwk is not valid", "keyid", activeJWK.KeyID)
|
|
return false
|
|
}
|
|
|
|
jwksData, ok := secret.Data[jwksKey]
|
|
if !ok {
|
|
plog.Debug("secret does not contain valid jwks")
|
|
}
|
|
|
|
var validJWKS jose.JSONWebKeySet
|
|
if err := json.Unmarshal(jwksData, &validJWKS); err != nil {
|
|
plog.Debug("cannot unmarshal valid jwks", "err", err)
|
|
return false
|
|
}
|
|
|
|
foundActiveJWK := false
|
|
for _, validJWK := range validJWKS.Keys {
|
|
if !validJWK.IsPublic() {
|
|
plog.Debug("jwks key is not public", "keyid", validJWK.KeyID)
|
|
return false
|
|
}
|
|
if !validJWK.Valid() {
|
|
plog.Debug("jwks key is not valid", "keyid", validJWK.KeyID)
|
|
return false
|
|
}
|
|
if validJWK.KeyID == activeJWK.KeyID {
|
|
foundActiveJWK = true
|
|
}
|
|
}
|
|
|
|
if !foundActiveJWK {
|
|
plog.Debug("did not find active jwk in valid jwks", "keyid", activeJWK.KeyID)
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|