From b9aad15ad038647c2b29cb8d2e1d8f678d9a9775 Mon Sep 17 00:00:00 2001 From: Matheus Pimenta Date: Sun, 1 Feb 2026 22:26:36 +0000 Subject: [PATCH] feat(jwt-exp): exp claim at the access entry level (#3761) Signed-off-by: Matheus Pimenta --- pkg/api/bearer.go | 14 ++++ pkg/api/bearer_test.go | 149 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+) diff --git a/pkg/api/bearer.go b/pkg/api/bearer.go index 5fad0470..7b4cd00c 100644 --- a/pkg/api/bearer.go +++ b/pkg/api/bearer.go @@ -5,6 +5,7 @@ import ( "fmt" "regexp" "slices" + "time" "github.com/golang-jwt/jwt/v5" @@ -16,9 +17,18 @@ var bearerTokenMatch = regexp.MustCompile("(?i)bearer (.*)") // ResourceAccess is a single entry in the private 'access' claim specified by the distribution token authentication // specification. type ResourceAccess struct { + // Standard claims defined in the Distribution spec: + // https://distribution.github.io/distribution/spec/auth/jwt/ + Type string `json:"type"` Name string `json:"name"` Actions []string `json:"actions"` + + // Zot extensions + + // ExpiresAt is an optional expiration time for this specific resource access entry. + // If not set, the overall token expiration time (the standard 'exp' claim) applies. + ExpiresAt *jwt.NumericDate `json:"exp,omitempty"` } type ResourceAction struct { @@ -123,6 +133,10 @@ func (a *BearerAuthorizer) Authorize(header string, requested *ResourceAction) e continue } + if allowed.ExpiresAt != nil && allowed.ExpiresAt.Time.Before(time.Now()) { + continue + } + // requested action is allowed, so don't return an error return nil } diff --git a/pkg/api/bearer_test.go b/pkg/api/bearer_test.go index 12237635..c2e06178 100644 --- a/pkg/api/bearer_test.go +++ b/pkg/api/bearer_test.go @@ -111,6 +111,155 @@ func TestBearerAuthorizer(t *testing.T) { }) }) + Convey("Access entry with per-entry ExpiresAt", func() { + now := time.Now() + + Convey("Authorized when ExpiresAt is in the future", func() { + access := []api.ResourceAccess{ + { + Name: "authorized-repository", + Type: "repository", + Actions: []string{"pull"}, + ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)), + }, + } + + claims := api.ClaimsWithAccess{ + Access: access, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)), + IssuedAt: jwt.NewNumericDate(now), + Issuer: "Zot", + Audience: []string{"Zot Registry"}, + }, + } + + token, err := jwt.NewWithClaims(signingMethod, claims).SignedString(privKey) + So(err, ShouldBeNil) + + requested := &api.ResourceAction{ + Type: "repository", + Name: "authorized-repository", + Action: "pull", + } + + err = authorizer.Authorize("Bearer "+token, requested) + So(err, ShouldBeNil) + }) + + Convey("Denied when ExpiresAt is in the past", func() { + access := []api.ResourceAccess{ + { + Name: "authorized-repository", + Type: "repository", + Actions: []string{"pull"}, + ExpiresAt: jwt.NewNumericDate(now.Add(-time.Hour)), + }, + } + + claims := api.ClaimsWithAccess{ + Access: access, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)), + IssuedAt: jwt.NewNumericDate(now), + Issuer: "Zot", + Audience: []string{"Zot Registry"}, + }, + } + + token, err := jwt.NewWithClaims(signingMethod, claims).SignedString(privKey) + So(err, ShouldBeNil) + + requested := &api.ResourceAction{ + Type: "repository", + Name: "authorized-repository", + Action: "pull", + } + + err = authorizer.Authorize("Bearer "+token, requested) + So(err, ShouldHaveSameTypeAs, &api.AuthChallengeError{}) + So(err, ShouldBeError, zerr.ErrInsufficientScope) + }) + + Convey("Only the expired entry is skipped, other entries still work", func() { + access := []api.ResourceAccess{ + { + Name: "authorized-repository", + Type: "repository", + Actions: []string{"pull"}, + ExpiresAt: jwt.NewNumericDate(now.Add(-time.Hour)), + }, + { + Name: "authorized-repository", + Type: "repository", + Actions: []string{"pull", "push"}, + }, + } + + claims := api.ClaimsWithAccess{ + Access: access, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)), + IssuedAt: jwt.NewNumericDate(now), + Issuer: "Zot", + Audience: []string{"Zot Registry"}, + }, + } + + token, err := jwt.NewWithClaims(signingMethod, claims).SignedString(privKey) + So(err, ShouldBeNil) + + requested := &api.ResourceAction{ + Type: "repository", + Name: "authorized-repository", + Action: "pull", + } + + err = authorizer.Authorize("Bearer "+token, requested) + So(err, ShouldBeNil) + }) + + Convey("All entries expired results in insufficient scope", func() { + access := []api.ResourceAccess{ + { + Name: "authorized-repository", + Type: "repository", + Actions: []string{"pull"}, + ExpiresAt: jwt.NewNumericDate(now.Add(-time.Hour)), + }, + { + Name: "authorized-repository", + Type: "repository", + Actions: []string{"pull"}, + ExpiresAt: jwt.NewNumericDate(now.Add(-2 * time.Hour)), + }, + } + + claims := api.ClaimsWithAccess{ + Access: access, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)), + IssuedAt: jwt.NewNumericDate(now), + Issuer: "Zot", + Audience: []string{"Zot Registry"}, + }, + } + + token, err := jwt.NewWithClaims(signingMethod, claims).SignedString(privKey) + So(err, ShouldBeNil) + + requested := &api.ResourceAction{ + Type: "repository", + Name: "authorized-repository", + Action: "pull", + } + + err = authorizer.Authorize("Bearer "+token, requested) + So(err, ShouldHaveSameTypeAs, &api.AuthChallengeError{}) + So(err, ShouldBeError, zerr.ErrInsufficientScope) + }) + }) + Convey("Invalid token", func() { authHeader := "invalid"