feat(jwt-exp): exp claim at the access entry level (#3761)

Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com>
This commit is contained in:
Matheus Pimenta
2026-02-01 22:26:36 +00:00
committed by GitHub
parent 063014a942
commit b9aad15ad0
2 changed files with 163 additions and 0 deletions
+14
View File
@@ -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
}
+149
View File
@@ -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"