From 3b040cc6d76ce3d4946ad6daf52bfabdc8ba45d0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 22:37:34 +0000 Subject: [PATCH] feat(api): support OIDC workload token via HTTP Basic auth Agent-Logs-Url: https://github.com/project-zot/zot/sessions/0c0a0243-d702-44d5-a93f-457595fe485d Co-authored-by: rchincha <45800463+rchincha@users.noreply.github.com> --- pkg/api/authn_test.go | 39 +++++++++++++++++++++++++++ pkg/api/bearer_oidc.go | 53 ++++++++++++++++++++++++++++++++----- pkg/api/bearer_oidc_test.go | 16 +++++++++++ 3 files changed, 101 insertions(+), 7 deletions(-) diff --git a/pkg/api/authn_test.go b/pkg/api/authn_test.go index f0e60c99..1d72bcbb 100644 --- a/pkg/api/authn_test.go +++ b/pkg/api/authn_test.go @@ -1239,6 +1239,45 @@ func TestBearerOIDCWorkloadIdentity(t *testing.T) { So(resp.StatusCode, ShouldEqual, http.StatusOK) }) + Convey("OIDC authentication success with token in basic auth password", func() { + conf := config.New() + 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}, + }}, + }, + } + 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 success with groups", func() { conf := config.New() port := test.GetFreePort() diff --git a/pkg/api/bearer_oidc.go b/pkg/api/bearer_oidc.go index 9b14f7a1..5873655f 100644 --- a/pkg/api/bearer_oidc.go +++ b/pkg/api/bearer_oidc.go @@ -4,11 +4,12 @@ import ( "context" "crypto/tls" "crypto/x509" + "encoding/base64" "errors" "fmt" "net/http" "os" - "regexp" + "strings" "sync" "time" @@ -25,8 +26,6 @@ import ( // a refresh roughly once per minute, but this is best-effort and not a strict upper bound. const oidcProviderRefreshInterval = 1 * time.Minute -var bearerOIDCTokenMatch = regexp.MustCompile("(?i)bearer (.*)") - // OIDCBearerAuthorizer validates OIDC ID tokens for workload identity authentication. type OIDCBearerAuthorizer struct { providers []*oidcProvider @@ -169,10 +168,10 @@ func (a *oidcProvider) authenticate(ctx context.Context, header string) (*cel.Cl return nil, zerr.ErrNoBearerToken } - // Extract token from Authorization header - tokenString := bearerOIDCTokenMatch.ReplaceAllString(header, "$1") - if tokenString == "" || tokenString == header { - return nil, zerr.ErrInvalidBearerToken + // Extract token from Authorization header. + tokenString, err := getOIDCTokenFromAuthorizationHeader(header) + if err != nil { + return nil, err } // Get verifier. @@ -210,6 +209,46 @@ func (a *oidcProvider) authenticate(ctx context.Context, header string) (*cel.Cl return res, nil } +func getOIDCTokenFromAuthorizationHeader(header string) (string, error) { + splitStr := strings.SplitN(header, " ", 2) //nolint:mnd + if len(splitStr) != 2 { + return "", zerr.ErrInvalidBearerToken + } + + switch strings.ToLower(splitStr[0]) { + case "bearer": + tokenString := strings.TrimSpace(splitStr[1]) + if tokenString == "" { + return "", zerr.ErrInvalidBearerToken + } + + return tokenString, nil + case "basic": + decodedStr, err := base64.StdEncoding.DecodeString(splitStr[1]) + if err != nil { + return "", zerr.ErrInvalidBearerToken + } + + pair := strings.SplitN(string(decodedStr), ":", 2) //nolint:mnd + if len(pair) != 2 { //nolint:mnd + return "", zerr.ErrInvalidBearerToken + } + + tokenString := pair[1] + if tokenString == "" { + tokenString = pair[0] + } + + if strings.TrimSpace(tokenString) == "" { + return "", zerr.ErrInvalidBearerToken + } + + return tokenString, nil + default: + return "", zerr.ErrInvalidBearerToken + } +} + // getVerifier retrieves or refreshes the oidc.IDTokenVerifier as needed. func (o *oidcProvider) getVerifier(ctx context.Context) (*oidc.IDTokenVerifier, error) { // If the verifier is still fresh, return it. diff --git a/pkg/api/bearer_oidc_test.go b/pkg/api/bearer_oidc_test.go index 045f90f9..449efdee 100644 --- a/pkg/api/bearer_oidc_test.go +++ b/pkg/api/bearer_oidc_test.go @@ -6,6 +6,7 @@ import ( "crypto/rsa" "crypto/x509" "crypto/x509/pkix" + "encoding/base64" "encoding/json" "encoding/pem" "maps" @@ -181,6 +182,21 @@ func TestOIDCBearerAuthorizer(t *testing.T) { So(result.Groups, ShouldBeEmpty) }) + Convey("Valid token in basic auth password", 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, ShouldBeNil) + So(result, ShouldNotBeNil) + So(result.Username, ShouldEqual, issuer+"/"+subject) + So(result.Groups, ShouldBeEmpty) + }) + Convey("Valid token with groups", func() { subject := "test-user" testGroups := []string{"group1", "group2"}