Skip to content

Commit f5da28d

Browse files
auth: add ClockSkew option to RequireBearerTokenOptions
Resource servers running behind a CDN, in distributed deployments, or communicating with an authorization server whose clock drifts a few seconds need a small positive tolerance when checking token expiration. The default zero value preserves the existing strict comparison. Adds a ClockSkew time.Duration field to RequireBearerTokenOptions plus a TestRequireBearerToken_ClockSkew test covering the four meaningful combinations (fresh accept, strict-expired reject, within-skew accept, beyond-skew reject). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bf69179 commit f5da28d

2 files changed

Lines changed: 79 additions & 2 deletions

File tree

auth/auth.go

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,19 @@ type RequireBearerTokenOptions struct {
4747
ResourceMetadataURL string
4848
// The required scopes.
4949
Scopes []string
50+
// ClockSkew bounds the tolerance applied to a token's Expiration when
51+
// deciding whether it has elapsed. A token is rejected only if
52+
// Expiration + ClockSkew is before the current time. Zero (the default)
53+
// preserves strict comparison: any expired token is rejected immediately.
54+
//
55+
// Resource servers running behind a CDN, in distributed deployments, or
56+
// communicating with an authorization server whose clock drifts a few
57+
// seconds (common with cloud-managed IdPs) need a small positive value
58+
// here to avoid rejecting tokens that are valid by the issuer's clock
59+
// but momentarily appear expired by the verifier's. The same tolerance
60+
// guards against an issuer's clock running slightly fast at /token
61+
// issuance time.
62+
ClockSkew time.Duration
5063
}
5164

5265
type tokenInfoKey struct{}
@@ -129,11 +142,15 @@ func verify(req *http.Request, verifier TokenVerifier, opts *RequireBearerTokenO
129142
}
130143
}
131144

132-
// Check expiration.
145+
// Check expiration with optional clock-skew tolerance.
133146
if tokenInfo.Expiration.IsZero() {
134147
return nil, "token missing expiration", http.StatusUnauthorized
135148
}
136-
if tokenInfo.Expiration.Before(time.Now()) {
149+
skew := time.Duration(0)
150+
if opts != nil {
151+
skew = opts.ClockSkew
152+
}
153+
if tokenInfo.Expiration.Add(skew).Before(time.Now()) {
137154
return nil, "token expired", http.StatusUnauthorized
138155
}
139156
return tokenInfo, "", 0

auth/auth_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,3 +284,63 @@ func TestRequireBearerToken(t *testing.T) {
284284
})
285285
}
286286
}
287+
288+
// TestRequireBearerToken_ClockSkew verifies that the ClockSkew option
289+
// extends the expiration check tolerance: a token whose Expiration is in the
290+
// recent past is accepted iff the elapsed interval is within ClockSkew.
291+
func TestRequireBearerToken_ClockSkew(t *testing.T) {
292+
tests := []struct {
293+
name string
294+
clockSkew time.Duration
295+
expiredAgo time.Duration
296+
wantStatus int
297+
}{
298+
{
299+
name: "no skew, fresh token accepted",
300+
clockSkew: 0,
301+
expiredAgo: -time.Minute, // expires in 1 minute
302+
wantStatus: http.StatusOK,
303+
},
304+
{
305+
name: "no skew, expired token rejected",
306+
clockSkew: 0,
307+
expiredAgo: 5 * time.Second, // expired 5s ago
308+
wantStatus: http.StatusUnauthorized,
309+
},
310+
{
311+
name: "with skew, recently-expired token accepted",
312+
clockSkew: 30 * time.Second,
313+
expiredAgo: 5 * time.Second,
314+
wantStatus: http.StatusOK,
315+
},
316+
{
317+
name: "with skew, token expired beyond tolerance rejected",
318+
clockSkew: 10 * time.Second,
319+
expiredAgo: 30 * time.Second,
320+
wantStatus: http.StatusUnauthorized,
321+
},
322+
}
323+
324+
for _, tt := range tests {
325+
t.Run(tt.name, func(t *testing.T) {
326+
verifier := func(_ context.Context, _ string, _ *http.Request) (*TokenInfo, error) {
327+
return &TokenInfo{Expiration: time.Now().Add(-tt.expiredAgo)}, nil
328+
}
329+
handler := RequireBearerToken(verifier, &RequireBearerTokenOptions{
330+
ClockSkew: tt.clockSkew,
331+
})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
332+
w.WriteHeader(http.StatusOK)
333+
}))
334+
335+
req := httptest.NewRequest("GET", "/", nil)
336+
req.Header.Set("Authorization", "Bearer anything")
337+
rec := httptest.NewRecorder()
338+
339+
handler.ServeHTTP(rec, req)
340+
341+
if rec.Code != tt.wantStatus {
342+
t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus)
343+
}
344+
})
345+
}
346+
}

0 commit comments

Comments
 (0)