diff --git a/README.md b/README.md index 1653ba9..7cb9e27 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,8 @@ wire a backend): via the instrumentation `Tracer` SPI and injects a W3C `traceparent` header. - `WithMetrics(meter)` — installs a metrics policy recording request duration and in-flight requests via the instrumentation `Meter` SPI. +- `WithTokenCache(cache)` — shares a bearer-token cache (an `auth.TokenCache`, in-memory + by default) across clients so a cached token is reused. - `WithBasicAuth(username, password)` — authenticates requests with HTTP Basic auth (HTTPS-only). - `WithAPIKey(header, key)` — sets an API-key header on every request (HTTPS-only). - `WithRedactionAllowlist(params...)` — preserves the listed query-param values in diff --git a/auth/bearer.go b/auth/bearer.go index 1b314c8..7657de6 100644 --- a/auth/bearer.go +++ b/auth/bearer.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "net/http" + "strings" "sync" "time" @@ -24,23 +25,39 @@ const expiryWindow = 5 * time.Minute var ErrInsecureTransport = errors.New("auth: refusing to send credentials over an insecure (non-HTTPS) connection") // BearerTokenPolicy attaches an "Authorization: Bearer " header to every -// request, fetching and caching the token from a [TokenCredential]. The cached -// token is refreshed once it is within five minutes of expiry. +// request, fetching the token from a [TokenCredential] and storing it in a +// [TokenCache]. The cached token is refreshed once it is within five minutes of +// expiry. // // The policy requires HTTPS and returns [ErrInsecureTransport] otherwise. It // implements pipeline.Policy and is safe for concurrent use. type BearerTokenPolicy struct { - cred TokenCredential - scopes []string + cred TokenCredential + scope []string + key string + cache TokenCache - mu sync.Mutex - cached AccessToken + mu sync.Mutex } // NewBearerTokenPolicy returns a policy that authenticates requests using cred -// for the given scopes. +// for the given scopes, caching the token in a private in-memory cache. func NewBearerTokenPolicy(cred TokenCredential, scopes ...string) *BearerTokenPolicy { - return &BearerTokenPolicy{cred: cred, scopes: scopes} + return NewBearerTokenPolicyWithCache(cred, NewInMemoryTokenCache(), scopes...) +} + +// NewBearerTokenPolicyWithCache is like [NewBearerTokenPolicy] but stores tokens +// in cache, which may be shared across policies so multiple clients reuse a +// cached token. Each policy serializes its own refresh; when the cached token is +// stale, two policies sharing a cache may refresh independently (last write wins). +// Cross-policy single-flight is not performed. +func NewBearerTokenPolicyWithCache(cred TokenCredential, cache TokenCache, scopes ...string) *BearerTokenPolicy { + return &BearerTokenPolicy{ + cred: cred, + scope: scopes, + key: strings.Join(scopes, " "), + cache: cache, + } } // Do implements pipeline.Policy. @@ -57,32 +74,32 @@ func (p *BearerTokenPolicy) Do(req *pipeline.Request) (*http.Response, error) { return req.Next() } -// token returns a cached token when fresh, otherwise acquires a new one. The -// lock is held across the credential call so concurrent requests share a single +// token returns a cached token when fresh, otherwise acquires a new one. The lock +// is held across the credential call so concurrent requests share a single // refresh rather than stampeding the token endpoint. func (p *BearerTokenPolicy) token(req *pipeline.Request) (string, error) { p.mu.Lock() defer p.mu.Unlock() - if p.fresh() { - return p.cached.Token, nil + if tok, ok := p.cache.Get(p.key); ok && fresh(tok) { + return tok.Token, nil } - tok, err := p.cred.GetToken(req.Raw().Context(), TokenRequestOptions{Scopes: p.scopes}) + tok, err := p.cred.GetToken(req.Raw().Context(), TokenRequestOptions{Scopes: p.scope}) if err != nil { return "", fmt.Errorf("auth: acquire token: %w", err) } - p.cached = tok + p.cache.Set(p.key, tok) return tok.Token, nil } -// fresh reports whether the cached token is present and not near expiry. A zero -// ExpiresOn means the token never expires. -func (p *BearerTokenPolicy) fresh() bool { - if p.cached.Token == "" { +// fresh reports whether tok is present and not near expiry. A zero ExpiresOn means +// the token never expires. +func fresh(tok AccessToken) bool { + if tok.Token == "" { return false } - if p.cached.ExpiresOn.IsZero() { + if tok.ExpiresOn.IsZero() { return true } - return time.Until(p.cached.ExpiresOn) > expiryWindow + return time.Until(tok.ExpiresOn) > expiryWindow } diff --git a/auth/bearer_test.go b/auth/bearer_test.go index c8a6bdb..bda1d25 100644 --- a/auth/bearer_test.go +++ b/auth/bearer_test.go @@ -9,6 +9,7 @@ import ( "io" "net/http" "strings" + "sync" "testing" "time" @@ -25,11 +26,15 @@ func (f transporterFunc) Do(req *http.Request) (*http.Response, error) { return type countingCredential struct { token string exp time.Time + + mu sync.Mutex calls int } func (c *countingCredential) GetToken(context.Context, auth.TokenRequestOptions) (auth.AccessToken, error) { + c.mu.Lock() c.calls++ + c.mu.Unlock() return auth.AccessToken{Token: c.token, ExpiresOn: c.exp}, nil } @@ -96,8 +101,60 @@ func TestBearerPropagatesCredentialError(t *testing.T) { } } +func TestBearerSharedCacheReusesToken(t *testing.T) { + t.Parallel() + + cred := &countingCredential{token: "tok", exp: time.Now().Add(time.Hour)} + cache := auth.NewInMemoryTokenCache() + + run := func(p *auth.BearerTokenPolicy) { + transport := transporterFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("")), Request: req}, nil + }) + pl := pipeline.New(transport, p) + req, _ := http.NewRequest(http.MethodGet, "https://api.example.test/", nil) + resp, err := pl.Do(req) + if err != nil { + t.Fatalf("Do: %v", err) + } + _ = resp.Body.Close() + } + + run(auth.NewBearerTokenPolicyWithCache(cred, cache, "scope/.default")) + run(auth.NewBearerTokenPolicyWithCache(cred, cache, "scope/.default")) + + if cred.calls != 1 { + t.Fatalf("GetToken calls = %d, want 1 (shared cache reuses the token)", cred.calls) + } +} + type errCredential struct{ err error } func (e errCredential) GetToken(context.Context, auth.TokenRequestOptions) (auth.AccessToken, error) { return auth.AccessToken{}, e.err } + +func TestBearerRefetchesNearExpiryToken(t *testing.T) { + t.Parallel() + + // Token expires in one minute — inside the five-minute freshness window, so it + // is never considered fresh and must be re-fetched on every request. + cred := &countingCredential{token: "tok", exp: time.Now().Add(time.Minute)} + pl := pipeline.New( + transporterFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("")), Request: req}, nil + }), + auth.NewBearerTokenPolicy(cred, "scope"), + ) + for range 2 { + req, _ := http.NewRequest(http.MethodGet, "https://api.example.test/", nil) + resp, err := pl.Do(req) + if err != nil { + t.Fatalf("Do: %v", err) + } + _ = resp.Body.Close() + } + if cred.calls != 2 { + t.Fatalf("GetToken calls = %d, want 2 (near-expiry token re-fetched each request)", cred.calls) + } +} diff --git a/auth/cache.go b/auth/cache.go new file mode 100644 index 0000000..e3eb161 --- /dev/null +++ b/auth/cache.go @@ -0,0 +1,41 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +package auth + +import "sync" + +// TokenCache stores access tokens keyed by an opaque key (the SDK uses the +// space-joined scope set). Implementations must be safe for concurrent use. +type TokenCache interface { + // Get returns the cached token for key and whether one was present. + Get(key string) (AccessToken, bool) + // Set stores token under key. + Set(key string, token AccessToken) +} + +// InMemoryTokenCache is a concurrency-safe in-memory [TokenCache]. +type InMemoryTokenCache struct { + mu sync.Mutex + tokens map[string]AccessToken +} + +// NewInMemoryTokenCache returns an empty in-memory cache. +func NewInMemoryTokenCache() *InMemoryTokenCache { + return &InMemoryTokenCache{tokens: make(map[string]AccessToken)} +} + +// Get implements [TokenCache]. +func (c *InMemoryTokenCache) Get(key string) (AccessToken, bool) { + c.mu.Lock() + defer c.mu.Unlock() + t, ok := c.tokens[key] + return t, ok +} + +// Set implements [TokenCache]. +func (c *InMemoryTokenCache) Set(key string, token AccessToken) { + c.mu.Lock() + defer c.mu.Unlock() + c.tokens[key] = token +} diff --git a/auth/cache_test.go b/auth/cache_test.go new file mode 100644 index 0000000..996b55e --- /dev/null +++ b/auth/cache_test.go @@ -0,0 +1,59 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +package auth_test + +import ( + "sync" + "testing" + + "github.com/dexpace/go-sdk/auth" +) + +func TestInMemoryTokenCacheGetSet(t *testing.T) { + t.Parallel() + + c := auth.NewInMemoryTokenCache() + if _, ok := c.Get("k"); ok { + t.Fatal("missing key should report not found") + } + c.Set("k", auth.AccessToken{Token: "t"}) + got, ok := c.Get("k") + if !ok || got.Token != "t" { + t.Fatalf("Get = (%v, %v), want token t / true", got, ok) + } +} + +func TestInMemoryTokenCacheConcurrent(t *testing.T) { + t.Parallel() + + c := auth.NewInMemoryTokenCache() + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(1) + go func() { + defer wg.Done() + c.Set("k", auth.AccessToken{Token: "t"}) + _, _ = c.Get("k") + }() + } + wg.Wait() +} + +func TestInMemoryTokenCacheKeysAreIsolated(t *testing.T) { + t.Parallel() + + c := auth.NewInMemoryTokenCache() + c.Set("a", auth.AccessToken{Token: "ta"}) + c.Set("a b", auth.AccessToken{Token: "tab"}) + + if got, ok := c.Get("a"); !ok || got.Token != "ta" { + t.Fatalf("Get(\"a\") = (%v, %v), want ta/true", got, ok) + } + if got, ok := c.Get("a b"); !ok || got.Token != "tab" { + t.Fatalf("Get(\"a b\") = (%v, %v), want tab/true", got, ok) + } + if _, ok := c.Get("b"); ok { + t.Fatal("Get(\"b\") should be a miss (distinct key)") + } +} diff --git a/client.go b/client.go index 535195e..358092a 100644 --- a/client.go +++ b/client.go @@ -119,8 +119,12 @@ func New(opts ...Option) *Client { } switch { case cfg.credential != nil: + cache := cfg.tokenCache + if cache == nil { + cache = auth.NewInMemoryTokenCache() + } placements = append(placements, - pipeline.At(pipeline.StageAuth, auth.NewBearerTokenPolicy(cfg.credential, cfg.scopes...))) + pipeline.At(pipeline.StageAuth, auth.NewBearerTokenPolicyWithCache(cfg.credential, cache, cfg.scopes...))) case cfg.basicAuth != nil: placements = append(placements, pipeline.At(pipeline.StageAuth, auth.NewBasicAuthPolicy(*cfg.basicAuth))) diff --git a/client_test.go b/client_test.go index 2f1ddce..5f3451c 100644 --- a/client_test.go +++ b/client_test.go @@ -697,6 +697,39 @@ func TestAuthPrecedenceBearerBeatsBasic(t *testing.T) { } } +type countingCred struct{ calls int } + +func (c *countingCred) GetToken(context.Context, auth.TokenRequestOptions) (auth.AccessToken, error) { + c.calls++ + return auth.AccessToken{Token: "t", ExpiresOn: time.Now().Add(time.Hour)}, nil +} + +func TestWithTokenCacheSharedAcrossClients(t *testing.T) { + t.Parallel() + + cred := &countingCred{} + cache := auth.NewInMemoryTokenCache() + + for range 2 { + var captured *http.Request + c := dexpace.New( + dexpace.WithTransport(captureTransport(&captured)), + dexpace.WithCredential(cred, "scope"), + dexpace.WithTokenCache(cache), + ) + req, _ := http.NewRequest(http.MethodGet, "https://api.example.test/", nil) + resp, err := c.Do(req) + if err != nil { + t.Fatalf("Do: %v", err) + } + _ = resp.Body.Close() + } + + if cred.calls != 1 { + t.Fatalf("GetToken calls = %d, want 1 (token cache shared across clients)", cred.calls) + } +} + func TestWithConfigAppliesHTTPTimeout(t *testing.T) { t.Setenv("DEXPACE_HTTP_TIMEOUT", "30ms") diff --git a/doc.go b/doc.go index 1616f50..2e0c4e5 100644 --- a/doc.go +++ b/doc.go @@ -22,6 +22,9 @@ // Beyond bearer tokens (WithCredential), WithBasicAuth and WithAPIKey authenticate // requests with HTTP Basic auth or an API-key header; both require HTTPS. // +// WithTokenCache shares a bearer-token cache across clients (auth.TokenCache, with +// an in-memory default). +// // WithConfig sources client defaults (User-Agent, retry settings, transport // timeout) from the environment via the config package, for any setting not set // explicitly. diff --git a/docs/superpowers/plans/2026-06-16-token-cache.md b/docs/superpowers/plans/2026-06-16-token-cache.md new file mode 100644 index 0000000..af9b479 --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-token-cache.md @@ -0,0 +1,423 @@ +# Pluggable Token Cache Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `BearerTokenPolicy`'s token cache pluggable: add an `auth.TokenCache` interface with an `InMemoryTokenCache` default, refactor the policy to use it (behaviour preserved), and add a `dexpace.WithTokenCache` umbrella option for sharing tokens across clients. + +**Architecture:** `BearerTokenPolicy` stores tokens in a `TokenCache` keyed by the scope set; `NewBearerTokenPolicy` keeps a private in-memory cache (unchanged behaviour), while `NewBearerTokenPolicyWithCache` injects a shared one. The per-policy refresh lock and freshness window are unchanged. + +**Tech Stack:** Go 1.26+, standard library only (`strings`, `sync`, `time`, `net/http`). Zero third-party dependencies. + +**Conventions every task must follow:** +- MIT license header on every `.go` file before the `package` clause. +- Import groups: stdlib, blank line, then `github.com/dexpace/go-sdk/...`. +- Tests use `t.Parallel()`; stdlib-only; bodies closed via `t.Cleanup`/`Close`. Bearer tests use `https://` URLs. +- Tools: Go 1.26.3; `gofumpt`/`golangci-lint` NOT installed — use `gofmt`, `go vet`, `go test -race`. +- Run commands from the repo root `/Users/omar/dexpace/go-sdk`. + +--- + +## File Structure + +| Path | Responsibility | +|---|---| +| `auth/cache.go` (new) + test | `TokenCache`, `InMemoryTokenCache` | +| `auth/bearer.go` (modify) | use `TokenCache`; add `NewBearerTokenPolicyWithCache`; `fresh` helper | +| `auth/bearer_test.go` (modify) | shared-cache test | +| `options.go`, `client.go` (modify) | `WithTokenCache` + wiring | +| `client_test.go` (modify) | shared-cache-across-clients test | +| `doc.go`, `README.md` (modify) | document | + +--- + +## Task 1: `TokenCache` + `InMemoryTokenCache` + bearer refactor + +**Files:** +- Create: `auth/cache.go`, `auth/cache_test.go` +- Modify: `auth/bearer.go`, `auth/bearer_test.go` + +- [ ] **Step 1: Write the failing tests** + +```go +// auth/cache_test.go +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +package auth_test + +import ( + "sync" + "testing" + + "github.com/dexpace/go-sdk/auth" +) + +func TestInMemoryTokenCacheGetSet(t *testing.T) { + t.Parallel() + + c := auth.NewInMemoryTokenCache() + if _, ok := c.Get("k"); ok { + t.Fatal("missing key should report not found") + } + c.Set("k", auth.AccessToken{Token: "t"}) + got, ok := c.Get("k") + if !ok || got.Token != "t" { + t.Fatalf("Get = (%v, %v), want token t / true", got, ok) + } +} + +func TestInMemoryTokenCacheConcurrent(t *testing.T) { + t.Parallel() + + c := auth.NewInMemoryTokenCache() + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(1) + go func() { + defer wg.Done() + c.Set("k", auth.AccessToken{Token: "t"}) + _, _ = c.Get("k") + }() + } + wg.Wait() +} +``` + +Append to `auth/bearer_test.go` (it already imports `context`, `errors`, `io`, `net/http`, `strings`, `testing`, `time`, `auth`, `header`, `pipeline` and defines `transporterFunc` and `countingCredential`): + +```go +func TestBearerSharedCacheReusesToken(t *testing.T) { + t.Parallel() + + cred := &countingCredential{token: "tok", exp: time.Now().Add(time.Hour)} + cache := auth.NewInMemoryTokenCache() + + run := func(p *auth.BearerTokenPolicy) { + transport := transporterFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("")), Request: req}, nil + }) + pl := pipeline.New(transport, p) + req, _ := http.NewRequest(http.MethodGet, "https://api.example.test/", nil) + resp, err := pl.Do(req) + if err != nil { + t.Fatalf("Do: %v", err) + } + _ = resp.Body.Close() + } + + run(auth.NewBearerTokenPolicyWithCache(cred, cache, "scope/.default")) + run(auth.NewBearerTokenPolicyWithCache(cred, cache, "scope/.default")) + + if cred.calls != 1 { + t.Fatalf("GetToken calls = %d, want 1 (shared cache reuses the token)", cred.calls) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./auth/ -run 'InMemoryTokenCache|SharedCache' -v` +Expected: FAIL — `auth.NewInMemoryTokenCache`/`NewBearerTokenPolicyWithCache` undefined. + +- [ ] **Step 3: Create `auth/cache.go`** + +```go +// auth/cache.go +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +package auth + +import "sync" + +// TokenCache stores access tokens keyed by an opaque key (the SDK uses the +// space-joined scope set). Implementations must be safe for concurrent use. +type TokenCache interface { + // Get returns the cached token for key and whether one was present. + Get(key string) (AccessToken, bool) + // Set stores token under key. + Set(key string, token AccessToken) +} + +// InMemoryTokenCache is a concurrency-safe in-memory [TokenCache]. +type InMemoryTokenCache struct { + mu sync.Mutex + tokens map[string]AccessToken +} + +// NewInMemoryTokenCache returns an empty in-memory cache. +func NewInMemoryTokenCache() *InMemoryTokenCache { + return &InMemoryTokenCache{tokens: make(map[string]AccessToken)} +} + +// Get implements [TokenCache]. +func (c *InMemoryTokenCache) Get(key string) (AccessToken, bool) { + c.mu.Lock() + defer c.mu.Unlock() + t, ok := c.tokens[key] + return t, ok +} + +// Set implements [TokenCache]. +func (c *InMemoryTokenCache) Set(key string, token AccessToken) { + c.mu.Lock() + defer c.mu.Unlock() + c.tokens[key] = token +} +``` + +- [ ] **Step 4: Refactor `auth/bearer.go`** + +Add `"strings"` to the stdlib import group. Replace the `BearerTokenPolicy` struct, its constructor, the `token` method, and the `fresh` method with: + +```go +// BearerTokenPolicy attaches an "Authorization: Bearer " header to every +// request, fetching the token from a [TokenCredential] and storing it in a +// [TokenCache]. The cached token is refreshed once it is within five minutes of +// expiry. +// +// The policy requires HTTPS and returns [ErrInsecureTransport] otherwise. It +// implements pipeline.Policy and is safe for concurrent use. +type BearerTokenPolicy struct { + cred TokenCredential + scope []string + key string + cache TokenCache + + mu sync.Mutex +} + +// NewBearerTokenPolicy returns a policy that authenticates requests using cred +// for the given scopes, caching the token in a private in-memory cache. +func NewBearerTokenPolicy(cred TokenCredential, scopes ...string) *BearerTokenPolicy { + return NewBearerTokenPolicyWithCache(cred, NewInMemoryTokenCache(), scopes...) +} + +// NewBearerTokenPolicyWithCache is like [NewBearerTokenPolicy] but stores tokens +// in cache, which may be shared across policies so multiple clients reuse a +// cached token. +func NewBearerTokenPolicyWithCache(cred TokenCredential, cache TokenCache, scopes ...string) *BearerTokenPolicy { + return &BearerTokenPolicy{ + cred: cred, + scope: scopes, + key: strings.Join(scopes, " "), + cache: cache, + } +} + +// Do implements pipeline.Policy. +func (p *BearerTokenPolicy) Do(req *pipeline.Request) (*http.Response, error) { + raw := req.Raw() + if raw.URL == nil || raw.URL.Scheme != "https" { + return nil, ErrInsecureTransport + } + token, err := p.token(req) + if err != nil { + return nil, err + } + raw.Header.Set(header.Authorization, "Bearer "+token) + return req.Next() +} + +// token returns a cached token when fresh, otherwise acquires a new one. The lock +// is held across the credential call so concurrent requests share a single +// refresh rather than stampeding the token endpoint. +func (p *BearerTokenPolicy) token(req *pipeline.Request) (string, error) { + p.mu.Lock() + defer p.mu.Unlock() + + if tok, ok := p.cache.Get(p.key); ok && fresh(tok) { + return tok.Token, nil + } + tok, err := p.cred.GetToken(req.Raw().Context(), TokenRequestOptions{Scopes: p.scope}) + if err != nil { + return "", fmt.Errorf("auth: acquire token: %w", err) + } + p.cache.Set(p.key, tok) + return tok.Token, nil +} + +// fresh reports whether tok is present and not near expiry. A zero ExpiresOn means +// the token never expires. +func fresh(tok AccessToken) bool { + if tok.Token == "" { + return false + } + if tok.ExpiresOn.IsZero() { + return true + } + return time.Until(tok.ExpiresOn) > expiryWindow +} +``` + +(Keep the `expiryWindow` constant and the `ErrInsecureTransport` var as they are. The struct no longer has a `cached` field.) + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test -race ./auth/ -v` +Expected: PASS — the new cache and shared-cache tests, AND the existing +`TestBearerAttachesHeaderAndCaches` (still one `GetToken` for three requests, now +via the private in-memory cache), `TestBearerRefusesInsecureScheme`, +`TestBearerPropagatesCredentialError`. + +- [ ] **Step 6: Commit** + +```bash +git add auth/cache.go auth/cache_test.go auth/bearer.go auth/bearer_test.go +git commit -m "feat(auth): add pluggable TokenCache; route BearerTokenPolicy through it" +``` + +--- + +## Task 2: umbrella `WithTokenCache` + +**Files:** +- Modify: `options.go`, `client.go` +- Test: `client_test.go` + +- [ ] **Step 1: Write the failing test** + +Append to `client_test.go`. Add `"context"` and `"time"` to the stdlib import group and `"github.com/dexpace/go-sdk/auth"` to the dexpace group (if not already present from earlier auth tests). + +```go +type countingCred struct{ calls int } + +func (c *countingCred) GetToken(context.Context, auth.TokenRequestOptions) (auth.AccessToken, error) { + c.calls++ + return auth.AccessToken{Token: "t", ExpiresOn: time.Now().Add(time.Hour)}, nil +} + +func TestWithTokenCacheSharedAcrossClients(t *testing.T) { + t.Parallel() + + cred := &countingCred{} + cache := auth.NewInMemoryTokenCache() + + for i := 0; i < 2; i++ { + var captured *http.Request + c := dexpace.New( + dexpace.WithTransport(captureTransport(&captured)), + dexpace.WithCredential(cred, "scope"), + dexpace.WithTokenCache(cache), + ) + req, _ := http.NewRequest(http.MethodGet, "https://api.example.test/", nil) + resp, err := c.Do(req) + if err != nil { + t.Fatalf("Do: %v", err) + } + _ = resp.Body.Close() + } + + if cred.calls != 1 { + t.Fatalf("GetToken calls = %d, want 1 (token cache shared across clients)", cred.calls) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test . -run TestWithTokenCache -v` +Expected: FAIL — `dexpace.WithTokenCache` undefined. + +- [ ] **Step 3: Add the option in `options.go`** + +Add a field to the `config` struct (near `credential`/`scopes`): +```go + tokenCache auth.TokenCache +``` +Add the option (after `WithCredential`): +```go +// WithTokenCache shares cache across the bearer-token policy installed by +// WithCredential, so multiple clients can reuse cached tokens. A nil cache or no +// credential means the default per-client in-memory cache is used. +func WithTokenCache(cache auth.TokenCache) Option { + return func(c *config) { c.tokenCache = cache } +} +``` + +- [ ] **Step 4: Wire it in `client.go`** + +In the auth `switch`, change the credential case to use the shared cache when set: +```go + case cfg.credential != nil: + cache := cfg.tokenCache + if cache == nil { + cache = auth.NewInMemoryTokenCache() + } + placements = append(placements, + pipeline.At(pipeline.StageAuth, auth.NewBearerTokenPolicyWithCache(cfg.credential, cache, cfg.scopes...))) +``` +(Leave the `basicAuth` and `apiKey` cases unchanged.) + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test . -v` +Expected: PASS — the new test plus all existing umbrella tests. + +- [ ] **Step 6: Run the full suite** + +Run: `go test ./...` +Expected: PASS across every package. + +- [ ] **Step 7: Commit** + +```bash +git add client.go options.go client_test.go +git commit -m "feat: add WithTokenCache to share bearer tokens across clients" +``` + +--- + +## Task 3: docs and full gate + +**Files:** +- Modify: `doc.go`, `README.md` + +- [ ] **Step 1: Mention the token cache in `doc.go`** + +Read `doc.go`. Within the `package dexpace` doc comment (single contiguous `//` +block; no second package clause / no duplicate header), add: + +```go +// WithTokenCache shares a bearer-token cache across clients (auth.TokenCache, with +// an in-memory default). +``` + +- [ ] **Step 2: Update `README.md`** + +Read `README.md`. In the options/usage section, add a short entry: `WithTokenCache(cache)` +shares a bearer-token cache (an `auth.TokenCache`, in-memory by default) across +clients so a cached token is reused. Keep it tight; match the surrounding style. + +- [ ] **Step 3: Run the full gate** + +Run: +```bash +gofmt -l . +go vet ./... +go test -race ./... +``` +Expected: `gofmt -l .` prints nothing; `go vet` clean; every package passes under +the race detector. + +- [ ] **Step 4: Commit** + +```bash +git add doc.go README.md +git commit -m "docs: document WithTokenCache" +``` + +--- + +## Self-Review notes (for the implementer) + +- **Spec coverage:** `TokenCache` + `InMemoryTokenCache` + bearer refactor (Task 1); + `WithTokenCache` umbrella (Task 2); docs (Task 3). +- **Behaviour preserved:** the existing `TestBearerAttachesHeaderAndCaches` passes + unchanged (private in-memory cache via `NewBearerTokenPolicy`). +- **Type consistency:** `auth.TokenCache`, `auth.NewInMemoryTokenCache`, + `auth.NewBearerTokenPolicyWithCache(cred, cache, scopes...)`, the package `fresh` + helper, and `dexpace.WithTokenCache(cache)` used identically across tasks. +- **Concurrency:** `InMemoryTokenCache` is mutex-guarded (asserted under `-race`); + the per-policy refresh lock is retained. +- **`make check`** green before opening the PR. diff --git a/docs/superpowers/specs/2026-06-16-token-cache-design.md b/docs/superpowers/specs/2026-06-16-token-cache-design.md new file mode 100644 index 0000000..1244da6 --- /dev/null +++ b/docs/superpowers/specs/2026-06-16-token-cache-design.md @@ -0,0 +1,128 @@ +# Pluggable token cache — design + +**Date:** 2026-06-16 +**Status:** Approved (standing delegation); ready for implementation planning +**Subsystem:** deferred-feature #4 (pluggable token cache from the auth-breadth roadmap item) + +## Context + +`BearerTokenPolicy` caches one token in a private field, refreshed within five +minutes of expiry. That cache is per-policy, so two policies (e.g. two clients to +the same API) each fetch their own token. A pluggable `TokenCache` lets callers +share (or persist) tokens. Java/Python expose the same seam with an in-memory +default. + +## Decisions + +1. **`TokenCache` interface + in-memory default.** Keyed by the scope set, with a + thread-safe `InMemoryTokenCache`. +2. **Preserve current behaviour.** `NewBearerTokenPolicy(cred, scopes...)` is + unchanged and uses a fresh per-policy `InMemoryTokenCache` — identical to today + (existing tests pass without edits). +3. **Add, don't break, the constructor.** A second constructor + `NewBearerTokenPolicyWithCache(cred, cache, scopes...)` injects a shared cache; + the variadic `scopes` parameter rules out a functional-options form on the + existing constructor. +4. **Per-policy refresh lock retained.** The existing mutex still serializes a + single policy's refresh (no stampede). A shared cache populated by one policy is + reused by others via the freshness check; cross-policy refresh coordination is + out of scope (acceptable: at worst a duplicate fetch, last write wins). +5. **Umbrella `WithTokenCache(cache)`.** Wires a shared cache into the bearer + policy `WithCredential` installs. + +## Architecture + +### `auth.TokenCache` (`auth/cache.go`) + +```go +// TokenCache stores access tokens keyed by an opaque key (the SDK uses the +// space-joined scope set). Implementations must be safe for concurrent use. +type TokenCache interface { + Get(key string) (AccessToken, bool) + Set(key string, token AccessToken) +} + +// InMemoryTokenCache is a concurrency-safe in-memory TokenCache. +type InMemoryTokenCache struct { /* mu, map */ } + +// NewInMemoryTokenCache returns an empty in-memory cache. +func NewInMemoryTokenCache() *InMemoryTokenCache + +func (c *InMemoryTokenCache) Get(key string) (AccessToken, bool) +func (c *InMemoryTokenCache) Set(key string, token AccessToken) +``` + +### `BearerTokenPolicy` refactor (`auth/bearer.go`) + +- Replace the `cached AccessToken` field with `cache TokenCache` and a precomputed + `key string` (= `strings.Join(scopes, " ")`). +- `NewBearerTokenPolicy(cred, scopes...)` delegates to + `NewBearerTokenPolicyWithCache(cred, NewInMemoryTokenCache(), scopes...)`. +- `token`: under the lock, `cache.Get(key)`; if present and fresh, return it; else + `cred.GetToken`, `cache.Set(key, tok)`, return. +- Freshness becomes a package helper `fresh(tok AccessToken) bool` (empty → false; + zero `ExpiresOn` → never expires → true; else `time.Until(ExpiresOn) > + expiryWindow`). The `expiryWindow` constant is unchanged. + +```go +// NewBearerTokenPolicyWithCache returns a bearer policy that stores tokens in +// cache (shareable across policies). NewBearerTokenPolicy uses a private +// in-memory cache. +func NewBearerTokenPolicyWithCache(cred TokenCredential, cache TokenCache, scopes ...string) *BearerTokenPolicy +``` + +### Umbrella `WithTokenCache` (`options.go` / `client.go`) + +```go +// WithTokenCache shares cache across the bearer-token policy installed by +// WithCredential, so multiple clients can reuse cached tokens. Ignored unless a +// credential is configured. +func WithTokenCache(cache auth.TokenCache) Option +``` + +`config` gains `tokenCache auth.TokenCache`. In `New`, the bearer case builds the +policy with `NewBearerTokenPolicyWithCache(cred, cacheOrDefault, scopes...)` where +`cacheOrDefault` is `cfg.tokenCache` when set, else `auth.NewInMemoryTokenCache()`. + +## Edge cases + +- A zero-`ExpiresOn` token is cached indefinitely (never refreshed) — unchanged. +- Two policies sharing a cache: the second sees the first's fresh token and skips + the fetch; if both miss concurrently, both fetch and the last `Set` wins (benign). +- Distinct scope sets cache under distinct keys, so a shared cache serves multiple + scopes correctly. +- `WithTokenCache(nil)` → treated as unset (default in-memory cache used). +- The `Do` HTTPS guard, header set, and error wrapping are unchanged. + +## Package layout + +| Path | Change | +|---|---| +| `auth/cache.go` (new) + test | `TokenCache`, `InMemoryTokenCache` | +| `auth/bearer.go` (modify) | use `TokenCache`; add `NewBearerTokenPolicyWithCache`; `fresh` helper | +| `auth/bearer_test.go` (modify) | add a shared-cache test (existing tests unchanged) | +| `options.go`, `client.go` (modify) | `WithTokenCache` + wiring | +| `client_test.go` (modify) | `WithTokenCache` reuse test | +| `doc.go`, `README.md` | document | + +## Testing + +- `InMemoryTokenCache`: Get on a missing key → (zero, false); Set then Get → + (token, true); concurrent Get/Set under `-race` (a short goroutine loop). +- Bearer behaviour preserved: the existing `TestBearerAttachesHeaderAndCaches` + still passes (per-policy cache, one `GetToken` for three requests). +- Shared cache: two `BearerTokenPolicy` instances built with the same cache and a + counting credential — only the first triggers `GetToken`; the second reuses the + cached token (counter stays 1). +- Umbrella: two clients built with `WithCredential(countingCred)` + a shared + `WithTokenCache(cache)` issue one request each; `GetToken` is called once. +- Table-driven where natural, parallel; stdlib-only; `gofmt`/`go vet`/`go test + -race` clean. + +## Out of scope (deferred) + +- Cross-policy single-flight refresh (per-key locking in the cache). The per-policy + lock plus freshness check is sufficient; add single-flight if a real stampede + appears. +- A persistent (disk/redis) cache implementation — users implement `TokenCache`. +- Negative caching of token-fetch errors. diff --git a/options.go b/options.go index 6e9ea5f..72e30bd 100644 --- a/options.go +++ b/options.go @@ -29,6 +29,7 @@ type config struct { retry *retry.Options credential auth.TokenCredential scopes []string + tokenCache auth.TokenCache basicAuth *auth.BasicCredential apiKey apiKeyConfig logger *slog.Logger @@ -72,6 +73,13 @@ func WithCredential(cred auth.TokenCredential, scopes ...string) Option { } } +// WithTokenCache shares cache across the bearer-token policy installed by +// WithCredential, so multiple clients can reuse cached tokens. A nil cache or no +// credential means the default per-client in-memory cache is used. +func WithTokenCache(cache auth.TokenCache) Option { + return func(c *config) { c.tokenCache = cache } +} + // WithBasicAuth authenticates requests with HTTP Basic auth. Like all credential // policies it requires HTTPS. If multiple auth options are set, the precedence is // WithCredential, then WithBasicAuth, then WithAPIKey.