auth: add ClockSkew option to RequireBearerTokenOptions (#969)
## Summary - Add a \`ClockSkew time.Duration\` field to \`RequireBearerTokenOptions\` that bounds the tolerance applied to a token's \`Expiration\` when checking elapsed validity. - Default zero preserves the existing strict comparison; positive values let resource servers tolerate the drift typical of distributed deployments and cloud-managed IdPs. ## Motivation Real-world resource servers running behind a CDN, in distributed deployments, or talking to an authorization server whose clock drifts a few seconds (common with cloud-managed IdPs) need a small positive tolerance when checking token expiration. Strict-equality comparison rejects tokens that are valid by the issuer's clock but momentarily appear expired by the verifier's. The reverse case — an issuer's clock running slightly fast at /token issuance — also reaches the verifier as a token whose \`exp\` lies microseconds in the past. ## Test plan - [x] \`go test ./auth/...\` passes. - [x] New \`TestRequireBearerToken_ClockSkew\` exercises four cases: fresh accept, strict-expired reject, within-skew accept, beyond-skew reject. - [x] Existing \`TestRequireBearerToken\` continues to pass with \`ClockSkew\` defaulting to zero. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Guglielmo Colombo <guglielmoc@google.com>
This commit is contained in:
parent
c60a318d22
commit
5045d86fb4
2 changed files with 81 additions and 3 deletions
24
auth/auth.go
24
auth/auth.go
|
|
@ -62,6 +62,19 @@ type RequireBearerTokenOptions struct {
|
|||
// validity check it can perform; this option only relaxes the middleware's
|
||||
// own expiration enforcement.
|
||||
AllowMissingExpiration bool
|
||||
// ClockSkew bounds the tolerance applied to a token's Expiration when
|
||||
// deciding whether it has elapsed. A token is rejected only if
|
||||
// Expiration + ClockSkew is before the current time. Zero (the default)
|
||||
// preserves strict comparison: any expired token is rejected immediately.
|
||||
//
|
||||
// Resource servers running behind a CDN, in distributed deployments, or
|
||||
// communicating with an authorization server whose clock drifts a few
|
||||
// seconds (common with cloud-managed IdPs) need a small positive value
|
||||
// here to avoid rejecting tokens that are valid by the issuer's clock
|
||||
// but momentarily appear expired by the verifier's. The same tolerance
|
||||
// guards against an issuer's clock running slightly fast at /token
|
||||
// issuance time.
|
||||
ClockSkew time.Duration
|
||||
}
|
||||
|
||||
type tokenInfoKey struct{}
|
||||
|
|
@ -144,12 +157,17 @@ func verify(req *http.Request, verifier TokenVerifier, opts *RequireBearerTokenO
|
|||
}
|
||||
}
|
||||
|
||||
// Check expiration.
|
||||
if opts == nil {
|
||||
opts = &RequireBearerTokenOptions{}
|
||||
}
|
||||
// Check expiration, with optional clock-skew tolerance. Skew only applies
|
||||
// when an expiration is present; a missing expiration is governed solely by
|
||||
// AllowMissingExpiration.
|
||||
if tokenInfo.Expiration.IsZero() {
|
||||
if opts == nil || !opts.AllowMissingExpiration {
|
||||
if !opts.AllowMissingExpiration {
|
||||
return nil, "token missing expiration", http.StatusUnauthorized
|
||||
}
|
||||
} else if tokenInfo.Expiration.Before(time.Now()) {
|
||||
} else if tokenInfo.Expiration.Add(opts.ClockSkew).Before(time.Now()) {
|
||||
return nil, "token expired", http.StatusUnauthorized
|
||||
}
|
||||
return tokenInfo, "", 0
|
||||
|
|
|
|||
|
|
@ -289,3 +289,63 @@ func TestRequireBearerToken(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequireBearerToken_ClockSkew verifies that the ClockSkew option
|
||||
// extends the expiration check tolerance: a token whose Expiration is in the
|
||||
// recent past is accepted iff the elapsed interval is within ClockSkew.
|
||||
func TestRequireBearerToken_ClockSkew(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
clockSkew time.Duration
|
||||
expiredAgo time.Duration
|
||||
wantStatus int
|
||||
}{
|
||||
{
|
||||
name: "no skew, fresh token accepted",
|
||||
clockSkew: 0,
|
||||
expiredAgo: -time.Minute, // expires in 1 minute
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "no skew, expired token rejected",
|
||||
clockSkew: 0,
|
||||
expiredAgo: 5 * time.Second, // expired 5s ago
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "with skew, recently-expired token accepted",
|
||||
clockSkew: 30 * time.Second,
|
||||
expiredAgo: 5 * time.Second,
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "with skew, token expired beyond tolerance rejected",
|
||||
clockSkew: 10 * time.Second,
|
||||
expiredAgo: 30 * time.Second,
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
verifier := func(_ context.Context, _ string, _ *http.Request) (*TokenInfo, error) {
|
||||
return &TokenInfo{Expiration: time.Now().Add(-tt.expiredAgo)}, nil
|
||||
}
|
||||
handler := RequireBearerToken(verifier, &RequireBearerTokenOptions{
|
||||
ClockSkew: tt.clockSkew,
|
||||
})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.Header.Set("Authorization", "Bearer anything")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != tt.wantStatus {
|
||||
t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue