From 6aed025c796f211bd8d857bf5938b419afec4e24 Mon Sep 17 00:00:00 2001 From: Andrew Keesler Date: Wed, 14 Oct 2020 09:47:34 -0400 Subject: [PATCH] supervisor-generate-key: initial spike Signed-off-by: Andrew Keesler --- .../v1alpha1/types_oidcproviderconfig.go.tmpl | 21 +- cmd/pinniped-supervisor/main.go | 41 ++- ...nfig.pinniped.dev_oidcproviderconfigs.yaml | 11 + deploy/supervisor/rbac.yaml | 3 + generated/1.17/README.adoc | 1 + .../v1alpha1/types_oidcproviderconfig.go | 21 +- .../config/v1alpha1/zz_generated.deepcopy.go | 1 + .../client/openapi/zz_generated.openapi.go | 8 +- ...nfig.pinniped.dev_oidcproviderconfigs.yaml | 11 + generated/1.18/README.adoc | 1 + .../v1alpha1/types_oidcproviderconfig.go | 21 +- .../config/v1alpha1/zz_generated.deepcopy.go | 1 + .../client/openapi/zz_generated.openapi.go | 8 +- ...nfig.pinniped.dev_oidcproviderconfigs.yaml | 11 + generated/1.19/README.adoc | 1 + .../v1alpha1/types_oidcproviderconfig.go | 21 +- .../config/v1alpha1/zz_generated.deepcopy.go | 1 + .../client/openapi/zz_generated.openapi.go | 8 +- ...nfig.pinniped.dev_oidcproviderconfigs.yaml | 11 + go.mod | 1 + go.sum | 2 + internal/controller/supervisorconfig/jwks.go | 332 ++++++++++++++++++ .../controller/supervisorconfig/jwks_test.go | 9 + 23 files changed, 512 insertions(+), 34 deletions(-) create mode 100644 internal/controller/supervisorconfig/jwks.go create mode 100644 internal/controller/supervisorconfig/jwks_test.go diff --git a/apis/config/v1alpha1/types_oidcproviderconfig.go.tmpl b/apis/config/v1alpha1/types_oidcproviderconfig.go.tmpl index a05f4339..b8b34560 100644 --- a/apis/config/v1alpha1/types_oidcproviderconfig.go.tmpl +++ b/apis/config/v1alpha1/types_oidcproviderconfig.go.tmpl @@ -3,7 +3,10 @@ package v1alpha1 -import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +import ( + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) // +kubebuilder:validation:Enum=Success;Duplicate;Invalid type OIDCProviderStatus string @@ -39,11 +42,17 @@ type OIDCProviderConfigStatus struct { // +optional Message string `json:"message,omitempty"` - // LastUpdateTime holds the time at which the Status was last updated. It is a pointer to get - // around some undesirable behavior with respect to the empty metav1.Time value (see - // https://github.com/kubernetes/kubernetes/issues/86811). - // +optional - LastUpdateTime *metav1.Time `json:"lastUpdateTime,omitempty"` + // LastUpdateTime holds the time at which the Status was last updated. It is a pointer to get + // around some undesirable behavior with respect to the empty metav1.Time value (see + // https://github.com/kubernetes/kubernetes/issues/86811). + // +optional + LastUpdateTime *metav1.Time `json:"lastUpdateTime,omitempty"` + + // JWKSSecret holds the name of the secret in which this OIDC Provider's signing/verification keys + // are stored. If it is empty, then the signing/verification keys are either unknown or they don't + // exist. + // +optional + JWKSSecret corev1.LocalObjectReference `json:"jwksSecret,omitempty"` } // OIDCProviderConfig describes the configuration of an OIDC provider. diff --git a/cmd/pinniped-supervisor/main.go b/cmd/pinniped-supervisor/main.go index fe04cd7f..445c9461 100644 --- a/cmd/pinniped-supervisor/main.go +++ b/cmd/pinniped-supervisor/main.go @@ -13,6 +13,8 @@ import ( "time" "k8s.io/apimachinery/pkg/util/clock" + kubeinformers "k8s.io/client-go/informers" + "k8s.io/client-go/kubernetes" "k8s.io/client-go/pkg/version" "k8s.io/client-go/rest" restclient "k8s.io/client-go/rest" @@ -62,7 +64,9 @@ func waitForSignal() os.Signal { func startControllers( ctx context.Context, issuerProvider *manager.Manager, + kubeClient kubernetes.Interface, pinnipedClient pinnipedclientset.Interface, + kubeInformers kubeinformers.SharedInformerFactory, pinnipedInformers pinnipedinformers.SharedInformerFactory, ) { // Create controller manager. @@ -77,37 +81,60 @@ func startControllers( controllerlib.WithInformer, ), singletonWorker, + ). + WithController( + supervisorconfig.NewJWKSController( + kubeClient, + pinnipedClient, + kubeInformers.Core().V1().Secrets(), + pinnipedInformers.Config().V1alpha1().OIDCProviderConfigs(), + controllerlib.WithInformer, + ), + singletonWorker, ) + kubeInformers.Start(ctx.Done()) pinnipedInformers.Start(ctx.Done()) go controllerManager.Start(ctx) } -func newPinnipedClient() (pinnipedclientset.Interface, error) { +func newClients() (kubernetes.Interface, pinnipedclientset.Interface, error) { kubeConfig, err := restclient.InClusterConfig() if err != nil { - return nil, fmt.Errorf("could not load in-cluster configuration: %w", err) + return nil, nil, fmt.Errorf("could not load in-cluster configuration: %w", err) } // Connect to the core Kubernetes API. - pinnipedClient, err := pinnipedclientset.NewForConfig(kubeConfig) + kubeClient, err := kubernetes.NewForConfig(kubeConfig) if err != nil { - return nil, fmt.Errorf("could not load in-cluster configuration: %w", err) + return nil, nil, fmt.Errorf("could not create kube client: %w", err) } - return pinnipedClient, nil + // Connect to the Pinniped API. + pinnipedClient, err := pinnipedclientset.NewForConfig(kubeConfig) + if err != nil { + return nil, nil, fmt.Errorf("could not create pinniped client: %w", err) + } + + return kubeClient, pinnipedClient, nil } func run(serverInstallationNamespace string) error { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - pinnipedClient, err := newPinnipedClient() + kubeClient, pinnipedClient, err := newClients() if err != nil { return fmt.Errorf("cannot create k8s client: %w", err) } + kubeInformers := kubeinformers.NewSharedInformerFactoryWithOptions( + kubeClient, + defaultResyncInterval, + kubeinformers.WithNamespace(serverInstallationNamespace), + ) + pinnipedInformers := pinnipedinformers.NewSharedInformerFactoryWithOptions( pinnipedClient, defaultResyncInterval, @@ -115,7 +142,7 @@ func run(serverInstallationNamespace string) error { ) oidProvidersManager := manager.NewManager(http.NotFoundHandler()) - startControllers(ctx, oidProvidersManager, pinnipedClient, pinnipedInformers) + startControllers(ctx, oidProvidersManager, kubeClient, pinnipedClient, kubeInformers, pinnipedInformers) //nolint: gosec // Intentionally binding to all network interfaces. l, err := net.Listen("tcp", ":80") diff --git a/deploy/supervisor/config.pinniped.dev_oidcproviderconfigs.yaml b/deploy/supervisor/config.pinniped.dev_oidcproviderconfigs.yaml index 062dca9f..727f20ec 100644 --- a/deploy/supervisor/config.pinniped.dev_oidcproviderconfigs.yaml +++ b/deploy/supervisor/config.pinniped.dev_oidcproviderconfigs.yaml @@ -55,6 +55,17 @@ spec: status: description: Status of the OIDC provider. properties: + jwksSecret: + description: JWKSSecret holds the name of the secret in which this + OIDC Provider's signing/verification keys are stored. If it is empty, + then the signing/verification keys are either unknown or they don't + exist. + properties: + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Add other useful fields. apiVersion, kind, uid?' + type: string + type: object lastUpdateTime: description: LastUpdateTime holds the time at which the Status was last updated. It is a pointer to get around some undesirable behavior diff --git a/deploy/supervisor/rbac.yaml b/deploy/supervisor/rbac.yaml index bfa4ac95..4066e139 100644 --- a/deploy/supervisor/rbac.yaml +++ b/deploy/supervisor/rbac.yaml @@ -13,6 +13,9 @@ metadata: labels: app: #@ data.values.app_name rules: + - apiGroups: [""] + resources: [secrets] + verbs: [create, get, list, patch, update, watch, delete] - apiGroups: [config.pinniped.dev] resources: [oidcproviderconfigs] verbs: [update, get, list, watch] diff --git a/generated/1.17/README.adoc b/generated/1.17/README.adoc index c74ee743..bc3adc11 100644 --- a/generated/1.17/README.adoc +++ b/generated/1.17/README.adoc @@ -151,6 +151,7 @@ OIDCProviderConfigStatus is a struct that describes the actual state of an OIDC | *`status`* __OIDCProviderStatus__ | Status holds an enum that describes the state of this OIDC Provider. Note that this Status can represent success or failure. | *`message`* __string__ | Message provides human-readable details about the Status. | *`lastUpdateTime`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.17/#time-v1-meta[$$Time$$]__ | LastUpdateTime holds the time at which the Status was last updated. It is a pointer to get around some undesirable behavior with respect to the empty metav1.Time value (see https://github.com/kubernetes/kubernetes/issues/86811). +| *`jwksSecret`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.17/#localobjectreference-v1-core[$$LocalObjectReference$$]__ | JWKSSecret holds the name of the secret in which this OIDC Provider's signing/verification keys are stored. If it is empty, then the signing/verification keys are either unknown or they don't exist. |=== diff --git a/generated/1.17/apis/config/v1alpha1/types_oidcproviderconfig.go b/generated/1.17/apis/config/v1alpha1/types_oidcproviderconfig.go index a05f4339..b8b34560 100644 --- a/generated/1.17/apis/config/v1alpha1/types_oidcproviderconfig.go +++ b/generated/1.17/apis/config/v1alpha1/types_oidcproviderconfig.go @@ -3,7 +3,10 @@ package v1alpha1 -import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +import ( + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) // +kubebuilder:validation:Enum=Success;Duplicate;Invalid type OIDCProviderStatus string @@ -39,11 +42,17 @@ type OIDCProviderConfigStatus struct { // +optional Message string `json:"message,omitempty"` - // LastUpdateTime holds the time at which the Status was last updated. It is a pointer to get - // around some undesirable behavior with respect to the empty metav1.Time value (see - // https://github.com/kubernetes/kubernetes/issues/86811). - // +optional - LastUpdateTime *metav1.Time `json:"lastUpdateTime,omitempty"` + // LastUpdateTime holds the time at which the Status was last updated. It is a pointer to get + // around some undesirable behavior with respect to the empty metav1.Time value (see + // https://github.com/kubernetes/kubernetes/issues/86811). + // +optional + LastUpdateTime *metav1.Time `json:"lastUpdateTime,omitempty"` + + // JWKSSecret holds the name of the secret in which this OIDC Provider's signing/verification keys + // are stored. If it is empty, then the signing/verification keys are either unknown or they don't + // exist. + // +optional + JWKSSecret corev1.LocalObjectReference `json:"jwksSecret,omitempty"` } // OIDCProviderConfig describes the configuration of an OIDC provider. diff --git a/generated/1.17/apis/config/v1alpha1/zz_generated.deepcopy.go b/generated/1.17/apis/config/v1alpha1/zz_generated.deepcopy.go index 262992cb..e2522c24 100644 --- a/generated/1.17/apis/config/v1alpha1/zz_generated.deepcopy.go +++ b/generated/1.17/apis/config/v1alpha1/zz_generated.deepcopy.go @@ -216,6 +216,7 @@ func (in *OIDCProviderConfigStatus) DeepCopyInto(out *OIDCProviderConfigStatus) in, out := &in.LastUpdateTime, &out.LastUpdateTime *out = (*in).DeepCopy() } + out.JWKSSecret = in.JWKSSecret return } diff --git a/generated/1.17/client/openapi/zz_generated.openapi.go b/generated/1.17/client/openapi/zz_generated.openapi.go index 8e5ad5b8..5d451690 100644 --- a/generated/1.17/client/openapi/zz_generated.openapi.go +++ b/generated/1.17/client/openapi/zz_generated.openapi.go @@ -431,11 +431,17 @@ func schema_117_apis_config_v1alpha1_OIDCProviderConfigStatus(ref common.Referen Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.Time"), }, }, + "jwksSecret": { + SchemaProps: spec.SchemaProps{ + Description: "JWKSSecret holds the name of the secret in which this OIDC Provider's signing/verification keys are stored. If it is empty, then the signing/verification keys are either unknown or they don't exist.", + Ref: ref("k8s.io/api/core/v1.LocalObjectReference"), + }, + }, }, }, }, Dependencies: []string{ - "k8s.io/apimachinery/pkg/apis/meta/v1.Time"}, + "k8s.io/api/core/v1.LocalObjectReference", "k8s.io/apimachinery/pkg/apis/meta/v1.Time"}, } } diff --git a/generated/1.17/crds/config.pinniped.dev_oidcproviderconfigs.yaml b/generated/1.17/crds/config.pinniped.dev_oidcproviderconfigs.yaml index 062dca9f..727f20ec 100644 --- a/generated/1.17/crds/config.pinniped.dev_oidcproviderconfigs.yaml +++ b/generated/1.17/crds/config.pinniped.dev_oidcproviderconfigs.yaml @@ -55,6 +55,17 @@ spec: status: description: Status of the OIDC provider. properties: + jwksSecret: + description: JWKSSecret holds the name of the secret in which this + OIDC Provider's signing/verification keys are stored. If it is empty, + then the signing/verification keys are either unknown or they don't + exist. + properties: + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Add other useful fields. apiVersion, kind, uid?' + type: string + type: object lastUpdateTime: description: LastUpdateTime holds the time at which the Status was last updated. It is a pointer to get around some undesirable behavior diff --git a/generated/1.18/README.adoc b/generated/1.18/README.adoc index eaa1e5bf..d0b69f73 100644 --- a/generated/1.18/README.adoc +++ b/generated/1.18/README.adoc @@ -151,6 +151,7 @@ OIDCProviderConfigStatus is a struct that describes the actual state of an OIDC | *`status`* __OIDCProviderStatus__ | Status holds an enum that describes the state of this OIDC Provider. Note that this Status can represent success or failure. | *`message`* __string__ | Message provides human-readable details about the Status. | *`lastUpdateTime`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#time-v1-meta[$$Time$$]__ | LastUpdateTime holds the time at which the Status was last updated. It is a pointer to get around some undesirable behavior with respect to the empty metav1.Time value (see https://github.com/kubernetes/kubernetes/issues/86811). +| *`jwksSecret`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#localobjectreference-v1-core[$$LocalObjectReference$$]__ | JWKSSecret holds the name of the secret in which this OIDC Provider's signing/verification keys are stored. If it is empty, then the signing/verification keys are either unknown or they don't exist. |=== diff --git a/generated/1.18/apis/config/v1alpha1/types_oidcproviderconfig.go b/generated/1.18/apis/config/v1alpha1/types_oidcproviderconfig.go index a05f4339..b8b34560 100644 --- a/generated/1.18/apis/config/v1alpha1/types_oidcproviderconfig.go +++ b/generated/1.18/apis/config/v1alpha1/types_oidcproviderconfig.go @@ -3,7 +3,10 @@ package v1alpha1 -import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +import ( + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) // +kubebuilder:validation:Enum=Success;Duplicate;Invalid type OIDCProviderStatus string @@ -39,11 +42,17 @@ type OIDCProviderConfigStatus struct { // +optional Message string `json:"message,omitempty"` - // LastUpdateTime holds the time at which the Status was last updated. It is a pointer to get - // around some undesirable behavior with respect to the empty metav1.Time value (see - // https://github.com/kubernetes/kubernetes/issues/86811). - // +optional - LastUpdateTime *metav1.Time `json:"lastUpdateTime,omitempty"` + // LastUpdateTime holds the time at which the Status was last updated. It is a pointer to get + // around some undesirable behavior with respect to the empty metav1.Time value (see + // https://github.com/kubernetes/kubernetes/issues/86811). + // +optional + LastUpdateTime *metav1.Time `json:"lastUpdateTime,omitempty"` + + // JWKSSecret holds the name of the secret in which this OIDC Provider's signing/verification keys + // are stored. If it is empty, then the signing/verification keys are either unknown or they don't + // exist. + // +optional + JWKSSecret corev1.LocalObjectReference `json:"jwksSecret,omitempty"` } // OIDCProviderConfig describes the configuration of an OIDC provider. diff --git a/generated/1.18/apis/config/v1alpha1/zz_generated.deepcopy.go b/generated/1.18/apis/config/v1alpha1/zz_generated.deepcopy.go index 262992cb..e2522c24 100644 --- a/generated/1.18/apis/config/v1alpha1/zz_generated.deepcopy.go +++ b/generated/1.18/apis/config/v1alpha1/zz_generated.deepcopy.go @@ -216,6 +216,7 @@ func (in *OIDCProviderConfigStatus) DeepCopyInto(out *OIDCProviderConfigStatus) in, out := &in.LastUpdateTime, &out.LastUpdateTime *out = (*in).DeepCopy() } + out.JWKSSecret = in.JWKSSecret return } diff --git a/generated/1.18/client/openapi/zz_generated.openapi.go b/generated/1.18/client/openapi/zz_generated.openapi.go index 75393f9a..bd28cdfd 100644 --- a/generated/1.18/client/openapi/zz_generated.openapi.go +++ b/generated/1.18/client/openapi/zz_generated.openapi.go @@ -431,11 +431,17 @@ func schema_118_apis_config_v1alpha1_OIDCProviderConfigStatus(ref common.Referen Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.Time"), }, }, + "jwksSecret": { + SchemaProps: spec.SchemaProps{ + Description: "JWKSSecret holds the name of the secret in which this OIDC Provider's signing/verification keys are stored. If it is empty, then the signing/verification keys are either unknown or they don't exist.", + Ref: ref("k8s.io/api/core/v1.LocalObjectReference"), + }, + }, }, }, }, Dependencies: []string{ - "k8s.io/apimachinery/pkg/apis/meta/v1.Time"}, + "k8s.io/api/core/v1.LocalObjectReference", "k8s.io/apimachinery/pkg/apis/meta/v1.Time"}, } } diff --git a/generated/1.18/crds/config.pinniped.dev_oidcproviderconfigs.yaml b/generated/1.18/crds/config.pinniped.dev_oidcproviderconfigs.yaml index 062dca9f..727f20ec 100644 --- a/generated/1.18/crds/config.pinniped.dev_oidcproviderconfigs.yaml +++ b/generated/1.18/crds/config.pinniped.dev_oidcproviderconfigs.yaml @@ -55,6 +55,17 @@ spec: status: description: Status of the OIDC provider. properties: + jwksSecret: + description: JWKSSecret holds the name of the secret in which this + OIDC Provider's signing/verification keys are stored. If it is empty, + then the signing/verification keys are either unknown or they don't + exist. + properties: + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Add other useful fields. apiVersion, kind, uid?' + type: string + type: object lastUpdateTime: description: LastUpdateTime holds the time at which the Status was last updated. It is a pointer to get around some undesirable behavior diff --git a/generated/1.19/README.adoc b/generated/1.19/README.adoc index 1dc10f4c..2e5b95fa 100644 --- a/generated/1.19/README.adoc +++ b/generated/1.19/README.adoc @@ -151,6 +151,7 @@ OIDCProviderConfigStatus is a struct that describes the actual state of an OIDC | *`status`* __OIDCProviderStatus__ | Status holds an enum that describes the state of this OIDC Provider. Note that this Status can represent success or failure. | *`message`* __string__ | Message provides human-readable details about the Status. | *`lastUpdateTime`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.19/#time-v1-meta[$$Time$$]__ | LastUpdateTime holds the time at which the Status was last updated. It is a pointer to get around some undesirable behavior with respect to the empty metav1.Time value (see https://github.com/kubernetes/kubernetes/issues/86811). +| *`jwksSecret`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.19/#localobjectreference-v1-core[$$LocalObjectReference$$]__ | JWKSSecret holds the name of the secret in which this OIDC Provider's signing/verification keys are stored. If it is empty, then the signing/verification keys are either unknown or they don't exist. |=== diff --git a/generated/1.19/apis/config/v1alpha1/types_oidcproviderconfig.go b/generated/1.19/apis/config/v1alpha1/types_oidcproviderconfig.go index a05f4339..b8b34560 100644 --- a/generated/1.19/apis/config/v1alpha1/types_oidcproviderconfig.go +++ b/generated/1.19/apis/config/v1alpha1/types_oidcproviderconfig.go @@ -3,7 +3,10 @@ package v1alpha1 -import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +import ( + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) // +kubebuilder:validation:Enum=Success;Duplicate;Invalid type OIDCProviderStatus string @@ -39,11 +42,17 @@ type OIDCProviderConfigStatus struct { // +optional Message string `json:"message,omitempty"` - // LastUpdateTime holds the time at which the Status was last updated. It is a pointer to get - // around some undesirable behavior with respect to the empty metav1.Time value (see - // https://github.com/kubernetes/kubernetes/issues/86811). - // +optional - LastUpdateTime *metav1.Time `json:"lastUpdateTime,omitempty"` + // LastUpdateTime holds the time at which the Status was last updated. It is a pointer to get + // around some undesirable behavior with respect to the empty metav1.Time value (see + // https://github.com/kubernetes/kubernetes/issues/86811). + // +optional + LastUpdateTime *metav1.Time `json:"lastUpdateTime,omitempty"` + + // JWKSSecret holds the name of the secret in which this OIDC Provider's signing/verification keys + // are stored. If it is empty, then the signing/verification keys are either unknown or they don't + // exist. + // +optional + JWKSSecret corev1.LocalObjectReference `json:"jwksSecret,omitempty"` } // OIDCProviderConfig describes the configuration of an OIDC provider. diff --git a/generated/1.19/apis/config/v1alpha1/zz_generated.deepcopy.go b/generated/1.19/apis/config/v1alpha1/zz_generated.deepcopy.go index 262992cb..e2522c24 100644 --- a/generated/1.19/apis/config/v1alpha1/zz_generated.deepcopy.go +++ b/generated/1.19/apis/config/v1alpha1/zz_generated.deepcopy.go @@ -216,6 +216,7 @@ func (in *OIDCProviderConfigStatus) DeepCopyInto(out *OIDCProviderConfigStatus) in, out := &in.LastUpdateTime, &out.LastUpdateTime *out = (*in).DeepCopy() } + out.JWKSSecret = in.JWKSSecret return } diff --git a/generated/1.19/client/openapi/zz_generated.openapi.go b/generated/1.19/client/openapi/zz_generated.openapi.go index 277a97cb..dd6e0519 100644 --- a/generated/1.19/client/openapi/zz_generated.openapi.go +++ b/generated/1.19/client/openapi/zz_generated.openapi.go @@ -432,11 +432,17 @@ func schema_119_apis_config_v1alpha1_OIDCProviderConfigStatus(ref common.Referen Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.Time"), }, }, + "jwksSecret": { + SchemaProps: spec.SchemaProps{ + Description: "JWKSSecret holds the name of the secret in which this OIDC Provider's signing/verification keys are stored. If it is empty, then the signing/verification keys are either unknown or they don't exist.", + Ref: ref("k8s.io/api/core/v1.LocalObjectReference"), + }, + }, }, }, }, Dependencies: []string{ - "k8s.io/apimachinery/pkg/apis/meta/v1.Time"}, + "k8s.io/api/core/v1.LocalObjectReference", "k8s.io/apimachinery/pkg/apis/meta/v1.Time"}, } } diff --git a/generated/1.19/crds/config.pinniped.dev_oidcproviderconfigs.yaml b/generated/1.19/crds/config.pinniped.dev_oidcproviderconfigs.yaml index 062dca9f..727f20ec 100644 --- a/generated/1.19/crds/config.pinniped.dev_oidcproviderconfigs.yaml +++ b/generated/1.19/crds/config.pinniped.dev_oidcproviderconfigs.yaml @@ -55,6 +55,17 @@ spec: status: description: Status of the OIDC provider. properties: + jwksSecret: + description: JWKSSecret holds the name of the secret in which this + OIDC Provider's signing/verification keys are stored. If it is empty, + then the signing/verification keys are either unknown or they don't + exist. + properties: + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Add other useful fields. apiVersion, kind, uid?' + type: string + type: object lastUpdateTime: description: LastUpdateTime holds the time at which the Status was last updated. It is a pointer to get around some undesirable behavior diff --git a/go.mod b/go.mod index a3f63b6b..6197d0e3 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( go.pinniped.dev/generated/1.19/client v0.0.0-00010101000000-000000000000 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6 + gopkg.in/square/go-jose.v2 v2.5.1 k8s.io/api v0.19.2 k8s.io/apimachinery v0.19.2 k8s.io/apiserver v0.19.2 diff --git a/go.sum b/go.sum index dae855b9..21f57d39 100644 --- a/go.sum +++ b/go.sum @@ -842,6 +842,8 @@ gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24 gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= gopkg.in/square/go-jose.v2 v2.2.2 h1:orlkJ3myw8CN1nVQHBFfloD+L3egixIa4FvUP6RosSA= gopkg.in/square/go-jose.v2 v2.2.2/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= +gopkg.in/square/go-jose.v2 v2.5.1 h1:7odma5RETjNHWJnR32wx8t+Io4djHE1PqxCFx3iiZ2w= +gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= diff --git a/internal/controller/supervisorconfig/jwks.go b/internal/controller/supervisorconfig/jwks.go new file mode 100644 index 00000000..f2b730ba --- /dev/null +++ b/internal/controller/supervisorconfig/jwks.go @@ -0,0 +1,332 @@ +// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package supervisorconfig + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/json" + "fmt" + + "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/config/v1alpha1" + pinnipedclientset "go.pinniped.dev/generated/1.19/client/clientset/versioned" + configinformers "go.pinniped.dev/generated/1.19/client/informers/externalversions/config/v1alpha1" + pinnipedcontroller "go.pinniped.dev/internal/controller" + "go.pinniped.dev/internal/controllerlib" +) + +// These constants are the keys in an OPC'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" + // validJWKSKey points to the current JWKS used to verify tokens. + // + // Note! The value for this key will contain only public key material! + validJWKSKey = "validJWKS" +) + +const ( + opcKind = "OIDCProviderConfig" +) + +// jwkController holds the field necessary for the JWKS controller to communicate with OPC's and +// secrets, both via a cache and via the API. +type jwksController struct { + pinnipedClient pinnipedclientset.Interface + kubeClient kubernetes.Interface + opcInformer configinformers.OIDCProviderConfigInformer + secretInformer corev1informers.SecretInformer +} + +// NewJWKSController returns a controllerlib.Controller that ensures an OPC has a corresponding +// Secret that contains a valid active JWK and JWKS. +func NewJWKSController( + kubeClient kubernetes.Interface, + pinnipedClient pinnipedclientset.Interface, + secretInformer corev1informers.SecretInformer, + opcInformer configinformers.OIDCProviderConfigInformer, + withInformer pinnipedcontroller.WithInformerOptionFunc, +) controllerlib.Controller { + return controllerlib.New( + controllerlib.Config{ + Name: "JWKSController", + Syncer: &jwksController{ + kubeClient: kubeClient, + pinnipedClient: pinnipedClient, + secretInformer: secretInformer, + opcInformer: opcInformer, + }, + }, + // We want to be notified when a OPC's secret gets updated or deleted. When this happens, we + // should get notified via the corresponding OPC key. + withInformer( + secretInformer, + controllerlib.FilterFuncs{ + ParentFunc: func(obj metav1.Object) controllerlib.Key { + if isOPCControllee(obj) { + controller := metav1.GetControllerOf(obj) + return controllerlib.Key{ + Name: controller.Name, + Namespace: obj.GetNamespace(), + } + } + return controllerlib.Key{} + }, + AddFunc: func(obj metav1.Object) bool { + return false + }, + UpdateFunc: func(oldObj, newObj metav1.Object) bool { + return isOPCControllee(oldObj) || isOPCControllee(newObj) + }, + DeleteFunc: func(obj metav1.Object) bool { + return isOPCControllee(obj) + }, + }, + controllerlib.InformerOption{}, + ), + // We want to be notified when anything happens to an OPC. + withInformer( + opcInformer, + pinnipedcontroller.NoOpFilter(), + controllerlib.InformerOption{}, + ), + ) +} + +// Sync implements controllerlib.Syncer. +func (c *jwksController) Sync(ctx controllerlib.Context) error { + opc, err := c.opcInformer.Lister().OIDCProviderConfigs(ctx.Key.Namespace).Get(ctx.Key.Name) + notFound := k8serrors.IsNotFound(err) + if err != nil && !notFound { + return fmt.Errorf( + "failed to get %s/%s OIDCProviderConfig: %w", + ctx.Key.Namespace, + ctx.Key.Name, + err, + ) + } + + if notFound { + // The corresponding secret to this OPC should have been garbage collected since it should have + // had this OPC as its owner. + klog.InfoS( + "oidcproviderconfig deleted", + "oidcproviderconfig", + klog.KRef(ctx.Key.Namespace, ctx.Key.Name), + ) + return nil + } + + var secret *corev1.Secret + if opc.Status.JWKSSecret.Name != "" { + // This OPC says it has a secret associated with it. Let's try to get it from the cache. + secret, err = c.secretInformer.Lister().Secrets(opc.Namespace).Get(opc.Status.JWKSSecret.Name) + if err != nil && !k8serrors.IsNotFound(err) { + return fmt.Errorf("cannot get secret: %w", err) + } + } + + if secret == nil { + // If the OPC does not have a secret associated with it, or that secret does not exist, we will + // generate a new secret (i.e., a JWKS). + secret, err = c.generateSecret(opc) + if err != nil { + return fmt.Errorf("cannot wire secret: %w", err) + } + } + + // Ensure that the secret exists and looks like it should. + if err := c.createOrUpdateSecret(ctx.Context, secret); err != nil { + return fmt.Errorf("cannot create or update secret: %w", err) + } + klog.InfoS("created/updated secret", "secret", klog.KObj(secret)) + + // Ensure that the OPC points to the secret. + newOPC := opc.DeepCopy() + newOPC.Status.JWKSSecret.Name = secret.Name + if err := c.updateOPC(ctx.Context, newOPC); err != nil { + return fmt.Errorf("cannot update opc: %w", err) + } + klog.InfoS("updated oidcproviderconfig", "oidcproviderconfig", klog.KObj(newOPC)) + + return nil +} + +func (c *jwksController) generateSecret(opc *configv1alpha1.OIDCProviderConfig) (*corev1.Secret, error) { + // Note! This is where we could potentially add more handling of OPC spec fields which tell us how + // this OIDC provider 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. + + privateKey, err := rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + return nil, fmt.Errorf("cannot generate key: %w", err) + } + + jwk := jose.JSONWebKey{ + Key: privateKey, + KeyID: "some-key", + Algorithm: "RS256", + 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}, + } + jwksData, err := json.Marshal(jwks) + if err != nil { + return nil, fmt.Errorf("cannot marshal jwks: %w", err) + } + + s := corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: opc.Name + "-jwks", + Namespace: opc.Namespace, + OwnerReferences: []metav1.OwnerReference{ + *metav1.NewControllerRef(opc, schema.GroupVersionKind{ + Group: configv1alpha1.SchemeGroupVersion.Group, + Version: configv1alpha1.SchemeGroupVersion.Version, + Kind: opcKind, + }), + }, + // TODO: custom labels. + }, + Data: map[string][]byte{ + activeJWKKey: jwkData, + validJWKSKey: jwksData, + }, + } + + return &s, nil +} + +func (c *jwksController) 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 + _, err = secretClient.Update(ctx, oldSecret, metav1.UpdateOptions{}) + return err + }) +} + +func (c *jwksController) updateOPC( + ctx context.Context, + newOPC *configv1alpha1.OIDCProviderConfig, +) error { + opcClient := c.pinnipedClient.ConfigV1alpha1().OIDCProviderConfigs(newOPC.Namespace) + return retry.RetryOnConflict(retry.DefaultRetry, func() error { + oldOPC, err := opcClient.Get(ctx, newOPC.Name, metav1.GetOptions{}) + notFound := k8serrors.IsNotFound(err) + if err != nil && !notFound { + return fmt.Errorf("cannot get secret: %w", err) + } + + if newOPC.Status.JWKSSecret.Name == oldOPC.Status.JWKSSecret.Name { + // If the existing OPC is up to date, we don't need to update it. + return nil + } + + oldOPC.Status.JWKSSecret.Name = newOPC.Status.JWKSSecret.Name + _, err = opcClient.Update(ctx, oldOPC, metav1.UpdateOptions{}) + return err + }) +} + +// isOPCControlle returns whether the provided obj is controlled by an OPC. +func isOPCControllee(obj metav1.Object) bool { + controller := metav1.GetControllerOf(obj) + return controller != nil && controller.Kind == opcKind +} + +// isValid returns whether the provided secret contains a valid active JWK and verification JWKS. +func isValid(secret *corev1.Secret) bool { + jwkData, ok := secret.Data[activeJWKKey] + if !ok { + klog.InfoS("secret does not contain active jwk") + return false + } + + var activeJWK jose.JSONWebKey + if err := json.Unmarshal(jwkData, &activeJWK); err != nil { + klog.InfoS("cannot unmarshal active jwk", "err", err) + return false + } + + if !activeJWK.Valid() { + klog.InfoS("active jwk is not valid", "keyid", activeJWK.KeyID) + return false + } + + jwksData, ok := secret.Data[validJWKSKey] + if !ok { + klog.InfoS("secret does not contain valid jwks") + } + + var validJWKS jose.JSONWebKeySet + if err := json.Unmarshal(jwksData, &validJWKS); err != nil { + klog.InfoS("cannot unmarshal valid jwks", "err", err) + return false + } + + foundActiveJWK := false + for _, validJWK := range validJWKS.Keys { + if !validJWK.Valid() { + klog.InfoS("jwks key is not valid", "keyid", validJWK.KeyID) + return false + } + if validJWK.KeyID == activeJWK.KeyID { + foundActiveJWK = true + } + } + + if !foundActiveJWK { + klog.InfoS("did not find active jwk in valid jwks", "keyid", activeJWK.KeyID) + return false + } + + return true +} diff --git a/internal/controller/supervisorconfig/jwks_test.go b/internal/controller/supervisorconfig/jwks_test.go new file mode 100644 index 00000000..7770a4e2 --- /dev/null +++ b/internal/controller/supervisorconfig/jwks_test.go @@ -0,0 +1,9 @@ +// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package supervisorconfig + +import "testing" + +func TestJWKSControllerSync(t *testing.T) { +}