diff --git a/auth/auth.go b/auth/auth.go index f2058700..dc02d7d6 100644 --- a/auth/auth.go +++ b/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 diff --git a/auth/auth_test.go b/auth/auth_test.go index fe523a14..654ca26b 100644 --- a/auth/auth_test.go +++ b/auth/auth_test.go @@ -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) + } + }) + } +}