feat(oidc): support per-issuer CA (#3760)

Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com>
This commit is contained in:
Matheus Pimenta
2026-02-01 21:57:27 +00:00
committed by GitHub
parent b905528b6c
commit c8fae88e37
5 changed files with 223 additions and 149 deletions
+47 -1
View File
@@ -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.
+2 -2
View File
@@ -322,7 +322,8 @@ cat <<EOF > /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
+35 -33
View File
@@ -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
}
+130 -113
View File
@@ -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)
})
})
}
+9
View File
@@ -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"`