From c8fae88e374320a6209c82441d28d0148d45b30b Mon Sep 17 00:00:00 2001 From: Matheus Pimenta Date: Sun, 1 Feb 2026 21:57:27 +0000 Subject: [PATCH] feat(oidc): support per-issuer CA (#3760) Signed-off-by: Matheus Pimenta --- examples/README-OIDC-WORKLOAD-IDENTITY.md | 48 +++- examples/kind/kind-oidc-workload-identity.sh | 4 +- pkg/api/bearer_oidc.go | 68 +++--- pkg/api/bearer_oidc_test.go | 243 ++++++++++--------- pkg/api/config/config.go | 9 + 5 files changed, 223 insertions(+), 149 deletions(-) diff --git a/examples/README-OIDC-WORKLOAD-IDENTITY.md b/examples/README-OIDC-WORKLOAD-IDENTITY.md index 3e4e112b..da74310e 100644 --- a/examples/README-OIDC-WORKLOAD-IDENTITY.md +++ b/examples/README-OIDC-WORKLOAD-IDENTITY.md @@ -55,6 +55,10 @@ Add OIDC workload identity configuration to your bearer authentication settings. - **`username`**: CEL expression to extract the username. Default: `"claims.iss + '/' + claims.sub"` - **`groups`**: CEL expression to extract groups. Default: none (no groups extracted) +- **`certificateAuthority`** (optional): PEM-encoded CA certificate to validate the OIDC provider's TLS certificate. Useful when the OIDC issuer uses a private CA (e.g., Kubernetes API server with a self-signed certificate). Mutually exclusive with `certificateAuthorityFile`. + +- **`certificateAuthorityFile`** (optional): Path to a PEM-encoded CA certificate file to validate the OIDC provider's TLS certificate. Mutually exclusive with `certificateAuthority`. + - **`skipIssuerVerification`** (optional): Skip issuer verification (for testing only). Default: `false`. ### CEL Expressions @@ -127,6 +131,48 @@ is specified (so the whole `claimMapping` section could be omitted in this examp } ``` +### Configuration with Custom CA + +When the OIDC issuer uses a private CA (e.g., Kubernetes API server), you can configure the CA certificate inline or via a file path: + +```json +{ + "http": { + "auth": { + "bearer": { + "oidc": [ + { + "issuer": "https://kubernetes.default.svc.cluster.local", + "audiences": ["zot"], + "certificateAuthorityFile": "/etc/zot/k8s-ca.pem" + } + ] + } + } + } +} +``` + +Alternatively, you can embed the CA certificate directly using `certificateAuthority`: + +```json +{ + "http": { + "auth": { + "bearer": { + "oidc": [ + { + "issuer": "https://kubernetes.default.svc.cluster.local", + "audiences": ["zot"], + "certificateAuthority": "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----" + } + ] + } + } + } +} +``` + ### Advanced Configuration with CEL Validations Use CEL expressions to validate claims and extract complex usernames: @@ -378,7 +424,7 @@ Set log level to `debug` to see detailed authentication logs: 5. **Validation failed**: Check that your token claims satisfy all configured validation expressions. -6. **JWKS endpoint not reachable**: Verify network connectivity to the OIDC issuer's JWKS endpoint. Note: Zot lazily initializes the OIDC provider on first authentication, so startup won't fail if the issuer is temporarily unreachable. +6. **JWKS endpoint not reachable**: Verify network connectivity to the OIDC issuer's JWKS endpoint. Note: Zot lazily initializes the OIDC provider on first authentication, so startup won't fail if the issuer is temporarily unreachable. If the issuer uses a private CA, configure `certificateAuthority` or `certificateAuthorityFile` for the corresponding OIDC provider. 7. **No username found**: Ensure the CEL expression for username evaluates to a non-empty string. Check that the required claims exist in the token. diff --git a/examples/kind/kind-oidc-workload-identity.sh b/examples/kind/kind-oidc-workload-identity.sh index 230ac178..4ed04520 100755 --- a/examples/kind/kind-oidc-workload-identity.sh +++ b/examples/kind/kind-oidc-workload-identity.sh @@ -322,7 +322,8 @@ cat < /tmp/zot-oidc-config.json "oidc": [ { "issuer": "${OIDC_ISSUER}", - "audiences": ["${AUDIENCE}"] + "audiences": ["${AUDIENCE}"], + "certificateAuthorityFile": "/etc/zot/kind-ca.pem" } ] } @@ -358,7 +359,6 @@ docker run -d \ -p "127.0.0.1:${ZOT_PORT}:${ZOT_PORT}" \ -v /tmp/zot-oidc-config.json:/etc/zot/config.json:ro \ -v /tmp/kind-ca.pem:/etc/zot/kind-ca.pem:ro \ - -e ZOT_BEARER_OIDC_TEST_CA_FILE=/etc/zot/kind-ca.pem \ "${IMAGE_NAME}" \ serve /etc/zot/config.json diff --git a/pkg/api/bearer_oidc.go b/pkg/api/bearer_oidc.go index 4ccc2bee..899f0152 100644 --- a/pkg/api/bearer_oidc.go +++ b/pkg/api/bearer_oidc.go @@ -39,6 +39,7 @@ type oidcProvider struct { audiences []string claimProcessor *cel.ClaimProcessor skipIssuerCheck bool + httpClient *http.Client log log.Logger // The *oidc.IDTokenVerifier is created lazily to avoid network calls during initialization. @@ -121,12 +122,44 @@ func newOIDCProvider(oidcConfig *config.BearerOIDCConfig, log log.Logger) (*oidc if err != nil { return nil, fmt.Errorf("failed to create claim processor: %w", err) } + if oidcConfig.CertificateAuthority != "" && oidcConfig.CertificateAuthorityFile != "" { + return nil, fmt.Errorf("%w: only one of certificateAuthority or certificateAuthorityFile can be set", + zerr.ErrBadConfig) + } + + // Prepare CA. + caCert := []byte(oidcConfig.CertificateAuthority) + if file := oidcConfig.CertificateAuthorityFile; file != "" { + caCert, err = os.ReadFile(file) + if err != nil { + return nil, fmt.Errorf("failed to read certificate authority file: %w", err) + } + } + + var httpClient *http.Client + if len(caCert) > 0 { + certPool := x509.NewCertPool() + if !certPool.AppendCertsFromPEM(caCert) { + return nil, fmt.Errorf("%w: failed to append certificate authority PEM", zerr.ErrBadConfig) + } + defaultTransport, ok := http.DefaultTransport.(*http.Transport) + if !ok { + return nil, fmt.Errorf("%w: failed to get default HTTP transport", zerr.ErrBadConfig) + } + testTransport := defaultTransport.Clone() + testTransport.TLSClientConfig = &tls.Config{ + RootCAs: certPool, + MinVersion: tls.VersionTLS12, + } + httpClient = &http.Client{Transport: testTransport} + } return &oidcProvider{ issuer: oidcConfig.Issuer, audiences: oidcConfig.Audiences, claimProcessor: claimProcessor, skipIssuerCheck: oidcConfig.SkipIssuerVerification, + httpClient: httpClient, log: log, }, nil } @@ -188,7 +221,7 @@ func (o *oidcProvider) getVerifier(ctx context.Context) (*oidc.IDTokenVerifier, } // Time to refresh the verifier. - if hc := GetBearerOIDCTestHTTPClient(); hc != nil { + if hc := o.httpClient; hc != nil { ctx = oidc.ClientContext(ctx, hc) } p, err := oidc.NewProvider(ctx, o.issuer) @@ -209,36 +242,5 @@ func (o *oidcProvider) getVerifier(ctx context.Context) (*oidc.IDTokenVerifier, o.verifierDeadline = time.Now().Add(oidcProviderRefreshInterval) o.verifierMu.Unlock() - return o.verifier, nil -} - -// GetBearerOIDCTestHTTPClient returns an HTTP client for testing purposes. -// It looks up a test environment variable pointing to a PEM-encoded -// CA certificate to trust when making requests to the OIDC issuer. -// If no such variable is set, it returns nil. This environment variable -// is not meant for production use. -func GetBearerOIDCTestHTTPClient() *http.Client { - caFile := os.Getenv("ZOT_BEARER_OIDC_TEST_CA_FILE") - if caFile == "" { - return nil - } - caCert, err := os.ReadFile(caFile) - if err != nil { - return nil - } - certPool := x509.NewCertPool() - if !certPool.AppendCertsFromPEM(caCert) { - return nil - } - defaultTransport, ok := http.DefaultTransport.(*http.Transport) - if !ok { - return nil - } - testTransport := defaultTransport.Clone() - testTransport.TLSClientConfig = &tls.Config{ - RootCAs: certPool, - MinVersion: tls.VersionTLS12, - } - - return &http.Client{Transport: testTransport} + return verifier, nil } diff --git a/pkg/api/bearer_oidc_test.go b/pkg/api/bearer_oidc_test.go index e0c34c52..045f90f9 100644 --- a/pkg/api/bearer_oidc_test.go +++ b/pkg/api/bearer_oidc_test.go @@ -8,7 +8,6 @@ import ( "crypto/x509/pkix" "encoding/json" "encoding/pem" - "errors" "maps" "math/big" "net/http" @@ -691,115 +690,7 @@ func TestBearerOIDCConfig(t *testing.T) { }) } -func TestGetBearerOIDCTestHTTPClient(t *testing.T) { - Convey("Test GetBearerOIDCTestHTTPClient", t, func() { - // Save original env var and restore after test - originalEnv := os.Getenv("ZOT_BEARER_OIDC_TEST_CA_FILE") - Reset(func() { - os.Setenv("ZOT_BEARER_OIDC_TEST_CA_FILE", originalEnv) - }) - - Convey("Returns nil when env var is empty", func() { - os.Unsetenv("ZOT_BEARER_OIDC_TEST_CA_FILE") - - client := api.GetBearerOIDCTestHTTPClient() - So(client, ShouldBeNil) - }) - - Convey("Returns nil when CA file does not exist", func() { - os.Setenv("ZOT_BEARER_OIDC_TEST_CA_FILE", "/nonexistent/path/to/ca.crt") - - client := api.GetBearerOIDCTestHTTPClient() - So(client, ShouldBeNil) - }) - - Convey("Returns nil when CA file contains invalid PEM data", func() { - tmpDir := t.TempDir() - caFile := filepath.Join(tmpDir, "invalid-ca.crt") - - err := os.WriteFile(caFile, []byte("not a valid PEM certificate"), 0o600) - So(err, ShouldBeNil) - - os.Setenv("ZOT_BEARER_OIDC_TEST_CA_FILE", caFile) - - client := api.GetBearerOIDCTestHTTPClient() - So(client, ShouldBeNil) - }) - - Convey("Returns nil when CA file contains valid PEM but not a certificate", func() { - tmpDir := t.TempDir() - caFile := filepath.Join(tmpDir, "not-a-cert.pem") - - // Create a valid PEM block but with wrong type - pemBlock := &pem.Block{ - Type: "PRIVATE KEY", - Bytes: []byte("fake key data"), - } - pemData := pem.EncodeToMemory(pemBlock) - - err := os.WriteFile(caFile, pemData, 0o600) - So(err, ShouldBeNil) - - os.Setenv("ZOT_BEARER_OIDC_TEST_CA_FILE", caFile) - - client := api.GetBearerOIDCTestHTTPClient() - So(client, ShouldBeNil) - }) - - Convey("Returns configured HTTP client with valid CA file", func() { - tmpDir := t.TempDir() - caFile := filepath.Join(tmpDir, "ca.crt") - - // Generate a self-signed CA certificate - caCert := createTestCACertificate(t) - - err := os.WriteFile(caFile, caCert, 0o600) - So(err, ShouldBeNil) - - os.Setenv("ZOT_BEARER_OIDC_TEST_CA_FILE", caFile) - - client := api.GetBearerOIDCTestHTTPClient() - So(client, ShouldNotBeNil) - So(client.Transport, ShouldNotBeNil) - }) - - Convey("Returns nil when http.DefaultTransport is not *http.Transport", func() { - tmpDir := t.TempDir() - caFile := filepath.Join(tmpDir, "ca.crt") - - // Generate a valid CA certificate - caCert := createTestCACertificate(t) - - err := os.WriteFile(caFile, caCert, 0o600) - So(err, ShouldBeNil) - - os.Setenv("ZOT_BEARER_OIDC_TEST_CA_FILE", caFile) - - // Save the original DefaultTransport and restore after test - originalTransport := http.DefaultTransport - defer func() { - http.DefaultTransport = originalTransport - }() - - // Replace with a custom RoundTripper that is not *http.Transport - http.DefaultTransport = &customRoundTripper{} - - client := api.GetBearerOIDCTestHTTPClient() - So(client, ShouldBeNil) - }) - }) -} - -// customRoundTripper is a mock RoundTripper that is not *http.Transport. -type customRoundTripper struct{} - -var errNotImplemented = errors.New("not implemented") - -func (c *customRoundTripper) RoundTrip(*http.Request) (*http.Response, error) { - return nil, errNotImplemented -} - -// createTestCACertificate generates a self-signed CA certificate for testing. +// createTestCACertificate generates a self-signed CA certificate PEM for testing. func createTestCACertificate(t *testing.T) []byte { t.Helper() @@ -825,10 +716,136 @@ func createTestCACertificate(t *testing.T) []byte { t.Fatalf("failed to create certificate: %v", err) } - certPEM := pem.EncodeToMemory(&pem.Block{ + return pem.EncodeToMemory(&pem.Block{ Type: "CERTIFICATE", Bytes: certDER, }) - - return certPEM +} + +func TestOIDCProviderCertificateAuthority(t *testing.T) { + Convey("Test OIDC provider certificate authority configuration", t, func() { + logger := log.NewLogger("debug", "") + + Convey("Both certificateAuthority and certificateAuthorityFile set should fail", func() { + caPEM := createTestCACertificate(t) + + tmpDir := t.TempDir() + caFile := filepath.Join(tmpDir, "ca.crt") + err := os.WriteFile(caFile, caPEM, 0o600) + So(err, ShouldBeNil) + + cfg := []config.BearerOIDCConfig{{ + Issuer: "https://issuer.example.com", + Audiences: []string{"zot"}, + CertificateAuthority: string(caPEM), + CertificateAuthorityFile: caFile, + }} + + _, err = api.NewOIDCBearerAuthorizer(cfg, logger) + So(err, ShouldNotBeNil) + So(err.Error(), ShouldContainSubstring, "only one of certificateAuthority or certificateAuthorityFile can be set") + }) + + Convey("Valid inline certificateAuthority should succeed", func() { + caPEM := createTestCACertificate(t) + + cfg := []config.BearerOIDCConfig{{ + Issuer: "https://issuer.example.com", + Audiences: []string{"zot"}, + CertificateAuthority: string(caPEM), + }} + + authorizer, err := api.NewOIDCBearerAuthorizer(cfg, logger) + So(err, ShouldBeNil) + So(authorizer, ShouldNotBeNil) + }) + + Convey("Valid certificateAuthorityFile should succeed", func() { + caPEM := createTestCACertificate(t) + + tmpDir := t.TempDir() + caFile := filepath.Join(tmpDir, "ca.crt") + err := os.WriteFile(caFile, caPEM, 0o600) + So(err, ShouldBeNil) + + cfg := []config.BearerOIDCConfig{{ + Issuer: "https://issuer.example.com", + Audiences: []string{"zot"}, + CertificateAuthorityFile: caFile, + }} + + authorizer, err := api.NewOIDCBearerAuthorizer(cfg, logger) + So(err, ShouldBeNil) + So(authorizer, ShouldNotBeNil) + }) + + Convey("Non-existent certificateAuthorityFile should fail", func() { + cfg := []config.BearerOIDCConfig{{ + Issuer: "https://issuer.example.com", + Audiences: []string{"zot"}, + CertificateAuthorityFile: "/nonexistent/path/to/ca.crt", + }} + + _, err := api.NewOIDCBearerAuthorizer(cfg, logger) + So(err, ShouldNotBeNil) + So(err.Error(), ShouldContainSubstring, "failed to read certificate authority file") + }) + + Convey("Invalid PEM in certificateAuthority should fail", func() { + cfg := []config.BearerOIDCConfig{{ + Issuer: "https://issuer.example.com", + Audiences: []string{"zot"}, + CertificateAuthority: "not a valid PEM certificate", + }} + + _, err := api.NewOIDCBearerAuthorizer(cfg, logger) + So(err, ShouldNotBeNil) + So(err.Error(), ShouldContainSubstring, "failed to append certificate authority PEM") + }) + + Convey("Invalid PEM in certificateAuthorityFile should fail", func() { + tmpDir := t.TempDir() + caFile := filepath.Join(tmpDir, "invalid-ca.crt") + err := os.WriteFile(caFile, []byte("not a valid PEM certificate"), 0o600) + So(err, ShouldBeNil) + + cfg := []config.BearerOIDCConfig{{ + Issuer: "https://issuer.example.com", + Audiences: []string{"zot"}, + CertificateAuthorityFile: caFile, + }} + + _, err = api.NewOIDCBearerAuthorizer(cfg, logger) + So(err, ShouldNotBeNil) + So(err.Error(), ShouldContainSubstring, "failed to append certificate authority PEM") + }) + + Convey("PEM block that is not a certificate should fail", func() { + pemBlock := pem.EncodeToMemory(&pem.Block{ + Type: "PRIVATE KEY", + Bytes: []byte("fake key data"), + }) + + cfg := []config.BearerOIDCConfig{{ + Issuer: "https://issuer.example.com", + Audiences: []string{"zot"}, + CertificateAuthority: string(pemBlock), + }} + + _, err := api.NewOIDCBearerAuthorizer(cfg, logger) + So(err, ShouldNotBeNil) + So(err.Error(), ShouldContainSubstring, "failed to append certificate authority PEM") + }) + + Convey("No certificate authority configured should succeed", func() { + cfg := []config.BearerOIDCConfig{{ + Issuer: "https://issuer.example.com", + Audiences: []string{"zot"}, + }} + + authorizer, err := api.NewOIDCBearerAuthorizer(cfg, logger) + So(err, ShouldBeNil) + So(authorizer, ShouldNotBeNil) + }) + }) } diff --git a/pkg/api/config/config.go b/pkg/api/config/config.go index 392cdf10..eefc4428 100644 --- a/pkg/api/config/config.go +++ b/pkg/api/config/config.go @@ -226,6 +226,15 @@ type BearerOIDCConfig struct { // Default: {"username":"claims.iss + '/' + claims.sub"} ClaimMapping *CELClaimValidationAndMapping `json:"claimMapping,omitempty" mapstructure:"claimMapping,omitempty"` + // CertificateAuthority is a PEM-encoded optional CA certificate to validate the OIDC provider's TLS certificate. + // Mutually exclusive with CertificateAuthorityFile. + CertificateAuthority string `json:"certificateAuthority,omitempty" mapstructure:"certificateAuthority,omitempty"` + + // CertificateAuthorityFile is the path to a PEM-encoded optional CA certificate + // to validate the OIDC provider's TLS certificate. + // Mutually exclusive with CertificateAuthority. + CertificateAuthorityFile string `json:"certificateAuthorityFile,omitempty" mapstructure:"certificateAuthorityFile,omitempty"` //nolint:lll + // SkipIssuerVerification skips issuer verification (for testing only). // Default: false SkipIssuerVerification bool `json:"skipIssuerVerification,omitempty" mapstructure:"skipIssuerVerification,omitempty"`