mirror of
https://github.com/project-zot/zot.git
synced 2026-06-17 21:17:58 +08:00
feat(oidc): support per-issuer CA (#3760)
Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user