Skip to content

Commit 2911255

Browse files
committed
feat(jwt-exp): exp claim at the access entry level
Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com>
1 parent b905528 commit 2911255

2 files changed

Lines changed: 163 additions & 0 deletions

File tree

pkg/api/bearer.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"regexp"
77
"slices"
8+
"time"
89

910
"github.com/golang-jwt/jwt/v5"
1011

@@ -16,9 +17,18 @@ var bearerTokenMatch = regexp.MustCompile("(?i)bearer (.*)")
1617
// ResourceAccess is a single entry in the private 'access' claim specified by the distribution token authentication
1718
// specification.
1819
type ResourceAccess struct {
20+
// Standard claims defined in the Distribution spec:
21+
// https://distribution.github.io/distribution/spec/auth/jwt/
22+
1923
Type string `json:"type"`
2024
Name string `json:"name"`
2125
Actions []string `json:"actions"`
26+
27+
// Zot extensions
28+
29+
// ExpiresAt is an optional expiration time for this specific resource access entry.
30+
// If not set, the overall token expiration time (the standard 'exp' claim) applies.
31+
ExpiresAt *jwt.NumericDate `json:"exp,omitempty"`
2232
}
2333

2434
type ResourceAction struct {
@@ -123,6 +133,10 @@ func (a *BearerAuthorizer) Authorize(header string, requested *ResourceAction) e
123133
continue
124134
}
125135

136+
if allowed.ExpiresAt != nil && allowed.ExpiresAt.Time.Before(time.Now()) {
137+
continue
138+
}
139+
126140
// requested action is allowed, so don't return an error
127141
return nil
128142
}

pkg/api/bearer_test.go

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,155 @@ func TestBearerAuthorizer(t *testing.T) {
111111
})
112112
})
113113

114+
Convey("Access entry with per-entry ExpiresAt", func() {
115+
now := time.Now()
116+
117+
Convey("Authorized when ExpiresAt is in the future", func() {
118+
access := []api.ResourceAccess{
119+
{
120+
Name: "authorized-repository",
121+
Type: "repository",
122+
Actions: []string{"pull"},
123+
ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)),
124+
},
125+
}
126+
127+
claims := api.ClaimsWithAccess{
128+
Access: access,
129+
RegisteredClaims: jwt.RegisteredClaims{
130+
ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)),
131+
IssuedAt: jwt.NewNumericDate(now),
132+
Issuer: "Zot",
133+
Audience: []string{"Zot Registry"},
134+
},
135+
}
136+
137+
token, err := jwt.NewWithClaims(signingMethod, claims).SignedString(privKey)
138+
So(err, ShouldBeNil)
139+
140+
requested := &api.ResourceAction{
141+
Type: "repository",
142+
Name: "authorized-repository",
143+
Action: "pull",
144+
}
145+
146+
err = authorizer.Authorize("Bearer "+token, requested)
147+
So(err, ShouldBeNil)
148+
})
149+
150+
Convey("Denied when ExpiresAt is in the past", func() {
151+
access := []api.ResourceAccess{
152+
{
153+
Name: "authorized-repository",
154+
Type: "repository",
155+
Actions: []string{"pull"},
156+
ExpiresAt: jwt.NewNumericDate(now.Add(-time.Hour)),
157+
},
158+
}
159+
160+
claims := api.ClaimsWithAccess{
161+
Access: access,
162+
RegisteredClaims: jwt.RegisteredClaims{
163+
ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)),
164+
IssuedAt: jwt.NewNumericDate(now),
165+
Issuer: "Zot",
166+
Audience: []string{"Zot Registry"},
167+
},
168+
}
169+
170+
token, err := jwt.NewWithClaims(signingMethod, claims).SignedString(privKey)
171+
So(err, ShouldBeNil)
172+
173+
requested := &api.ResourceAction{
174+
Type: "repository",
175+
Name: "authorized-repository",
176+
Action: "pull",
177+
}
178+
179+
err = authorizer.Authorize("Bearer "+token, requested)
180+
So(err, ShouldHaveSameTypeAs, &api.AuthChallengeError{})
181+
So(err, ShouldBeError, zerr.ErrInsufficientScope)
182+
})
183+
184+
Convey("Only the expired entry is skipped, other entries still work", func() {
185+
access := []api.ResourceAccess{
186+
{
187+
Name: "authorized-repository",
188+
Type: "repository",
189+
Actions: []string{"pull"},
190+
ExpiresAt: jwt.NewNumericDate(now.Add(-time.Hour)),
191+
},
192+
{
193+
Name: "authorized-repository",
194+
Type: "repository",
195+
Actions: []string{"pull", "push"},
196+
},
197+
}
198+
199+
claims := api.ClaimsWithAccess{
200+
Access: access,
201+
RegisteredClaims: jwt.RegisteredClaims{
202+
ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)),
203+
IssuedAt: jwt.NewNumericDate(now),
204+
Issuer: "Zot",
205+
Audience: []string{"Zot Registry"},
206+
},
207+
}
208+
209+
token, err := jwt.NewWithClaims(signingMethod, claims).SignedString(privKey)
210+
So(err, ShouldBeNil)
211+
212+
requested := &api.ResourceAction{
213+
Type: "repository",
214+
Name: "authorized-repository",
215+
Action: "pull",
216+
}
217+
218+
err = authorizer.Authorize("Bearer "+token, requested)
219+
So(err, ShouldBeNil)
220+
})
221+
222+
Convey("All entries expired results in insufficient scope", func() {
223+
access := []api.ResourceAccess{
224+
{
225+
Name: "authorized-repository",
226+
Type: "repository",
227+
Actions: []string{"pull"},
228+
ExpiresAt: jwt.NewNumericDate(now.Add(-time.Hour)),
229+
},
230+
{
231+
Name: "authorized-repository",
232+
Type: "repository",
233+
Actions: []string{"pull"},
234+
ExpiresAt: jwt.NewNumericDate(now.Add(-2 * time.Hour)),
235+
},
236+
}
237+
238+
claims := api.ClaimsWithAccess{
239+
Access: access,
240+
RegisteredClaims: jwt.RegisteredClaims{
241+
ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)),
242+
IssuedAt: jwt.NewNumericDate(now),
243+
Issuer: "Zot",
244+
Audience: []string{"Zot Registry"},
245+
},
246+
}
247+
248+
token, err := jwt.NewWithClaims(signingMethod, claims).SignedString(privKey)
249+
So(err, ShouldBeNil)
250+
251+
requested := &api.ResourceAction{
252+
Type: "repository",
253+
Name: "authorized-repository",
254+
Action: "pull",
255+
}
256+
257+
err = authorizer.Authorize("Bearer "+token, requested)
258+
So(err, ShouldHaveSameTypeAs, &api.AuthChallengeError{})
259+
So(err, ShouldBeError, zerr.ErrInsufficientScope)
260+
})
261+
})
262+
114263
Convey("Invalid token", func() {
115264
authHeader := "invalid"
116265

0 commit comments

Comments
 (0)