From f5da28d3526336ab3983c6769eacb7476203de21 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Fri, 22 May 2026 11:50:46 +0200 Subject: [PATCH] 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) --- auth/auth.go | 21 +++++++++++++++-- auth/auth_test.go | 60 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/auth/auth.go b/auth/auth.go index 40fa259f..7be79dba 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -47,6 +47,19 @@ type RequireBearerTokenOptions struct { ResourceMetadataURL string // The required scopes. Scopes []string + // 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{} @@ -129,11 +142,15 @@ func verify(req *http.Request, verifier TokenVerifier, opts *RequireBearerTokenO } } - // Check expiration. + // Check expiration with optional clock-skew tolerance. if tokenInfo.Expiration.IsZero() { return nil, "token missing expiration", http.StatusUnauthorized } - if tokenInfo.Expiration.Before(time.Now()) { + skew := time.Duration(0) + if opts != nil { + skew = opts.ClockSkew + } + if tokenInfo.Expiration.Add(skew).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 4028c907..a00fce73 100644 --- a/auth/auth_test.go +++ b/auth/auth_test.go @@ -284,3 +284,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) + } + }) + } +}