Skip to content

Commit 599f97f

Browse files
committed
✨ feat(auth): proactive token refresh with configurable skew
Add ReuseTokenSourceWithSkew, a drop-in oauth2.ReuseTokenSource wrapper that refreshes cached tokens when time.Until(exp) <= skew instead of waiting for expiry to pass. Closes the in-flight 401 window where a request starts at T-100ms with a token expiring at T and reaches GitHub already expired. NewApplicationTokenSource and NewInstallationTokenSource now wrap with DefaultExpirySkew (30s); tune via WithExpirySkew and WithInstallationExpirySkew. Zero/negative skew delegates to oauth2.ReuseTokenSource verbatim for backwards compatibility. Tests cover the skew window, always-refresh when skew > validity, zero-skew parity with oauth2.ReuseTokenSource, 100-goroutine concurrent collapse to a single upstream fetch, seed reuse, and error-not-cached.
1 parent a6a8b1b commit 599f97f

3 files changed

Lines changed: 507 additions & 9 deletions

File tree

README.md

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
- Sign JWTs with external `crypto.Signer` backends (AWS KMS, GCP KMS, Azure Key Vault, HashiCorp Vault Transit, PKCS#11 HSMs, ssh-agent) so the private key never touches process memory
4848
- RS256-signed JWTs with proper clock drift protection
4949
- Support for both legacy App IDs and modern Client IDs (recommended by GitHub)
50-
- Intelligent token caching with automatic refresh for optimal performance
50+
- Intelligent token caching with **proactive refresh** — tokens are regenerated 30s before expiry to eliminate in-flight 401s (tunable via `WithExpirySkew` / `WithInstallationExpirySkew`)
5151
- Clean HTTP clients with connection pooling and no shared state
5252

5353
### Requirements
@@ -278,6 +278,37 @@ func main() {
278278
}
279279
```
280280

281+
### Proactive Token Refresh
282+
283+
`oauth2.ReuseTokenSource` only refreshes a cached token *after* its expiry has passed. A request that starts at `T-100ms` with a token expiring at `T` can arrive at GitHub with an already-expired credential and receive a 401 that the caller must manually retry. This is especially painful with short application-token windows (default 10 min, optionally lower).
284+
285+
Both `NewApplicationTokenSource` and `NewInstallationTokenSource` wrap the returned source in `ReuseTokenSourceWithSkew`, which refreshes when `time.Until(exp) <= skew`. The default `DefaultExpirySkew` is **30 seconds**, so a default 10-minute application JWT has ~9m30s of effective validity — the tail risk is closed, the overhead is negligible.
286+
287+
```go
288+
// Use a shorter skew (e.g. to maximize validity for very short JWTs).
289+
appTokenSource, err := githubauth.NewApplicationTokenSource(
290+
clientID,
291+
privateKey,
292+
githubauth.WithApplicationTokenExpiration(1*time.Minute),
293+
githubauth.WithExpirySkew(5*time.Second),
294+
)
295+
296+
// Override the installation-layer skew too (default 30s is usually fine for 1h tokens).
297+
installationTokenSource := githubauth.NewInstallationTokenSource(
298+
installationID,
299+
appTokenSource,
300+
githubauth.WithInstallationExpirySkew(1*time.Minute),
301+
)
302+
```
303+
304+
Passing `WithExpirySkew(0)` (or any non-positive value) disables the skew and falls back to the exact `oauth2.ReuseTokenSource` behavior. For third-party `oauth2.TokenSource` implementations outside this package, `ReuseTokenSourceWithSkew` is exported directly:
305+
306+
```go
307+
src := githubauth.ReuseTokenSourceWithSkew(nil, someTokenSource, 30*time.Second)
308+
```
309+
310+
The wrapper is safe for concurrent use — concurrent `Token()` calls that find the cache stale collapse into a single upstream fetch.
311+
281312
### Signing with External Key Stores (AWS KMS, GCP KMS, Vault, HSM)
282313

283314
For regulated or high-security environments, the private key should never leave its secure boundary. `NewApplicationTokenSourceFromSigner` accepts any `crypto.Signer` whose public key is RSA — AWS KMS, Google Cloud KMS, Azure Key Vault, HashiCorp Vault Transit, PKCS#11 HSMs, and ssh-agent all fit.

auth.go

Lines changed: 106 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"errors"
1717
"net/http"
1818
"strconv"
19+
"sync"
1920
"time"
2021

2122
jwt "github.com/golang-jwt/jwt/v5"
@@ -27,10 +28,73 @@ const (
2728
// The maximum allowed expiration is 10 minutes.
2829
DefaultApplicationTokenExpiration = 10 * time.Minute
2930

31+
// DefaultExpirySkew is the default early-refresh window applied to cached
32+
// tokens returned by NewApplicationTokenSource and NewInstallationTokenSource.
33+
// At 30s the effective validity of a default 10-minute application JWT becomes
34+
// 9m30s, which is acceptable and eliminates the common in-flight 401 caused
35+
// by a request starting near exp and arriving at GitHub after exp.
36+
DefaultExpirySkew = 30 * time.Second
37+
3038
// bearerTokenType is the token type used for OAuth2 Bearer tokens.
3139
bearerTokenType = "Bearer"
3240
)
3341

42+
// ReuseTokenSourceWithSkew wraps src so cached tokens are refreshed proactively,
43+
// skew before their expiry. oauth2.ReuseTokenSource refreshes only once exp has
44+
// passed (via oauth2.Token.Valid), so a request that starts at T-100ms with a
45+
// token expiring at T can arrive at GitHub already expired and yield a 401 the
46+
// caller must manually retry. This wrapper refreshes when
47+
// time.Until(t.Expiry) <= skew, cutting out that race.
48+
//
49+
// If skew is zero or negative the wrapper delegates to oauth2.ReuseTokenSource,
50+
// preserving its exact behavior. An initial non-nil t is used until it needs
51+
// refresh under the same rule. The returned source is safe for concurrent use;
52+
// concurrent Token calls that find the cache stale collapse into a single
53+
// upstream fetch.
54+
func ReuseTokenSourceWithSkew(t *oauth2.Token, src oauth2.TokenSource, skew time.Duration) oauth2.TokenSource {
55+
if skew <= 0 {
56+
return oauth2.ReuseTokenSource(t, src)
57+
}
58+
return &reuseTokenSourceWithSkew{
59+
t: t,
60+
src: src,
61+
skew: skew,
62+
}
63+
}
64+
65+
type reuseTokenSourceWithSkew struct {
66+
mu sync.Mutex
67+
t *oauth2.Token
68+
src oauth2.TokenSource
69+
skew time.Duration
70+
}
71+
72+
// Token returns the cached token if it is still valid beyond the configured
73+
// skew, otherwise it calls the underlying source and caches the result.
74+
func (r *reuseTokenSourceWithSkew) Token() (*oauth2.Token, error) {
75+
r.mu.Lock()
76+
defer r.mu.Unlock()
77+
if r.valid() {
78+
return r.t, nil
79+
}
80+
t, err := r.src.Token()
81+
if err != nil {
82+
return nil, err
83+
}
84+
r.t = t
85+
return t, nil
86+
}
87+
88+
func (r *reuseTokenSourceWithSkew) valid() bool {
89+
if r.t == nil || r.t.AccessToken == "" {
90+
return false
91+
}
92+
if r.t.Expiry.IsZero() {
93+
return true
94+
}
95+
return time.Until(r.t.Expiry) > r.skew
96+
}
97+
3498
// Identifier constrains GitHub App identifiers to int64 (App ID) or string (Client ID).
3599
type Identifier interface {
36100
~int64 | ~string
@@ -45,6 +109,7 @@ type applicationTokenSource struct {
45109
issuer string // App ID (numeric) or Client ID (alphanumeric)
46110
signer crypto.Signer
47111
expiration time.Duration
112+
skew time.Duration
48113
}
49114

50115
// ApplicationTokenOpt is a functional option for configuring an applicationTokenSource.
@@ -62,15 +127,32 @@ func WithApplicationTokenExpiration(exp time.Duration) ApplicationTokenOpt {
62127
}
63128
}
64129

130+
// WithExpirySkew overrides the default early-refresh window (DefaultExpirySkew,
131+
// 30s) applied to the token cache returned by NewApplicationTokenSource. The
132+
// cached token is refreshed when time.Until(exp) <= d. A zero or negative value
133+
// disables the skew and falls back to oauth2.ReuseTokenSource behavior
134+
// (refresh only after exp has passed).
135+
//
136+
// Tune this when your application token expiration (see
137+
// WithApplicationTokenExpiration) is short: the effective validity is
138+
// expiration - skew, so with the default 10-minute expiration and 30s skew
139+
// tokens are refreshed at 9m30s.
140+
func WithExpirySkew(d time.Duration) ApplicationTokenOpt {
141+
return func(a *applicationTokenSource) {
142+
a.skew = d
143+
}
144+
}
145+
65146
// NewApplicationTokenSource creates a GitHub App JWT token source from a
66147
// PEM-encoded RSA private key.
67148
// Accepts either int64 App ID or string Client ID. GitHub recommends Client IDs for new apps.
68149
// Generated JWTs are RS256-signed with iat, exp, and iss claims.
69150
// JWTs expire in max 10 minutes and include clock drift protection (iat set 60s in past).
70151
//
71-
// The returned token source is wrapped in oauth2.ReuseTokenSource to prevent unnecessary
72-
// token regeneration. Don't worry about wrapping the result again since ReuseTokenSource
73-
// prevents re-wrapping automatically.
152+
// The returned token source is wrapped in ReuseTokenSourceWithSkew with
153+
// DefaultExpirySkew (30s), so cached tokens are refreshed before exp rather
154+
// than after. With the default 10-minute expiration the effective validity
155+
// is 9m30s. Override with WithExpirySkew.
74156
//
75157
// For KMS, HSM, Vault, or ssh-agent backed signing, use
76158
// NewApplicationTokenSourceFromSigner instead — the private key never leaves
@@ -140,11 +222,12 @@ func newApplicationTokenSource(issuer string, signer crypto.Signer, opts ...Appl
140222
issuer: issuer,
141223
signer: signer,
142224
expiration: DefaultApplicationTokenExpiration,
225+
skew: DefaultExpirySkew,
143226
}
144227
for _, opt := range opts {
145228
opt(t)
146229
}
147-
return oauth2.ReuseTokenSource(nil, t)
230+
return ReuseTokenSourceWithSkew(nil, t, t.skew)
148231
}
149232

150233
// Token generates a GitHub App JWT with required claims: iat, exp, iss, and alg.
@@ -223,6 +306,18 @@ func WithContext(ctx context.Context) InstallationTokenSourceOpt {
223306
}
224307
}
225308

309+
// WithInstallationExpirySkew overrides the default early-refresh window
310+
// (DefaultExpirySkew, 30s) applied to the installation token cache returned
311+
// by NewInstallationTokenSource. Installation tokens live 1 hour, so the 30s
312+
// default leaves ~59m30s effective validity — this option exists mostly for
313+
// parity with WithExpirySkew. A zero or negative value falls back to
314+
// oauth2.ReuseTokenSource behavior.
315+
func WithInstallationExpirySkew(d time.Duration) InstallationTokenSourceOpt {
316+
return func(i *installationTokenSource) {
317+
i.skew = d
318+
}
319+
}
320+
226321
// installationTokenSource represents a GitHub App installation token source
227322
// that generates access tokens for authenticating as a specific GitHub App installation.
228323
//
@@ -233,14 +328,16 @@ type installationTokenSource struct {
233328
src oauth2.TokenSource
234329
client *githubClient
235330
opts *InstallationTokenOptions
331+
skew time.Duration
236332
}
237333

238334
// NewInstallationTokenSource creates a GitHub App installation token source.
239335
// Requires installation ID and a GitHub App JWT token source for authentication.
240336
//
241-
// The returned token source is wrapped in oauth2.ReuseTokenSource to prevent unnecessary
242-
// token regeneration. Don't worry about wrapping the result again since ReuseTokenSource
243-
// prevents re-wrapping automatically.
337+
// The returned token source is wrapped in ReuseTokenSourceWithSkew so cached
338+
// tokens are refreshed DefaultExpirySkew before their expiry, eliminating
339+
// in-flight 401s when a request starts close to exp and reaches GitHub after.
340+
// Override the window with WithInstallationExpirySkew.
244341
//
245342
// See https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-an-installation-access-token-for-a-github-app
246343
func NewInstallationTokenSource(id int64, src oauth2.TokenSource, opts ...InstallationTokenSourceOpt) oauth2.TokenSource {
@@ -257,13 +354,14 @@ func NewInstallationTokenSource(id int64, src oauth2.TokenSource, opts ...Instal
257354
ctx: ctx,
258355
src: src,
259356
client: newGitHubClient(httpClient),
357+
skew: DefaultExpirySkew,
260358
}
261359

262360
for _, opt := range opts {
263361
opt(i)
264362
}
265363

266-
return oauth2.ReuseTokenSource(nil, i)
364+
return ReuseTokenSourceWithSkew(nil, i, i.skew)
267365
}
268366

269367
// Token generates a new GitHub App installation token for authenticating as a GitHub App installation.

0 commit comments

Comments
 (0)