diff --git a/examples/README-OIDC-WORKLOAD-IDENTITY.md b/examples/README-OIDC-WORKLOAD-IDENTITY.md index da74310e..ea4c9961 100644 --- a/examples/README-OIDC-WORKLOAD-IDENTITY.md +++ b/examples/README-OIDC-WORKLOAD-IDENTITY.md @@ -31,7 +31,8 @@ Add OIDC workload identity configuration to your bearer authentication settings. "oidc": [ { "issuer": "https://kubernetes.default.svc.cluster.local", - "audiences": ["zot"] + "audiences": ["zot"], + "allowBasicAuth": true } ] } @@ -55,6 +56,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) +- **`allowBasicAuth`** (optional): Allow OIDC token extraction from HTTP Basic credentials (`username:token`). + - Default: `false` + - Use this only when clients cannot send Bearer tokens. + - **`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`. @@ -105,6 +110,7 @@ is specified (so the whole `claimMapping` section could be omitted in this examp { "issuer": "https://kubernetes.default.svc.cluster.local", "audiences": ["zot", "https://zot.example.com"], + "allowBasicAuth": true, "claimMapping": { "username": "claims.iss + '/' + claims.sub" } diff --git a/examples/config-bearer-oidc-workload.json b/examples/config-bearer-oidc-workload.json index 20b11626..4318d2d2 100644 --- a/examples/config-bearer-oidc-workload.json +++ b/examples/config-bearer-oidc-workload.json @@ -14,6 +14,7 @@ { "issuer": "https://kubernetes.default.svc.cluster.local", "audiences": ["zot", "https://zot.example.com"], + "allowBasicAuth": true, "claimMapping": { "username": "claims.sub" } diff --git a/pkg/api/authn_test.go b/pkg/api/authn_test.go index 1d72bcbb..dd9becd4 100644 --- a/pkg/api/authn_test.go +++ b/pkg/api/authn_test.go @@ -1244,6 +1244,46 @@ func TestBearerOIDCWorkloadIdentity(t *testing.T) { port := test.GetFreePort() baseURL := test.GetBaseURL(port) + conf.HTTP.Port = port + conf.HTTP.Auth = &config.AuthConfig{ + Bearer: &config.BearerConfig{ + OIDC: []config.BearerOIDCConfig{{ + Issuer: issuer, + Audiences: []string{audience}, + AllowBasicAuth: true, + }}, + }, + } + conf.Storage.RootDirectory = t.TempDir() + + ctlr := api.NewController(conf) + cm := test.NewControllerManager(ctlr) + + cm.StartAndWait(port) + defer cm.StopServer() + + token, err := createWorkloadOIDCToken(privKey, issuer, audience, nil) + So(err, ShouldBeNil) + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, baseURL+"/v2/_catalog", nil) + So(err, ShouldBeNil) + + basicAuth := base64.StdEncoding.EncodeToString([]byte("workload:" + token)) + req.Header.Set("Authorization", "Basic "+basicAuth) + + client := &http.Client{} + resp, err := client.Do(req) + So(err, ShouldBeNil) + defer resp.Body.Close() + + So(resp.StatusCode, ShouldEqual, http.StatusOK) + }) + + Convey("OIDC authentication with token in basic auth password is disabled by default", func() { + conf := config.New() + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + conf.HTTP.Port = port conf.HTTP.Auth = &config.AuthConfig{ Bearer: &config.BearerConfig{ @@ -1275,7 +1315,7 @@ func TestBearerOIDCWorkloadIdentity(t *testing.T) { So(err, ShouldBeNil) defer resp.Body.Close() - So(resp.StatusCode, ShouldEqual, http.StatusOK) + So(resp.StatusCode, ShouldEqual, http.StatusUnauthorized) }) Convey("OIDC authentication success with groups", func() { diff --git a/pkg/api/bearer_oidc.go b/pkg/api/bearer_oidc.go index c1d01fde..6d412766 100644 --- a/pkg/api/bearer_oidc.go +++ b/pkg/api/bearer_oidc.go @@ -37,6 +37,7 @@ type oidcProvider struct { issuer string audiences []string claimProcessor *cel.ClaimProcessor + allowBasicAuth bool skipIssuerCheck bool httpClient *http.Client log log.Logger @@ -157,6 +158,7 @@ func newOIDCProvider(oidcConfig *config.BearerOIDCConfig, log log.Logger) (*oidc issuer: oidcConfig.Issuer, audiences: oidcConfig.Audiences, claimProcessor: claimProcessor, + allowBasicAuth: oidcConfig.AllowBasicAuth, skipIssuerCheck: oidcConfig.SkipIssuerVerification, httpClient: httpClient, log: log, @@ -169,7 +171,7 @@ func (a *oidcProvider) authenticate(ctx context.Context, header string) (*cel.Cl } // Extract token from Authorization header. - tokenString, err := getOIDCTokenFromAuthorizationHeader(header) + tokenString, err := getOIDCTokenFromAuthorizationHeader(header, a.allowBasicAuth) if err != nil { return nil, err } @@ -209,7 +211,7 @@ func (a *oidcProvider) authenticate(ctx context.Context, header string) (*cel.Cl return res, nil } -func getOIDCTokenFromAuthorizationHeader(header string) (string, error) { +func getOIDCTokenFromAuthorizationHeader(header string, allowBasicAuth bool) (string, error) { splitStr := strings.SplitN(header, " ", 2) //nolint:mnd if len(splitStr) != 2 { return "", zerr.ErrInvalidBearerToken @@ -224,6 +226,10 @@ func getOIDCTokenFromAuthorizationHeader(header string) (string, error) { return tokenString, nil case "basic": + if !allowBasicAuth { + return "", zerr.ErrInvalidBearerToken + } + decodedStr, err := base64.StdEncoding.DecodeString(splitStr[1]) if err != nil { return "", fmt.Errorf("%w: %w", zerr.ErrInvalidBearerToken, err) diff --git a/pkg/api/bearer_oidc_test.go b/pkg/api/bearer_oidc_test.go index 449efdee..cfac7299 100644 --- a/pkg/api/bearer_oidc_test.go +++ b/pkg/api/bearer_oidc_test.go @@ -184,6 +184,14 @@ func TestOIDCBearerAuthorizer(t *testing.T) { Convey("Valid token in basic auth password", func() { subject := "test-user" //nolint:goconst + cfg := []config.BearerOIDCConfig{{ + Issuer: issuer, + Audiences: []string{audience}, + AllowBasicAuth: true, + }} + authorizer, err := api.NewOIDCBearerAuthorizer(cfg, logger) + So(err, ShouldBeNil) + token, err := createTestOIDCToken(privKey, issuer, audience, subject, nil) So(err, ShouldBeNil) @@ -197,6 +205,19 @@ func TestOIDCBearerAuthorizer(t *testing.T) { So(result.Groups, ShouldBeEmpty) }) + Convey("Basic auth token is rejected by default", func() { + subject := "test-user" //nolint:goconst + token, err := createTestOIDCToken(privKey, issuer, audience, subject, nil) + So(err, ShouldBeNil) + + basicCredentials := base64.StdEncoding.EncodeToString([]byte("workload:" + token)) + authHeader := "Basic " + basicCredentials + + result, err := authorizer.Authenticate(ctx, authHeader) + So(err, ShouldNotBeNil) + So(result, ShouldBeNil) + }) + Convey("Valid token with groups", func() { subject := "test-user" testGroups := []string{"group1", "group2"} diff --git a/pkg/api/config/config.go b/pkg/api/config/config.go index 43614748..b29e163b 100644 --- a/pkg/api/config/config.go +++ b/pkg/api/config/config.go @@ -250,6 +250,11 @@ type BearerOIDCConfig struct { // Default: {"username":"claims.iss + '/' + claims.sub"} ClaimMapping *CELClaimValidationAndMapping `json:"claimMapping,omitempty" mapstructure:"claimMapping,omitempty"` + // AllowBasicAuth enables extracting OIDC tokens from HTTP Basic credentials + // (using the password field, or username if password is empty). + // Default: false + AllowBasicAuth bool `json:"allowBasicAuth,omitempty" mapstructure:"allowBasicAuth,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"`