supervisor-oidc: checkpoint: controller watches OIDCProviderConfig
Signed-off-by: Andrew Keesler <akeesler@vmware.com>
This commit is contained in:
parent
8a772793b8
commit
019f44982c
@ -6,22 +6,20 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
kubeinformers "k8s.io/client-go/informers"
|
|
||||||
"k8s.io/client-go/kubernetes"
|
|
||||||
"k8s.io/client-go/pkg/version"
|
"k8s.io/client-go/pkg/version"
|
||||||
"k8s.io/client-go/rest"
|
"k8s.io/client-go/rest"
|
||||||
restclient "k8s.io/client-go/rest"
|
restclient "k8s.io/client-go/rest"
|
||||||
"k8s.io/component-base/logs"
|
"k8s.io/component-base/logs"
|
||||||
"k8s.io/klog/v2"
|
"k8s.io/klog/v2"
|
||||||
"sigs.k8s.io/yaml"
|
|
||||||
|
|
||||||
|
pinnipedclientset "go.pinniped.dev/generated/1.19/client/clientset/versioned"
|
||||||
|
pinnipedinformers "go.pinniped.dev/generated/1.19/client/informers/externalversions"
|
||||||
"go.pinniped.dev/internal/controller/supervisorconfig"
|
"go.pinniped.dev/internal/controller/supervisorconfig"
|
||||||
"go.pinniped.dev/internal/controllerlib"
|
"go.pinniped.dev/internal/controllerlib"
|
||||||
"go.pinniped.dev/internal/downward"
|
"go.pinniped.dev/internal/downward"
|
||||||
@ -69,63 +67,57 @@ func waitForSignal() os.Signal {
|
|||||||
func startControllers(
|
func startControllers(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
issuerProvider *issuerprovider.Provider,
|
issuerProvider *issuerprovider.Provider,
|
||||||
kubeClient kubernetes.Interface,
|
pinnipedInformers pinnipedinformers.SharedInformerFactory,
|
||||||
kubeInformers kubeinformers.SharedInformerFactory,
|
|
||||||
serverInstallationNamespace string,
|
|
||||||
staticConfig StaticConfig,
|
|
||||||
) {
|
) {
|
||||||
// Create controller manager.
|
// Create controller manager.
|
||||||
controllerManager := controllerlib.
|
controllerManager := controllerlib.
|
||||||
NewManager().
|
NewManager().
|
||||||
WithController(
|
WithController(
|
||||||
supervisorconfig.NewDynamicConfigWatcherController(
|
supervisorconfig.NewDynamicConfigWatcherController(
|
||||||
serverInstallationNamespace,
|
|
||||||
staticConfig.NamesConfig.DynamicConfigMap,
|
|
||||||
issuerProvider,
|
issuerProvider,
|
||||||
kubeClient,
|
pinnipedInformers.Config().V1alpha1().OIDCProviderConfigs(),
|
||||||
kubeInformers.Core().V1().ConfigMaps(),
|
|
||||||
controllerlib.WithInformer,
|
controllerlib.WithInformer,
|
||||||
),
|
),
|
||||||
singletonWorker,
|
singletonWorker,
|
||||||
)
|
)
|
||||||
|
|
||||||
kubeInformers.Start(ctx.Done())
|
pinnipedInformers.Start(ctx.Done())
|
||||||
|
|
||||||
go controllerManager.Start(ctx)
|
go controllerManager.Start(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newK8sClient() (kubernetes.Interface, error) {
|
func newPinnipedClient() (pinnipedclientset.Interface, error) {
|
||||||
kubeConfig, err := restclient.InClusterConfig()
|
kubeConfig, err := restclient.InClusterConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("could not load in-cluster configuration: %w", err)
|
return nil, fmt.Errorf("could not load in-cluster configuration: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect to the core Kubernetes API.
|
// Connect to the core Kubernetes API.
|
||||||
kubeClient, err := kubernetes.NewForConfig(kubeConfig)
|
pinnipedClient, err := pinnipedclientset.NewForConfig(kubeConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("could not load in-cluster configuration: %w", err)
|
return nil, fmt.Errorf("could not load in-cluster configuration: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return kubeClient, nil
|
return pinnipedClient, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func run(serverInstallationNamespace string, staticConfig StaticConfig) error {
|
func run(serverInstallationNamespace string) error {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
kubeClient, err := newK8sClient()
|
pinnipedClient, err := newPinnipedClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot create k8s client: %w", err)
|
return fmt.Errorf("cannot create k8s client: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
kubeInformers := kubeinformers.NewSharedInformerFactoryWithOptions(
|
pinnipedInformers := pinnipedinformers.NewSharedInformerFactoryWithOptions(
|
||||||
kubeClient,
|
pinnipedClient,
|
||||||
defaultResyncInterval,
|
defaultResyncInterval,
|
||||||
kubeinformers.WithNamespace(serverInstallationNamespace),
|
pinnipedinformers.WithNamespace(serverInstallationNamespace),
|
||||||
)
|
)
|
||||||
|
|
||||||
issuerProvider := issuerprovider.New()
|
issuerProvider := issuerprovider.New()
|
||||||
startControllers(ctx, issuerProvider, kubeClient, kubeInformers, serverInstallationNamespace, staticConfig)
|
startControllers(ctx, issuerProvider, pinnipedInformers)
|
||||||
|
|
||||||
//nolint: gosec // Intentionally binding to all network interfaces.
|
//nolint: gosec // Intentionally binding to all network interfaces.
|
||||||
l, err := net.Listen("tcp", ":80")
|
l, err := net.Listen("tcp", ":80")
|
||||||
@ -143,14 +135,6 @@ func run(serverInstallationNamespace string, staticConfig StaticConfig) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type StaticConfig struct {
|
|
||||||
NamesConfig NamesConfigSpec `json:"names"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type NamesConfigSpec struct {
|
|
||||||
DynamicConfigMap string `json:"dynamicConfigMap"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
logs.InitLogs()
|
logs.InitLogs()
|
||||||
defer logs.FlushLogs()
|
defer logs.FlushLogs()
|
||||||
@ -164,17 +148,7 @@ func main() {
|
|||||||
klog.Fatal(fmt.Errorf("could not read pod metadata: %w", err))
|
klog.Fatal(fmt.Errorf("could not read pod metadata: %w", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read static config.
|
if err := run(podInfo.Namespace); err != nil {
|
||||||
data, err := ioutil.ReadFile(os.Args[2])
|
|
||||||
if err != nil {
|
|
||||||
klog.Fatal(fmt.Errorf("read file: %w", err))
|
|
||||||
}
|
|
||||||
var staticConfig StaticConfig
|
|
||||||
if err := yaml.Unmarshal(data, &staticConfig); err != nil {
|
|
||||||
klog.Fatal(fmt.Errorf("decode yaml: %w", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := run(podInfo.Namespace, staticConfig); err != nil {
|
|
||||||
klog.Fatal(err)
|
klog.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -13,8 +13,8 @@ metadata:
|
|||||||
labels:
|
labels:
|
||||||
app: #@ data.values.app_name
|
app: #@ data.values.app_name
|
||||||
rules:
|
rules:
|
||||||
- apiGroups: [""]
|
- apiGroups: [config.pinniped.dev]
|
||||||
resources: [configmaps]
|
resources: [oidcproviderconfigs]
|
||||||
verbs: [get, list, watch]
|
verbs: [get, list, watch]
|
||||||
---
|
---
|
||||||
kind: RoleBinding
|
kind: RoleBinding
|
||||||
|
@ -5,12 +5,12 @@ package supervisorconfig
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
corev1informers "k8s.io/client-go/informers/core/v1"
|
|
||||||
"k8s.io/client-go/kubernetes"
|
|
||||||
"k8s.io/klog/v2"
|
"k8s.io/klog/v2"
|
||||||
|
|
||||||
|
configinformers "go.pinniped.dev/generated/1.19/client/informers/externalversions/config/v1alpha1"
|
||||||
pinnipedcontroller "go.pinniped.dev/internal/controller"
|
pinnipedcontroller "go.pinniped.dev/internal/controller"
|
||||||
"go.pinniped.dev/internal/controllerlib"
|
"go.pinniped.dev/internal/controllerlib"
|
||||||
)
|
)
|
||||||
@ -22,41 +22,36 @@ const (
|
|||||||
// IssuerSetter can be notified of a valid issuer with its SetIssuer function. If there is no
|
// IssuerSetter can be notified of a valid issuer with its SetIssuer function. If there is no
|
||||||
// longer any valid issuer, then nil can be passed to this interface.
|
// longer any valid issuer, then nil can be passed to this interface.
|
||||||
//
|
//
|
||||||
|
// If the IssuerSetter doesn't like the provided issuer, it can return an error.
|
||||||
|
//
|
||||||
// Implementations of this type should be thread-safe to support calls from multiple goroutines.
|
// Implementations of this type should be thread-safe to support calls from multiple goroutines.
|
||||||
type IssuerSetter interface {
|
type IssuerSetter interface {
|
||||||
SetIssuer(issuer *string)
|
SetIssuer(issuer *url.URL) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type dynamicConfigWatcherController struct {
|
type dynamicConfigWatcherController struct {
|
||||||
configMapName string
|
|
||||||
configMapNamespace string
|
|
||||||
issuerSetter IssuerSetter
|
issuerSetter IssuerSetter
|
||||||
k8sClient kubernetes.Interface
|
opcInformer configinformers.OIDCProviderConfigInformer
|
||||||
configMapInformer corev1informers.ConfigMapInformer
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewDynamicConfigWatcherController creates a controllerlib.Controller that watches
|
||||||
|
// OIDCProviderConfig objects and notifies a callback object of their creation or deletion.
|
||||||
func NewDynamicConfigWatcherController(
|
func NewDynamicConfigWatcherController(
|
||||||
serverInstallationNamespace string,
|
|
||||||
configMapName string,
|
|
||||||
issuerObserver IssuerSetter,
|
issuerObserver IssuerSetter,
|
||||||
k8sClient kubernetes.Interface,
|
opcInformer configinformers.OIDCProviderConfigInformer,
|
||||||
configMapInformer corev1informers.ConfigMapInformer,
|
|
||||||
withInformer pinnipedcontroller.WithInformerOptionFunc,
|
withInformer pinnipedcontroller.WithInformerOptionFunc,
|
||||||
) controllerlib.Controller {
|
) controllerlib.Controller {
|
||||||
return controllerlib.New(
|
return controllerlib.New(
|
||||||
controllerlib.Config{
|
controllerlib.Config{
|
||||||
Name: "DynamicConfigWatcherController",
|
Name: "DynamicConfigWatcherController",
|
||||||
Syncer: &dynamicConfigWatcherController{
|
Syncer: &dynamicConfigWatcherController{
|
||||||
configMapNamespace: serverInstallationNamespace,
|
|
||||||
configMapName: configMapName,
|
|
||||||
issuerSetter: issuerObserver,
|
issuerSetter: issuerObserver,
|
||||||
k8sClient: k8sClient,
|
opcInformer: opcInformer,
|
||||||
configMapInformer: configMapInformer,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
withInformer(
|
withInformer(
|
||||||
configMapInformer,
|
opcInformer,
|
||||||
pinnipedcontroller.NameAndNamespaceExactMatchFilterFactory(configMapName, serverInstallationNamespace),
|
pinnipedcontroller.NoOpFilter(),
|
||||||
controllerlib.InformerOption{},
|
controllerlib.InformerOption{},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@ -69,44 +64,49 @@ func (c *dynamicConfigWatcherController) Sync(ctx controllerlib.Context) error {
|
|||||||
// TODO The discovery endpoint would return an error until all missing configuration options are
|
// TODO The discovery endpoint would return an error until all missing configuration options are
|
||||||
// filled in.
|
// filled in.
|
||||||
|
|
||||||
configMap, err := c.configMapInformer.
|
opc, err := c.opcInformer.
|
||||||
Lister().
|
Lister().
|
||||||
ConfigMaps(c.configMapNamespace).
|
OIDCProviderConfigs(ctx.Key.Namespace).
|
||||||
Get(c.configMapName)
|
Get(ctx.Key.Name)
|
||||||
notFound := k8serrors.IsNotFound(err)
|
notFound := k8serrors.IsNotFound(err)
|
||||||
if err != nil && !notFound {
|
if err != nil && !notFound {
|
||||||
return fmt.Errorf("failed to get %s/%s secret: %w", c.configMapNamespace, c.configMapName, err)
|
return fmt.Errorf("failed to get %s/%s oidcproviderconfig: %w", ctx.Key.Namespace, ctx.Key.Name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if notFound {
|
if notFound {
|
||||||
klog.InfoS(
|
klog.InfoS(
|
||||||
"dynamicConfigWatcherController Sync found no configmap",
|
"dynamicConfigWatcherController Sync found no oidcproviderconfig",
|
||||||
"configmap",
|
"oidcproviderconfig",
|
||||||
klog.KRef(c.configMapNamespace, c.configMapName),
|
klog.KRef(ctx.Key.Namespace, ctx.Key.Name),
|
||||||
)
|
)
|
||||||
c.issuerSetter.SetIssuer(nil)
|
c.issuerSetter.SetIssuer(nil)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
issuer, ok := configMap.Data[issuerConfigMapKey]
|
url, err := url.Parse(opc.Spec.Issuer)
|
||||||
if !ok {
|
if err != nil {
|
||||||
klog.InfoS(
|
klog.InfoS(
|
||||||
"dynamicConfigWatcherController Sync found no issuer",
|
"dynamicConfigWatcherController Sync failed to parse issuer",
|
||||||
"configmap",
|
"err",
|
||||||
klog.KObj(configMap),
|
err,
|
||||||
)
|
)
|
||||||
c.issuerSetter.SetIssuer(nil)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
klog.InfoS(
|
klog.InfoS(
|
||||||
"dynamicConfigWatcherController Sync issuer",
|
"dynamicConfigWatcherController Sync issuer",
|
||||||
"configmap",
|
"oidcproviderconfig",
|
||||||
klog.KObj(configMap),
|
klog.KObj(opc),
|
||||||
"issuer",
|
"issuer",
|
||||||
issuer,
|
url,
|
||||||
)
|
)
|
||||||
c.issuerSetter.SetIssuer(&issuer)
|
if err := c.issuerSetter.SetIssuer(url); err != nil {
|
||||||
|
klog.InfoS(
|
||||||
|
"dynamicConfigWatcherController Sync failed to set issuer",
|
||||||
|
"err",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -8,6 +8,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Metadata holds all fields (that we care about) from the OpenID Provider Metadata section in the
|
// Metadata holds all fields (that we care about) from the OpenID Provider Metadata section in the
|
||||||
@ -30,7 +31,7 @@ type Metadata struct {
|
|||||||
//
|
//
|
||||||
// Implementations of this type should be thread-safe to support calls from multiple goroutines.
|
// Implementations of this type should be thread-safe to support calls from multiple goroutines.
|
||||||
type IssuerGetter interface {
|
type IssuerGetter interface {
|
||||||
GetIssuer() *string
|
GetIssuer() *url.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns an http.Handler that will use information from the provided IssuerGetter to serve an
|
// New returns an http.Handler that will use information from the provided IssuerGetter to serve an
|
||||||
@ -39,22 +40,23 @@ func New(ig IssuerGetter) http.Handler {
|
|||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
issuer := ig.GetIssuer()
|
||||||
|
if issuer == nil {
|
||||||
|
http.Error(w, `{"error": "OIDC discovery not available (unknown issuer)"}`, http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
http.Error(w, `{"error": "Method not allowed (try GET)"}`, http.StatusMethodNotAllowed)
|
http.Error(w, `{"error": "Method not allowed (try GET)"}`, http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
issuer := ig.GetIssuer()
|
issuerURL := issuer.String()
|
||||||
if issuer == nil {
|
|
||||||
http.Error(w, `{"error": "OIDC discovery not available (unknown issuer)"}`, http.StatusServiceUnavailable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
oidcConfig := Metadata{
|
oidcConfig := Metadata{
|
||||||
Issuer: *issuer,
|
Issuer: issuerURL,
|
||||||
AuthorizationEndpoint: fmt.Sprintf("%s/oauth2/v0/auth", *issuer),
|
AuthorizationEndpoint: fmt.Sprintf("%s/oauth2/v0/auth", issuerURL),
|
||||||
TokenEndpoint: fmt.Sprintf("%s/oauth2/v0/token", *issuer),
|
TokenEndpoint: fmt.Sprintf("%s/oauth2/v0/token", issuerURL),
|
||||||
JWKSURL: fmt.Sprintf("%s/oauth2/v0/keys", *issuer),
|
JWKSURL: fmt.Sprintf("%s/oauth2/v0/keys", issuerURL),
|
||||||
ResponseTypesSupported: []string{},
|
ResponseTypesSupported: []string{},
|
||||||
SubjectTypesSupported: []string{},
|
SubjectTypesSupported: []string{},
|
||||||
IDTokenSigningAlgValuesSupported: []string{},
|
IDTokenSigningAlgValuesSupported: []string{},
|
||||||
|
@ -7,6 +7,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@ -18,7 +19,7 @@ func TestDiscovery(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
||||||
issuer string
|
issuer *url.URL
|
||||||
method string
|
method string
|
||||||
|
|
||||||
wantStatus int
|
wantStatus int
|
||||||
@ -26,16 +27,16 @@ func TestDiscovery(t *testing.T) {
|
|||||||
wantBody interface{}
|
wantBody interface{}
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "issuer returns nil issuer",
|
name: "nil issuer",
|
||||||
method: http.MethodGet,
|
method: http.MethodGet,
|
||||||
wantStatus: http.StatusServiceUnavailable,
|
wantStatus: http.StatusNotFound,
|
||||||
wantBody: map[string]string{
|
wantBody: map[string]string{
|
||||||
"error": "OIDC discovery not available (unknown issuer)",
|
"error": "OIDC discovery not available (unknown issuer)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "issuer returns non-nil issuer",
|
name: "issuer without path",
|
||||||
issuer: "https://some-issuer.com",
|
issuer: must(url.Parse("https://some-issuer.com")),
|
||||||
method: http.MethodGet,
|
method: http.MethodGet,
|
||||||
wantStatus: http.StatusOK,
|
wantStatus: http.StatusOK,
|
||||||
wantContentType: "application/json",
|
wantContentType: "application/json",
|
||||||
@ -49,9 +50,25 @@ func TestDiscovery(t *testing.T) {
|
|||||||
IDTokenSigningAlgValuesSupported: []string{},
|
IDTokenSigningAlgValuesSupported: []string{},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "issuer with path",
|
||||||
|
issuer: must(url.Parse("https://some-issuer.com/some/path")),
|
||||||
|
method: http.MethodGet,
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
wantContentType: "application/json",
|
||||||
|
wantBody: &Metadata{
|
||||||
|
Issuer: "https://some-issuer.com/some/path",
|
||||||
|
AuthorizationEndpoint: "https://some-issuer.com/some/path/oauth2/v0/auth",
|
||||||
|
TokenEndpoint: "https://some-issuer.com/some/path/oauth2/v0/token",
|
||||||
|
JWKSURL: "https://some-issuer.com/some/path/oauth2/v0/keys",
|
||||||
|
ResponseTypesSupported: []string{},
|
||||||
|
SubjectTypesSupported: []string{},
|
||||||
|
IDTokenSigningAlgValuesSupported: []string{},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "bad method",
|
name: "bad method",
|
||||||
issuer: "https://some-issuer.com",
|
issuer: must(url.Parse("https://some-issuer.com")),
|
||||||
method: http.MethodPost,
|
method: http.MethodPost,
|
||||||
wantStatus: http.StatusMethodNotAllowed,
|
wantStatus: http.StatusMethodNotAllowed,
|
||||||
wantBody: map[string]string{
|
wantBody: map[string]string{
|
||||||
@ -63,11 +80,7 @@ func TestDiscovery(t *testing.T) {
|
|||||||
test := test
|
test := test
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
p := issuerprovider.New()
|
p := issuerprovider.New()
|
||||||
if test.issuer != "" {
|
p.SetIssuer(test.issuer)
|
||||||
p.SetIssuer(&test.issuer)
|
|
||||||
} else {
|
|
||||||
p.SetIssuer(nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
handler := New(p)
|
handler := New(p)
|
||||||
req := httptest.NewRequest(test.method, "/this/path/shouldnt/matter", nil)
|
req := httptest.NewRequest(test.method, "/this/path/shouldnt/matter", nil)
|
||||||
@ -88,3 +101,10 @@ func TestDiscovery(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func must(u *url.URL, err error) *url.URL {
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
@ -4,14 +4,20 @@
|
|||||||
// Package issuerprovider provides a thread-safe type that can hold on to an OIDC issuer name.
|
// Package issuerprovider provides a thread-safe type that can hold on to an OIDC issuer name.
|
||||||
package issuerprovider
|
package issuerprovider
|
||||||
|
|
||||||
import "sync"
|
import (
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"go.pinniped.dev/internal/constable"
|
||||||
|
)
|
||||||
|
|
||||||
// Provider is a type that can hold onto an issuer value, which may be nil.
|
// Provider is a type that can hold onto an issuer value, which may be nil.
|
||||||
//
|
//
|
||||||
// It is thread-safe.
|
// It is thread-safe.
|
||||||
type Provider struct {
|
type Provider struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
issuer *string
|
issuer *url.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns an empty Provider, i.e., one that holds a nil issuer.
|
// New returns an empty Provider, i.e., one that holds a nil issuer.
|
||||||
@ -19,14 +25,56 @@ func New() *Provider {
|
|||||||
return &Provider{}
|
return &Provider{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Provider) SetIssuer(issuer *string) {
|
// SetIssuer validates and sets the provided issuer. If validation fails, SetIssuer will return
|
||||||
|
// an error.
|
||||||
|
func (p *Provider) SetIssuer(issuer *url.URL) error {
|
||||||
|
if err := p.validateIssuer(issuer); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p.setIssuer(issuer)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) validateIssuer(issuer *url.URL) error {
|
||||||
|
if issuer == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if issuer.Scheme != "https" && removeMeAfterWeNoLongerNeedHTTPIssuerSupport(issuer.Scheme) {
|
||||||
|
return constable.Error(`issuer must have "https" scheme`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if issuer.User != nil {
|
||||||
|
return constable.Error(`issuer must not have username or password`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(issuer.Path, "/") {
|
||||||
|
return constable.Error(`issuer must not have trailing slash in path`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if issuer.RawQuery != "" {
|
||||||
|
return constable.Error(`issuer must not have query`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if issuer.Fragment != "" {
|
||||||
|
return constable.Error(`issuer must not have fragment`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) setIssuer(issuer *url.URL) {
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
defer p.mu.Unlock()
|
defer p.mu.Unlock()
|
||||||
p.issuer = issuer
|
p.issuer = issuer
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Provider) GetIssuer() *string {
|
func (p *Provider) GetIssuer() *url.URL {
|
||||||
p.mu.RLock()
|
p.mu.RLock()
|
||||||
defer p.mu.RUnlock()
|
defer p.mu.RUnlock()
|
||||||
return p.issuer
|
return p.issuer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func removeMeAfterWeNoLongerNeedHTTPIssuerSupport(scheme string) bool {
|
||||||
|
return scheme != "http"
|
||||||
|
}
|
||||||
|
84
internal/oidc/issuerprovider/issuerprovider_test.go
Normal file
84
internal/oidc/issuerprovider/issuerprovider_test.go
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
package issuerprovider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProvider(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
issuer *url.URL
|
||||||
|
wantError string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil issuer",
|
||||||
|
issuer: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no scheme",
|
||||||
|
issuer: must(url.Parse("tuna.com")),
|
||||||
|
wantError: `issuer must have "https" scheme`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bad scheme",
|
||||||
|
issuer: must(url.Parse("ftp://tuna.com")),
|
||||||
|
wantError: `issuer must have "https" scheme`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fragment",
|
||||||
|
issuer: must(url.Parse("https://tuna.com/fish#some-frag")),
|
||||||
|
wantError: `issuer must not have fragment`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "query",
|
||||||
|
issuer: must(url.Parse("https://tuna.com?some=query")),
|
||||||
|
wantError: `issuer must not have query`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "username",
|
||||||
|
issuer: must(url.Parse("https://username@tuna.com")),
|
||||||
|
wantError: `issuer must not have username or password`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "password",
|
||||||
|
issuer: must(url.Parse("https://username:password@tuna.com")),
|
||||||
|
wantError: `issuer must not have username or password`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "without path",
|
||||||
|
issuer: must(url.Parse("https://tuna.com")),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with path",
|
||||||
|
issuer: must(url.Parse("https://tuna.com/fish/marlin")),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trailing slash in path",
|
||||||
|
issuer: must(url.Parse("https://tuna.com/")),
|
||||||
|
wantError: `issuer must not have trailing slash in path`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
p := New()
|
||||||
|
err := p.SetIssuer(tt.issuer)
|
||||||
|
if tt.wantError != "" {
|
||||||
|
require.EqualError(t, err, tt.wantError)
|
||||||
|
require.Nil(t, p.GetIssuer())
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, tt.issuer, p.GetIssuer())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func must(u *url.URL, err error) *url.URL {
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
@ -40,9 +40,13 @@ func TestSupervisorOIDCDiscovery(t *testing.T) {
|
|||||||
|
|
||||||
// When this test has finished, recreate any OIDCProviderConfigs that had existed on the cluster before this test.
|
// When this test has finished, recreate any OIDCProviderConfigs that had existed on the cluster before this test.
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
|
cleanupCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
for _, config := range originalConfigList.Items {
|
for _, config := range originalConfigList.Items {
|
||||||
thisConfig := config
|
thisConfig := config
|
||||||
_, err := client.ConfigV1alpha1().OIDCProviderConfigs(ns).Create(ctx, &thisConfig, metav1.CreateOptions{})
|
thisConfig.ResourceVersion = "" // Get rid of resource version since we can't create an object with one.
|
||||||
|
_, err := client.ConfigV1alpha1().OIDCProviderConfigs(ns).Create(cleanupCtx, &thisConfig, metav1.CreateOptions{})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -64,6 +68,10 @@ func TestSupervisorOIDCDiscovery(t *testing.T) {
|
|||||||
// Create a new OIDCProviderConfig with a known issuer.
|
// Create a new OIDCProviderConfig with a known issuer.
|
||||||
issuer := fmt.Sprintf("http://%s/nested/issuer", env.SupervisorAddress)
|
issuer := fmt.Sprintf("http://%s/nested/issuer", env.SupervisorAddress)
|
||||||
newOIDCProviderConfig := v1alpha1.OIDCProviderConfig{
|
newOIDCProviderConfig := v1alpha1.OIDCProviderConfig{
|
||||||
|
TypeMeta: metav1.TypeMeta{
|
||||||
|
Kind: "OIDCProviderConfig",
|
||||||
|
APIVersion: v1alpha1.SchemeGroupVersion.String(),
|
||||||
|
},
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
Name: "nested-issuser-config-from-integration-test",
|
Name: "nested-issuser-config-from-integration-test",
|
||||||
Namespace: ns,
|
Namespace: ns,
|
||||||
@ -77,7 +85,10 @@ func TestSupervisorOIDCDiscovery(t *testing.T) {
|
|||||||
|
|
||||||
// When this test has finished, clean up the new OIDCProviderConfig.
|
// When this test has finished, clean up the new OIDCProviderConfig.
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
err = client.ConfigV1alpha1().OIDCProviderConfigs(ns).Delete(ctx, newOIDCProviderConfig.Name, metav1.DeleteOptions{})
|
cleanupCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
err = client.ConfigV1alpha1().OIDCProviderConfigs(ns).Delete(cleanupCtx, newOIDCProviderConfig.Name, metav1.DeleteOptions{})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -94,9 +105,11 @@ func TestSupervisorOIDCDiscovery(t *testing.T) {
|
|||||||
var response *http.Response
|
var response *http.Response
|
||||||
assert.Eventually(t, func() bool {
|
assert.Eventually(t, func() bool {
|
||||||
response, err = httpClient.Do(requestDiscoveryEndpoint) //nolint:bodyclose // the body is closed below after it is read
|
response, err = httpClient.Do(requestDiscoveryEndpoint) //nolint:bodyclose // the body is closed below after it is read
|
||||||
return err == nil
|
return err == nil && response.StatusCode == http.StatusOK
|
||||||
}, 10*time.Second, 200*time.Millisecond)
|
}, 10*time.Second, 200*time.Millisecond)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, http.StatusOK, response.StatusCode)
|
||||||
|
|
||||||
responseBody, err := ioutil.ReadAll(response.Body)
|
responseBody, err := ioutil.ReadAll(response.Body)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
err = response.Body.Close()
|
err = response.Body.Close()
|
||||||
@ -116,7 +129,6 @@ func TestSupervisorOIDCDiscovery(t *testing.T) {
|
|||||||
}`)
|
}`)
|
||||||
expectedJSON := fmt.Sprintf(expectedResultTemplate, issuer, issuer, issuer, issuer)
|
expectedJSON := fmt.Sprintf(expectedResultTemplate, issuer, issuer, issuer, issuer)
|
||||||
|
|
||||||
require.Equal(t, 200, response.StatusCode)
|
|
||||||
require.Equal(t, "application/json", response.Header.Get("content-type"))
|
require.Equal(t, "application/json", response.Header.Get("content-type"))
|
||||||
require.JSONEq(t, expectedJSON, string(responseBody))
|
require.JSONEq(t, expectedJSON, string(responseBody))
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user