82ae98d9d0
We believe this API is more forwards compatible with future secrets management use cases. The implementation is a cry for help, but I was trying to follow the previously established pattern of encapsulating the secret generation functionality to a single group of packages. This commit makes a breaking change to the current OIDCProvider API, but that OIDCProvider API was added after the latest release, so it is technically still in development until we release, and therefore we can continue to thrash on it. I also took this opportunity to make some things private that didn't need to be public. Signed-off-by: Andrew Keesler <akeesler@vmware.com>
373 lines
14 KiB
Go
373 lines
14 KiB
Go
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package supervisorconfig
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/sclevine/spec"
|
|
"github.com/sclevine/spec/report"
|
|
"github.com/stretchr/testify/require"
|
|
"gopkg.in/square/go-jose.v2"
|
|
corev1 "k8s.io/api/core/v1"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
kubeinformers "k8s.io/client-go/informers"
|
|
kubernetesfake "k8s.io/client-go/kubernetes/fake"
|
|
|
|
"go.pinniped.dev/generated/1.19/apis/supervisor/config/v1alpha1"
|
|
pinnipedfake "go.pinniped.dev/generated/1.19/client/supervisor/clientset/versioned/fake"
|
|
pinnipedinformers "go.pinniped.dev/generated/1.19/client/supervisor/informers/externalversions"
|
|
"go.pinniped.dev/internal/controllerlib"
|
|
"go.pinniped.dev/internal/testutil"
|
|
)
|
|
|
|
func TestJWKSObserverControllerInformerFilters(t *testing.T) {
|
|
spec.Run(t, "informer filters", func(t *testing.T, when spec.G, it spec.S) {
|
|
var (
|
|
r *require.Assertions
|
|
observableWithInformerOption *testutil.ObservableWithInformerOption
|
|
secretsInformerFilter controllerlib.Filter
|
|
oidcProviderInformerFilter controllerlib.Filter
|
|
)
|
|
|
|
it.Before(func() {
|
|
r = require.New(t)
|
|
observableWithInformerOption = testutil.NewObservableWithInformerOption()
|
|
secretsInformer := kubeinformers.NewSharedInformerFactory(nil, 0).Core().V1().Secrets()
|
|
oidcProviderInformer := pinnipedinformers.NewSharedInformerFactory(nil, 0).Config().V1alpha1().OIDCProviders()
|
|
_ = NewJWKSObserverController(
|
|
nil,
|
|
secretsInformer,
|
|
oidcProviderInformer,
|
|
observableWithInformerOption.WithInformer, // make it possible to observe the behavior of the Filters
|
|
)
|
|
secretsInformerFilter = observableWithInformerOption.GetFilterForInformer(secretsInformer)
|
|
oidcProviderInformerFilter = observableWithInformerOption.GetFilterForInformer(oidcProviderInformer)
|
|
})
|
|
|
|
when("watching Secret objects", func() {
|
|
var (
|
|
subject controllerlib.Filter
|
|
secret, otherSecret *corev1.Secret
|
|
)
|
|
|
|
it.Before(func() {
|
|
subject = secretsInformerFilter
|
|
secret = &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "any-name", Namespace: "any-namespace"}}
|
|
otherSecret = &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "any-other-name", Namespace: "any-other-namespace"}}
|
|
})
|
|
|
|
when("any Secret changes", func() {
|
|
it("returns true to trigger the sync method", func() {
|
|
r.True(subject.Add(secret))
|
|
r.True(subject.Update(secret, otherSecret))
|
|
r.True(subject.Update(otherSecret, secret))
|
|
r.True(subject.Delete(secret))
|
|
})
|
|
})
|
|
})
|
|
|
|
when("watching OIDCProvider objects", func() {
|
|
var (
|
|
subject controllerlib.Filter
|
|
provider, otherProvider *v1alpha1.OIDCProvider
|
|
)
|
|
|
|
it.Before(func() {
|
|
subject = oidcProviderInformerFilter
|
|
provider = &v1alpha1.OIDCProvider{ObjectMeta: metav1.ObjectMeta{Name: "any-name", Namespace: "any-namespace"}}
|
|
otherProvider = &v1alpha1.OIDCProvider{ObjectMeta: metav1.ObjectMeta{Name: "any-other-name", Namespace: "any-other-namespace"}}
|
|
})
|
|
|
|
when("any OIDCProvider changes", func() {
|
|
it("returns true to trigger the sync method", func() {
|
|
r.True(subject.Add(provider))
|
|
r.True(subject.Update(provider, otherProvider))
|
|
r.True(subject.Update(otherProvider, provider))
|
|
r.True(subject.Delete(provider))
|
|
})
|
|
})
|
|
})
|
|
}, spec.Parallel(), spec.Report(report.Terminal{}))
|
|
}
|
|
|
|
type fakeIssuerToJWKSMapSetter struct {
|
|
setIssuerToJWKSMapWasCalled bool
|
|
issuerToJWKSMapReceived map[string]*jose.JSONWebKeySet
|
|
issuerToActiveJWKMapReceived map[string]*jose.JSONWebKey
|
|
}
|
|
|
|
func (f *fakeIssuerToJWKSMapSetter) SetIssuerToJWKSMap(
|
|
issuerToJWKSMap map[string]*jose.JSONWebKeySet,
|
|
issuerToActiveJWKMap map[string]*jose.JSONWebKey,
|
|
) {
|
|
f.setIssuerToJWKSMapWasCalled = true
|
|
f.issuerToJWKSMapReceived = issuerToJWKSMap
|
|
f.issuerToActiveJWKMapReceived = issuerToActiveJWKMap
|
|
}
|
|
|
|
func TestJWKSObserverControllerSync(t *testing.T) {
|
|
spec.Run(t, "Sync", func(t *testing.T, when spec.G, it spec.S) {
|
|
const installedInNamespace = "some-namespace"
|
|
|
|
var (
|
|
r *require.Assertions
|
|
subject controllerlib.Controller
|
|
pinnipedInformerClient *pinnipedfake.Clientset
|
|
kubeInformerClient *kubernetesfake.Clientset
|
|
pinnipedInformers pinnipedinformers.SharedInformerFactory
|
|
kubeInformers kubeinformers.SharedInformerFactory
|
|
timeoutContext context.Context
|
|
timeoutContextCancel context.CancelFunc
|
|
syncContext *controllerlib.Context
|
|
issuerToJWKSSetter *fakeIssuerToJWKSMapSetter
|
|
)
|
|
|
|
// Defer starting the informers until the last possible moment so that the
|
|
// nested Before's can keep adding things to the informer caches.
|
|
var startInformersAndController = func() {
|
|
// Set this at the last second to allow for injection of server override.
|
|
subject = NewJWKSObserverController(
|
|
issuerToJWKSSetter,
|
|
kubeInformers.Core().V1().Secrets(),
|
|
pinnipedInformers.Config().V1alpha1().OIDCProviders(),
|
|
controllerlib.WithInformer,
|
|
)
|
|
|
|
// Set this at the last second to support calling subject.Name().
|
|
syncContext = &controllerlib.Context{
|
|
Context: timeoutContext,
|
|
Name: subject.Name(),
|
|
Key: controllerlib.Key{
|
|
Namespace: installedInNamespace,
|
|
Name: "any-name",
|
|
},
|
|
}
|
|
|
|
// Must start informers before calling TestRunSynchronously()
|
|
kubeInformers.Start(timeoutContext.Done())
|
|
pinnipedInformers.Start(timeoutContext.Done())
|
|
controllerlib.TestRunSynchronously(t, subject)
|
|
}
|
|
|
|
it.Before(func() {
|
|
r = require.New(t)
|
|
|
|
timeoutContext, timeoutContextCancel = context.WithTimeout(context.Background(), time.Second*3)
|
|
|
|
kubeInformerClient = kubernetesfake.NewSimpleClientset()
|
|
kubeInformers = kubeinformers.NewSharedInformerFactory(kubeInformerClient, 0)
|
|
pinnipedInformerClient = pinnipedfake.NewSimpleClientset()
|
|
pinnipedInformers = pinnipedinformers.NewSharedInformerFactory(pinnipedInformerClient, 0)
|
|
issuerToJWKSSetter = &fakeIssuerToJWKSMapSetter{}
|
|
|
|
unrelatedSecret := &corev1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "some other unrelated secret",
|
|
Namespace: installedInNamespace,
|
|
},
|
|
}
|
|
r.NoError(kubeInformerClient.Tracker().Add(unrelatedSecret))
|
|
})
|
|
|
|
it.After(func() {
|
|
timeoutContextCancel()
|
|
})
|
|
|
|
when("there are no OIDCProviders and no JWKS Secrets yet", func() {
|
|
it("sets the issuerToJWKSSetter's map to be empty", func() {
|
|
startInformersAndController()
|
|
err := controllerlib.TestSync(t, subject, *syncContext)
|
|
r.NoError(err)
|
|
|
|
r.True(issuerToJWKSSetter.setIssuerToJWKSMapWasCalled)
|
|
r.Empty(issuerToJWKSSetter.issuerToJWKSMapReceived)
|
|
r.Empty(issuerToJWKSSetter.issuerToActiveJWKMapReceived)
|
|
})
|
|
})
|
|
|
|
when("there are OIDCProviders where some have corresponding JWKS Secrets and some don't", func() {
|
|
var (
|
|
expectedJWK1, expectedJWK2 string
|
|
)
|
|
|
|
it.Before(func() {
|
|
oidcProviderWithoutSecret1 := &v1alpha1.OIDCProvider{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "no-secret-oidcprovider1",
|
|
Namespace: installedInNamespace,
|
|
},
|
|
Spec: v1alpha1.OIDCProviderSpec{Issuer: "https://no-secret-issuer1.com"},
|
|
Status: v1alpha1.OIDCProviderStatus{}, // no Secrets.JWKS field
|
|
}
|
|
oidcProviderWithoutSecret2 := &v1alpha1.OIDCProvider{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "no-secret-oidcprovider2",
|
|
Namespace: installedInNamespace,
|
|
},
|
|
Spec: v1alpha1.OIDCProviderSpec{Issuer: "https://no-secret-issuer2.com"},
|
|
// no Status field
|
|
}
|
|
oidcProviderWithBadSecret := &v1alpha1.OIDCProvider{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "bad-secret-oidcprovider",
|
|
Namespace: installedInNamespace,
|
|
},
|
|
Spec: v1alpha1.OIDCProviderSpec{Issuer: "https://bad-secret-issuer.com"},
|
|
Status: v1alpha1.OIDCProviderStatus{
|
|
Secrets: v1alpha1.OIDCProviderSecrets{
|
|
JWKS: corev1.LocalObjectReference{Name: "bad-secret-name"},
|
|
},
|
|
},
|
|
}
|
|
oidcProviderWithBadJWKSSecret := &v1alpha1.OIDCProvider{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "bad-jwks-secret-oidcprovider",
|
|
Namespace: installedInNamespace,
|
|
},
|
|
Spec: v1alpha1.OIDCProviderSpec{Issuer: "https://bad-jwks-secret-issuer.com"},
|
|
Status: v1alpha1.OIDCProviderStatus{
|
|
Secrets: v1alpha1.OIDCProviderSecrets{
|
|
JWKS: corev1.LocalObjectReference{Name: "bad-jwks-secret-name"},
|
|
},
|
|
},
|
|
}
|
|
oidcProviderWithBadActiveJWKSecret := &v1alpha1.OIDCProvider{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "bad-active-jwk-secret-oidcprovider",
|
|
Namespace: installedInNamespace,
|
|
},
|
|
Spec: v1alpha1.OIDCProviderSpec{Issuer: "https://bad-active-jwk-secret-issuer.com"},
|
|
Status: v1alpha1.OIDCProviderStatus{
|
|
Secrets: v1alpha1.OIDCProviderSecrets{
|
|
JWKS: corev1.LocalObjectReference{Name: "bad-active-jwk-secret-name"},
|
|
},
|
|
},
|
|
}
|
|
oidcProviderWithGoodSecret1 := &v1alpha1.OIDCProvider{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "good-secret-oidcprovider1",
|
|
Namespace: installedInNamespace,
|
|
},
|
|
Spec: v1alpha1.OIDCProviderSpec{Issuer: "https://issuer-with-good-secret1.com"},
|
|
Status: v1alpha1.OIDCProviderStatus{
|
|
Secrets: v1alpha1.OIDCProviderSecrets{
|
|
JWKS: corev1.LocalObjectReference{Name: "good-jwks-secret-name1"},
|
|
},
|
|
},
|
|
}
|
|
oidcProviderWithGoodSecret2 := &v1alpha1.OIDCProvider{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "good-secret-oidcprovider2",
|
|
Namespace: installedInNamespace,
|
|
},
|
|
Spec: v1alpha1.OIDCProviderSpec{Issuer: "https://issuer-with-good-secret2.com"},
|
|
Status: v1alpha1.OIDCProviderStatus{
|
|
Secrets: v1alpha1.OIDCProviderSecrets{
|
|
JWKS: corev1.LocalObjectReference{Name: "good-jwks-secret-name2"},
|
|
},
|
|
},
|
|
}
|
|
expectedJWK1 = string(readJWKJSON(t, "testdata/public-jwk.json"))
|
|
r.NotEmpty(expectedJWK1)
|
|
expectedJWK2 = string(readJWKJSON(t, "testdata/public-jwk2.json"))
|
|
r.NotEmpty(expectedJWK2)
|
|
goodJWKSSecret1 := &corev1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "good-jwks-secret-name1",
|
|
Namespace: installedInNamespace,
|
|
},
|
|
Data: map[string][]byte{
|
|
"activeJWK": []byte(expectedJWK1),
|
|
"jwks": []byte(`{"keys": [` + expectedJWK1 + `]}`),
|
|
},
|
|
}
|
|
goodJWKSSecret2 := &corev1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "good-jwks-secret-name2",
|
|
Namespace: installedInNamespace,
|
|
},
|
|
Data: map[string][]byte{
|
|
"activeJWK": []byte(expectedJWK2),
|
|
"jwks": []byte(`{"keys": [` + expectedJWK2 + `]}`),
|
|
},
|
|
}
|
|
badSecret := &corev1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "bad-secret-name",
|
|
Namespace: installedInNamespace,
|
|
},
|
|
Data: map[string][]byte{"junk": nil},
|
|
}
|
|
badJWKSSecret := &corev1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "bad-jwks-secret-name",
|
|
Namespace: installedInNamespace,
|
|
},
|
|
Data: map[string][]byte{
|
|
"activeJWK": []byte(expectedJWK2),
|
|
"jwks": []byte("bad"),
|
|
},
|
|
}
|
|
badActiveJWKSecret := &corev1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "bad-active-jwk-secret-name",
|
|
Namespace: installedInNamespace,
|
|
},
|
|
Data: map[string][]byte{
|
|
"activeJWK": []byte("bad"),
|
|
"jwks": []byte(`{"keys": [` + expectedJWK2 + `]}`),
|
|
},
|
|
}
|
|
r.NoError(pinnipedInformerClient.Tracker().Add(oidcProviderWithoutSecret1))
|
|
r.NoError(pinnipedInformerClient.Tracker().Add(oidcProviderWithoutSecret2))
|
|
r.NoError(pinnipedInformerClient.Tracker().Add(oidcProviderWithBadSecret))
|
|
r.NoError(pinnipedInformerClient.Tracker().Add(oidcProviderWithBadJWKSSecret))
|
|
r.NoError(pinnipedInformerClient.Tracker().Add(oidcProviderWithBadActiveJWKSecret))
|
|
r.NoError(pinnipedInformerClient.Tracker().Add(oidcProviderWithGoodSecret1))
|
|
r.NoError(pinnipedInformerClient.Tracker().Add(oidcProviderWithGoodSecret2))
|
|
r.NoError(kubeInformerClient.Tracker().Add(goodJWKSSecret1))
|
|
r.NoError(kubeInformerClient.Tracker().Add(goodJWKSSecret2))
|
|
r.NoError(kubeInformerClient.Tracker().Add(badSecret))
|
|
r.NoError(kubeInformerClient.Tracker().Add(badJWKSSecret))
|
|
r.NoError(kubeInformerClient.Tracker().Add(badActiveJWKSecret))
|
|
})
|
|
|
|
requireJWKSJSON := func(expectedJWKJSON string, actualJWKS *jose.JSONWebKeySet) {
|
|
r.NotNil(actualJWKS)
|
|
r.Len(actualJWKS.Keys, 1)
|
|
actualJWK := actualJWKS.Keys[0]
|
|
actualJWKJSON, err := json.Marshal(actualJWK)
|
|
r.NoError(err)
|
|
r.JSONEq(expectedJWKJSON, string(actualJWKJSON))
|
|
}
|
|
|
|
requireJWKJSON := func(expectedJWKJSON string, actualJWK *jose.JSONWebKey) {
|
|
r.NotNil(actualJWK)
|
|
actualJWKJSON, err := json.Marshal(actualJWK)
|
|
r.NoError(err)
|
|
r.JSONEq(expectedJWKJSON, string(actualJWKJSON))
|
|
}
|
|
|
|
it("updates the issuerToJWKSSetter's map to include only the issuers that had valid JWKS", func() {
|
|
startInformersAndController()
|
|
r.NoError(controllerlib.TestSync(t, subject, *syncContext))
|
|
|
|
r.True(issuerToJWKSSetter.setIssuerToJWKSMapWasCalled)
|
|
r.Len(issuerToJWKSSetter.issuerToJWKSMapReceived, 2)
|
|
r.Len(issuerToJWKSSetter.issuerToActiveJWKMapReceived, 2)
|
|
|
|
// the actual JWK should match the one from the test fixture that was put into the secret
|
|
requireJWKSJSON(expectedJWK1, issuerToJWKSSetter.issuerToJWKSMapReceived["https://issuer-with-good-secret1.com"])
|
|
requireJWKJSON(expectedJWK1, issuerToJWKSSetter.issuerToActiveJWKMapReceived["https://issuer-with-good-secret1.com"])
|
|
requireJWKSJSON(expectedJWK2, issuerToJWKSSetter.issuerToJWKSMapReceived["https://issuer-with-good-secret2.com"])
|
|
requireJWKJSON(expectedJWK2, issuerToJWKSSetter.issuerToActiveJWKMapReceived["https://issuer-with-good-secret2.com"])
|
|
})
|
|
})
|
|
}, spec.Parallel(), spec.Report(report.Terminal{}))
|
|
}
|