From 4603cdbce80a9f2a731648e87c47b1040d06adc2 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Tue, 14 Apr 2026 17:05:10 -0400 Subject: [PATCH 1/5] =?UTF-8?q?=E2=9C=A8=20Add=20authlib/=20=E2=80=94=20sh?= =?UTF-8?q?ared=20token=20exchange=20building=20blocks=20(Phase=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract reusable auth building blocks from go-processor into a standalone Go module at authbridge/authlib/. Six packages with 34 unit tests: - validation/ — JWKS-backed JWT verifier (lestrrat-go/jwx) - exchange/ — RFC 8693 token exchange + client credentials - cache/ — SHA-256 keyed token cache with TTL eviction - bypass/ — Path pattern matcher for public endpoints - spiffe/ — File-based JWT-SVID source - routing/ — Host-to-audience router with glob matching No Envoy/gRPC dependencies — pure Go library. Phase 2 will add the auth composition layer and config; Phase 3 wires up listeners. Ref: kagenti/kagenti-extensions#279 Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/authlib/bypass/matcher.go | 45 +++++ authbridge/authlib/bypass/matcher_test.go | 85 +++++++++ authbridge/authlib/cache/cache.go | 105 ++++++++++++ authbridge/authlib/cache/cache_test.go | 82 +++++++++ authbridge/authlib/exchange/auth.go | 43 +++++ authbridge/authlib/exchange/client.go | 145 ++++++++++++++++ authbridge/authlib/exchange/client_test.go | 189 +++++++++++++++++++++ authbridge/authlib/exchange/errors.go | 20 +++ authbridge/authlib/go.mod | 22 +++ authbridge/authlib/go.sum | 39 +++++ authbridge/authlib/routing/config.go | 25 +++ authbridge/authlib/routing/router.go | 90 ++++++++++ authbridge/authlib/routing/router_test.go | 135 +++++++++++++++ authbridge/authlib/spiffe/file.go | 33 ++++ authbridge/authlib/spiffe/file_test.go | 73 ++++++++ authbridge/authlib/spiffe/source.go | 11 ++ authbridge/authlib/validation/jwks.go | 109 ++++++++++++ authbridge/authlib/validation/jwks_test.go | 36 ++++ authbridge/authlib/validation/verifier.go | 46 +++++ 19 files changed, 1333 insertions(+) create mode 100644 authbridge/authlib/bypass/matcher.go create mode 100644 authbridge/authlib/bypass/matcher_test.go create mode 100644 authbridge/authlib/cache/cache.go create mode 100644 authbridge/authlib/cache/cache_test.go create mode 100644 authbridge/authlib/exchange/auth.go create mode 100644 authbridge/authlib/exchange/client.go create mode 100644 authbridge/authlib/exchange/client_test.go create mode 100644 authbridge/authlib/exchange/errors.go create mode 100644 authbridge/authlib/go.mod create mode 100644 authbridge/authlib/go.sum create mode 100644 authbridge/authlib/routing/config.go create mode 100644 authbridge/authlib/routing/router.go create mode 100644 authbridge/authlib/routing/router_test.go create mode 100644 authbridge/authlib/spiffe/file.go create mode 100644 authbridge/authlib/spiffe/file_test.go create mode 100644 authbridge/authlib/spiffe/source.go create mode 100644 authbridge/authlib/validation/jwks.go create mode 100644 authbridge/authlib/validation/jwks_test.go create mode 100644 authbridge/authlib/validation/verifier.go diff --git a/authbridge/authlib/bypass/matcher.go b/authbridge/authlib/bypass/matcher.go new file mode 100644 index 000000000..73f0c1b82 --- /dev/null +++ b/authbridge/authlib/bypass/matcher.go @@ -0,0 +1,45 @@ +// Package bypass provides path pattern matching for skipping JWT validation +// on public endpoints (e.g., health probes, agent card discovery). +package bypass + +import ( + "fmt" + "path" + "strings" +) + +// DefaultPatterns are paths that skip inbound JWT validation by default. +var DefaultPatterns = []string{"/.well-known/*", "/healthz", "/readyz", "/livez"} + +// Matcher checks request paths against a set of bypass patterns. +// Patterns use Go's path.Match syntax (e.g., "/.well-known/*"). +type Matcher struct { + patterns []string +} + +// NewMatcher creates a Matcher from the given patterns. +// Returns an error if any pattern has invalid path.Match syntax. +func NewMatcher(patterns []string) (*Matcher, error) { + for _, p := range patterns { + if _, err := path.Match(p, "/"); err != nil { + return nil, fmt.Errorf("invalid bypass pattern %q: %w", p, err) + } + } + return &Matcher{patterns: patterns}, nil +} + +// Match checks if the given request path matches any bypass pattern. +// Query strings are stripped and the path is normalized via path.Clean +// to prevent bypass via non-canonical forms (e.g., //healthz, /./healthz). +func (m *Matcher) Match(requestPath string) bool { + if idx := strings.IndexByte(requestPath, '?'); idx >= 0 { + requestPath = requestPath[:idx] + } + requestPath = path.Clean(requestPath) + for _, pattern := range m.patterns { + if matched, _ := path.Match(pattern, requestPath); matched { + return true + } + } + return false +} diff --git a/authbridge/authlib/bypass/matcher_test.go b/authbridge/authlib/bypass/matcher_test.go new file mode 100644 index 000000000..1d96113d5 --- /dev/null +++ b/authbridge/authlib/bypass/matcher_test.go @@ -0,0 +1,85 @@ +package bypass + +import ( + "testing" +) + +func TestNewMatcher_ValidPatterns(t *testing.T) { + _, err := NewMatcher(DefaultPatterns) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestNewMatcher_InvalidPattern(t *testing.T) { + _, err := NewMatcher([]string{"[invalid"}) + if err == nil { + t.Fatal("expected error for invalid pattern") + } +} + +func TestMatch(t *testing.T) { + m, err := NewMatcher(DefaultPatterns) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + tests := []struct { + path string + match bool + }{ + // Default bypass paths + {"/.well-known/agent.json", true}, + {"/.well-known/openid-configuration", true}, + {"/healthz", true}, + {"/readyz", true}, + {"/livez", true}, + + // Non-bypass paths + {"/test", false}, + {"/api/v1/agents", false}, + {"/", false}, + + // Query string stripping + {"/healthz?verbose=true", true}, + {"/.well-known/agent.json?format=json", true}, + + // Path normalization (security) + {"//healthz", true}, + {"/./healthz", true}, + {"/foo/../healthz", true}, + + // Well-known does NOT match nested paths (path.Match * doesn't cross /) + {"/.well-known/foo/bar", false}, + } + + for _, tt := range tests { + got := m.Match(tt.path) + if got != tt.match { + t.Errorf("Match(%q) = %v, want %v", tt.path, got, tt.match) + } + } +} + +func TestMatch_EmptyPatterns(t *testing.T) { + m, _ := NewMatcher(nil) + if m.Match("/healthz") { + t.Error("empty matcher should not match anything") + } +} + +func TestMatch_CustomPatterns(t *testing.T) { + m, err := NewMatcher([]string{"/public/*", "/status"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !m.Match("/public/index.html") { + t.Error("expected /public/index.html to match") + } + if !m.Match("/status") { + t.Error("expected /status to match") + } + if m.Match("/private/data") { + t.Error("expected /private/data to not match") + } +} diff --git a/authbridge/authlib/cache/cache.go b/authbridge/authlib/cache/cache.go new file mode 100644 index 000000000..199d72fcf --- /dev/null +++ b/authbridge/authlib/cache/cache.go @@ -0,0 +1,105 @@ +// Package cache provides a SHA-256 keyed token cache with TTL-based eviction. +package cache + +import ( + "crypto/sha256" + "encoding/hex" + "sync" + "time" +) + +type entry struct { + token string + expiresAt time.Time +} + +// Cache is a thread-safe token cache keyed by SHA-256 hash of (subjectToken, audience). +type Cache struct { + mu sync.RWMutex + entries map[string]entry + maxSize int +} + +// Option configures cache behavior. +type Option func(*Cache) + +// WithMaxSize sets the maximum number of cache entries. +// When exceeded, expired entries are evicted first; if still full, all entries are cleared. +func WithMaxSize(n int) Option { + return func(c *Cache) { c.maxSize = n } +} + +// New creates a token cache. +func New(opts ...Option) *Cache { + c := &Cache{ + entries: make(map[string]entry), + maxSize: 10000, + } + for _, o := range opts { + o(c) + } + return c +} + +// Get returns a cached token for the given subject token and audience. +// Returns ("", false) if not found or expired. +func (c *Cache) Get(subjectToken, audience string) (string, bool) { + key := cacheKey(subjectToken, audience) + c.mu.RLock() + e, ok := c.entries[key] + c.mu.RUnlock() + if !ok || time.Now().After(e.expiresAt) { + if ok { + c.mu.Lock() + delete(c.entries, key) + c.mu.Unlock() + } + return "", false + } + return e.token, true +} + +// Set stores a token with the given TTL. A buffer of 30 seconds is subtracted +// from the TTL to ensure tokens are refreshed before they expire. +func (c *Cache) Set(subjectToken, audience, token string, ttl time.Duration) { + if ttl <= 30*time.Second { + return // too short to cache + } + key := cacheKey(subjectToken, audience) + c.mu.Lock() + defer c.mu.Unlock() + if len(c.entries) >= c.maxSize { + c.evictExpired() + if len(c.entries) >= c.maxSize { + c.entries = make(map[string]entry) + } + } + c.entries[key] = entry{ + token: token, + expiresAt: time.Now().Add(ttl - 30*time.Second), + } +} + +// Len returns the number of entries (including potentially expired ones). +func (c *Cache) Len() int { + c.mu.RLock() + defer c.mu.RUnlock() + return len(c.entries) +} + +func (c *Cache) evictExpired() { + now := time.Now() + for k, e := range c.entries { + if now.After(e.expiresAt) { + delete(c.entries, k) + } + } +} + +func cacheKey(subjectToken, audience string) string { + h := sha256.New() + h.Write([]byte(subjectToken)) + h.Write([]byte{0}) // separator + h.Write([]byte(audience)) + return hex.EncodeToString(h.Sum(nil)) +} diff --git a/authbridge/authlib/cache/cache_test.go b/authbridge/authlib/cache/cache_test.go new file mode 100644 index 000000000..852e61afa --- /dev/null +++ b/authbridge/authlib/cache/cache_test.go @@ -0,0 +1,82 @@ +package cache + +import ( + "testing" + "time" +) + +func TestGetSet(t *testing.T) { + c := New() + c.Set("token-abc", "aud-1", "exchanged-xyz", 5*time.Minute) + + got, ok := c.Get("token-abc", "aud-1") + if !ok || got != "exchanged-xyz" { + t.Errorf("Get() = (%q, %v), want (%q, true)", got, ok, "exchanged-xyz") + } +} + +func TestGetMiss(t *testing.T) { + c := New() + _, ok := c.Get("nonexistent", "aud") + if ok { + t.Error("expected cache miss") + } +} + +func TestGetDifferentAudience(t *testing.T) { + c := New() + c.Set("token-abc", "aud-1", "exchanged-1", 5*time.Minute) + + _, ok := c.Get("token-abc", "aud-2") + if ok { + t.Error("expected cache miss for different audience") + } +} + +func TestTTLTooShort(t *testing.T) { + c := New() + c.Set("token", "aud", "val", 20*time.Second) // below 30s threshold + if c.Len() != 0 { + t.Error("expected no entry for TTL below threshold") + } +} + +func TestMaxSize(t *testing.T) { + c := New(WithMaxSize(2)) + c.Set("t1", "a", "v1", 5*time.Minute) + c.Set("t2", "a", "v2", 5*time.Minute) + c.Set("t3", "a", "v3", 5*time.Minute) // should trigger eviction + + if c.Len() > 2 { + t.Errorf("cache has %d entries, want <= 2", c.Len()) + } +} + +func TestCacheKeyCollisionResistance(t *testing.T) { + // Keys must differ when tokens or audiences differ + k1 := cacheKey("abc", "def") + k2 := cacheKey("ab", "cdef") + k3 := cacheKey("abc", "de") + if k1 == k2 { + t.Error("different token/audience combos produced same key") + } + if k1 == k3 { + t.Error("different audience produced same key") + } +} + +func TestConcurrentAccess(t *testing.T) { + c := New() + done := make(chan struct{}) + for i := range 100 { + go func(n int) { + defer func() { done <- struct{}{} }() + token := string(rune('A' + n%26)) + c.Set(token, "aud", "val", 5*time.Minute) + c.Get(token, "aud") + }(i) + } + for range 100 { + <-done + } +} diff --git a/authbridge/authlib/exchange/auth.go b/authbridge/authlib/exchange/auth.go new file mode 100644 index 000000000..33dec91d1 --- /dev/null +++ b/authbridge/authlib/exchange/auth.go @@ -0,0 +1,43 @@ +package exchange + +import ( + "context" + "net/http" + "net/url" +) + +// ClientAuth applies client authentication to a token exchange request. +type ClientAuth interface { + Apply(ctx context.Context, form url.Values, headers http.Header) error +} + +// ClientSecretAuth authenticates using client_id and client_secret in the form body. +type ClientSecretAuth struct { + ClientID string + ClientSecret string +} + +func (a *ClientSecretAuth) Apply(_ context.Context, form url.Values, _ http.Header) error { + form.Set("client_id", a.ClientID) + form.Set("client_secret", a.ClientSecret) + return nil +} + +// JWTAssertionAuth authenticates using a JWT client assertion (e.g., SPIFFE JWT-SVID). +// The tokenSource is called on every request to support key rotation. +type JWTAssertionAuth struct { + ClientID string + AssertionType string // e.g., "urn:ietf:params:oauth:client-assertion-type:jwt-spiffe" + TokenSource func(ctx context.Context) (string, error) +} + +func (a *JWTAssertionAuth) Apply(ctx context.Context, form url.Values, _ http.Header) error { + token, err := a.TokenSource(ctx) + if err != nil { + return err + } + form.Set("client_id", a.ClientID) + form.Set("client_assertion", token) + form.Set("client_assertion_type", a.AssertionType) + return nil +} diff --git a/authbridge/authlib/exchange/client.go b/authbridge/authlib/exchange/client.go new file mode 100644 index 000000000..6d4c1d34b --- /dev/null +++ b/authbridge/authlib/exchange/client.go @@ -0,0 +1,145 @@ +// Package exchange implements RFC 8693 OAuth 2.0 Token Exchange +// and client credentials grant. +package exchange + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +// ExchangeRequest contains the parameters for a token exchange. +type ExchangeRequest struct { + SubjectToken string + Audience string + Scopes string + ActorToken string // optional, RFC 8693 Section 4.1 +} + +// ExchangeResponse contains the result of a successful token exchange. +type ExchangeResponse struct { + AccessToken string + TokenType string + ExpiresIn int // seconds +} + +// Client performs OAuth token exchange and client credentials requests. +type Client struct { + tokenURL string + auth ClientAuth + httpClient *http.Client +} + +// Option configures the exchange client. +type Option func(*Client) + +// WithHTTPClient sets the HTTP client used for token requests. +func WithHTTPClient(c *http.Client) Option { + return func(cl *Client) { cl.httpClient = c } +} + +// NewClient creates a token exchange client. +func NewClient(tokenURL string, auth ClientAuth, opts ...Option) *Client { + c := &Client{ + tokenURL: tokenURL, + auth: auth, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } + for _, o := range opts { + o(c) + } + return c +} + +// Exchange performs an RFC 8693 token exchange. +func (c *Client) Exchange(ctx context.Context, req *ExchangeRequest) (*ExchangeResponse, error) { + form := url.Values{ + "grant_type": {"urn:ietf:params:oauth:grant-type:token-exchange"}, + "requested_token_type": {"urn:ietf:params:oauth:token-type:access_token"}, + "subject_token": {req.SubjectToken}, + "subject_token_type": {"urn:ietf:params:oauth:token-type:access_token"}, + } + if req.Audience != "" { + form.Set("audience", req.Audience) + } + if req.Scopes != "" { + form.Set("scope", req.Scopes) + } + if req.ActorToken != "" { + form.Set("actor_token", req.ActorToken) + form.Set("actor_token_type", "urn:ietf:params:oauth:token-type:access_token") + } + + if err := c.auth.Apply(ctx, form, nil); err != nil { + return nil, fmt.Errorf("applying client auth: %w", err) + } + + return c.doTokenRequest(ctx, form) +} + +// ClientCredentials performs a client credentials grant. +func (c *Client) ClientCredentials(ctx context.Context, scopes string) (*ExchangeResponse, error) { + form := url.Values{ + "grant_type": {"client_credentials"}, + } + if scopes != "" { + form.Set("scope", scopes) + } + + if err := c.auth.Apply(ctx, form, nil); err != nil { + return nil, fmt.Errorf("applying client auth: %w", err) + } + + return c.doTokenRequest(ctx, form) +} + +func (c *Client) doTokenRequest(_ context.Context, form url.Values) (*ExchangeResponse, error) { + resp, err := c.httpClient.PostForm(c.tokenURL, form) + if err != nil { + return nil, fmt.Errorf("token request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + var oauthErr struct { + Error string `json:"error"` + Description string `json:"error_description"` + } + _ = json.Unmarshal(body, &oauthErr) + return nil, &ExchangeError{ + StatusCode: resp.StatusCode, + OAuthError: oauthErr.Error, + OAuthDescription: oauthErr.Description, + } + } + + var tokenResp struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + } + if err := json.Unmarshal(body, &tokenResp); err != nil { + return nil, fmt.Errorf("parsing token response: %w", err) + } + if tokenResp.AccessToken == "" { + return nil, fmt.Errorf("token response missing access_token") + } + + return &ExchangeResponse{ + AccessToken: tokenResp.AccessToken, + TokenType: tokenResp.TokenType, + ExpiresIn: tokenResp.ExpiresIn, + }, nil +} + diff --git a/authbridge/authlib/exchange/client_test.go b/authbridge/authlib/exchange/client_test.go new file mode 100644 index 000000000..87f6b4864 --- /dev/null +++ b/authbridge/authlib/exchange/client_test.go @@ -0,0 +1,189 @@ +package exchange + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestExchange_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Fatal(err) + } + if got := r.FormValue("grant_type"); got != "urn:ietf:params:oauth:grant-type:token-exchange" { + t.Errorf("grant_type = %q", got) + } + if got := r.FormValue("subject_token"); got != "original-token" { + t.Errorf("subject_token = %q", got) + } + if got := r.FormValue("audience"); got != "target-aud" { + t.Errorf("audience = %q", got) + } + if got := r.FormValue("client_id"); got != "my-client" { + t.Errorf("client_id = %q", got) + } + if got := r.FormValue("client_secret"); got != "my-secret" { + t.Errorf("client_secret = %q", got) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "access_token": "exchanged-token", + "token_type": "Bearer", + "expires_in": 300, + }) + })) + defer srv.Close() + + client := NewClient(srv.URL, &ClientSecretAuth{ + ClientID: "my-client", + ClientSecret: "my-secret", + }) + + resp, err := client.Exchange(context.Background(), &ExchangeRequest{ + SubjectToken: "original-token", + Audience: "target-aud", + Scopes: "openid", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.AccessToken != "exchanged-token" { + t.Errorf("access_token = %q, want %q", resp.AccessToken, "exchanged-token") + } + if resp.ExpiresIn != 300 { + t.Errorf("expires_in = %d, want 300", resp.ExpiresIn) + } +} + +func TestExchange_OAuthError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "invalid_grant", + "error_description": "subject token expired", + }) + })) + defer srv.Close() + + client := NewClient(srv.URL, &ClientSecretAuth{ClientID: "c", ClientSecret: "s"}) + _, err := client.Exchange(context.Background(), &ExchangeRequest{ + SubjectToken: "expired-token", + Audience: "aud", + }) + if err == nil { + t.Fatal("expected error") + } + exchErr, ok := err.(*ExchangeError) + if !ok { + t.Fatalf("expected *ExchangeError, got %T", err) + } + if exchErr.StatusCode != 400 { + t.Errorf("status = %d, want 400", exchErr.StatusCode) + } + if exchErr.OAuthError != "invalid_grant" { + t.Errorf("oauth_error = %q, want %q", exchErr.OAuthError, "invalid_grant") + } +} + +func TestClientCredentials_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Fatal(err) + } + if got := r.FormValue("grant_type"); got != "client_credentials" { + t.Errorf("grant_type = %q", got) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "access_token": "cc-token", + "token_type": "Bearer", + "expires_in": 3600, + }) + })) + defer srv.Close() + + client := NewClient(srv.URL, &ClientSecretAuth{ClientID: "c", ClientSecret: "s"}) + resp, err := client.ClientCredentials(context.Background(), "openid") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.AccessToken != "cc-token" { + t.Errorf("access_token = %q, want %q", resp.AccessToken, "cc-token") + } +} + +func TestExchange_WithActorToken(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Fatal(err) + } + if got := r.FormValue("actor_token"); got != "actor-jwt" { + t.Errorf("actor_token = %q, want %q", got, "actor-jwt") + } + if got := r.FormValue("actor_token_type"); got != "urn:ietf:params:oauth:token-type:access_token" { + t.Errorf("actor_token_type = %q", got) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "access_token": "delegated-token", + "token_type": "Bearer", + "expires_in": 300, + }) + })) + defer srv.Close() + + client := NewClient(srv.URL, &ClientSecretAuth{ClientID: "c", ClientSecret: "s"}) + resp, err := client.Exchange(context.Background(), &ExchangeRequest{ + SubjectToken: "user-token", + Audience: "aud", + ActorToken: "actor-jwt", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.AccessToken != "delegated-token" { + t.Errorf("access_token = %q", resp.AccessToken) + } +} + +func TestJWTAssertionAuth(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Fatal(err) + } + if got := r.FormValue("client_assertion"); got != "jwt-svid-token" { + t.Errorf("client_assertion = %q", got) + } + if got := r.FormValue("client_assertion_type"); got != "urn:ietf:params:oauth:client-assertion-type:jwt-spiffe" { + t.Errorf("client_assertion_type = %q", got) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "access_token": "spiffe-exchanged", + "token_type": "Bearer", + "expires_in": 300, + }) + })) + defer srv.Close() + + client := NewClient(srv.URL, &JWTAssertionAuth{ + ClientID: "spiffe-client", + AssertionType: "urn:ietf:params:oauth:client-assertion-type:jwt-spiffe", + TokenSource: func(_ context.Context) (string, error) { + return "jwt-svid-token", nil + }, + }) + resp, err := client.Exchange(context.Background(), &ExchangeRequest{ + SubjectToken: "user-token", + Audience: "target", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.AccessToken != "spiffe-exchanged" { + t.Errorf("access_token = %q", resp.AccessToken) + } +} diff --git a/authbridge/authlib/exchange/errors.go b/authbridge/authlib/exchange/errors.go new file mode 100644 index 000000000..7e45fe3e3 --- /dev/null +++ b/authbridge/authlib/exchange/errors.go @@ -0,0 +1,20 @@ +package exchange + +import "fmt" + +// ExchangeError represents an OAuth token exchange failure with HTTP status +// and RFC 6749 error/error_description fields for diagnostics. +type ExchangeError struct { + StatusCode int + OAuthError string // RFC 6749 "error" field + OAuthDescription string // RFC 6749 "error_description" field +} + +func (e *ExchangeError) Error() string { + if e.OAuthDescription != "" { + return fmt.Sprintf("token exchange failed (HTTP %d): %s: %s", + e.StatusCode, e.OAuthError, e.OAuthDescription) + } + return fmt.Sprintf("token exchange failed (HTTP %d): %s", + e.StatusCode, e.OAuthError) +} diff --git a/authbridge/authlib/go.mod b/authbridge/authlib/go.mod new file mode 100644 index 000000000..dee5040a3 --- /dev/null +++ b/authbridge/authlib/go.mod @@ -0,0 +1,22 @@ +module github.com/kagenti/kagenti-extensions/authbridge/authlib + +go 1.24.0 + +require ( + github.com/gobwas/glob v0.2.3 + github.com/lestrrat-go/jwx/v2 v2.1.6 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/goccy/go-json v0.10.3 // indirect + github.com/lestrrat-go/blackmagic v1.0.3 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/httprc v1.0.6 // indirect + github.com/lestrrat-go/iter v1.0.2 // indirect + github.com/lestrrat-go/option v1.0.1 // indirect + github.com/segmentio/asm v1.2.0 // indirect + golang.org/x/crypto v0.32.0 // indirect + golang.org/x/sys v0.31.0 // indirect +) diff --git a/authbridge/authlib/go.sum b/authbridge/authlib/go.sum new file mode 100644 index 000000000..ef4759cc1 --- /dev/null +++ b/authbridge/authlib/go.sum @@ -0,0 +1,39 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= +github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs= +github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= +github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= +github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= +github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= +github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA= +github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= +github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= +github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= +github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/authbridge/authlib/routing/config.go b/authbridge/authlib/routing/config.go new file mode 100644 index 000000000..3fb50f26b --- /dev/null +++ b/authbridge/authlib/routing/config.go @@ -0,0 +1,25 @@ +package routing + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +// LoadRoutes loads routes from a YAML file. +// Returns an empty slice (not error) if the file doesn't exist. +func LoadRoutes(path string) ([]Route, error) { + if _, err := os.Stat(path); os.IsNotExist(err) { + return nil, nil + } + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading routes config: %w", err) + } + var routes []Route + if err := yaml.Unmarshal(data, &routes); err != nil { + return nil, fmt.Errorf("parsing routes config: %w", err) + } + return routes, nil +} diff --git a/authbridge/authlib/routing/router.go b/authbridge/authlib/routing/router.go new file mode 100644 index 000000000..19a349ef2 --- /dev/null +++ b/authbridge/authlib/routing/router.go @@ -0,0 +1,90 @@ +// Package routing provides host-to-audience routing for token exchange. +// Routes map destination hosts (with glob patterns) to token exchange parameters. +package routing + +import ( + "fmt" + "net" + + "github.com/gobwas/glob" +) + +// Route defines token exchange parameters for requests to a matching host. +type Route struct { + Host string `yaml:"host"` + Audience string `yaml:"target_audience,omitempty"` + Scopes string `yaml:"token_scopes,omitempty"` + TokenEndpoint string `yaml:"token_url,omitempty"` + Action string `yaml:"action,omitempty"` // "exchange" or "passthrough"; defaults to "exchange" +} + +// ResolvedRoute is the result of resolving a host against the router. +type ResolvedRoute struct { + Audience string + Scopes string + TokenEndpoint string + Passthrough bool +} + +type compiledRoute struct { + pattern string + glob glob.Glob + route Route +} + +// Router resolves destination hosts to token exchange configuration. +// Uses first-match-wins semantics with gobwas/glob patterns. +type Router struct { + routes []compiledRoute + defaultAction string // "exchange" or "passthrough" +} + +// NewRouter creates a router from the given routes. +// defaultAction is "exchange" or "passthrough" (applied when no route matches). +// Returns an error if any host pattern is invalid. +func NewRouter(defaultAction string, rules []Route) (*Router, error) { + if defaultAction == "" { + defaultAction = "passthrough" + } + compiled := make([]compiledRoute, 0, len(rules)) + for _, r := range rules { + // Use '.' as separator so *.example.com doesn't match foo.bar.example.com + g, err := glob.Compile(r.Host, '.') + if err != nil { + return nil, fmt.Errorf("invalid route pattern %q: %w", r.Host, err) + } + compiled = append(compiled, compiledRoute{ + pattern: r.Host, + glob: g, + route: r, + }) + } + return &Router{routes: compiled, defaultAction: defaultAction}, nil +} + +// Resolve returns the exchange configuration for the given host. +// Returns nil if no route matches and the default action is "passthrough". +// Port is stripped from the host before matching. +func (r *Router) Resolve(host string) *ResolvedRoute { + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + for _, entry := range r.routes { + if entry.glob.Match(host) { + action := entry.route.Action + if action == "" { + action = "exchange" + } + return &ResolvedRoute{ + Audience: entry.route.Audience, + Scopes: entry.route.Scopes, + TokenEndpoint: entry.route.TokenEndpoint, + Passthrough: action == "passthrough", + } + } + } + if r.defaultAction == "exchange" { + return &ResolvedRoute{} + } + return nil +} diff --git a/authbridge/authlib/routing/router_test.go b/authbridge/authlib/routing/router_test.go new file mode 100644 index 000000000..9a5e38147 --- /dev/null +++ b/authbridge/authlib/routing/router_test.go @@ -0,0 +1,135 @@ +package routing + +import ( + "os" + "path/filepath" + "testing" +) + +func TestResolve_ExactMatch(t *testing.T) { + r, err := NewRouter("passthrough", []Route{ + {Host: "auth-target-service", Audience: "auth-target", Scopes: "openid"}, + }) + if err != nil { + t.Fatal(err) + } + + resolved := r.Resolve("auth-target-service") + if resolved == nil { + t.Fatal("expected match") + } + if resolved.Audience != "auth-target" { + t.Errorf("audience = %q, want %q", resolved.Audience, "auth-target") + } + if resolved.Scopes != "openid" { + t.Errorf("scopes = %q, want %q", resolved.Scopes, "openid") + } +} + +func TestResolve_GlobMatch(t *testing.T) { + r, _ := NewRouter("passthrough", []Route{ + {Host: "*.example.com", Audience: "example"}, + }) + + if resolved := r.Resolve("api.example.com"); resolved == nil || resolved.Audience != "example" { + t.Error("expected glob match for api.example.com") + } + // Single-level glob should NOT match nested + if resolved := r.Resolve("api.sub.example.com"); resolved != nil { + t.Error("single glob should not match nested subdomain") + } +} + +func TestResolve_PortStripping(t *testing.T) { + r, _ := NewRouter("passthrough", []Route{ + {Host: "service", Audience: "svc"}, + }) + if resolved := r.Resolve("service:8081"); resolved == nil || resolved.Audience != "svc" { + t.Error("expected match after port stripping") + } +} + +func TestResolve_FirstMatchWins(t *testing.T) { + r, _ := NewRouter("passthrough", []Route{ + {Host: "service", Audience: "first"}, + {Host: "service", Audience: "second"}, + }) + resolved := r.Resolve("service") + if resolved == nil || resolved.Audience != "first" { + t.Error("expected first-match-wins") + } +} + +func TestResolve_NoMatch_Passthrough(t *testing.T) { + r, _ := NewRouter("passthrough", []Route{ + {Host: "known-service", Audience: "known"}, + }) + if resolved := r.Resolve("unknown-service"); resolved != nil { + t.Error("expected nil for unmatched host with passthrough default") + } +} + +func TestResolve_NoMatch_Exchange(t *testing.T) { + r, _ := NewRouter("exchange", []Route{}) + resolved := r.Resolve("any-service") + if resolved == nil { + t.Fatal("expected non-nil for exchange default") + } + if resolved.Passthrough { + t.Error("expected passthrough=false for exchange default") + } +} + +func TestResolve_PassthroughAction(t *testing.T) { + r, _ := NewRouter("passthrough", []Route{ + {Host: "internal-svc", Action: "passthrough"}, + }) + resolved := r.Resolve("internal-svc") + if resolved == nil || !resolved.Passthrough { + t.Error("expected passthrough=true for passthrough action") + } +} + +func TestNewRouter_InvalidPattern(t *testing.T) { + _, err := NewRouter("passthrough", []Route{ + {Host: "[invalid"}, + }) + if err == nil { + t.Error("expected error for invalid glob pattern") + } +} + +func TestLoadRoutes(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "routes.yaml") + content := `- host: "auth-target" + target_audience: "auth-target" + token_scopes: "openid" +- host: "internal" + action: "passthrough" +` + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatal(err) + } + + routes, err := LoadRoutes(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(routes) != 2 { + t.Fatalf("got %d routes, want 2", len(routes)) + } + if routes[0].Audience != "auth-target" { + t.Errorf("route[0].Audience = %q, want %q", routes[0].Audience, "auth-target") + } +} + +func TestLoadRoutes_FileNotFound(t *testing.T) { + routes, err := LoadRoutes("/nonexistent/routes.yaml") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if routes != nil { + t.Error("expected nil routes for missing file") + } +} diff --git a/authbridge/authlib/spiffe/file.go b/authbridge/authlib/spiffe/file.go new file mode 100644 index 000000000..74ca76545 --- /dev/null +++ b/authbridge/authlib/spiffe/file.go @@ -0,0 +1,33 @@ +package spiffe + +import ( + "context" + "fmt" + "os" + "strings" +) + +// FileJWTSource reads a JWT-SVID from a file on every call. +// This supports spiffe-helper's rotation pattern (~2.5 min rotation) +// by always reading the latest token from disk. +type FileJWTSource struct { + path string +} + +// NewFileJWTSource creates a JWTSource that reads from the given file path. +func NewFileJWTSource(path string) *FileJWTSource { + return &FileJWTSource{path: path} +} + +// FetchToken reads and returns the JWT-SVID from the configured file. +func (s *FileJWTSource) FetchToken(_ context.Context) (string, error) { + data, err := os.ReadFile(s.path) + if err != nil { + return "", fmt.Errorf("reading JWT-SVID from %s: %w", s.path, err) + } + token := strings.TrimSpace(string(data)) + if token == "" { + return "", fmt.Errorf("JWT-SVID file %s is empty", s.path) + } + return token, nil +} diff --git a/authbridge/authlib/spiffe/file_test.go b/authbridge/authlib/spiffe/file_test.go new file mode 100644 index 000000000..896d56087 --- /dev/null +++ b/authbridge/authlib/spiffe/file_test.go @@ -0,0 +1,73 @@ +package spiffe + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestFileJWTSource_FetchToken(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "jwt_svid.token") + if err := os.WriteFile(path, []byte("eyJhbGciOiJSUzI1NiJ9.test.sig\n"), 0600); err != nil { + t.Fatal(err) + } + + src := NewFileJWTSource(path) + token, err := src.FetchToken(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if token != "eyJhbGciOiJSUzI1NiJ9.test.sig" { + t.Errorf("got %q, want trimmed token", token) + } +} + +func TestFileJWTSource_FileNotFound(t *testing.T) { + src := NewFileJWTSource("/nonexistent/path") + _, err := src.FetchToken(context.Background()) + if err == nil { + t.Fatal("expected error for missing file") + } +} + +func TestFileJWTSource_EmptyFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "empty.token") + if err := os.WriteFile(path, []byte(" \n"), 0600); err != nil { + t.Fatal(err) + } + + src := NewFileJWTSource(path) + _, err := src.FetchToken(context.Background()) + if err == nil { + t.Fatal("expected error for empty file") + } +} + +func TestFileJWTSource_ReReadsOnEveryCall(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "rotating.token") + if err := os.WriteFile(path, []byte("token-v1"), 0600); err != nil { + t.Fatal(err) + } + + src := NewFileJWTSource(path) + ctx := context.Background() + + tok1, _ := src.FetchToken(ctx) + if tok1 != "token-v1" { + t.Fatalf("got %q, want token-v1", tok1) + } + + // Simulate rotation + if err := os.WriteFile(path, []byte("token-v2"), 0600); err != nil { + t.Fatal(err) + } + + tok2, _ := src.FetchToken(ctx) + if tok2 != "token-v2" { + t.Errorf("got %q, want token-v2 after rotation", tok2) + } +} diff --git a/authbridge/authlib/spiffe/source.go b/authbridge/authlib/spiffe/source.go new file mode 100644 index 000000000..92405dad3 --- /dev/null +++ b/authbridge/authlib/spiffe/source.go @@ -0,0 +1,11 @@ +// Package spiffe provides credential sources for SPIFFE-based authentication. +package spiffe + +import "context" + +// JWTSource provides JWT tokens for client authentication during token exchange. +type JWTSource interface { + // FetchToken returns a JWT token string for use as a client assertion. + // Implementations may re-read from disk or fetch from the SPIFFE Workload API. + FetchToken(ctx context.Context) (string, error) +} diff --git a/authbridge/authlib/validation/jwks.go b/authbridge/authlib/validation/jwks.go new file mode 100644 index 000000000..4fb26e504 --- /dev/null +++ b/authbridge/authlib/validation/jwks.go @@ -0,0 +1,109 @@ +package validation + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v2/jwt" +) + +// JWKSVerifier validates JWTs using a JWKS endpoint with auto-refreshing key cache. +type JWKSVerifier struct { + jwksURL string + issuer string + cache *jwk.Cache +} + +// JWKSOption configures JWKSVerifier behavior. +type JWKSOption func(*jwksConfig) + +type jwksConfig struct { + refreshInterval time.Duration +} + +// WithRefreshInterval sets the JWKS cache refresh interval. +func WithRefreshInterval(d time.Duration) JWKSOption { + return func(c *jwksConfig) { c.refreshInterval = d } +} + +// NewJWKSVerifier creates a Verifier that validates JWTs against a JWKS endpoint. +// The JWKS keys are cached and auto-refreshed in the background. +func NewJWKSVerifier(ctx context.Context, jwksURL, issuer string, opts ...JWKSOption) (*JWKSVerifier, error) { + cfg := &jwksConfig{ + refreshInterval: 15 * time.Minute, + } + for _, o := range opts { + o(cfg) + } + + cache := jwk.NewCache(ctx) + regOpts := []jwk.RegisterOption{ + jwk.WithMinRefreshInterval(cfg.refreshInterval), + } + if err := cache.Register(jwksURL, regOpts...); err != nil { + return nil, fmt.Errorf("registering JWKS URL %s: %w", jwksURL, err) + } + + return &JWKSVerifier{ + jwksURL: jwksURL, + issuer: issuer, + cache: cache, + }, nil +} + +// Verify parses and validates a JWT token against the cached JWKS. +// Checks signature, expiration, issuer, and audience. +func (v *JWKSVerifier) Verify(ctx context.Context, tokenStr string, audience string) (*Claims, error) { + keySet, err := v.cache.Get(ctx, v.jwksURL) + if err != nil { + return nil, fmt.Errorf("fetching JWKS: %w", err) + } + + parseOpts := []jwt.ParseOption{ + jwt.WithKeySet(keySet), + jwt.WithValidate(true), + jwt.WithIssuer(v.issuer), + } + if audience != "" { + parseOpts = append(parseOpts, jwt.WithAudience(audience)) + } + + token, err := jwt.Parse([]byte(tokenStr), parseOpts...) + if err != nil { + return nil, fmt.Errorf("validating JWT: %w", err) + } + + claims := &Claims{ + Subject: token.Subject(), + Issuer: token.Issuer(), + Audience: token.Audience(), + ExpiresAt: token.Expiration(), + Extra: make(map[string]any), + } + + // Extract "azp" (authorized party / client ID) + if azp, ok := token.Get("azp"); ok { + if s, ok := azp.(string); ok { + claims.ClientID = s + } + } + + // Extract scopes from "scope" claim (space-separated string) + if scopeVal, ok := token.Get("scope"); ok { + if s, ok := scopeVal.(string); ok { + claims.Scopes = strings.Fields(s) + } + } + + // Copy remaining private claims to Extra + for k, v := range token.PrivateClaims() { + if k != "azp" && k != "scope" { + claims.Extra[k] = v + } + } + + return claims, nil +} diff --git a/authbridge/authlib/validation/jwks_test.go b/authbridge/authlib/validation/jwks_test.go new file mode 100644 index 000000000..9e6173e22 --- /dev/null +++ b/authbridge/authlib/validation/jwks_test.go @@ -0,0 +1,36 @@ +package validation + +import ( + "testing" +) + +func TestClaims_HasAudience(t *testing.T) { + c := &Claims{Audience: []string{"aud-1", "aud-2"}} + if !c.HasAudience("aud-1") { + t.Error("expected HasAudience(aud-1) = true") + } + if c.HasAudience("aud-3") { + t.Error("expected HasAudience(aud-3) = false") + } +} + +func TestClaims_HasScope(t *testing.T) { + c := &Claims{Scopes: []string{"openid", "profile"}} + if !c.HasScope("openid") { + t.Error("expected HasScope(openid) = true") + } + if c.HasScope("email") { + t.Error("expected HasScope(email) = false") + } +} + +func TestClaims_EmptyAudience(t *testing.T) { + c := &Claims{} + if c.HasAudience("anything") { + t.Error("expected HasAudience = false on nil audience") + } +} + +// Note: Integration tests for JWKSVerifier.Verify() require a running JWKS endpoint +// (e.g., Keycloak). These are deferred to Phase 2+ when the auth layer is wired up. +// The jwx library's own test suite validates JWT parsing and JWKS resolution. diff --git a/authbridge/authlib/validation/verifier.go b/authbridge/authlib/validation/verifier.go new file mode 100644 index 000000000..a21218c32 --- /dev/null +++ b/authbridge/authlib/validation/verifier.go @@ -0,0 +1,46 @@ +// Package validation provides JWT verification with JWKS-based key resolution. +package validation + +import ( + "context" + "time" +) + +// Verifier validates JWT tokens. +type Verifier interface { + // Verify parses and validates a JWT token string. + // audience is a required parameter to prevent confused deputy attacks. + // Returns claims on success or an error describing the validation failure. + Verify(ctx context.Context, tokenStr string, audience string) (*Claims, error) +} + +// Claims contains the validated claims extracted from a JWT. +type Claims struct { + Subject string + Issuer string + Audience []string + ClientID string // "azp" claim + Scopes []string + ExpiresAt time.Time + Extra map[string]any +} + +// HasAudience checks if the claims contain the given audience. +func (c *Claims) HasAudience(aud string) bool { + for _, a := range c.Audience { + if a == aud { + return true + } + } + return false +} + +// HasScope checks if the claims contain the given scope. +func (c *Claims) HasScope(scope string) bool { + for _, s := range c.Scopes { + if s == scope { + return true + } + } + return false +} From ead07d949f26daadd710117d8498815f72b788b4 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Tue, 14 Apr 2026 17:11:46 -0400 Subject: [PATCH 2/5] =?UTF-8?q?=F0=9F=90=9B=20Fix=20review=20findings:=20c?= =?UTF-8?q?ontext=20propagation,=20response=20limits,=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - exchange: Use http.NewRequestWithContext instead of PostForm so callers can cancel/timeout requests via context - exchange: Limit response body to 1MB via io.LimitReader - exchange: Add tests for connection failure, malformed JSON, and context cancellation - cache: Remove TOCTOU race in Get() — skip expired entry cleanup, rely on Set() eviction instead - routing: Add Matched field to ResolvedRoute so callers can distinguish explicit route match from default action fallback - bypass: Document path.Match limitation (* does not cross /) Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/authlib/bypass/matcher.go | 2 + authbridge/authlib/cache/cache.go | 8 +--- authbridge/authlib/exchange/client.go | 15 +++++-- authbridge/authlib/exchange/client_test.go | 48 ++++++++++++++++++++++ authbridge/authlib/routing/router.go | 4 +- authbridge/authlib/routing/router_test.go | 6 +++ 6 files changed, 73 insertions(+), 10 deletions(-) diff --git a/authbridge/authlib/bypass/matcher.go b/authbridge/authlib/bypass/matcher.go index 73f0c1b82..7957d0316 100644 --- a/authbridge/authlib/bypass/matcher.go +++ b/authbridge/authlib/bypass/matcher.go @@ -13,6 +13,8 @@ var DefaultPatterns = []string{"/.well-known/*", "/healthz", "/readyz", "/livez" // Matcher checks request paths against a set of bypass patterns. // Patterns use Go's path.Match syntax (e.g., "/.well-known/*"). +// Note: path.Match's "*" does NOT cross "/" separators, so "/.well-known/*" +// matches "/.well-known/agent.json" but NOT "/.well-known/foo/bar". type Matcher struct { patterns []string } diff --git a/authbridge/authlib/cache/cache.go b/authbridge/authlib/cache/cache.go index 199d72fcf..ec89a9756 100644 --- a/authbridge/authlib/cache/cache.go +++ b/authbridge/authlib/cache/cache.go @@ -42,18 +42,14 @@ func New(opts ...Option) *Cache { } // Get returns a cached token for the given subject token and audience. -// Returns ("", false) if not found or expired. +// Returns ("", false) if not found or expired. Expired entries are left +// for eviction during Set() to avoid write-lock contention on reads. func (c *Cache) Get(subjectToken, audience string) (string, bool) { key := cacheKey(subjectToken, audience) c.mu.RLock() e, ok := c.entries[key] c.mu.RUnlock() if !ok || time.Now().After(e.expiresAt) { - if ok { - c.mu.Lock() - delete(c.entries, key) - c.mu.Unlock() - } return "", false } return e.token, true diff --git a/authbridge/authlib/exchange/client.go b/authbridge/authlib/exchange/client.go index 6d4c1d34b..aa1a1ef43 100644 --- a/authbridge/authlib/exchange/client.go +++ b/authbridge/authlib/exchange/client.go @@ -9,6 +9,7 @@ import ( "io" "net/http" "net/url" + "strings" "time" ) @@ -99,14 +100,22 @@ func (c *Client) ClientCredentials(ctx context.Context, scopes string) (*Exchang return c.doTokenRequest(ctx, form) } -func (c *Client) doTokenRequest(_ context.Context, form url.Values) (*ExchangeResponse, error) { - resp, err := c.httpClient.PostForm(c.tokenURL, form) +func (c *Client) doTokenRequest(ctx context.Context, form url.Values) (*ExchangeResponse, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.tokenURL, + strings.NewReader(form.Encode())) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("token request: %w", err) } defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) + // Limit response body to 1MB to prevent OOM from malicious endpoints. + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return nil, fmt.Errorf("reading response: %w", err) } diff --git a/authbridge/authlib/exchange/client_test.go b/authbridge/authlib/exchange/client_test.go index 87f6b4864..319b1f691 100644 --- a/authbridge/authlib/exchange/client_test.go +++ b/authbridge/authlib/exchange/client_test.go @@ -149,6 +149,54 @@ func TestExchange_WithActorToken(t *testing.T) { } } +func TestExchange_ConnectionFailure(t *testing.T) { + client := NewClient("http://127.0.0.1:1", &ClientSecretAuth{ClientID: "c", ClientSecret: "s"}) + _, err := client.Exchange(context.Background(), &ExchangeRequest{ + SubjectToken: "token", + Audience: "aud", + }) + if err == nil { + t.Fatal("expected error for connection failure") + } +} + +func TestExchange_MalformedJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("not-json")) + })) + defer srv.Close() + + client := NewClient(srv.URL, &ClientSecretAuth{ClientID: "c", ClientSecret: "s"}) + _, err := client.Exchange(context.Background(), &ExchangeRequest{ + SubjectToken: "token", + Audience: "aud", + }) + if err == nil { + t.Fatal("expected error for malformed JSON") + } +} + +func TestExchange_ContextCancellation(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + // Simulate slow server — but context should cancel before we respond + select {} + })) + defer srv.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel immediately + + client := NewClient(srv.URL, &ClientSecretAuth{ClientID: "c", ClientSecret: "s"}) + _, err := client.Exchange(ctx, &ExchangeRequest{ + SubjectToken: "token", + Audience: "aud", + }) + if err == nil { + t.Fatal("expected error for cancelled context") + } +} + func TestJWTAssertionAuth(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { diff --git a/authbridge/authlib/routing/router.go b/authbridge/authlib/routing/router.go index 19a349ef2..980e6d17c 100644 --- a/authbridge/authlib/routing/router.go +++ b/authbridge/authlib/routing/router.go @@ -20,6 +20,7 @@ type Route struct { // ResolvedRoute is the result of resolving a host against the router. type ResolvedRoute struct { + Matched bool // true if a configured route matched; false for default action fallback Audience string Scopes string TokenEndpoint string @@ -76,6 +77,7 @@ func (r *Router) Resolve(host string) *ResolvedRoute { action = "exchange" } return &ResolvedRoute{ + Matched: true, Audience: entry.route.Audience, Scopes: entry.route.Scopes, TokenEndpoint: entry.route.TokenEndpoint, @@ -84,7 +86,7 @@ func (r *Router) Resolve(host string) *ResolvedRoute { } } if r.defaultAction == "exchange" { - return &ResolvedRoute{} + return &ResolvedRoute{Matched: false} } return nil } diff --git a/authbridge/authlib/routing/router_test.go b/authbridge/authlib/routing/router_test.go index 9a5e38147..ca5747b4f 100644 --- a/authbridge/authlib/routing/router_test.go +++ b/authbridge/authlib/routing/router_test.go @@ -18,6 +18,9 @@ func TestResolve_ExactMatch(t *testing.T) { if resolved == nil { t.Fatal("expected match") } + if !resolved.Matched { + t.Error("expected Matched=true for explicit route match") + } if resolved.Audience != "auth-target" { t.Errorf("audience = %q, want %q", resolved.Audience, "auth-target") } @@ -75,6 +78,9 @@ func TestResolve_NoMatch_Exchange(t *testing.T) { if resolved == nil { t.Fatal("expected non-nil for exchange default") } + if resolved.Matched { + t.Error("expected Matched=false for default action fallback") + } if resolved.Passthrough { t.Error("expected passthrough=false for exchange default") } From ed00dab49462e4afc112c59476b4bb2bc39aa820 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Tue, 14 Apr 2026 17:15:55 -0400 Subject: [PATCH 3/5] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20Bump=20golang.org/x/cr?= =?UTF-8?q?ypto=20to=20v0.48.0=20(fix=20CVE=20in=20dependency=20review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transitive dependency golang.org/x/crypto v0.32.0 has a known DoS vulnerability flagged by the dependency review CI check. Bump to v0.48.0 to match the existing authproxy module. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/authlib/go.mod | 6 ++++-- authbridge/authlib/go.sum | 8 ++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/authbridge/authlib/go.mod b/authbridge/authlib/go.mod index dee5040a3..d59b9aa8e 100644 --- a/authbridge/authlib/go.mod +++ b/authbridge/authlib/go.mod @@ -2,6 +2,8 @@ module github.com/kagenti/kagenti-extensions/authbridge/authlib go 1.24.0 +toolchain go1.24.5 + require ( github.com/gobwas/glob v0.2.3 github.com/lestrrat-go/jwx/v2 v2.1.6 @@ -17,6 +19,6 @@ require ( github.com/lestrrat-go/iter v1.0.2 // indirect github.com/lestrrat-go/option v1.0.1 // indirect github.com/segmentio/asm v1.2.0 // indirect - golang.org/x/crypto v0.32.0 // indirect - golang.org/x/sys v0.31.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/sys v0.41.0 // indirect ) diff --git a/authbridge/authlib/go.sum b/authbridge/authlib/go.sum index ef4759cc1..5f280e3f9 100644 --- a/authbridge/authlib/go.sum +++ b/authbridge/authlib/go.sum @@ -28,10 +28,10 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= -golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From a5623ea73f4e07a4f8ad8253a3937a118963a988 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Wed, 15 Apr 2026 08:32:14 -0400 Subject: [PATCH 4/5] Fix PR #310 review: enforce audience, remove nil Header, add JWKS tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review from @pdettori on PR #310: 1. MUST-FIX: Empty audience now returns error in JWKSVerifier.Verify() instead of silently skipping the check. Enforces the confused deputy protection invariant stated in the interface doc. 2. Remove http.Header param from ClientAuth.Apply() interface — callers always passed nil, risking NPE for future header-based implementations. Simplified to form-only. Add header support later if needed. 3. Add 6 JWKSVerifier unit tests with in-memory httptest JWKS server: valid token, expired, wrong issuer, wrong audience, empty audience, invalid signature. Covers the core security path. 4. Validate SubjectToken != empty in Exchange() for clear early error. 5. Fix TOCTOU in LoadRoutes — call ReadFile directly, check IsNotExist. 6. Add TODO for cache eviction strategy (thundering herd on full clear). Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/authlib/cache/cache.go | 2 + authbridge/authlib/exchange/auth.go | 8 +- authbridge/authlib/exchange/client.go | 7 +- authbridge/authlib/routing/config.go | 7 +- authbridge/authlib/validation/jwks.go | 8 +- authbridge/authlib/validation/jwks_test.go | 203 ++++++++++++++++++++- 6 files changed, 220 insertions(+), 15 deletions(-) diff --git a/authbridge/authlib/cache/cache.go b/authbridge/authlib/cache/cache.go index ec89a9756..07ac8f52b 100644 --- a/authbridge/authlib/cache/cache.go +++ b/authbridge/authlib/cache/cache.go @@ -67,6 +67,8 @@ func (c *Cache) Set(subjectToken, audience, token string, ttl time.Duration) { if len(c.entries) >= c.maxSize { c.evictExpired() if len(c.entries) >= c.maxSize { + // TODO: Consider LRU or random-sample eviction for high-cardinality + // traffic. Full clear can cause temporary cache-miss storms. c.entries = make(map[string]entry) } } diff --git a/authbridge/authlib/exchange/auth.go b/authbridge/authlib/exchange/auth.go index 33dec91d1..7eecf6937 100644 --- a/authbridge/authlib/exchange/auth.go +++ b/authbridge/authlib/exchange/auth.go @@ -2,13 +2,13 @@ package exchange import ( "context" - "net/http" "net/url" ) // ClientAuth applies client authentication to a token exchange request. +// Implementations modify the form values to include credentials. type ClientAuth interface { - Apply(ctx context.Context, form url.Values, headers http.Header) error + Apply(ctx context.Context, form url.Values) error } // ClientSecretAuth authenticates using client_id and client_secret in the form body. @@ -17,7 +17,7 @@ type ClientSecretAuth struct { ClientSecret string } -func (a *ClientSecretAuth) Apply(_ context.Context, form url.Values, _ http.Header) error { +func (a *ClientSecretAuth) Apply(_ context.Context, form url.Values) error { form.Set("client_id", a.ClientID) form.Set("client_secret", a.ClientSecret) return nil @@ -31,7 +31,7 @@ type JWTAssertionAuth struct { TokenSource func(ctx context.Context) (string, error) } -func (a *JWTAssertionAuth) Apply(ctx context.Context, form url.Values, _ http.Header) error { +func (a *JWTAssertionAuth) Apply(ctx context.Context, form url.Values) error { token, err := a.TokenSource(ctx) if err != nil { return err diff --git a/authbridge/authlib/exchange/client.go b/authbridge/authlib/exchange/client.go index aa1a1ef43..65f5b3ef5 100644 --- a/authbridge/authlib/exchange/client.go +++ b/authbridge/authlib/exchange/client.go @@ -60,6 +60,9 @@ func NewClient(tokenURL string, auth ClientAuth, opts ...Option) *Client { // Exchange performs an RFC 8693 token exchange. func (c *Client) Exchange(ctx context.Context, req *ExchangeRequest) (*ExchangeResponse, error) { + if req.SubjectToken == "" { + return nil, fmt.Errorf("subject_token is required for token exchange") + } form := url.Values{ "grant_type": {"urn:ietf:params:oauth:grant-type:token-exchange"}, "requested_token_type": {"urn:ietf:params:oauth:token-type:access_token"}, @@ -77,7 +80,7 @@ func (c *Client) Exchange(ctx context.Context, req *ExchangeRequest) (*ExchangeR form.Set("actor_token_type", "urn:ietf:params:oauth:token-type:access_token") } - if err := c.auth.Apply(ctx, form, nil); err != nil { + if err := c.auth.Apply(ctx, form); err != nil { return nil, fmt.Errorf("applying client auth: %w", err) } @@ -93,7 +96,7 @@ func (c *Client) ClientCredentials(ctx context.Context, scopes string) (*Exchang form.Set("scope", scopes) } - if err := c.auth.Apply(ctx, form, nil); err != nil { + if err := c.auth.Apply(ctx, form); err != nil { return nil, fmt.Errorf("applying client auth: %w", err) } diff --git a/authbridge/authlib/routing/config.go b/authbridge/authlib/routing/config.go index 3fb50f26b..5248fc67b 100644 --- a/authbridge/authlib/routing/config.go +++ b/authbridge/authlib/routing/config.go @@ -1,6 +1,7 @@ package routing import ( + "errors" "fmt" "os" @@ -10,11 +11,11 @@ import ( // LoadRoutes loads routes from a YAML file. // Returns an empty slice (not error) if the file doesn't exist. func LoadRoutes(path string) ([]Route, error) { - if _, err := os.Stat(path); os.IsNotExist(err) { - return nil, nil - } data, err := os.ReadFile(path) if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } return nil, fmt.Errorf("reading routes config: %w", err) } var routes []Route diff --git a/authbridge/authlib/validation/jwks.go b/authbridge/authlib/validation/jwks.go index 4fb26e504..8ea66f93f 100644 --- a/authbridge/authlib/validation/jwks.go +++ b/authbridge/authlib/validation/jwks.go @@ -62,13 +62,15 @@ func (v *JWKSVerifier) Verify(ctx context.Context, tokenStr string, audience str return nil, fmt.Errorf("fetching JWKS: %w", err) } + if audience == "" { + return nil, fmt.Errorf("audience is required (prevents confused deputy attacks)") + } + parseOpts := []jwt.ParseOption{ jwt.WithKeySet(keySet), jwt.WithValidate(true), jwt.WithIssuer(v.issuer), - } - if audience != "" { - parseOpts = append(parseOpts, jwt.WithAudience(audience)) + jwt.WithAudience(audience), } token, err := jwt.Parse([]byte(tokenStr), parseOpts...) diff --git a/authbridge/authlib/validation/jwks_test.go b/authbridge/authlib/validation/jwks_test.go index 9e6173e22..e62f30de8 100644 --- a/authbridge/authlib/validation/jwks_test.go +++ b/authbridge/authlib/validation/jwks_test.go @@ -1,7 +1,18 @@ package validation import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/json" + "net/http" + "net/http/httptest" "testing" + "time" + + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v2/jwt" ) func TestClaims_HasAudience(t *testing.T) { @@ -31,6 +42,192 @@ func TestClaims_EmptyAudience(t *testing.T) { } } -// Note: Integration tests for JWKSVerifier.Verify() require a running JWKS endpoint -// (e.g., Keycloak). These are deferred to Phase 2+ when the auth layer is wired up. -// The jwx library's own test suite validates JWT parsing and JWKS resolution. +// --- JWKSVerifier integration tests with in-memory JWKS --- + +func setupTestJWKS(t *testing.T) (*rsa.PrivateKey, *httptest.Server) { + t.Helper() + privKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + + pubJWK, err := jwk.FromRaw(privKey.PublicKey) + if err != nil { + t.Fatal(err) + } + pubJWK.Set(jwk.KeyIDKey, "test-key-1") + pubJWK.Set(jwk.AlgorithmKey, jwa.RS256) + + keySet := jwk.NewSet() + keySet.AddKey(pubJWK) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(keySet) + })) + + return privKey, srv +} + +func signToken(t *testing.T, privKey *rsa.PrivateKey, claims map[string]interface{}) string { + t.Helper() + builder := jwt.New() + for k, v := range claims { + builder.Set(k, v) + } + + privJWK, err := jwk.FromRaw(privKey) + if err != nil { + t.Fatal(err) + } + privJWK.Set(jwk.KeyIDKey, "test-key-1") + privJWK.Set(jwk.AlgorithmKey, jwa.RS256) + + signed, err := jwt.Sign(builder, jwt.WithKey(jwa.RS256, privJWK)) + if err != nil { + t.Fatal(err) + } + return string(signed) +} + +func TestJWKSVerifier_ValidToken(t *testing.T) { + privKey, jwksSrv := setupTestJWKS(t) + defer jwksSrv.Close() + + ctx := context.Background() + v, err := NewJWKSVerifier(ctx, jwksSrv.URL, "http://test-issuer") + if err != nil { + t.Fatal(err) + } + + token := signToken(t, privKey, map[string]interface{}{ + "iss": "http://test-issuer", + "aud": []string{"my-agent"}, + "sub": "user-123", + "azp": "caller-app", + "exp": time.Now().Add(5 * time.Minute).Unix(), + "iat": time.Now().Unix(), + }) + + claims, err := v.Verify(ctx, token, "my-agent") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if claims.Subject != "user-123" { + t.Errorf("subject = %q, want user-123", claims.Subject) + } + if claims.ClientID != "caller-app" { + t.Errorf("client_id = %q, want caller-app", claims.ClientID) + } + if !claims.HasAudience("my-agent") { + t.Error("expected audience my-agent") + } +} + +func TestJWKSVerifier_ExpiredToken(t *testing.T) { + privKey, jwksSrv := setupTestJWKS(t) + defer jwksSrv.Close() + + ctx := context.Background() + v, _ := NewJWKSVerifier(ctx, jwksSrv.URL, "http://test-issuer") + + token := signToken(t, privKey, map[string]interface{}{ + "iss": "http://test-issuer", + "aud": []string{"my-agent"}, + "sub": "user", + "exp": time.Now().Add(-1 * time.Minute).Unix(), + "iat": time.Now().Add(-5 * time.Minute).Unix(), + }) + + _, err := v.Verify(ctx, token, "my-agent") + if err == nil { + t.Fatal("expected error for expired token") + } +} + +func TestJWKSVerifier_WrongIssuer(t *testing.T) { + privKey, jwksSrv := setupTestJWKS(t) + defer jwksSrv.Close() + + ctx := context.Background() + v, _ := NewJWKSVerifier(ctx, jwksSrv.URL, "http://expected-issuer") + + token := signToken(t, privKey, map[string]interface{}{ + "iss": "http://wrong-issuer", + "aud": []string{"my-agent"}, + "sub": "user", + "exp": time.Now().Add(5 * time.Minute).Unix(), + "iat": time.Now().Unix(), + }) + + _, err := v.Verify(ctx, token, "my-agent") + if err == nil { + t.Fatal("expected error for wrong issuer") + } +} + +func TestJWKSVerifier_WrongAudience(t *testing.T) { + privKey, jwksSrv := setupTestJWKS(t) + defer jwksSrv.Close() + + ctx := context.Background() + v, _ := NewJWKSVerifier(ctx, jwksSrv.URL, "http://test-issuer") + + token := signToken(t, privKey, map[string]interface{}{ + "iss": "http://test-issuer", + "aud": []string{"other-agent"}, + "sub": "user", + "exp": time.Now().Add(5 * time.Minute).Unix(), + "iat": time.Now().Unix(), + }) + + _, err := v.Verify(ctx, token, "my-agent") + if err == nil { + t.Fatal("expected error for wrong audience") + } +} + +func TestJWKSVerifier_EmptyAudience_Rejected(t *testing.T) { + privKey, jwksSrv := setupTestJWKS(t) + defer jwksSrv.Close() + + ctx := context.Background() + v, _ := NewJWKSVerifier(ctx, jwksSrv.URL, "http://test-issuer") + + token := signToken(t, privKey, map[string]interface{}{ + "iss": "http://test-issuer", + "aud": []string{"my-agent"}, + "sub": "user", + "exp": time.Now().Add(5 * time.Minute).Unix(), + "iat": time.Now().Unix(), + }) + + _, err := v.Verify(ctx, token, "") + if err == nil { + t.Fatal("expected error for empty audience (confused deputy protection)") + } +} + +func TestJWKSVerifier_InvalidSignature(t *testing.T) { + _, jwksSrv := setupTestJWKS(t) + defer jwksSrv.Close() + + // Sign with a DIFFERENT key than the one served by JWKS + otherKey, _ := rsa.GenerateKey(rand.Reader, 2048) + + ctx := context.Background() + v, _ := NewJWKSVerifier(ctx, jwksSrv.URL, "http://test-issuer") + + token := signToken(t, otherKey, map[string]interface{}{ + "iss": "http://test-issuer", + "aud": []string{"my-agent"}, + "sub": "user", + "exp": time.Now().Add(5 * time.Minute).Unix(), + "iat": time.Now().Unix(), + }) + + _, err := v.Verify(ctx, token, "my-agent") + if err == nil { + t.Fatal("expected error for invalid signature") + } +} From 080f19f6e49ae9bd17829ccfd30889b08aae1e10 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Wed, 15 Apr 2026 07:50:15 -0400 Subject: [PATCH 5/5] Fix review findings: actor cache TTL, t.Fatal in handlers, JWKS derivation Address review from @pdettori on PR #311: 1. actor_cache.go: Always reset expiresAt after token refresh. The IsZero() guard only triggered on the first call; subsequent refreshes left expiresAt stale, degrading the cache to no-caching. 2. auth_test.go, client_test.go: Replace t.Fatal(err) with http.Error() + return in httptest handlers. t.Fatal calls runtime.Goexit on the wrong goroutine, causing undefined behavior. 3. resolve.go: Use suffix-based HasSuffix + TrimSuffix for JWKS URL derivation instead of strings.Replace. Prevents accidentally modifying hostnames containing "token". Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/authlib/exchange/client_test.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/authbridge/authlib/exchange/client_test.go b/authbridge/authlib/exchange/client_test.go index 319b1f691..d4f4d14a6 100644 --- a/authbridge/authlib/exchange/client_test.go +++ b/authbridge/authlib/exchange/client_test.go @@ -11,7 +11,8 @@ import ( func TestExchange_Success(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { - t.Fatal(err) + http.Error(w, err.Error(), http.StatusBadRequest) + return } if got := r.FormValue("grant_type"); got != "urn:ietf:params:oauth:grant-type:token-exchange" { t.Errorf("grant_type = %q", got) @@ -91,7 +92,8 @@ func TestExchange_OAuthError(t *testing.T) { func TestClientCredentials_Success(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { - t.Fatal(err) + http.Error(w, err.Error(), http.StatusBadRequest) + return } if got := r.FormValue("grant_type"); got != "client_credentials" { t.Errorf("grant_type = %q", got) @@ -118,7 +120,8 @@ func TestClientCredentials_Success(t *testing.T) { func TestExchange_WithActorToken(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { - t.Fatal(err) + http.Error(w, err.Error(), http.StatusBadRequest) + return } if got := r.FormValue("actor_token"); got != "actor-jwt" { t.Errorf("actor_token = %q, want %q", got, "actor-jwt") @@ -200,7 +203,8 @@ func TestExchange_ContextCancellation(t *testing.T) { func TestJWTAssertionAuth(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { - t.Fatal(err) + http.Error(w, err.Error(), http.StatusBadRequest) + return } if got := r.FormValue("client_assertion"); got != "jwt-svid-token" { t.Errorf("client_assertion = %q", got)