From 30114135ba25b04ea1760148bbe78a475d12b39a Mon Sep 17 00:00:00 2001 From: Krzysztof Ostrowski Date: Sat, 16 Nov 2024 11:38:33 -0500 Subject: [PATCH 1/4] Add cert utilities from sig-auth branch This partially cherry-picks commit 204148cb from the sig-auth-acceptance branch so that the certificate utilities can be used. Signed-off-by: Allain Legacy --- test/kubetest/kubernetes.go | 62 ++++++++++++ test/kubetest/tls.go | 181 ++++++++++++++++++++++++++++++++++++ 2 files changed, 243 insertions(+) create mode 100644 test/kubetest/tls.go diff --git a/test/kubetest/kubernetes.go b/test/kubetest/kubernetes.go index af6711339..40d07d9a2 100644 --- a/test/kubetest/kubernetes.go +++ b/test/kubetest/kubernetes.go @@ -19,6 +19,8 @@ package kubetest import ( "bytes" "context" + "crypto/x509" + "encoding/pem" "fmt" "io" "os" @@ -36,6 +38,66 @@ import ( "k8s.io/client-go/kubernetes" ) +func CreateServerCerts(client kubernetes.Interface, name string) Action { + return createCerts(client, name, createSignedServerCert) +} + +func CreateClientCerts(client kubernetes.Interface, name string) Action { + return createCerts(client, name, createSignedClientCert) +} + +func createCerts(client kubernetes.Interface, name string, createSignedCert certer) Action { + return func(ctx *ScenarioContext) error { + caCert, caKey, err := createSelfSignedCA(fmt.Sprintf("%s-ca", name)) + if err != nil { + return err + } + cert, key, err := createSignedCert(caCert, caKey, fmt.Sprintf("%s.default.svc.cluster.local", name)) + if err != nil { + return err + } + + configMapName := fmt.Sprintf("%s-certs", name) + _, err = client.CoreV1().ConfigMaps(ctx.Namespace).Create(context.TODO(), &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: configMapName, + }, + Data: map[string]string{ + "ca.crt": string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caCert.Raw})), + "tls.crt": string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw})), + }, + }, metav1.CreateOptions{}) + if err != nil { + return err + } + ctx.AddCleanUp(func() error { + return client.CoreV1().ConfigMaps(ctx.Namespace).Delete(context.TODO(), configMapName, metav1.DeleteOptions{}) + }) + + secretName := fmt.Sprintf("%s-keys", name) + _, err = client.CoreV1().Secrets(ctx.Namespace).Create(context.TODO(), &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + }, + Data: map[string][]byte{ + "tls.key": []byte(pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + })), + "ca.key": []byte(pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(caKey), + })), + }, + }, metav1.CreateOptions{}) + ctx.AddCleanUp(func() error { + return client.CoreV1().Secrets(ctx.Namespace).Delete(context.TODO(), secretName, metav1.DeleteOptions{}) + }) + + return err + } +} + func CreatedManifests(client kubernetes.Interface, paths ...string) Action { return func(ctx *ScenarioContext) error { for _, path := range paths { diff --git a/test/kubetest/tls.go b/test/kubetest/tls.go new file mode 100644 index 000000000..b11918dbd --- /dev/null +++ b/test/kubetest/tls.go @@ -0,0 +1,181 @@ +/* +Copyright 2024 the kube-rbac-proxy maintainers. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package kubetest + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/sha1" + "crypto/x509" + "crypto/x509/pkix" + "fmt" + "math/big" + "time" + + "k8s.io/client-go/util/cert" +) + +var ( + year = 365 * 24 * time.Hour + minimalRSAKeySize = 2048 +) + +type certer func(*x509.Certificate, *rsa.PrivateKey, string) (*x509.Certificate, *rsa.PrivateKey, error) + +func createSignedClientCert(cacert *x509.Certificate, caPrivateKey *rsa.PrivateKey, name string) (*x509.Certificate, *rsa.PrivateKey, error) { + // Generate a private key. + privateKey, err := rsa.GenerateKey(rand.Reader, minimalRSAKeySize) + if err != nil { + return nil, nil, err + } + + // Generate subject key id. + subjectKeyID := sha1.Sum(privateKey.PublicKey.N.Bytes()) + authorityKeyID := cacert.SubjectKeyId + + // Generate serial number with at least 20 bits of entropy. + serialNumber, err := generateSerialNumber() + if err != nil { + return nil, nil, err + } + + // Create certificate template. + template := &x509.Certificate{ + Subject: pkix.Name{CommonName: name}, + + NotBefore: time.Now().Add(-1 * time.Second), + NotAfter: time.Now().Add(year), + + SerialNumber: serialNumber, + SubjectKeyId: subjectKeyID[:], + AuthorityKeyId: authorityKeyID, + + SignatureAlgorithm: x509.SHA256WithRSA, + + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + + BasicConstraintsValid: true, + } + + // Sign Certificate + derBytes, err := x509.CreateCertificate( + rand.Reader, + template, + cacert, + privateKey.Public(), + caPrivateKey, + ) + if err != nil { + return nil, nil, err + } + + // Parse Certificate into x509.Certificate. + certs, err := x509.ParseCertificates(derBytes) + if err != nil { + return nil, nil, err + } + if len(certs) != 1 { + return nil, nil, fmt.Errorf("expected 1 certificate, got %d", len(certs)) + } + + return certs[0], privateKey, nil +} + +func createSignedServerCert(caCert *x509.Certificate, caPrivateKey *rsa.PrivateKey, dnsName string) (*x509.Certificate, *rsa.PrivateKey, error) { + // Generate a private key. + privateKey, err := rsa.GenerateKey(rand.Reader, minimalRSAKeySize) + if err != nil { + return nil, nil, err + } + + // Generate subject key id. + subjectKeyID := sha1.Sum(privateKey.PublicKey.N.Bytes()) + authorityKeyID := caCert.SubjectKeyId + + // Generate serial number with at least 20 bits of entropy. + serialNumber, err := generateSerialNumber() + if err != nil { + return nil, nil, err + } + + // Create certificate template. + template := &x509.Certificate{ + Subject: pkix.Name{CommonName: dnsName}, + + NotBefore: time.Now().Add(-1 * time.Second), + NotAfter: time.Now().Add(year), + + SerialNumber: serialNumber, + SubjectKeyId: subjectKeyID[:], + AuthorityKeyId: authorityKeyID, + + SignatureAlgorithm: x509.SHA256WithRSA, + + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + + DNSNames: []string{dnsName}, + + BasicConstraintsValid: true, + } + + // Sign Certificate + derBytes, err := x509.CreateCertificate( + rand.Reader, + template, + caCert, + privateKey.Public(), + caPrivateKey, + ) + if err != nil { + return nil, nil, err + } + + // Parse Certificate into x509.Certificate. + certs, err := x509.ParseCertificates(derBytes) + if err != nil { + return nil, nil, err + } + if len(certs) != 1 { + return nil, nil, fmt.Errorf("expected 1 certificate, got %d", len(certs)) + } + + return certs[0], privateKey, nil +} + +func createSelfSignedCA(name string) (*x509.Certificate, *rsa.PrivateKey, error) { + privateKey, err := rsa.GenerateKey(rand.Reader, minimalRSAKeySize) + if err != nil { + return nil, nil, err + } + + cert, err := cert.NewSelfSignedCACert(cert.Config{ + CommonName: name, + }, privateKey) + + return cert, privateKey, err +} + +func generateSerialNumber() (*big.Int, error) { + max := new(big.Int).Lsh(big.NewInt(1), 63) + serialNumber, err := rand.Int(rand.Reader, max) + if err != nil { + return nil, err + } + + return serialNumber, nil +} From 6e545b5595fa4168d23ac08a01e27043bf6ebbff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20L=C3=A1zni=C4=8Dka?= Date: Tue, 26 Nov 2024 14:15:24 +0100 Subject: [PATCH 2/4] e2e: tls - simplify certgen conde Unify cert template generation. SKID and AKID should be properly computed by Golang, no need to add them explicitly - unless we need explicit SKID but that's not the case in our tests. --- test/kubetest/tls.go | 90 +++++++++++++++++--------------------------- 1 file changed, 34 insertions(+), 56 deletions(-) diff --git a/test/kubetest/tls.go b/test/kubetest/tls.go index b11918dbd..8deb52428 100644 --- a/test/kubetest/tls.go +++ b/test/kubetest/tls.go @@ -18,7 +18,6 @@ package kubetest import ( "crypto/rand" "crypto/rsa" - "crypto/sha1" "crypto/x509" "crypto/x509/pkix" "fmt" @@ -33,7 +32,7 @@ var ( minimalRSAKeySize = 2048 ) -type certer func(*x509.Certificate, *rsa.PrivateKey, string) (*x509.Certificate, *rsa.PrivateKey, error) +type createCertsFunc func(*x509.Certificate, *rsa.PrivateKey, string) (*x509.Certificate, *rsa.PrivateKey, error) func createSignedClientCert(cacert *x509.Certificate, caPrivateKey *rsa.PrivateKey, name string) (*x509.Certificate, *rsa.PrivateKey, error) { // Generate a private key. @@ -42,34 +41,12 @@ func createSignedClientCert(cacert *x509.Certificate, caPrivateKey *rsa.PrivateK return nil, nil, err } - // Generate subject key id. - subjectKeyID := sha1.Sum(privateKey.PublicKey.N.Bytes()) - authorityKeyID := cacert.SubjectKeyId - - // Generate serial number with at least 20 bits of entropy. - serialNumber, err := generateSerialNumber() + template, err := certTemplate() if err != nil { - return nil, nil, err - } - - // Create certificate template. - template := &x509.Certificate{ - Subject: pkix.Name{CommonName: name}, - - NotBefore: time.Now().Add(-1 * time.Second), - NotAfter: time.Now().Add(year), - - SerialNumber: serialNumber, - SubjectKeyId: subjectKeyID[:], - AuthorityKeyId: authorityKeyID, - - SignatureAlgorithm: x509.SHA256WithRSA, - - KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, - - BasicConstraintsValid: true, + return nil, nil, fmt.Errorf("failed to generate cert template: %v", err) } + template.Subject = pkix.Name{CommonName: name} + template.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth} // Sign Certificate derBytes, err := x509.CreateCertificate( @@ -102,36 +79,13 @@ func createSignedServerCert(caCert *x509.Certificate, caPrivateKey *rsa.PrivateK return nil, nil, err } - // Generate subject key id. - subjectKeyID := sha1.Sum(privateKey.PublicKey.N.Bytes()) - authorityKeyID := caCert.SubjectKeyId - - // Generate serial number with at least 20 bits of entropy. - serialNumber, err := generateSerialNumber() + template, err := certTemplate() if err != nil { - return nil, nil, err - } - - // Create certificate template. - template := &x509.Certificate{ - Subject: pkix.Name{CommonName: dnsName}, - - NotBefore: time.Now().Add(-1 * time.Second), - NotAfter: time.Now().Add(year), - - SerialNumber: serialNumber, - SubjectKeyId: subjectKeyID[:], - AuthorityKeyId: authorityKeyID, - - SignatureAlgorithm: x509.SHA256WithRSA, - - KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - - DNSNames: []string{dnsName}, - - BasicConstraintsValid: true, + return nil, nil, fmt.Errorf("failed to generate cert template: %v", err) } + template.Subject = pkix.Name{CommonName: dnsName} + template.DNSNames = []string{dnsName} + template.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth} // Sign Certificate derBytes, err := x509.CreateCertificate( @@ -157,6 +111,30 @@ func createSignedServerCert(caCert *x509.Certificate, caPrivateKey *rsa.PrivateK return certs[0], privateKey, nil } +// certTemplate creates a basic cert template to use in tests. The caller must +// add its own Subject and any extensions specific to their use. +func certTemplate() (*x509.Certificate, error) { + // Generate serial number with at least 20 bits of entropy. + serialNumber, err := generateSerialNumber() + if err != nil { + return nil, err + } + + return &x509.Certificate{ + NotBefore: time.Now().Add(-1 * time.Second), + NotAfter: time.Now().Add(year), + + SerialNumber: serialNumber, + + SignatureAlgorithm: x509.SHA256WithRSA, + + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + + BasicConstraintsValid: true, + }, nil +} + func createSelfSignedCA(name string) (*x509.Certificate, *rsa.PrivateKey, error) { privateKey, err := rsa.GenerateKey(rand.Reader, minimalRSAKeySize) if err != nil { From 12db7dd0aeb543704b96a3f25a6611bbc6a16e3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20L=C3=A1zni=C4=8Dka?= Date: Tue, 26 Nov 2024 14:17:42 +0100 Subject: [PATCH 3/4] e2e: rewire certs in CMs/Secrets (partial) The previous design was mixing CA and leaf certificates, making it easy to confuse them at place of use. Have all the leaf cryptomaterial in a secret, and the trust in the CM to avoid those issues. --- test/kubetest/kubernetes.go | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/test/kubetest/kubernetes.go b/test/kubetest/kubernetes.go index 40d07d9a2..db4a0caec 100644 --- a/test/kubetest/kubernetes.go +++ b/test/kubetest/kubernetes.go @@ -46,7 +46,7 @@ func CreateClientCerts(client kubernetes.Interface, name string) Action { return createCerts(client, name, createSignedClientCert) } -func createCerts(client kubernetes.Interface, name string, createSignedCert certer) Action { +func createCerts(client kubernetes.Interface, name string, createSignedCert createCertsFunc) Action { return func(ctx *ScenarioContext) error { caCert, caKey, err := createSelfSignedCA(fmt.Sprintf("%s-ca", name)) if err != nil { @@ -57,14 +57,13 @@ func createCerts(client kubernetes.Interface, name string, createSignedCert cert return err } - configMapName := fmt.Sprintf("%s-certs", name) + configMapName := fmt.Sprintf("%s-trust", name) _, err = client.CoreV1().ConfigMaps(ctx.Namespace).Create(context.TODO(), &corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ Name: configMapName, }, Data: map[string]string{ - "ca.crt": string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caCert.Raw})), - "tls.crt": string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw})), + "ca.crt": string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caCert.Raw})), }, }, metav1.CreateOptions{}) if err != nil { @@ -74,20 +73,20 @@ func createCerts(client kubernetes.Interface, name string, createSignedCert cert return client.CoreV1().ConfigMaps(ctx.Namespace).Delete(context.TODO(), configMapName, metav1.DeleteOptions{}) }) - secretName := fmt.Sprintf("%s-keys", name) + secretName := fmt.Sprintf("%s-certs", name) _, err = client.CoreV1().Secrets(ctx.Namespace).Create(context.TODO(), &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: secretName, }, Data: map[string][]byte{ + "tls.crt": pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: cert.Raw, + }), "tls.key": []byte(pem.EncodeToMemory(&pem.Block{ Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key), })), - "ca.key": []byte(pem.EncodeToMemory(&pem.Block{ - Type: "RSA PRIVATE KEY", - Bytes: x509.MarshalPKCS1PrivateKey(caKey), - })), }, }, metav1.CreateOptions{}) ctx.AddCleanUp(func() error { From 634fe0afa46b7c14d29bd222d74cd9f1ebb2a20c Mon Sep 17 00:00:00 2001 From: Allain Legacy Date: Wed, 13 Nov 2024 14:33:52 -0500 Subject: [PATCH 4/4] Add E2E tests for OIDC token validation This adds end-to-end tests and supporting material to execute tests that validate parts of the OIDC functionality. Signed-off-by: Allain Legacy --- go.mod | 6 + go.sum | 12 + test/e2e/main_test.go | 1 + test/e2e/oidc.go | 363 +++++++++++++++++++ test/e2e/oidc/README.md | 41 +++ test/e2e/oidc/clusterRole-client.yaml | 7 + test/e2e/oidc/clusterRole.yaml | 14 + test/e2e/oidc/clusterRoleBinding-client.yaml | 12 + test/e2e/oidc/clusterRoleBinding-group.yaml | 11 + test/e2e/oidc/clusterRoleBinding.yaml | 13 + test/e2e/oidc/deployment.yaml | 49 +++ test/e2e/oidc/mock-issuer-service.yaml | 15 + test/e2e/oidc/mock-issuer.yaml | 54 +++ test/e2e/oidc/service.yaml | 14 + test/e2e/oidc/serviceAccount.yaml | 5 + test/kubetest/forwarder.go | 98 +++++ test/kubetest/kubernetes.go | 23 ++ test/kubetest/kubetest.go | 16 +- test/kubetest/mock-server.go | 43 +++ test/kubetest/oidc.go | 229 ++++++++++++ test/kubetest/tls.go | 131 +++++++ 21 files changed, 1156 insertions(+), 1 deletion(-) create mode 100644 test/e2e/oidc.go create mode 100644 test/e2e/oidc/README.md create mode 100644 test/e2e/oidc/clusterRole-client.yaml create mode 100644 test/e2e/oidc/clusterRole.yaml create mode 100644 test/e2e/oidc/clusterRoleBinding-client.yaml create mode 100644 test/e2e/oidc/clusterRoleBinding-group.yaml create mode 100644 test/e2e/oidc/clusterRoleBinding.yaml create mode 100644 test/e2e/oidc/deployment.yaml create mode 100644 test/e2e/oidc/mock-issuer-service.yaml create mode 100644 test/e2e/oidc/mock-issuer.yaml create mode 100644 test/e2e/oidc/service.yaml create mode 100644 test/e2e/oidc/serviceAccount.yaml create mode 100644 test/kubetest/forwarder.go create mode 100644 test/kubetest/mock-server.go create mode 100644 test/kubetest/oidc.go diff --git a/go.mod b/go.mod index 3fcaa128b..0b9952832 100644 --- a/go.mod +++ b/go.mod @@ -4,10 +4,12 @@ go 1.23 require ( github.com/ghodss/yaml v1.0.0 + github.com/go-jose/go-jose/v4 v4.0.4 github.com/google/go-cmp v0.6.0 github.com/oklog/run v1.1.0 github.com/spf13/cobra v1.8.1 github.com/spf13/pflag v1.0.5 + github.com/wiremock/go-wiremock v1.11.0 golang.org/x/net v0.31.0 gopkg.in/yaml.v2 v2.4.0 k8s.io/api v0.31.3 @@ -16,6 +18,7 @@ require ( k8s.io/client-go v0.31.3 k8s.io/component-base v0.31.3 k8s.io/klog/v2 v2.130.1 + software.sslmate.com/src/go-pkcs12 v0.5.0 ) require ( @@ -47,6 +50,7 @@ require ( github.com/google/gnostic-models v0.6.8 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.0 // indirect github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect github.com/imdario/mergo v0.3.6 // indirect @@ -54,10 +58,12 @@ require ( github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/moby/spdystream v0.4.0 // indirect github.com/moby/term v0.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pquerna/cachecontrol v0.1.0 // indirect github.com/prometheus/client_golang v1.19.1 // indirect diff --git a/go.sum b/go.sum index c0d6a6d77..e1d9a0c2d 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cq github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a h1:idn718Q4B6AGu/h5Sxe66HYVdqdGu2l9Iebqhi/AEoA= github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -42,6 +44,8 @@ github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-jose/go-jose v2.6.3+incompatible h1:eU70erXEHN0wZl7K7kBTRLel/hu4P09qqopkDaXiXso= github.com/go-jose/go-jose v2.6.3+incompatible/go.mod h1:coBhWG9DQz8V/JlBMg3LkUGnarUaxjQlWQUUv9Cv7tw= +github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E= +github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -114,6 +118,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/moby/spdystream v0.4.0 h1:Vy79D6mHeJJjiPdFEL2yku1kl0chZpJfZcPpb16BRl8= +github.com/moby/spdystream v0.4.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -123,6 +129,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= github.com/onsi/ginkgo/v2 v2.19.0 h1:9Cnnf7UHo57Hy3k6/m5k3dRfGTMXGvxhHFvkDTCTpvA= @@ -170,6 +178,8 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 h1:6fotK7otjonDflCTK0BCfls4SPy3NcCVb5dqqmbRknE= github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75/go.mod h1:KO6IkyS8Y3j8OdNO85qEYBsRPuteD+YciPomcXdrMnk= +github.com/wiremock/go-wiremock v1.11.0 h1:BL4uVNgUV3uHbavAro4c0EIa/IIOeV7icO34Jg87ZjQ= +github.com/wiremock/go-wiremock v1.11.0/go.mod h1:/uvO0XFheyy8XetvQqm4TbNQRsGPlByeNegzLzvXs0c= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2 h1:eY9dn8+vbi4tKz5Qo6v2eYzo7kUS51QINcR5jNpbZS8= @@ -316,3 +326,5 @@ sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+s sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= +software.sslmate.com/src/go-pkcs12 v0.5.0 h1:EC6R394xgENTpZ4RltKydeDUjtlM5drOYIG9c6TVj2M= +software.sslmate.com/src/go-pkcs12 v0.5.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/test/e2e/main_test.go b/test/e2e/main_test.go index fbaa9a167..12d5567d0 100644 --- a/test/e2e/main_test.go +++ b/test/e2e/main_test.go @@ -49,6 +49,7 @@ func Test(t *testing.T) { "HTTP2": testHTTP2(client), "Flags": testFlags(client), "TokenMasking": testTokenMasking(client), + "OIDC": testOIDC(client, clientConfig), } for name, tc := range tests { diff --git a/test/e2e/oidc.go b/test/e2e/oidc.go new file mode 100644 index 000000000..a258604f1 --- /dev/null +++ b/test/e2e/oidc.go @@ -0,0 +1,363 @@ +/* +Copyright 2017 Frederic Branczyk All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package e2e + +import ( + "fmt" + "k8s.io/client-go/rest" + "testing" + "time" + + "k8s.io/client-go/kubernetes" + + "github.com/brancz/kube-rbac-proxy/test/kubetest" +) + +const ( + defaultLocalPort = 18080 + defaultIssuerPort = 8080 + defaultIssuerName = "mock-issuer" +) + +func testOIDC(client kubernetes.Interface, config *rest.Config) kubetest.TestSuite { + return func(t *testing.T) { + command := `curl --connect-timeout 5 -v -s -k --fail -H "Authorization: Bearer $(cat /tokens/token.jwt)" https://kube-rbac-proxy.default.svc.cluster.local:8443/metrics` + + expiredToken := kubetest.NewTokenConfig("expired") + expiredToken.IssuedAt = time.Now().Add(-1 * time.Hour) + expiredToken.ExpiresAt = time.Now().Add(-10 * time.Minute) + + // Set up the mock-server URL to be a local port forwarded to the internal cluster Pod. + mockServerURL := fmt.Sprintf("http://localhost:%d", defaultLocalPort) + defaultIssuer := expiredToken.Issuer + + kubetest.Scenario{ + Name: "Expired Token", + Description: "As a client with an expired token my request fails.", + Given: kubetest.Actions( + kubetest.CreateClientCerts(client, "client"), + kubetest.CreateServerCerts(client, "kube-rbac-proxy-server"), + kubetest.CreateServerCerts(client, "mock-issuer"), + kubetest.WrapCertsInPKSC12(client, "mock-issuer"), + kubetest.CreatedManifests( + client, + "oidc/mock-issuer.yaml", + "oidc/mock-issuer-service.yaml", + ), + kubetest.PodsAreReady(client, 1, "app=mock-issuer"), + kubetest.CreatePortForwarder(client, config, defaultIssuerName, defaultLocalPort, defaultIssuerPort), + kubetest.CreateOIDCIssuer(defaultIssuer, mockServerURL), + kubetest.CreateOIDCToken(client, expiredToken, mockServerURL), + kubetest.CreatedManifests( + client, + "oidc/clusterRole.yaml", + "oidc/clusterRoleBinding.yaml", + "oidc/clusterRole-client.yaml", + "oidc/clusterRoleBinding-client.yaml", + "oidc/service.yaml", + "oidc/serviceAccount.yaml", + "oidc/deployment.yaml", + ), + ), + When: kubetest.Actions( + kubetest.PodsAreReady( + client, + 1, + "app=kube-rbac-proxy", + ), + kubetest.ServiceIsReady( + client, + "kube-rbac-proxy", + ), + ), + Then: kubetest.Actions( + kubetest.ClientFails( + client, + command, + &kubetest.RunOptions{OIDCToken: expiredToken.Name}, + ), + kubetest.VerifyExactly(mockServerURL, kubetest.DiscoveryStub, 1), + kubetest.VerifyExactly(mockServerURL, kubetest.WebKeySetStub, 0), + ), + }.Run(t) + + unknownKeyToken := kubetest.NewTokenConfig("unknown-key-id") + publishedKeyID := unknownKeyToken.KeyID + unknownKeyToken.PublishedKeyID = &publishedKeyID + unknownKeyToken.KeyID = "unknown-key-id" + + kubetest.Scenario{ + Name: "Unknown Key ID Token", + Description: "As a client with a token signed with an unknown key id my request fails", + Given: kubetest.Actions( + kubetest.CreateClientCerts(client, "client"), + kubetest.CreateServerCerts(client, "kube-rbac-proxy-server"), + kubetest.CreateServerCerts(client, "mock-issuer"), + kubetest.WrapCertsInPKSC12(client, "mock-issuer"), + kubetest.CreatedManifests( + client, + "oidc/mock-issuer.yaml", + "oidc/mock-issuer-service.yaml", + ), + kubetest.PodsAreReady(client, 1, "app=mock-issuer"), + kubetest.CreatePortForwarder(client, config, defaultIssuerName, defaultLocalPort, defaultIssuerPort), + kubetest.CreateOIDCIssuer(defaultIssuer, mockServerURL), + kubetest.CreateOIDCToken(client, unknownKeyToken, mockServerURL), + kubetest.CreatedManifests( + client, + "oidc/clusterRole.yaml", + "oidc/clusterRoleBinding.yaml", + "oidc/clusterRole-client.yaml", + "oidc/clusterRoleBinding-client.yaml", + "oidc/service.yaml", + "oidc/serviceAccount.yaml", + "oidc/deployment.yaml", + ), + ), + When: kubetest.Actions( + kubetest.PodsAreReady( + client, + 1, + "app=kube-rbac-proxy", + ), + kubetest.ServiceIsReady( + client, + "kube-rbac-proxy", + ), + ), + Then: kubetest.Actions( + kubetest.ClientFails( + client, + command, + &kubetest.RunOptions{OIDCToken: unknownKeyToken.Name}, + ), + kubetest.VerifyExactly(mockServerURL, kubetest.DiscoveryStub, 1), + kubetest.VerifyExactly(mockServerURL, kubetest.WebKeySetStub, 3), + ), + }.Run(t) + + unknownUsernameToken := kubetest.NewTokenConfig("unknown-user") + unknownUsernameToken.Username = "unknown-username" + + kubetest.Scenario{ + Name: "Unknown Username", + Description: "As a client with a token for an unknown user my request fails", + Given: kubetest.Actions( + kubetest.CreateClientCerts(client, "client"), + kubetest.CreateServerCerts(client, "kube-rbac-proxy-server"), + kubetest.CreateServerCerts(client, "mock-issuer"), + kubetest.WrapCertsInPKSC12(client, "mock-issuer"), + kubetest.CreatedManifests( + client, + "oidc/mock-issuer.yaml", + "oidc/mock-issuer-service.yaml", + ), + kubetest.PodsAreReady(client, 1, "app=mock-issuer"), + kubetest.CreatePortForwarder(client, config, defaultIssuerName, defaultLocalPort, defaultIssuerPort), + kubetest.CreateOIDCIssuer(defaultIssuer, mockServerURL), + kubetest.CreateOIDCToken(client, unknownUsernameToken, mockServerURL), + kubetest.CreatedManifests( + client, + "oidc/clusterRole.yaml", + "oidc/clusterRoleBinding.yaml", + "oidc/clusterRole-client.yaml", + "oidc/clusterRoleBinding-client.yaml", + "oidc/service.yaml", + "oidc/serviceAccount.yaml", + "oidc/deployment.yaml", + ), + ), + When: kubetest.Actions( + kubetest.PodsAreReady( + client, + 1, + "app=kube-rbac-proxy", + ), + kubetest.ServiceIsReady( + client, + "kube-rbac-proxy", + ), + ), + Then: kubetest.Actions( + kubetest.ClientFails( + client, + command, + &kubetest.RunOptions{OIDCToken: unknownUsernameToken.Name}, + ), + kubetest.VerifyExactly(mockServerURL, kubetest.DiscoveryStub, 1), + kubetest.VerifyExactly(mockServerURL, kubetest.WebKeySetStub, 1), + ), + }.Run(t) + + unknownAudienceToken := kubetest.NewTokenConfig("unknown-audience") + unknownAudienceToken.Audience = []string{"unknown-audience"} + + kubetest.Scenario{ + Name: "Unknown Audience", + Description: "As a client with a token for an unknown audience my request fails", + Given: kubetest.Actions( + kubetest.CreateClientCerts(client, "client"), + kubetest.CreateServerCerts(client, "kube-rbac-proxy-server"), + kubetest.CreateServerCerts(client, "mock-issuer"), + kubetest.WrapCertsInPKSC12(client, "mock-issuer"), + kubetest.CreatedManifests( + client, + "oidc/mock-issuer.yaml", + "oidc/mock-issuer-service.yaml", + ), + kubetest.PodsAreReady(client, 1, "app=mock-issuer"), + kubetest.CreatePortForwarder(client, config, defaultIssuerName, defaultLocalPort, defaultIssuerPort), + kubetest.CreateOIDCIssuer(defaultIssuer, mockServerURL), + kubetest.CreateOIDCToken(client, unknownAudienceToken, mockServerURL), + kubetest.CreatedManifests( + client, + "oidc/clusterRole.yaml", + "oidc/clusterRoleBinding.yaml", + "oidc/clusterRole-client.yaml", + "oidc/clusterRoleBinding-client.yaml", + "oidc/service.yaml", + "oidc/serviceAccount.yaml", + "oidc/deployment.yaml", + ), + ), + When: kubetest.Actions( + kubetest.PodsAreReady( + client, + 1, + "app=kube-rbac-proxy", + ), + kubetest.ServiceIsReady( + client, + "kube-rbac-proxy", + ), + ), + Then: kubetest.Actions( + kubetest.ClientFails( + client, + command, + &kubetest.RunOptions{OIDCToken: unknownAudienceToken.Name}, + ), + kubetest.VerifyExactly(mockServerURL, kubetest.DiscoveryStub, 1), + kubetest.VerifyExactly(mockServerURL, kubetest.WebKeySetStub, 0), + ), + }.Run(t) + + validToken := kubetest.NewTokenConfig("valid") + + kubetest.Scenario{ + Name: "Valid Token", + Description: "As a client with a valid token for a known username my request succeeds", + Given: kubetest.Actions( + kubetest.CreateClientCerts(client, "client"), + kubetest.CreateServerCerts(client, "kube-rbac-proxy-server"), + kubetest.CreateServerCerts(client, "mock-issuer"), + kubetest.WrapCertsInPKSC12(client, "mock-issuer"), + kubetest.CreatedManifests( + client, + "oidc/mock-issuer.yaml", + "oidc/mock-issuer-service.yaml", + ), + kubetest.PodsAreReady(client, 1, "app=mock-issuer"), + kubetest.CreatePortForwarder(client, config, defaultIssuerName, defaultLocalPort, defaultIssuerPort), + kubetest.CreateOIDCIssuer(defaultIssuer, mockServerURL), + kubetest.CreateOIDCToken(client, validToken, mockServerURL), + kubetest.CreatedManifests( + client, + "oidc/clusterRole.yaml", + "oidc/clusterRoleBinding.yaml", + "oidc/clusterRole-client.yaml", + "oidc/clusterRoleBinding-client.yaml", + "oidc/service.yaml", + "oidc/serviceAccount.yaml", + "oidc/deployment.yaml", + ), + ), + When: kubetest.Actions( + kubetest.PodsAreReady( + client, + 1, + "app=kube-rbac-proxy", + ), + kubetest.ServiceIsReady( + client, + "kube-rbac-proxy", + ), + ), + Then: kubetest.Actions( + kubetest.ClientSucceeds( + client, + command, + &kubetest.RunOptions{OIDCToken: validToken.Name}, + ), + kubetest.VerifyExactly(mockServerURL, kubetest.DiscoveryStub, 1), + kubetest.VerifyExactly(mockServerURL, kubetest.WebKeySetStub, 1), + ), + }.Run(t) + + groupToken := kubetest.NewTokenConfig("group") + + kubetest.Scenario{ + Name: "Valid Token Matching A Group Binding", + Description: "As a client with a valid token with a role matching a group binding my request succeeds", + Given: kubetest.Actions( + kubetest.CreateClientCerts(client, "client"), + kubetest.CreateServerCerts(client, "kube-rbac-proxy-server"), + kubetest.CreateServerCerts(client, "mock-issuer"), + kubetest.WrapCertsInPKSC12(client, "mock-issuer"), + kubetest.CreatedManifests( + client, + "oidc/mock-issuer.yaml", + "oidc/mock-issuer-service.yaml", + ), + kubetest.PodsAreReady(client, 1, "app=mock-issuer"), + kubetest.CreatePortForwarder(client, config, defaultIssuerName, defaultLocalPort, defaultIssuerPort), + kubetest.CreateOIDCIssuer(defaultIssuer, mockServerURL), + kubetest.CreateOIDCToken(client, groupToken, mockServerURL), + kubetest.CreatedManifests( + client, + "oidc/clusterRole.yaml", + "oidc/clusterRoleBinding.yaml", + "oidc/clusterRole-client.yaml", + "oidc/clusterRoleBinding-group.yaml", + "oidc/service.yaml", + "oidc/serviceAccount.yaml", + "oidc/deployment.yaml", + ), + ), + When: kubetest.Actions( + kubetest.PodsAreReady( + client, + 1, + "app=kube-rbac-proxy", + ), + kubetest.ServiceIsReady( + client, + "kube-rbac-proxy", + ), + ), + Then: kubetest.Actions( + kubetest.ClientSucceeds( + client, + command, + &kubetest.RunOptions{OIDCToken: groupToken.Name}, + ), + kubetest.VerifyExactly(mockServerURL, kubetest.DiscoveryStub, 1), + kubetest.VerifyExactly(mockServerURL, kubetest.WebKeySetStub, 1), + ), + }.Run(t) + } +} diff --git a/test/e2e/oidc/README.md b/test/e2e/oidc/README.md new file mode 100644 index 000000000..d93121231 --- /dev/null +++ b/test/e2e/oidc/README.md @@ -0,0 +1,41 @@ +## OIDC Test Scenarios + +The purpose of these tests is to verify the OIDC functionality of the kube-rbac-proxy. When configured with OIDC +enabled, the kube-rbac-proxy validates a token received in the Authorization header and uses it to authenticate and +authorize incoming requests. Valid requests are passed to the upstream server while failed requests are rejected. To +test these scenarios a mock server (i.e., wiremock) is used in place of a real OIDC issuer (i.e., authorization server) +since the authorization server is not the subject of these tests. It is more convenient to control the test conditions +using a mock server than it would be if using an actual authorization server. + +### Test Topology + + ┌───────────────┐ + │ │ + │ OAuth Server │ + │ (mock server) │ + │ │ + └────────-──────┘ + ▲ + │ + │ + │ + │ + ┌──────────────┐ ┌────────-─────────┐ ┌────────────────────────┐ + │ │ │ │ │ │ + │ client ┼───────────────────►| kube-rbac-proxy |──────────────────►| prometheus-example-app │ + │ │ │ │ │ │ + └──────────────┘ └──────────────────┘ └────────────────────────┘ + +### Test Use Cases + +1. Using a valid token +2. Using an expired token +3. Using a token signed by an unknown signer +4. Using a token for an unknown user +5. Using a token matching a group binding +6. Using a token for an audience not matching the configured client-id + + + + + diff --git a/test/e2e/oidc/clusterRole-client.yaml b/test/e2e/oidc/clusterRole-client.yaml new file mode 100644 index 000000000..ffa2f030c --- /dev/null +++ b/test/e2e/oidc/clusterRole-client.yaml @@ -0,0 +1,7 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: metrics +rules: + - nonResourceURLs: [ "/metrics" ] + verbs: [ "get" ] diff --git a/test/e2e/oidc/clusterRole.yaml b/test/e2e/oidc/clusterRole.yaml new file mode 100644 index 000000000..b8457cd07 --- /dev/null +++ b/test/e2e/oidc/clusterRole.yaml @@ -0,0 +1,14 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: kube-rbac-proxy + namespace: default +rules: + - apiGroups: [ "authentication.k8s.io" ] + resources: + - tokenreviews + verbs: [ "create" ] + - apiGroups: [ "authorization.k8s.io" ] + resources: + - subjectaccessreviews + verbs: [ "create" ] diff --git a/test/e2e/oidc/clusterRoleBinding-client.yaml b/test/e2e/oidc/clusterRoleBinding-client.yaml new file mode 100644 index 000000000..fa8304f75 --- /dev/null +++ b/test/e2e/oidc/clusterRoleBinding-client.yaml @@ -0,0 +1,12 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: metrics +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: metrics +subjects: + - kind: User + name: test-client + namespace: default diff --git a/test/e2e/oidc/clusterRoleBinding-group.yaml b/test/e2e/oidc/clusterRoleBinding-group.yaml new file mode 100644 index 000000000..0f75ebdf5 --- /dev/null +++ b/test/e2e/oidc/clusterRoleBinding-group.yaml @@ -0,0 +1,11 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: metrics-group +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: metrics +subjects: + - kind: Group + name: metrics diff --git a/test/e2e/oidc/clusterRoleBinding.yaml b/test/e2e/oidc/clusterRoleBinding.yaml new file mode 100644 index 000000000..f7be8fa4e --- /dev/null +++ b/test/e2e/oidc/clusterRoleBinding.yaml @@ -0,0 +1,13 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: kube-rbac-proxy + namespace: default +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: kube-rbac-proxy +subjects: + - kind: ServiceAccount + name: kube-rbac-proxy + namespace: default diff --git a/test/e2e/oidc/deployment.yaml b/test/e2e/oidc/deployment.yaml new file mode 100644 index 000000000..e72c96bc8 --- /dev/null +++ b/test/e2e/oidc/deployment.yaml @@ -0,0 +1,49 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: kube-rbac-proxy + namespace: default +spec: + replicas: 1 + selector: + matchLabels: + app: kube-rbac-proxy + template: + metadata: + labels: + app: kube-rbac-proxy + spec: + serviceAccountName: kube-rbac-proxy + volumes: + - name: server-certs + secret: + secretName: kube-rbac-proxy-server-certs + - name: mock-issuer-trust + configMap: + name: mock-issuer-trust + containers: + - name: kube-rbac-proxy + image: quay.io/brancz/kube-rbac-proxy:local + args: + - "--secure-listen-address=0.0.0.0:8443" + - "--tls-cert-file=/usr/local/etc/kube-rbac-proxy/server-certs/tls.crt" + - "--tls-private-key-file=/usr/local/etc/kube-rbac-proxy/server-certs/tls.key" + - "--upstream=http://127.0.0.1:8081/" + - "--oidc-issuer=https://mock-issuer.default.svc.cluster.local:8443" + - "--oidc-clientID=test-client-id" + - "--oidc-username-claim=preferred_username" + - "--oidc-groups-claim=roles" + - "--oidc-ca-file=/usr/local/etc/kube-rbac-proxy/mock-issuer-trust/ca.crt" + - "--v=10" + ports: + - containerPort: 8443 + name: https + volumeMounts: + - mountPath: /usr/local/etc/kube-rbac-proxy/server-certs + name: server-certs + - mountPath: /usr/local/etc/kube-rbac-proxy/mock-issuer-trust + name: mock-issuer-trust + - name: prometheus-example-app + image: quay.io/brancz/prometheus-example-app:v0.5.0 + args: + - "--bind=127.0.0.1:8081" diff --git a/test/e2e/oidc/mock-issuer-service.yaml b/test/e2e/oidc/mock-issuer-service.yaml new file mode 100644 index 000000000..1309f41f8 --- /dev/null +++ b/test/e2e/oidc/mock-issuer-service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + app: mock-issuer + name: mock-issuer +spec: + ports: + - name: api + port: 8443 + protocol: TCP + targetPort: api + selector: + app: mock-issuer + type: ClusterIP diff --git a/test/e2e/oidc/mock-issuer.yaml b/test/e2e/oidc/mock-issuer.yaml new file mode 100644 index 000000000..1d2437fba --- /dev/null +++ b/test/e2e/oidc/mock-issuer.yaml @@ -0,0 +1,54 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mock-issuer + labels: + app: mock-issuer +spec: + replicas: 1 + selector: + matchLabels: + app: mock-issuer + template: + metadata: + name: mock-issuer + labels: + app: mock-issuer + spec: + containers: + - name: mock-issuer + image: wiremock/wiremock:3.9.2 + imagePullPolicy: IfNotPresent + env: + # wiremock has an issue handling arguments properly when run in + # Kubernetes so instead pass everything in an env variable. + - name: WIREMOCK_OPTIONS + value: > + --https-port 8443 + --port 8080 + --https-keystore /usr/local/etc/mock-issuer/keystore/keystore.p12 + --keystore-password password + --keystore-type pkcs12 + --key-manager-password password + --verbose + ports: + - name: api + containerPort: 8443 + protocol: TCP + - name: admin + containerPort: 8080 + protocol: TCP + readinessProbe: + tcpSocket: + port: admin + initialDelaySeconds: 2 + periodSeconds: 2 + successThreshold: 1 + failureThreshold: 10 + volumeMounts: + - name: mock-issuer-keystore + mountPath: /usr/local/etc/mock-issuer/keystore + volumes: + - name: mock-issuer-keystore + secret: + secretName: mock-issuer-keystore diff --git a/test/e2e/oidc/service.yaml b/test/e2e/oidc/service.yaml new file mode 100644 index 000000000..b1ae11686 --- /dev/null +++ b/test/e2e/oidc/service.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + app: kube-rbac-proxy + name: kube-rbac-proxy + namespace: default +spec: + ports: + - name: https + port: 8443 + targetPort: https + selector: + app: kube-rbac-proxy diff --git a/test/e2e/oidc/serviceAccount.yaml b/test/e2e/oidc/serviceAccount.yaml new file mode 100644 index 000000000..45feecc9c --- /dev/null +++ b/test/e2e/oidc/serviceAccount.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: kube-rbac-proxy + namespace: default diff --git a/test/kubetest/forwarder.go b/test/kubetest/forwarder.go new file mode 100644 index 000000000..4daa6b1ed --- /dev/null +++ b/test/kubetest/forwarder.go @@ -0,0 +1,98 @@ +/* +Copyright 2024 the kube-rbac-proxy maintainers. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package kubetest + +import ( + "context" + "fmt" + "io" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/portforward" + "k8s.io/client-go/transport/spdy" + "net/http" + "time" +) + +// findPodByAppName attempts lookup a Pod using its app=X label +func findPodByAppName(client kubernetes.Interface, namespace, name string) (*corev1.Pod, error) { + pods, err := client.CoreV1().Pods(namespace).List(context.Background(), metav1.ListOptions{ + LabelSelector: fmt.Sprintf("app=%s", name), + }) + if err != nil { + return nil, fmt.Errorf("failed to list pods matching app=%s: %w", name, err) + } + + if len(pods.Items) == 0 { + return nil, fmt.Errorf("failed to find pod matching app=%s", name) + } + + return &pods.Items[0], nil +} + +// CreatePortForwarder creates a port forwarder session from a local port to a Pod so that the test infrastructure can +// invoke API endpoint directly if necessary. +func CreatePortForwarder(client kubernetes.Interface, config *rest.Config, podName string, localPort, remotePort int) Action { + return func(ctx *ScenarioContext) error { + pod, err := findPodByAppName(client, ctx.Namespace, podName) + if err != nil { + return fmt.Errorf("failed to find pod matching app=%s: %w", podName, err) + } + + restClient := client.CoreV1().RESTClient() + url := restClient. + Post(). + Resource("pods"). + Namespace(ctx.Namespace). + Name(pod.Name). + SubResource("portforward"). + URL() + + transport, upgrader, err := spdy.RoundTripperFor(config) + if err != nil { + return fmt.Errorf("could not create roundtripper: %w", err) + } + + stopCh := make(chan struct{}, 1) + readyCh := make(chan struct{}, 1) + + dialer := spdy.NewDialer(upgrader, &http.Client{Transport: transport}, http.MethodPost, url) + forwarder, err := portforward.New(dialer, []string{fmt.Sprintf("%d:%d", localPort, remotePort)}, stopCh, readyCh, io.Discard, io.Discard) + if err != nil { + return fmt.Errorf("could not create forwarder: %w", err) + } + go func() { + err := forwarder.ForwardPorts() + if err != nil { + fmt.Println("failed to forward ports: ", err) + return + } + }() + + ctx.AddCleanUp(func() error { stopCh <- struct{}{}; return nil }) + + // Wait for the forwarder to become ready + timeoutCh := time.After(30 * time.Second) + select { + case <-timeoutCh: + return fmt.Errorf("timed out waiting for port forwarder") + case <-readyCh: + return nil + } + } +} diff --git a/test/kubetest/kubernetes.go b/test/kubetest/kubernetes.go index db4a0caec..b6f8bf6d4 100644 --- a/test/kubetest/kubernetes.go +++ b/test/kubetest/kubernetes.go @@ -46,6 +46,10 @@ func CreateClientCerts(client kubernetes.Interface, name string) Action { return createCerts(client, name, createSignedClientCert) } +func WrapCertsInPKSC12(client kubernetes.Interface, name string) Action { + return wrapCertsInPKCS12(client, name) +} + func createCerts(client kubernetes.Interface, name string, createSignedCert createCertsFunc) Action { return func(ctx *ScenarioContext) error { caCert, caKey, err := createSelfSignedCA(fmt.Sprintf("%s-ca", name)) @@ -425,6 +429,7 @@ type RunOptions struct { ServiceAccount string TokenAudience string ClientCertificates bool + OIDCToken string } func RunSucceeds(client kubernetes.Interface, image string, name string, command []string, opts *RunOptions) Action { @@ -511,6 +516,24 @@ func run(client kubernetes.Interface, ctx *ScenarioContext, image string, name s ) } + if opts != nil && opts.OIDCToken != "" { + pod.Spec.Volumes = append(pod.Spec.Volumes, corev1.Volume{ + Name: "clienttoken", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: fmt.Sprintf("%s-token", opts.OIDCToken), + }, + }, + }) + + pod.Spec.Containers[0].VolumeMounts = append(pod.Spec.Containers[0].VolumeMounts, + corev1.VolumeMount{ + Name: "clienttoken", + MountPath: "/tokens", + }, + ) + } + parallelism := int32(1) completions := int32(1) activeDeadlineSeconds := int64(60) diff --git a/test/kubetest/kubetest.go b/test/kubetest/kubetest.go index 10c97fe76..a53c00938 100644 --- a/test/kubetest/kubetest.go +++ b/test/kubetest/kubetest.go @@ -17,10 +17,11 @@ limitations under the License. package kubetest import ( + "github.com/wiremock/go-wiremock" + "k8s.io/client-go/tools/clientcmd" "testing" "k8s.io/client-go/kubernetes" - "k8s.io/client-go/tools/clientcmd" ) func NewClientFromKubeconfig(path string) (kubernetes.Interface, error) { @@ -53,6 +54,7 @@ type Scenario struct { func (s Scenario) Run(t *testing.T) bool { ctx := &ScenarioContext{ Namespace: "default", + stubs: map[string]*wiremock.StubRule{}, } defer func(ctx *ScenarioContext) { @@ -87,12 +89,24 @@ func (s Scenario) Run(t *testing.T) bool { type ScenarioContext struct { Namespace string CleanUp []CleanUp + stubs map[string]*wiremock.StubRule } func (ctx *ScenarioContext) AddCleanUp(f CleanUp) { ctx.CleanUp = append(ctx.CleanUp, f) } +// AddStub adds a wiremock test stub to the test scenario context +func (ctx *ScenarioContext) AddStub(name string, stub *wiremock.StubRule) { + ctx.stubs[name] = stub +} + +// GetStub retrieves a wiremock test stub by the given name +func (ctx *ScenarioContext) GetStub(name string) (*wiremock.StubRule, bool) { + stub, found := ctx.stubs[name] + return stub, found +} + type CleanUp func() error type Action func(ctx *ScenarioContext) error diff --git a/test/kubetest/mock-server.go b/test/kubetest/mock-server.go new file mode 100644 index 000000000..1a7c09458 --- /dev/null +++ b/test/kubetest/mock-server.go @@ -0,0 +1,43 @@ +/* +Copyright 2024 the kube-rbac-proxy maintainers. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package kubetest + +import ( + "fmt" + "github.com/wiremock/go-wiremock" +) + +// VerifyExactly verifies that the stub has been accessed exactly `times` times. +func VerifyExactly(mockServerURL, stubName string, times int64) Action { + return func(ctx *ScenarioContext) error { + mockClient := wiremock.NewClient(mockServerURL) + stub, found := ctx.GetStub(stubName) + if !found { + return fmt.Errorf("stub '%s' not found", stubName) + } + + result, err := mockClient.Verify(stub.Request(), times) + if err != nil { + return fmt.Errorf("error when verifying stub '%s': %v", stubName, err) + } + + if result != true { + return fmt.Errorf("stub '%s' not called %d time(s)", stubName, times) + } + + return nil + } +} diff --git a/test/kubetest/oidc.go b/test/kubetest/oidc.go new file mode 100644 index 000000000..7f341b6b0 --- /dev/null +++ b/test/kubetest/oidc.go @@ -0,0 +1,229 @@ +/* +Copyright 2024 the kube-rbac-proxy maintainers. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package kubetest + +import ( + "context" + "crypto/sha1" + "crypto/sha256" + "crypto/x509" + "encoding/json" + "fmt" + "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4/jwt" + "github.com/wiremock/go-wiremock" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "net/http" + "time" +) + +// Test paths on the mock server +const ( + testWebKeySetURL = "/jwks" + testDiscoveryURL = "/.well-known/openid-configuration" +) + +// Test stubs on the mock server +const ( + WebKeySetStub = "jwks-stub" + DiscoveryStub = "discovery-stub" +) + +// CustomClaims defines additional claims that need to be added to the JWT to test the functionality supported by the +// proxy. +type CustomClaims struct { + *jwt.Claims + Username string `json:"preferred_username,omitempty"` + Roles []string `json:"roles,omitempty"` +} + +// OIDCTokenConfig contains the data required to create a new token and the associated materials. +type OIDCTokenConfig struct { + Algorithm string + Audience []string + ExpiresAt time.Time + IssuedAt time.Time + Issuer string + KeyID string + Name string + PublishedKeyID *string + Roles []string + Subject string + Use string + Username string +} + +// NewTokenConfig creates a token +func NewTokenConfig(name string) *OIDCTokenConfig { + return &OIDCTokenConfig{ + Algorithm: "RS256", + Audience: []string{"test-client-id"}, + ExpiresAt: time.Now().Add(30 * time.Minute), + IssuedAt: time.Now(), + Issuer: "https://mock-issuer.default.svc.cluster.local:8443", + KeyID: "01234567890", + Name: name, + Roles: []string{"metrics"}, + Subject: "test-client-id", + Use: "sign", + Username: "test-client", + } +} + +// CreateOIDCIssuer inserts expectations into a mock-server to act as a basic OIDC issuer +func CreateOIDCIssuer(issuerURL, mockServerURL string) Action { + return func(ctx *ScenarioContext) error { + mockClient := wiremock.NewClient(mockServerURL) + stub := wiremock.Get(wiremock.URLPathEqualTo(testDiscoveryURL)). + WillReturnResponse(wiremock.NewResponse(). + WithJSONBody(map[string]interface{}{ + "issuer": issuerURL, + "authorization_endpoint": fmt.Sprintf("%s/authorize", issuerURL), + "token_endpoint": fmt.Sprintf("%s/token", issuerURL), + "jwks_uri": fmt.Sprintf("%s%s", issuerURL, testWebKeySetURL), + "userinfo_endpoint": fmt.Sprintf("%s/userinfo", issuerURL), + "id_token_signing_alg_values_supported": []string{"RS256"}, + }). + WithStatus(http.StatusOK). + WithHeader("Content-Type", "application/json")). + AtPriority(1) + err := mockClient.StubFor(stub) + + if err != nil { + return fmt.Errorf("failed to register expections with server: %w", err) + } + + ctx.AddStub(DiscoveryStub, stub) + + return err + } +} + +// CreateOIDCToken creates the token and associated materials required to execute a test +func CreateOIDCToken(client kubernetes.Interface, token *OIDCTokenConfig, mockServerURL string) Action { + return func(ctx *ScenarioContext) error { + // Set up the token materials + jsonWebKey, idToken, err := createOIDCToken(token.Name, token) + if err != nil { + return fmt.Errorf("failed to create OIDC token: %w", err) + } + + // Store the token in a secret + secretName := fmt.Sprintf("%s-token", token.Name) + _, err = client.CoreV1().Secrets(ctx.Namespace).Create(context.TODO(), &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + }, + Data: map[string][]byte{ + "token.jwt": idToken, + }, + }, metav1.CreateOptions{}) + ctx.AddCleanUp(func() error { + return client.CoreV1().Secrets(ctx.Namespace).Delete(context.TODO(), secretName, metav1.DeleteOptions{}) + }) + + var keysEntry interface{} + err = json.Unmarshal(jsonWebKey, &keysEntry) + if err != nil { + return fmt.Errorf("failed to unmarshal JWK into object: %w", err) + } + + // Register an expectation with the mock-server for the /jwks endpoint to return the JWK entry + mockClient := wiremock.NewClient(mockServerURL) + stub := wiremock.Get(wiremock.URLPathEqualTo(testWebKeySetURL)). + WillReturnResponse(wiremock.NewResponse(). + WithJSONBody(map[string]interface{}{ + "keys": []interface{}{keysEntry}, + }). + WithStatus(http.StatusOK). + WithHeader("Content-Type", "application/json")). + AtPriority(1) + err = mockClient.StubFor(stub) + if err != nil { + return fmt.Errorf("failed to register /jwks expectations with mock-issuer: %w", err) + } + + ctx.AddStub(WebKeySetStub, stub) + return err + } +} + +// createOIDCToken is a private utility that is capable of create a JWT and its associated JWK entry +func createOIDCToken(name string, token *OIDCTokenConfig) ([]byte, []byte, error) { + // Set up the correct token type + signerOptions := jose.SignerOptions{} + signerOptions.WithType("JWT") + + // Create a self-signed CA cert that can sign the token + cert, key, err := createSelfSignedCA(fmt.Sprintf("%s-token-signer", name)) + signer, err := jose.NewSigner(jose.SigningKey{ + Algorithm: jose.SignatureAlgorithm(token.Algorithm), + Key: jose.JSONWebKey{Key: key, KeyID: token.KeyID}, + }, &signerOptions) + if err != nil { + return nil, nil, fmt.Errorf("failed to create jwt signer: %s", err) + } + + builder := jwt.Signed(signer) + + claims := jwt.Claims{ + Issuer: token.Issuer, + Subject: token.Subject, + IssuedAt: jwt.NewNumericDate(token.IssuedAt), + Expiry: jwt.NewNumericDate(token.ExpiresAt), + Audience: token.Audience, + } + + customClaims := CustomClaims{ + Claims: &claims, + Username: token.Username, + Roles: token.Roles, + } + + // Build the signed token + serializedToken, err := builder.Claims(customClaims).Serialize() + if err != nil { + return nil, nil, fmt.Errorf("failed to serialize token: %s", err) + } + + // Build the corresponding JWK object for the signer + x5tSHA1 := sha1.Sum(cert.Raw) + x5tSHA256 := sha256.Sum256(cert.Raw) + + keyID := token.KeyID + if token.PublishedKeyID != nil { + keyID = *token.PublishedKeyID + } + + jwk := jose.JSONWebKey{ + Key: cert.PublicKey, + KeyID: keyID, + Algorithm: token.Algorithm, + Use: token.Use, + Certificates: []*x509.Certificate{cert}, + CertificateThumbprintSHA1: x5tSHA1[:], + CertificateThumbprintSHA256: x5tSHA256[:], + } + + marshaledJWK, err := jwk.MarshalJSON() + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal JWK: %s", err) + } + + return marshaledJWK, []byte(serializedToken), nil +} diff --git a/test/kubetest/tls.go b/test/kubetest/tls.go index 8deb52428..46a9a9461 100644 --- a/test/kubetest/tls.go +++ b/test/kubetest/tls.go @@ -16,12 +16,18 @@ limitations under the License. package kubetest import ( + "context" "crypto/rand" "crypto/rsa" "crypto/x509" "crypto/x509/pkix" + "encoding/pem" "fmt" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" "math/big" + p12 "software.sslmate.com/src/go-pkcs12" "time" "k8s.io/client-go/util/cert" @@ -157,3 +163,128 @@ func generateSerialNumber() (*big.Int, error) { return serialNumber, nil } + +// extractCaCertFromConfigMap extracts the ca.crt and tls.crt from a ConfigMap +func extractCaCertFromConfigMap(certs *corev1.ConfigMap) (*x509.Certificate, error) { + // Extract the CA PEM bytes from the config map and convert them to a cert object + caCertPEM, ok := certs.Data["ca.crt"] + if !ok { + return nil, fmt.Errorf("failed to find ca.crt from configmap %s", certs.Name) + } + block, _ := pem.Decode([]byte(caCertPEM)) + if block == nil { + return nil, fmt.Errorf("failed to decode ca.crt from configmap %s", certs.Name) + } + caCert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse ca.crt bytes into certificate from configmap %s; %w", certs.Name, err) + } + + return caCert, err +} + +// extractCertAndKeyFromSecret extracts the ca.key and tls.key from a Secret +func extractCertAndKeyFromSecret(keys *corev1.Secret) (*x509.Certificate, *rsa.PrivateKey, error) { + // Extract the PEM bytes from the secret and convert them to a cert object + certPEM, ok := keys.Data["tls.crt"] + if !ok { + return nil, nil, fmt.Errorf("failed to find tls.crt in secret %s", keys.Name) + } + block, _ := pem.Decode(certPEM) + if block == nil { + return nil, nil, fmt.Errorf("failed to decode tls.crt from secret %s", keys.Name) + } + certs, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, nil, fmt.Errorf("failed to parse certificate from secret %s", keys.Name) + } + + // Extract the PEM bytes from the secret and convert them to a cert object + keyPEM, ok := keys.Data["tls.key"] + if !ok { + return nil, nil, fmt.Errorf("failed to find tls.key in secret %s", keys.Name) + } + block, _ = pem.Decode(keyPEM) + if block == nil { + return nil, nil, fmt.Errorf("failed to decode tls.key from secret %s", keys.Name) + } + key, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return nil, nil, fmt.Errorf("failed to parse private key from secret %s", keys.Name) + } + + return certs, key, nil +} + +// wrapCertsInPKSC12 finds certificates and keys that were previously created and stored in a pair of ConfigMap and +// Secret, extracts their contents and repackages them into PKCS12 objects so they can be re-used by components that +// may not directly support PEM encoded certificates or key files and instead require a PKCS12 object. +func wrapCertsInPKCS12(client kubernetes.Interface, name string) Action { + return func(ctx *ScenarioContext) error { + // Fetch the cert and key which were stored in a secret + certsName := fmt.Sprintf("%s-certs", name) + secret, err := client.CoreV1().Secrets(ctx.Namespace).Get(context.TODO(), certsName, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("failed to get secret %s: %w", certsName, err) + } + + // Fetch the CA certificate which was stored in a configmap + trustName := fmt.Sprintf("%s-trust", name) + trust, err := client.CoreV1().ConfigMaps(ctx.Namespace).Get(context.TODO(), trustName, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("failed to get configmap %s: %w", trustName, err) + } + + // Parse out the certs and keys + caCert, err := extractCaCertFromConfigMap(trust) + if err != nil { + return err + } + cert, key, err := extractCertAndKeyFromSecret(secret) + if err != nil { + return err + } + + // Repackage the certs and keys into PKCS12 objects + keyStore, err := p12.Modern.Encode(key, cert, []*x509.Certificate{caCert}, "password") + if err != nil { + return fmt.Errorf("failed to encode keystore: %w", err) + } + + trustStore, err := p12.Modern.EncodeTrustStore([]*x509.Certificate{caCert}, "password") + if err != nil { + return fmt.Errorf("failed to encode truststore: %w", err) + } + + configMapName := fmt.Sprintf("%s-truststore", name) + _, err = client.CoreV1().ConfigMaps(ctx.Namespace).Create(context.TODO(), &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: configMapName, + }, + BinaryData: map[string][]byte{ + "truststore.p12": trustStore, + }, + }, metav1.CreateOptions{}) + if err != nil { + return err + } + ctx.AddCleanUp(func() error { + return client.CoreV1().ConfigMaps(ctx.Namespace).Delete(context.TODO(), configMapName, metav1.DeleteOptions{}) + }) + + secretName := fmt.Sprintf("%s-keystore", name) + _, err = client.CoreV1().Secrets(ctx.Namespace).Create(context.TODO(), &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + }, + Data: map[string][]byte{ + "keystore.p12": keyStore, + }, + }, metav1.CreateOptions{}) + ctx.AddCleanUp(func() error { + return client.CoreV1().Secrets(ctx.Namespace).Delete(context.TODO(), secretName, metav1.DeleteOptions{}) + }) + + return err + } +}