From b7f79e49c72829d9c862f23a74150e24b1839c72 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Tue, 14 Apr 2026 17:05:10 -0400 Subject: [PATCH 01/10] =?UTF-8?q?=E2=9C=A8=20Add=20authlib/=20=E2=80=94=20?= =?UTF-8?q?shared=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 b2b8a2d640bcd57aaa9c3feb88a378699c8387b0 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Tue, 14 Apr 2026 17:11:46 -0400 Subject: [PATCH 02/10] =?UTF-8?q?=F0=9F=90=9B=20Fix=20review=20findings:?= =?UTF-8?q?=20context=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 d832977a83d5035e835eafcb2ba998133a433608 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Tue, 14 Apr 2026 17:15:55 -0400 Subject: [PATCH 03/10] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20Bump=20golang.org/x/?= =?UTF-8?q?crypto=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 03573bc4ba81e12874156507988ab6d4d5917925 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Tue, 14 Apr 2026 18:35:09 -0400 Subject: [PATCH 04/10] =?UTF-8?q?=E2=9C=A8=20Add=20auth/=20and=20config/?= =?UTF-8?q?=20=E2=80=94=20composition=20layer=20and=20mode=20presets=20(Ph?= =?UTF-8?q?ase=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the application layer that wires Phase 1 authlib building blocks into two functions all listeners will call: auth/: - HandleInbound: bypass → extract bearer → validate JWT → allow/deny - HandleOutbound: resolve route → cache check → exchange → cache result - Per-request audience override for waypoint mode - Actor token source for RFC 8693 act claim chaining - Per-route token endpoint override - Fail-closed: nil verifier denies (not allows) - Case-insensitive bearer token extraction (RFC 7235) config/: - YAML config with ${ENV_VAR} expansion (preserves undefined refs) - Three mode presets: envoy-sidecar, waypoint, proxy-sidecar - Startup validation rejects invalid mode+listener combos - Resolve() wires all authlib packages from config - RouteConfig backwards compat (passthrough: true → action: passthrough) Also updates exchange.Client to support per-request TokenEndpoint override and ExchangeRequest.TokenEndpoint field. 56 tests across 8 packages, all passing. Ref: kagenti/kagenti-extensions#279 Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/authlib/auth/auth.go | 247 +++++++++++++++++ authbridge/authlib/auth/auth_test.go | 334 +++++++++++++++++++++++ authbridge/authlib/auth/result.go | 42 +++ authbridge/authlib/config/config.go | 102 +++++++ authbridge/authlib/config/config_test.go | 244 +++++++++++++++++ authbridge/authlib/config/presets.go | 56 ++++ authbridge/authlib/config/resolve.go | 120 ++++++++ authbridge/authlib/config/validate.go | 129 +++++++++ authbridge/authlib/exchange/client.go | 21 +- 9 files changed, 1287 insertions(+), 8 deletions(-) create mode 100644 authbridge/authlib/auth/auth.go create mode 100644 authbridge/authlib/auth/auth_test.go create mode 100644 authbridge/authlib/auth/result.go create mode 100644 authbridge/authlib/config/config.go create mode 100644 authbridge/authlib/config/config_test.go create mode 100644 authbridge/authlib/config/presets.go create mode 100644 authbridge/authlib/config/resolve.go create mode 100644 authbridge/authlib/config/validate.go diff --git a/authbridge/authlib/auth/auth.go b/authbridge/authlib/auth/auth.go new file mode 100644 index 000000000..9db41dcc1 --- /dev/null +++ b/authbridge/authlib/auth/auth.go @@ -0,0 +1,247 @@ +package auth + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/bypass" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/cache" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/exchange" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/routing" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/validation" +) + +// IdentityConfig holds the agent's identity for audience validation and token exchange. +type IdentityConfig struct { + ClientID string // agent's OAuth client ID + Audience string // expected inbound JWT audience (usually same as ClientID) +} + +// ActorTokenSource provides actor tokens for RFC 8693 Section 4.1 act claim chaining. +// Returns ("", nil) when no actor token is available. +type ActorTokenSource func(ctx context.Context) (string, error) + +// Config holds the resolved dependencies for the auth layer. +type Config struct { + Verifier validation.Verifier + Exchanger *exchange.Client + Cache *cache.Cache + Bypass *bypass.Matcher + Router *routing.Router + Identity IdentityConfig + NoTokenPolicy string // NoTokenClientCredentials, NoTokenAllow, or NoTokenDeny + ActorTokenSource ActorTokenSource // optional, for act claim chaining + Logger *slog.Logger +} + +// Auth composes authlib building blocks into inbound validation and outbound exchange. +type Auth struct { + verifier validation.Verifier + exchanger *exchange.Client + cache *cache.Cache + bypass *bypass.Matcher + router *routing.Router + identity IdentityConfig + noTokenPolicy string + actorTokenSource ActorTokenSource + log *slog.Logger +} + +// New creates an Auth instance from resolved configuration. +func New(cfg Config) *Auth { + logger := cfg.Logger + if logger == nil { + logger = slog.Default() + } + return &Auth{ + verifier: cfg.Verifier, + exchanger: cfg.Exchanger, + cache: cfg.Cache, + bypass: cfg.Bypass, + router: cfg.Router, + identity: cfg.Identity, + noTokenPolicy: cfg.NoTokenPolicy, + actorTokenSource: cfg.ActorTokenSource, + log: logger, + } +} + +// HandleInbound validates an inbound request's JWT token. +// audience overrides the default expected audience when non-empty. This supports +// waypoint mode where audience is derived per-request from the destination host. +// For envoy-sidecar and proxy-sidecar modes, pass "" to use the configured default. +func (a *Auth) HandleInbound(ctx context.Context, authHeader, path, audience string) *InboundResult { + // 1. Bypass check + if a.bypass != nil && a.bypass.Match(path) { + a.log.Debug("bypass path matched", "path", path) + return &InboundResult{Action: ActionAllow} + } + + // 2. Extract bearer token + if authHeader == "" { + return &InboundResult{ + Action: ActionDeny, + DenyStatus: http.StatusUnauthorized, + DenyReason: "missing Authorization header", + } + } + token := extractBearer(authHeader) + if token == "" { + return &InboundResult{ + Action: ActionDeny, + DenyStatus: http.StatusUnauthorized, + DenyReason: "invalid Authorization header format", + } + } + + // 3. Validate JWT + if a.verifier == nil { + return &InboundResult{ + Action: ActionDeny, + DenyStatus: http.StatusUnauthorized, + DenyReason: "inbound validation not configured", + } + } + if audience == "" { + audience = a.identity.Audience + } + claims, err := a.verifier.Verify(ctx, token, audience) + if err != nil { + a.log.Info("JWT validation failed", "error", err) + return &InboundResult{ + Action: ActionDeny, + DenyStatus: http.StatusUnauthorized, + DenyReason: fmt.Sprintf("token validation failed: %v", err), + } + } + + // 4. Allow with claims + a.log.Debug("JWT validated", "subject", claims.Subject, "client_id", claims.ClientID) + return &InboundResult{Action: ActionAllow, Claims: claims} +} + +// HandleOutbound processes an outbound request, performing token exchange if needed. +func (a *Auth) HandleOutbound(ctx context.Context, authHeader, host string) *OutboundResult { + // 1. Resolve route + var resolved *routing.ResolvedRoute + if a.router != nil { + resolved = a.router.Resolve(host) + } + + // 2. Passthrough + if resolved == nil { + return &OutboundResult{Action: ActionAllow} + } + if resolved.Passthrough { + a.log.Debug("passthrough route", "host", host) + return &OutboundResult{Action: ActionAllow} + } + + // 3. Determine audience/scopes + audience := resolved.Audience + scopes := resolved.Scopes + + // 4. Extract bearer token + subjectToken := extractBearer(authHeader) + + if subjectToken == "" { + // No token — apply no-token policy + return a.handleNoToken(ctx, audience, scopes) + } + + // 5. Cache check + if a.cache != nil { + if cached, ok := a.cache.Get(subjectToken, audience); ok { + a.log.Debug("cache hit", "host", host, "audience", audience) + return &OutboundResult{Action: ActionReplaceToken, Token: cached} + } + } + + // 6. Token exchange + if a.exchanger == nil { + a.log.Warn("exchanger not configured, passing through") + return &OutboundResult{Action: ActionAllow} + } + + // Obtain actor token for act claim chaining (RFC 8693 Section 4.1) + var actorToken string + if a.actorTokenSource != nil { + var err error + actorToken, err = a.actorTokenSource(ctx) + if err != nil { + a.log.Warn("failed to obtain actor token, proceeding without", "error", err) + } + } + + resp, err := a.exchanger.Exchange(ctx, &exchange.ExchangeRequest{ + SubjectToken: subjectToken, + Audience: audience, + Scopes: scopes, + ActorToken: actorToken, + TokenEndpoint: resolved.TokenEndpoint, // per-route override + }) + if err != nil { + a.log.Info("token exchange failed", "host", host, "error", err) + return &OutboundResult{ + Action: ActionDeny, + DenyStatus: http.StatusServiceUnavailable, + DenyReason: "token exchange failed", + } + } + + // 7. Cache result + if a.cache != nil && resp.ExpiresIn > 0 { + a.cache.Set(subjectToken, audience, resp.AccessToken, + time.Duration(resp.ExpiresIn)*time.Second) + } + + a.log.Debug("token exchanged", "host", host, "audience", audience) + return &OutboundResult{Action: ActionReplaceToken, Token: resp.AccessToken} +} + +func (a *Auth) handleNoToken(ctx context.Context, audience, scopes string) *OutboundResult { + switch a.noTokenPolicy { + case NoTokenPolicyAllow: + a.log.Debug("no token, policy=allow") + return &OutboundResult{Action: ActionAllow} + + case NoTokenPolicyClientCredentials: + if a.exchanger == nil { + return &OutboundResult{ + Action: ActionDeny, + DenyStatus: http.StatusServiceUnavailable, + DenyReason: "exchanger not configured for client credentials", + } + } + a.log.Debug("no token, falling back to client_credentials") + resp, err := a.exchanger.ClientCredentials(ctx, scopes) + if err != nil { + a.log.Info("client credentials grant failed", "error", err) + return &OutboundResult{ + Action: ActionDeny, + DenyStatus: http.StatusServiceUnavailable, + DenyReason: "client credentials token acquisition failed", + } + } + return &OutboundResult{Action: ActionReplaceToken, Token: resp.AccessToken} + + default: // NoTokenDeny or unknown + return &OutboundResult{ + Action: ActionDeny, + DenyStatus: http.StatusUnauthorized, + DenyReason: "missing Authorization header", + } + } +} + +func extractBearer(authHeader string) string { + // RFC 7235: auth scheme is case-insensitive + if len(authHeader) > 7 && strings.EqualFold(authHeader[:7], "bearer ") { + return authHeader[7:] + } + return "" +} diff --git a/authbridge/authlib/auth/auth_test.go b/authbridge/authlib/auth/auth_test.go new file mode 100644 index 000000000..8ed1df16d --- /dev/null +++ b/authbridge/authlib/auth/auth_test.go @@ -0,0 +1,334 @@ +package auth + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/bypass" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/cache" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/exchange" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/routing" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/validation" +) + +// mockVerifier captures the audience arg and returns configured claims/error. +type mockVerifier struct { + claims *validation.Claims + err error + lastAudience string +} + +func (m *mockVerifier) Verify(_ context.Context, _ string, audience string) (*validation.Claims, error) { + m.lastAudience = audience + return m.claims, m.err +} + +func validClaims() *validation.Claims { + return &validation.Claims{ + Subject: "user-123", + Issuer: "http://keycloak/realms/test", + Audience: []string{"my-agent"}, + ClientID: "caller-app", + Scopes: []string{"openid"}, + } +} + +// --- Inbound Tests --- + +func TestHandleInbound_BypassPath(t *testing.T) { + m, _ := bypass.NewMatcher(bypass.DefaultPatterns) + a := New(Config{Bypass: m, Verifier: &mockVerifier{claims: validClaims()}}) + result := a.HandleInbound(context.Background(), "", "/healthz", "") + if result.Action != ActionAllow { + t.Errorf("expected allow for bypass path, got %s", result.Action) + } +} + +func TestHandleInbound_MissingAuth(t *testing.T) { + a := New(Config{Verifier: &mockVerifier{}}) + result := a.HandleInbound(context.Background(), "", "/api/test", "") + if result.Action != ActionDeny || result.DenyStatus != http.StatusUnauthorized { + t.Errorf("expected deny/401, got %s/%d", result.Action, result.DenyStatus) + } +} + +func TestHandleInbound_InvalidFormat(t *testing.T) { + a := New(Config{Verifier: &mockVerifier{}}) + result := a.HandleInbound(context.Background(), "Basic abc123", "/api/test", "") + if result.Action != ActionDeny { + t.Errorf("expected deny for non-Bearer auth, got %s", result.Action) + } +} + +func TestHandleInbound_CaseInsensitiveBearer(t *testing.T) { + a := New(Config{ + Verifier: &mockVerifier{claims: validClaims()}, + Identity: IdentityConfig{Audience: "my-agent"}, + }) + // RFC 7235: auth scheme is case-insensitive + for _, header := range []string{"Bearer token", "bearer token", "BEARER token", "beArer token"} { + result := a.HandleInbound(context.Background(), header, "/api/test", "") + if result.Action != ActionAllow { + t.Errorf("expected allow for %q, got %s: %s", header, result.Action, result.DenyReason) + } + } +} + +func TestHandleInbound_ValidJWT(t *testing.T) { + a := New(Config{ + Verifier: &mockVerifier{claims: validClaims()}, + Identity: IdentityConfig{Audience: "my-agent"}, + }) + result := a.HandleInbound(context.Background(), "Bearer valid-token", "/api/test", "") + if result.Action != ActionAllow { + t.Errorf("expected allow, got %s: %s", result.Action, result.DenyReason) + } + if result.Claims == nil || result.Claims.Subject != "user-123" { + t.Error("expected claims with subject user-123") + } +} + +func TestHandleInbound_InvalidJWT(t *testing.T) { + a := New(Config{ + Verifier: &mockVerifier{err: fmt.Errorf("token expired")}, + Identity: IdentityConfig{Audience: "my-agent"}, + }) + result := a.HandleInbound(context.Background(), "Bearer expired-token", "/api/test", "") + if result.Action != ActionDeny || result.DenyStatus != http.StatusUnauthorized { + t.Errorf("expected deny/401, got %s/%d", result.Action, result.DenyStatus) + } +} + +func TestHandleInbound_NoVerifier_Denies(t *testing.T) { + a := New(Config{}) // no verifier = fail-closed + result := a.HandleInbound(context.Background(), "Bearer some-token", "/api/test", "") + if result.Action != ActionDeny { + t.Errorf("expected deny when verifier not configured, got %s", result.Action) + } +} + +func TestHandleInbound_AudienceOverride(t *testing.T) { + mv := &mockVerifier{claims: validClaims()} + a := New(Config{ + Verifier: mv, + Identity: IdentityConfig{Audience: "default-aud"}, + }) + + // Empty audience uses default + a.HandleInbound(context.Background(), "Bearer t", "/api", "") + if mv.lastAudience != "default-aud" { + t.Errorf("expected default-aud, got %q", mv.lastAudience) + } + + // Explicit audience overrides default (waypoint mode) + a.HandleInbound(context.Background(), "Bearer t", "/api", "derived-from-host") + if mv.lastAudience != "derived-from-host" { + t.Errorf("expected derived-from-host, got %q", mv.lastAudience) + } +} + +// --- Outbound Tests --- + +func newTestExchangeServer(t *testing.T) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Fatal(err) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "access_token": "exchanged-" + r.FormValue("audience"), + "token_type": "Bearer", + "expires_in": 300, + }) + })) +} + +func TestHandleOutbound_NoRouter(t *testing.T) { + a := New(Config{}) + result := a.HandleOutbound(context.Background(), "Bearer token", "some-host") + if result.Action != ActionAllow { + t.Errorf("expected allow with no router, got %s", result.Action) + } +} + +func TestHandleOutbound_PassthroughRoute(t *testing.T) { + router, _ := routing.NewRouter("passthrough", []routing.Route{ + {Host: "internal-svc", Action: "passthrough"}, + }) + a := New(Config{Router: router}) + result := a.HandleOutbound(context.Background(), "Bearer token", "internal-svc") + if result.Action != ActionAllow { + t.Errorf("expected allow for passthrough route, got %s", result.Action) + } +} + +func TestHandleOutbound_NoMatch_Passthrough(t *testing.T) { + router, _ := routing.NewRouter("passthrough", []routing.Route{ + {Host: "known-svc", Audience: "known"}, + }) + a := New(Config{Router: router}) + result := a.HandleOutbound(context.Background(), "Bearer token", "unknown-svc") + if result.Action != ActionAllow { + t.Errorf("expected allow for unmatched host with passthrough default, got %s", result.Action) + } +} + +func TestHandleOutbound_Exchange(t *testing.T) { + srv := newTestExchangeServer(t) + defer srv.Close() + + router, _ := routing.NewRouter("passthrough", []routing.Route{ + {Host: "target-svc", Audience: "target-aud", Scopes: "openid"}, + }) + exchanger := exchange.NewClient(srv.URL, &exchange.ClientSecretAuth{ + ClientID: "agent", ClientSecret: "secret", + }) + a := New(Config{ + Router: router, + Exchanger: exchanger, + Cache: cache.New(), + }) + + result := a.HandleOutbound(context.Background(), "Bearer user-token", "target-svc") + if result.Action != ActionReplaceToken { + t.Fatalf("expected replace_token, got %s: %s", result.Action, result.DenyReason) + } + if result.Token != "exchanged-target-aud" { + t.Errorf("token = %q, want %q", result.Token, "exchanged-target-aud") + } +} + +func TestHandleOutbound_CacheHit(t *testing.T) { + router, _ := routing.NewRouter("passthrough", []routing.Route{ + {Host: "target-svc", Audience: "target-aud"}, + }) + c := cache.New() + c.Set("user-token", "target-aud", "cached-token", 5*time.Minute) + + a := New(Config{Router: router, Cache: c}) + + result := a.HandleOutbound(context.Background(), "Bearer user-token", "target-svc") + if result.Action != ActionReplaceToken || result.Token != "cached-token" { + t.Errorf("expected cached token, got action=%s token=%q", result.Action, result.Token) + } +} + +func TestHandleOutbound_NoToken_ClientCredentials(t *testing.T) { + srv := newTestExchangeServer(t) + defer srv.Close() + + router, _ := routing.NewRouter("exchange", []routing.Route{}) + exchanger := exchange.NewClient(srv.URL, &exchange.ClientSecretAuth{ + ClientID: "agent", ClientSecret: "secret", + }) + a := New(Config{ + Router: router, + Exchanger: exchanger, + NoTokenPolicy: NoTokenPolicyClientCredentials, + }) + + result := a.HandleOutbound(context.Background(), "", "any-svc") + if result.Action != ActionReplaceToken { + t.Fatalf("expected replace_token from client_credentials, got %s: %s", result.Action, result.DenyReason) + } +} + +func TestHandleOutbound_NoToken_Allow(t *testing.T) { + router, _ := routing.NewRouter("exchange", []routing.Route{}) + a := New(Config{ + Router: router, + NoTokenPolicy: NoTokenPolicyAllow, + }) + + result := a.HandleOutbound(context.Background(), "", "any-svc") + if result.Action != ActionAllow { + t.Errorf("expected allow for no-token allow policy, got %s", result.Action) + } +} + +func TestHandleOutbound_NoToken_Deny(t *testing.T) { + router, _ := routing.NewRouter("exchange", []routing.Route{}) + a := New(Config{ + Router: router, + NoTokenPolicy: NoTokenPolicyDeny, + }) + + result := a.HandleOutbound(context.Background(), "", "any-svc") + if result.Action != ActionDeny { + t.Errorf("expected deny for no-token deny policy, got %s", result.Action) + } +} + +func TestHandleOutbound_PerRouteTokenEndpoint(t *testing.T) { + // Main server should NOT be called + mainSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + t.Error("main token URL should not be called when route overrides it") + w.WriteHeader(http.StatusInternalServerError) + })) + defer mainSrv.Close() + + // Per-route server SHOULD be called + routeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "access_token": "from-route-endpoint", + "token_type": "Bearer", + "expires_in": 300, + }) + })) + defer routeSrv.Close() + + router, _ := routing.NewRouter("passthrough", []routing.Route{ + {Host: "custom-svc", Audience: "custom-aud", TokenEndpoint: routeSrv.URL}, + }) + exchanger := exchange.NewClient(mainSrv.URL, &exchange.ClientSecretAuth{ + ClientID: "agent", ClientSecret: "secret", + }) + a := New(Config{Router: router, Exchanger: exchanger}) + + result := a.HandleOutbound(context.Background(), "Bearer token", "custom-svc") + if result.Action != ActionReplaceToken || result.Token != "from-route-endpoint" { + t.Errorf("expected token from route endpoint, got action=%s token=%q", result.Action, result.Token) + } +} + +func TestHandleOutbound_ActorToken(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 actor-jwt", got) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "access_token": "delegated", + "token_type": "Bearer", + "expires_in": 300, + }) + })) + defer srv.Close() + + router, _ := routing.NewRouter("exchange", []routing.Route{}) + exchanger := exchange.NewClient(srv.URL, &exchange.ClientSecretAuth{ + ClientID: "agent", ClientSecret: "secret", + }) + a := New(Config{ + Router: router, + Exchanger: exchanger, + ActorTokenSource: func(_ context.Context) (string, error) { + return "actor-jwt", nil + }, + }) + + result := a.HandleOutbound(context.Background(), "Bearer user-token", "any-svc") + if result.Action != ActionReplaceToken || result.Token != "delegated" { + t.Errorf("expected delegated token, got action=%s token=%q", result.Action, result.Token) + } +} diff --git a/authbridge/authlib/auth/result.go b/authbridge/authlib/auth/result.go new file mode 100644 index 000000000..f4e360431 --- /dev/null +++ b/authbridge/authlib/auth/result.go @@ -0,0 +1,42 @@ +// Package auth composes authlib building blocks into HandleInbound and +// HandleOutbound — the two functions that all listeners call. +package auth + +import ( + "github.com/kagenti/kagenti-extensions/authbridge/authlib/validation" +) + +// Inbound actions. +const ( + ActionAllow = "allow" + ActionDeny = "deny" +) + +// Outbound actions. +const ( + ActionReplaceToken = "replace_token" + // ActionAllow and ActionDeny are reused from inbound. +) + +// No-token outbound policies (set by mode preset). +const ( + NoTokenPolicyClientCredentials = "client-credentials" + NoTokenPolicyAllow = "allow" + NoTokenPolicyDeny = "deny" +) + +// InboundResult is the outcome of inbound JWT validation. +type InboundResult struct { + Action string // ActionAllow or ActionDeny + Claims *validation.Claims // non-nil when a valid JWT was present + DenyStatus int // HTTP status code (e.g., 401) + DenyReason string // human-readable error +} + +// OutboundResult is the outcome of outbound token exchange. +type OutboundResult struct { + Action string // ActionAllow, ActionReplaceToken, or ActionDeny + Token string // replacement token (when Action == ActionReplaceToken) + DenyStatus int // HTTP status code (e.g., 503) + DenyReason string // human-readable error +} diff --git a/authbridge/authlib/config/config.go b/authbridge/authlib/config/config.go new file mode 100644 index 000000000..ca589d09e --- /dev/null +++ b/authbridge/authlib/config/config.go @@ -0,0 +1,102 @@ +// Package config provides YAML-based configuration with mode presets +// and startup validation for the AuthBridge auth layer. +package config + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +// Config is the top-level AuthBridge configuration. +type Config struct { + Mode string `yaml:"mode"` // "envoy-sidecar", "waypoint", "proxy-sidecar" + Inbound InboundConfig `yaml:"inbound"` + Outbound OutboundConfig `yaml:"outbound"` + Identity IdentityConfig `yaml:"identity"` + Listener ListenerConfig `yaml:"listener"` + Bypass BypassConfig `yaml:"bypass"` + Routes RoutesConfig `yaml:"routes"` +} + +// InboundConfig holds JWT validation settings. +type InboundConfig struct { + JWKSURL string `yaml:"jwks_url"` + Issuer string `yaml:"issuer"` +} + +// OutboundConfig holds token exchange settings. +type OutboundConfig struct { + TokenURL string `yaml:"token_url"` + DefaultPolicy string `yaml:"default_policy"` // "exchange" or "passthrough" +} + +// IdentityConfig holds agent identity and credentials. +type IdentityConfig struct { + Type string `yaml:"type"` // "spiffe", "client-secret", "k8s-sa" + ClientID string `yaml:"client_id"` + ClientSecret string `yaml:"client_secret"` + SocketPath string `yaml:"socket_path"` // SPIFFE Workload API + JWTSVIDPath string `yaml:"jwt_svid_path"` // file-based SPIFFE + JWTAudience []string `yaml:"jwt_audience"` // SPIFFE JWT audience +} + +// ListenerConfig holds per-mode listener addresses. +type ListenerConfig struct { + ExtProcAddr string `yaml:"ext_proc_addr"` + ExtAuthzAddr string `yaml:"ext_authz_addr"` + ForwardProxyAddr string `yaml:"forward_proxy_addr"` + ReverseProxyAddr string `yaml:"reverse_proxy_addr"` + ReverseProxyBackend string `yaml:"reverse_proxy_backend"` +} + +// BypassConfig holds path patterns that skip inbound JWT validation. +type BypassConfig struct { + InboundPaths []string `yaml:"inbound_paths"` +} + +// RoutesConfig holds outbound routing rules. +type RoutesConfig struct { + File string `yaml:"file"` // path to routes YAML file + Rules []RouteConfig `yaml:"rules"` // inline rules (alternative to file) +} + +// RouteConfig is the YAML representation of an outbound route. +// Supports both legacy `passthrough: true` and new `action: passthrough` formats. +type RouteConfig struct { + Host string `yaml:"host"` + TargetAudience string `yaml:"target_audience,omitempty"` + TokenScopes string `yaml:"token_scopes,omitempty"` + TokenURL string `yaml:"token_url,omitempty"` + Passthrough bool `yaml:"passthrough,omitempty"` // legacy format + Action string `yaml:"action,omitempty"` // "exchange" or "passthrough" +} + +// Valid mode strings. +const ( + ModeEnvoySidecar = "envoy-sidecar" + ModeWaypoint = "waypoint" + ModeProxySidecar = "proxy-sidecar" +) + +// Load reads and parses a YAML config file with environment variable expansion. +// Defined env vars are expanded; undefined references like ${UNDEFINED} are left as-is +// to avoid silent empty-string substitution. +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading config: %w", err) + } + expanded := os.Expand(string(data), func(key string) string { + if val, ok := os.LookupEnv(key); ok { + return val + } + return "${" + key + "}" // preserve undefined references + }) + var cfg Config + if err := yaml.Unmarshal([]byte(expanded), &cfg); err != nil { + return nil, fmt.Errorf("parsing config: %w", err) + } + return &cfg, nil +} diff --git a/authbridge/authlib/config/config_test.go b/authbridge/authlib/config/config_test.go new file mode 100644 index 000000000..28127ad51 --- /dev/null +++ b/authbridge/authlib/config/config_test.go @@ -0,0 +1,244 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +// --- Preset Tests --- + +func TestApplyPreset_EnvoySidecar(t *testing.T) { + cfg := &Config{Mode: ModeEnvoySidecar} + ApplyPreset(cfg) + if cfg.Identity.Type != "spiffe" { + t.Errorf("identity.type = %q, want spiffe", cfg.Identity.Type) + } + if cfg.Outbound.DefaultPolicy != "passthrough" { + t.Errorf("default_policy = %q, want passthrough", cfg.Outbound.DefaultPolicy) + } + if cfg.Listener.ExtProcAddr != ":9090" { + t.Errorf("ext_proc_addr = %q, want :9090", cfg.Listener.ExtProcAddr) + } + if len(cfg.Bypass.InboundPaths) == 0 { + t.Error("expected default bypass paths") + } +} + +func TestApplyPreset_Waypoint(t *testing.T) { + cfg := &Config{Mode: ModeWaypoint} + ApplyPreset(cfg) + if cfg.Identity.Type != "client-secret" { + t.Errorf("identity.type = %q, want client-secret", cfg.Identity.Type) + } + if cfg.Outbound.DefaultPolicy != "exchange" { + t.Errorf("default_policy = %q, want exchange", cfg.Outbound.DefaultPolicy) + } +} + +func TestApplyPreset_ProxySidecar(t *testing.T) { + cfg := &Config{Mode: ModeProxySidecar} + ApplyPreset(cfg) + if cfg.Identity.Type != "spiffe" { + t.Errorf("identity.type = %q, want spiffe", cfg.Identity.Type) + } + if cfg.Listener.ReverseProxyAddr != ":8080" { + t.Errorf("reverse_proxy_addr = %q, want :8080", cfg.Listener.ReverseProxyAddr) + } +} + +func TestApplyPreset_UserOverride(t *testing.T) { + cfg := &Config{ + Mode: ModeEnvoySidecar, + Identity: IdentityConfig{Type: "client-secret"}, // user override + } + ApplyPreset(cfg) + if cfg.Identity.Type != "client-secret" { + t.Errorf("user override lost: identity.type = %q", cfg.Identity.Type) + } +} + +func TestNoTokenPolicyForMode(t *testing.T) { + tests := []struct { + mode string + want string + }{ + {ModeEnvoySidecar, "client-credentials"}, + {ModeWaypoint, "allow"}, + {ModeProxySidecar, "deny"}, + {"unknown", "deny"}, + } + for _, tt := range tests { + if got := NoTokenPolicyForMode(tt.mode); got != tt.want { + t.Errorf("NoTokenPolicyForMode(%q) = %q, want %q", tt.mode, got, tt.want) + } + } +} + +// --- Validation Tests --- + +func TestValidate_MissingMode(t *testing.T) { + cfg := &Config{} + if err := Validate(cfg); err == nil { + t.Error("expected error for missing mode") + } +} + +func TestValidate_InvalidMode(t *testing.T) { + cfg := &Config{Mode: "invalid"} + if err := Validate(cfg); err == nil { + t.Error("expected error for invalid mode") + } +} + +func TestValidate_MissingRequired(t *testing.T) { + base := Config{ + Mode: ModeWaypoint, + Identity: IdentityConfig{Type: "client-secret", ClientID: "c", ClientSecret: "s"}, + } + + // Missing issuer + cfg := base + cfg.Inbound = InboundConfig{JWKSURL: "http://jwks"} + cfg.Outbound = OutboundConfig{TokenURL: "http://token"} + if err := Validate(&cfg); err == nil { + t.Error("expected error for missing issuer") + } + + // Missing jwks_url + cfg = base + cfg.Inbound = InboundConfig{Issuer: "http://issuer"} + cfg.Outbound = OutboundConfig{TokenURL: "http://token"} + if err := Validate(&cfg); err == nil { + t.Error("expected error for missing jwks_url") + } + + // Missing token_url + cfg = base + cfg.Inbound = InboundConfig{JWKSURL: "http://jwks", Issuer: "http://issuer"} + if err := Validate(&cfg); err == nil { + t.Error("expected error for missing token_url") + } +} + +func TestValidate_SpiffeIdentityRequiresPath(t *testing.T) { + cfg := validEnvoySidecarConfig() + cfg.Identity = IdentityConfig{Type: "spiffe"} + if err := Validate(cfg); err == nil { + t.Error("expected error for spiffe without path") + } +} + +func TestValidate_InvalidListenerCombo(t *testing.T) { + cfg := validEnvoySidecarConfig() + cfg.Listener.ReverseProxyAddr = ":8080" // invalid for envoy-sidecar + if err := Validate(cfg); err == nil { + t.Error("expected error for envoy-sidecar + reverse_proxy_addr") + } +} + +func TestValidate_WaypointRejectsExtProc(t *testing.T) { + cfg := validWaypointConfig() + cfg.Listener.ExtProcAddr = ":9090" + if err := Validate(cfg); err == nil { + t.Error("expected error for waypoint + ext_proc_addr") + } +} + +func TestValidate_ProxySidecarRequiresBackend(t *testing.T) { + cfg := validProxySidecarConfig() + cfg.Listener.ReverseProxyBackend = "" + if err := Validate(cfg); err == nil { + t.Error("expected error for proxy-sidecar without backend") + } +} + +func TestValidate_ValidConfig(t *testing.T) { + for _, cfg := range []*Config{ + validEnvoySidecarConfig(), + validWaypointConfig(), + validProxySidecarConfig(), + } { + if err := Validate(cfg); err != nil { + t.Errorf("unexpected error for mode %s: %v", cfg.Mode, err) + } + } +} + +// --- Load Tests --- + +func TestLoad(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + content := `mode: waypoint +inbound: + jwks_url: "${TEST_JWKS_URL}" + issuer: "http://issuer" +outbound: + token_url: "http://token" +identity: + type: client-secret + client_id: "svc" + client_secret: "secret" +` + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatal(err) + } + os.Setenv("TEST_JWKS_URL", "http://expanded-jwks") + defer os.Unsetenv("TEST_JWKS_URL") + + cfg, err := Load(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.Mode != ModeWaypoint { + t.Errorf("mode = %q, want waypoint", cfg.Mode) + } + if cfg.Inbound.JWKSURL != "http://expanded-jwks" { + t.Errorf("jwks_url = %q, want expanded value", cfg.Inbound.JWKSURL) + } +} + +// --- RouteConfig backwards compat --- + +func TestRouteConfig_LegacyPassthrough(t *testing.T) { + rc := RouteConfig{Host: "internal", Passthrough: true} + // Simulate what resolve.go does + action := rc.Action + if action == "" && rc.Passthrough { + action = "passthrough" + } + if action != "passthrough" { + t.Errorf("expected passthrough, got %q", action) + } +} + +// --- Helpers --- + +func validEnvoySidecarConfig() *Config { + return &Config{ + Mode: ModeEnvoySidecar, + Inbound: InboundConfig{JWKSURL: "http://jwks", Issuer: "http://issuer"}, + Outbound: OutboundConfig{TokenURL: "http://token"}, + Identity: IdentityConfig{Type: "spiffe", JWTSVIDPath: "/opt/svid.token", ClientID: "agent"}, + } +} + +func validWaypointConfig() *Config { + return &Config{ + Mode: ModeWaypoint, + Inbound: InboundConfig{JWKSURL: "http://jwks", Issuer: "http://issuer"}, + Outbound: OutboundConfig{TokenURL: "http://token"}, + Identity: IdentityConfig{Type: "client-secret", ClientID: "svc", ClientSecret: "secret"}, + } +} + +func validProxySidecarConfig() *Config { + return &Config{ + Mode: ModeProxySidecar, + Inbound: InboundConfig{JWKSURL: "http://jwks", Issuer: "http://issuer"}, + Outbound: OutboundConfig{TokenURL: "http://token"}, + Identity: IdentityConfig{Type: "spiffe", JWTSVIDPath: "/opt/svid.token", ClientID: "agent"}, + Listener: ListenerConfig{ReverseProxyBackend: "http://localhost:8081"}, + } +} diff --git a/authbridge/authlib/config/presets.go b/authbridge/authlib/config/presets.go new file mode 100644 index 000000000..e6b90300e --- /dev/null +++ b/authbridge/authlib/config/presets.go @@ -0,0 +1,56 @@ +package config + +import ( + "github.com/kagenti/kagenti-extensions/authbridge/authlib/auth" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/bypass" +) + +// ApplyPreset fills in mode-specific defaults for settings the user didn't specify. +// Locked settings (which listeners to start) are enforced by Phase 3 listeners, +// not here — this only sets defaults for user-overridable settings. +func ApplyPreset(cfg *Config) { + switch cfg.Mode { + case ModeEnvoySidecar: + setDefault(&cfg.Identity.Type, "spiffe") + setDefault(&cfg.Outbound.DefaultPolicy, "passthrough") + setDefault(&cfg.Listener.ExtProcAddr, ":9090") + + case ModeWaypoint: + setDefault(&cfg.Identity.Type, "client-secret") + setDefault(&cfg.Outbound.DefaultPolicy, "exchange") + setDefault(&cfg.Listener.ExtAuthzAddr, ":9090") + setDefault(&cfg.Listener.ForwardProxyAddr, ":8080") + + case ModeProxySidecar: + setDefault(&cfg.Identity.Type, "spiffe") + setDefault(&cfg.Outbound.DefaultPolicy, "passthrough") + setDefault(&cfg.Listener.ReverseProxyAddr, ":8080") + setDefault(&cfg.Listener.ForwardProxyAddr, ":8081") + } + + // All modes share the same default bypass paths + if len(cfg.Bypass.InboundPaths) == 0 { + cfg.Bypass.InboundPaths = bypass.DefaultPatterns + } +} + +// NoTokenPolicyForMode returns the no-token outbound policy for a mode. +// This is locked per mode — users cannot override it. +func NoTokenPolicyForMode(mode string) string { + switch mode { + case ModeEnvoySidecar: + return auth.NoTokenPolicyClientCredentials + case ModeWaypoint: + return auth.NoTokenPolicyAllow + case ModeProxySidecar: + return auth.NoTokenPolicyDeny + default: + return auth.NoTokenPolicyDeny + } +} + +func setDefault(field *string, value string) { + if *field == "" { + *field = value + } +} diff --git a/authbridge/authlib/config/resolve.go b/authbridge/authlib/config/resolve.go new file mode 100644 index 000000000..5cc22abb1 --- /dev/null +++ b/authbridge/authlib/config/resolve.go @@ -0,0 +1,120 @@ +package config + +import ( + "context" + "fmt" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/auth" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/bypass" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/cache" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/exchange" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/routing" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/spiffe" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/validation" +) + +// Resolve applies presets, validates, and instantiates all authlib components +// from the configuration. Returns a fully wired auth.Config ready for auth.New(). +func Resolve(ctx context.Context, cfg *Config) (*auth.Config, error) { + ApplyPreset(cfg) + + if err := Validate(cfg); err != nil { + return nil, fmt.Errorf("config validation: %w", err) + } + if err := ValidateOutboundPolicy(cfg.Outbound.DefaultPolicy); err != nil { + return nil, err + } + + // Bypass matcher + matcher, err := bypass.NewMatcher(cfg.Bypass.InboundPaths) + if err != nil { + return nil, fmt.Errorf("bypass patterns: %w", err) + } + + // JWT verifier + verifier, err := validation.NewJWKSVerifier(ctx, cfg.Inbound.JWKSURL, cfg.Inbound.Issuer) + if err != nil { + return nil, fmt.Errorf("JWKS verifier: %w", err) + } + + // Client auth for token exchange + clientAuth, err := resolveClientAuth(cfg) + if err != nil { + return nil, fmt.Errorf("client auth: %w", err) + } + + exchanger := exchange.NewClient(cfg.Outbound.TokenURL, clientAuth) + + // Router + router, err := resolveRouter(cfg) + if err != nil { + return nil, fmt.Errorf("router: %w", err) + } + + return &auth.Config{ + Verifier: verifier, + Exchanger: exchanger, + Cache: cache.New(), + Bypass: matcher, + Router: router, + Identity: auth.IdentityConfig{ + ClientID: cfg.Identity.ClientID, + Audience: cfg.Identity.ClientID, // inbound audience defaults to client ID + }, + NoTokenPolicy: NoTokenPolicyForMode(cfg.Mode), + }, nil +} + +func resolveClientAuth(cfg *Config) (exchange.ClientAuth, error) { + switch cfg.Identity.Type { + case "spiffe": + if cfg.Identity.JWTSVIDPath != "" { + source := spiffe.NewFileJWTSource(cfg.Identity.JWTSVIDPath) + return &exchange.JWTAssertionAuth{ + ClientID: cfg.Identity.ClientID, + AssertionType: "urn:ietf:params:oauth:client-assertion-type:jwt-spiffe", + TokenSource: source.FetchToken, + }, nil + } + return nil, fmt.Errorf("spiffe identity requires jwt_svid_path (Workload API not yet supported)") + + case "client-secret": + return &exchange.ClientSecretAuth{ + ClientID: cfg.Identity.ClientID, + ClientSecret: cfg.Identity.ClientSecret, + }, nil + + default: + return nil, fmt.Errorf("unsupported identity type %q for client auth", cfg.Identity.Type) + } +} + +func resolveRouter(cfg *Config) (*routing.Router, error) { + var rules []routing.Route + + // Load from file if specified + if cfg.Routes.File != "" { + fileRoutes, err := routing.LoadRoutes(cfg.Routes.File) + if err != nil { + return nil, err + } + rules = append(rules, fileRoutes...) + } + + // Add inline rules, converting from RouteConfig to routing.Route + for _, rc := range cfg.Routes.Rules { + action := rc.Action + if action == "" && rc.Passthrough { + action = "passthrough" // backwards compatibility + } + rules = append(rules, routing.Route{ + Host: rc.Host, + Audience: rc.TargetAudience, + Scopes: rc.TokenScopes, + TokenEndpoint: rc.TokenURL, + Action: action, + }) + } + + return routing.NewRouter(cfg.Outbound.DefaultPolicy, rules) +} diff --git a/authbridge/authlib/config/validate.go b/authbridge/authlib/config/validate.go new file mode 100644 index 000000000..e8edb7d4e --- /dev/null +++ b/authbridge/authlib/config/validate.go @@ -0,0 +1,129 @@ +package config + +import ( + "fmt" + "log/slog" +) + +// Validate checks the configuration for errors and warnings. +// Returns an error for invalid configurations that would fail at runtime. +// Logs warnings for unusual-but-valid combinations. +func Validate(cfg *Config) error { + // Mode is required + switch cfg.Mode { + case ModeEnvoySidecar, ModeWaypoint, ModeProxySidecar: + // valid + case "": + return fmt.Errorf("mode is required (envoy-sidecar, waypoint, or proxy-sidecar)") + default: + return fmt.Errorf("unknown mode %q (valid: envoy-sidecar, waypoint, proxy-sidecar)", cfg.Mode) + } + + // Required fields + if cfg.Inbound.Issuer == "" { + return fmt.Errorf("inbound.issuer is required") + } + if cfg.Inbound.JWKSURL == "" { + return fmt.Errorf("inbound.jwks_url is required") + } + if cfg.Outbound.TokenURL == "" { + return fmt.Errorf("outbound.token_url is required") + } + + // Identity validation + if err := validateIdentity(cfg); err != nil { + return err + } + + // Mode-specific listener validation + if err := validateListeners(cfg); err != nil { + return err + } + + // Warnings for unusual combinations + warnUnusual(cfg) + + return nil +} + +func validateIdentity(cfg *Config) error { + switch cfg.Identity.Type { + case "spiffe": + if cfg.Identity.SocketPath == "" && cfg.Identity.JWTSVIDPath == "" { + return fmt.Errorf("identity.type=spiffe requires socket_path or jwt_svid_path") + } + if cfg.Identity.ClientID == "" { + return fmt.Errorf("identity.type=spiffe requires client_id") + } + case "client-secret": + if cfg.Identity.ClientID == "" { + return fmt.Errorf("identity.type=client-secret requires client_id") + } + if cfg.Identity.ClientSecret == "" { + return fmt.Errorf("identity.type=client-secret requires client_secret") + } + case "k8s-sa": + // Future: validate service account token path + case "": + return fmt.Errorf("identity.type is required") + default: + return fmt.Errorf("unknown identity.type %q (valid: spiffe, client-secret, k8s-sa)", cfg.Identity.Type) + } + return nil +} + +func validateListeners(cfg *Config) error { + switch cfg.Mode { + case ModeEnvoySidecar: + if cfg.Listener.ReverseProxyAddr != "" { + return fmt.Errorf("envoy-sidecar mode does not support reverse_proxy_addr (use proxy-sidecar mode)") + } + if cfg.Listener.ExtAuthzAddr != "" { + return fmt.Errorf("envoy-sidecar mode does not support ext_authz_addr (use waypoint mode)") + } + case ModeWaypoint: + if cfg.Listener.ExtProcAddr != "" { + return fmt.Errorf("waypoint mode does not support ext_proc_addr (use envoy-sidecar mode)") + } + if cfg.Listener.ReverseProxyAddr != "" { + return fmt.Errorf("waypoint mode does not support reverse_proxy_addr") + } + case ModeProxySidecar: + if cfg.Listener.ExtProcAddr != "" { + return fmt.Errorf("proxy-sidecar mode does not support ext_proc_addr (use envoy-sidecar mode)") + } + if cfg.Listener.ExtAuthzAddr != "" { + return fmt.Errorf("proxy-sidecar mode does not support ext_authz_addr (use waypoint mode)") + } + if cfg.Listener.ReverseProxyBackend == "" { + return fmt.Errorf("proxy-sidecar mode requires listener.reverse_proxy_backend") + } + } + return nil +} + +func warnUnusual(cfg *Config) { + warnings := []string{} + + if cfg.Mode == ModeEnvoySidecar && cfg.Identity.Type == "client-secret" { + warnings = append(warnings, "envoy-sidecar with client-secret identity is unusual (typically uses spiffe)") + } + if cfg.Mode == ModeWaypoint && cfg.Identity.Type == "spiffe" { + warnings = append(warnings, "waypoint with spiffe identity is unusual (typically uses client-secret)") + } + + for _, w := range warnings { + slog.Warn(w) + } +} + +// ValidateOutboundPolicy checks that default_policy is a valid value. +func ValidateOutboundPolicy(policy string) error { + switch policy { + case "exchange", "passthrough", "": + return nil + default: + return fmt.Errorf("unknown outbound.default_policy %q (valid: exchange, passthrough)", policy) + } +} + diff --git a/authbridge/authlib/exchange/client.go b/authbridge/authlib/exchange/client.go index aa1a1ef43..e55dc52eb 100644 --- a/authbridge/authlib/exchange/client.go +++ b/authbridge/authlib/exchange/client.go @@ -15,10 +15,11 @@ import ( // 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 + SubjectToken string + Audience string + Scopes string + ActorToken string // optional, RFC 8693 Section 4.1 + TokenEndpoint string // optional per-request override of the client's tokenURL } // ExchangeResponse contains the result of a successful token exchange. @@ -81,7 +82,11 @@ func (c *Client) Exchange(ctx context.Context, req *ExchangeRequest) (*ExchangeR return nil, fmt.Errorf("applying client auth: %w", err) } - return c.doTokenRequest(ctx, form) + tokenURL := c.tokenURL + if req.TokenEndpoint != "" { + tokenURL = req.TokenEndpoint + } + return c.doTokenRequest(ctx, tokenURL, form) } // ClientCredentials performs a client credentials grant. @@ -97,11 +102,11 @@ func (c *Client) ClientCredentials(ctx context.Context, scopes string) (*Exchang return nil, fmt.Errorf("applying client auth: %w", err) } - return c.doTokenRequest(ctx, form) + return c.doTokenRequest(ctx, c.tokenURL, form) } -func (c *Client) doTokenRequest(ctx context.Context, form url.Values) (*ExchangeResponse, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.tokenURL, +func (c *Client) doTokenRequest(ctx context.Context, tokenURL string, form url.Values) (*ExchangeResponse, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode())) if err != nil { return nil, fmt.Errorf("creating request: %w", err) From 47d24e3ee680f60716858ae6385ff293da9168b0 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Tue, 14 Apr 2026 20:55:17 -0400 Subject: [PATCH 05/10] Add feature parity gaps: URL derivation, credential files, actor cache Close 4 feature parity gaps between old go-processor and new authbridge: - Derive TOKEN_URL and ISSUER from keycloak_url + keycloak_realm (operator sets these, not explicit URLs) - Derive JWKS URL from TOKEN_URL using Keycloak /token -> /certs convention - Support client_id_file and client_secret_file with startup polling (handles race with client-registration and spiffe-helper) - Add CachedActorTokenSource for actor token caching with 30s pre-expiry refresh (matches old actorTokenCache behavior) 7 new tests for derivation logic and file-based credentials. 63 total tests passing. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/authlib/auth/actor_cache.go | 68 ++++++++++++++ authbridge/authlib/config/config.go | 16 ++-- authbridge/authlib/config/config_test.go | 94 ++++++++++++++++++++ authbridge/authlib/config/resolve.go | 107 +++++++++++++++++++++-- authbridge/authlib/config/validate.go | 15 ++-- 5 files changed, 282 insertions(+), 18 deletions(-) create mode 100644 authbridge/authlib/auth/actor_cache.go diff --git a/authbridge/authlib/auth/actor_cache.go b/authbridge/authlib/auth/actor_cache.go new file mode 100644 index 000000000..9d9fa3516 --- /dev/null +++ b/authbridge/authlib/auth/actor_cache.go @@ -0,0 +1,68 @@ +package auth + +import ( + "context" + "sync" + "time" +) + +// CachedActorTokenSource wraps an ActorTokenSource with a single-entry cache. +// The cached token is refreshed 30 seconds before expiry, matching the +// old go-processor's actorTokenCache behavior. +type CachedActorTokenSource struct { + source ActorTokenSource + mu sync.RWMutex + token string + expiresAt time.Time + ttlBuffer time.Duration +} + +// NewCachedActorTokenSource wraps the given source with caching. +func NewCachedActorTokenSource(source ActorTokenSource) *CachedActorTokenSource { + return &CachedActorTokenSource{ + source: source, + ttlBuffer: 30 * time.Second, + } +} + +// FetchToken returns a cached actor token, refreshing if expired or near expiry. +func (c *CachedActorTokenSource) FetchToken(ctx context.Context) (string, error) { + c.mu.RLock() + if c.token != "" && time.Now().Add(c.ttlBuffer).Before(c.expiresAt) { + token := c.token + c.mu.RUnlock() + return token, nil + } + c.mu.RUnlock() + + c.mu.Lock() + defer c.mu.Unlock() + + // Double-check after acquiring write lock + if c.token != "" && time.Now().Add(c.ttlBuffer).Before(c.expiresAt) { + return c.token, nil + } + + token, err := c.source(ctx) + if err != nil { + return "", err + } + c.token = token + // Default TTL of 5 minutes if the source doesn't provide one. + // The actual TTL is set via SetTTL after the client-credentials grant. + if c.expiresAt.IsZero() { + c.expiresAt = time.Now().Add(5 * time.Minute) + } + return token, nil +} + +// SetTTL updates the cache expiry based on the token's expires_in value. +// Called by the auth layer after a successful client-credentials grant. +func (c *CachedActorTokenSource) SetTTL(expiresIn time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + if expiresIn < 60*time.Second { + expiresIn = 60 * time.Second + } + c.expiresAt = time.Now().Add(expiresIn) +} diff --git a/authbridge/authlib/config/config.go b/authbridge/authlib/config/config.go index ca589d09e..a2325a05d 100644 --- a/authbridge/authlib/config/config.go +++ b/authbridge/authlib/config/config.go @@ -29,17 +29,21 @@ type InboundConfig struct { // OutboundConfig holds token exchange settings. type OutboundConfig struct { TokenURL string `yaml:"token_url"` + KeycloakURL string `yaml:"keycloak_url"` // alternative: derives token_url and issuer + KeycloakRealm string `yaml:"keycloak_realm"` // used with keycloak_url DefaultPolicy string `yaml:"default_policy"` // "exchange" or "passthrough" } // IdentityConfig holds agent identity and credentials. type IdentityConfig struct { - Type string `yaml:"type"` // "spiffe", "client-secret", "k8s-sa" - ClientID string `yaml:"client_id"` - ClientSecret string `yaml:"client_secret"` - SocketPath string `yaml:"socket_path"` // SPIFFE Workload API - JWTSVIDPath string `yaml:"jwt_svid_path"` // file-based SPIFFE - JWTAudience []string `yaml:"jwt_audience"` // SPIFFE JWT audience + Type string `yaml:"type"` // "spiffe", "client-secret", "k8s-sa" + ClientID string `yaml:"client_id"` + ClientSecret string `yaml:"client_secret"` + ClientIDFile string `yaml:"client_id_file"` // alternative: read client_id from file + ClientSecretFile string `yaml:"client_secret_file"` // alternative: read client_secret from file + SocketPath string `yaml:"socket_path"` // SPIFFE Workload API + JWTSVIDPath string `yaml:"jwt_svid_path"` // file-based SPIFFE + JWTAudience []string `yaml:"jwt_audience"` // SPIFFE JWT audience } // ListenerConfig holds per-mode listener addresses. diff --git a/authbridge/authlib/config/config_test.go b/authbridge/authlib/config/config_test.go index 28127ad51..c7fd04a44 100644 --- a/authbridge/authlib/config/config_test.go +++ b/authbridge/authlib/config/config_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "testing" + "time" ) // --- Preset Tests --- @@ -213,6 +214,99 @@ func TestRouteConfig_LegacyPassthrough(t *testing.T) { } } +// --- Keycloak URL derivation --- + +func TestDeriveKeycloakURLs(t *testing.T) { + cfg := &Config{ + Mode: ModeWaypoint, + Outbound: OutboundConfig{KeycloakURL: "http://keycloak:8080", KeycloakRealm: "kagenti"}, + Identity: IdentityConfig{Type: "client-secret", ClientID: "svc", ClientSecret: "secret"}, + } + deriveKeycloakURLs(cfg) + if cfg.Outbound.TokenURL != "http://keycloak:8080/realms/kagenti/protocol/openid-connect/token" { + t.Errorf("token_url = %q", cfg.Outbound.TokenURL) + } + if cfg.Inbound.Issuer != "http://keycloak:8080/realms/kagenti" { + t.Errorf("issuer = %q", cfg.Inbound.Issuer) + } +} + +func TestDeriveKeycloakURLs_ExplicitTakesPrecedence(t *testing.T) { + cfg := &Config{ + Inbound: InboundConfig{Issuer: "http://explicit-issuer"}, + Outbound: OutboundConfig{TokenURL: "http://explicit-token", KeycloakURL: "http://keycloak:8080", KeycloakRealm: "kagenti"}, + } + deriveKeycloakURLs(cfg) + if cfg.Outbound.TokenURL != "http://explicit-token" { + t.Errorf("explicit token_url should not be overridden, got %q", cfg.Outbound.TokenURL) + } + if cfg.Inbound.Issuer != "http://explicit-issuer" { + t.Errorf("explicit issuer should not be overridden, got %q", cfg.Inbound.Issuer) + } +} + +// --- JWKS URL derivation --- + +func TestDeriveJWKSURL(t *testing.T) { + cfg := &Config{ + Outbound: OutboundConfig{TokenURL: "http://keycloak:8080/realms/kagenti/protocol/openid-connect/token"}, + } + deriveJWKSURL(cfg) + if cfg.Inbound.JWKSURL != "http://keycloak:8080/realms/kagenti/protocol/openid-connect/certs" { + t.Errorf("jwks_url = %q", cfg.Inbound.JWKSURL) + } +} + +func TestDeriveJWKSURL_ExplicitTakesPrecedence(t *testing.T) { + cfg := &Config{ + Inbound: InboundConfig{JWKSURL: "http://explicit-jwks"}, + Outbound: OutboundConfig{TokenURL: "http://keycloak:8080/realms/kagenti/protocol/openid-connect/token"}, + } + deriveJWKSURL(cfg) + if cfg.Inbound.JWKSURL != "http://explicit-jwks" { + t.Errorf("explicit jwks_url should not be overridden, got %q", cfg.Inbound.JWKSURL) + } +} + +// --- Credential file validation --- + +func TestValidate_ClientIDFile(t *testing.T) { + cfg := &Config{ + Mode: ModeWaypoint, + Inbound: InboundConfig{JWKSURL: "http://jwks", Issuer: "http://issuer"}, + Outbound: OutboundConfig{TokenURL: "http://token"}, + Identity: IdentityConfig{Type: "client-secret", ClientIDFile: "/shared/client-id.txt", ClientSecretFile: "/shared/client-secret.txt"}, + } + // Should pass validation — file paths accepted as alternatives + if err := Validate(cfg); err != nil { + t.Errorf("unexpected error: %v", err) + } +} + +// --- Credential file reading --- + +func TestWaitAndReadFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "client-id.txt") + if err := os.WriteFile(path, []byte(" my-agent \n"), 0600); err != nil { + t.Fatal(err) + } + var dest string + if err := waitAndReadFile(path, &dest, 5*time.Second); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if dest != "my-agent" { + t.Errorf("got %q, want trimmed value", dest) + } +} + +func TestWaitForFile_Timeout(t *testing.T) { + err := waitForFile("/nonexistent/file", 1*time.Second) + if err == nil { + t.Error("expected timeout error") + } +} + // --- Helpers --- func validEnvoySidecarConfig() *Config { diff --git a/authbridge/authlib/config/resolve.go b/authbridge/authlib/config/resolve.go index 5cc22abb1..10b23e418 100644 --- a/authbridge/authlib/config/resolve.go +++ b/authbridge/authlib/config/resolve.go @@ -3,6 +3,10 @@ package config import ( "context" "fmt" + "log/slog" + "os" + "strings" + "time" "github.com/kagenti/kagenti-extensions/authbridge/authlib/auth" "github.com/kagenti/kagenti-extensions/authbridge/authlib/bypass" @@ -18,6 +22,17 @@ import ( func Resolve(ctx context.Context, cfg *Config) (*auth.Config, error) { ApplyPreset(cfg) + // Derive URLs from KEYCLOAK_URL + KEYCLOAK_REALM when explicit values are missing + deriveKeycloakURLs(cfg) + + // Derive JWKS URL from TOKEN_URL when not explicitly set (Keycloak convention) + deriveJWKSURL(cfg) + + // Wait for and load credentials from files when configured + if err := resolveCredentialFiles(cfg); err != nil { + return nil, fmt.Errorf("credential files: %w", err) + } + if err := Validate(cfg); err != nil { return nil, fmt.Errorf("config validation: %w", err) } @@ -52,11 +67,11 @@ func Resolve(ctx context.Context, cfg *Config) (*auth.Config, error) { } return &auth.Config{ - Verifier: verifier, - Exchanger: exchanger, - Cache: cache.New(), - Bypass: matcher, - Router: router, + Verifier: verifier, + Exchanger: exchanger, + Cache: cache.New(), + Bypass: matcher, + Router: router, Identity: auth.IdentityConfig{ ClientID: cfg.Identity.ClientID, Audience: cfg.Identity.ClientID, // inbound audience defaults to client ID @@ -65,6 +80,88 @@ func Resolve(ctx context.Context, cfg *Config) (*auth.Config, error) { }, nil } +// deriveKeycloakURLs derives TOKEN_URL and ISSUER from KEYCLOAK_URL + KEYCLOAK_REALM. +// Explicit values always take precedence. +func deriveKeycloakURLs(cfg *Config) { + keycloakURL := strings.TrimRight(cfg.Outbound.KeycloakURL, "/") + realm := cfg.Outbound.KeycloakRealm + if keycloakURL == "" || realm == "" { + return + } + base := keycloakURL + "/realms/" + realm + if cfg.Outbound.TokenURL == "" { + cfg.Outbound.TokenURL = base + "/protocol/openid-connect/token" + slog.Info("derived token_url from keycloak_url + keycloak_realm", + "token_url", cfg.Outbound.TokenURL) + } + if cfg.Inbound.Issuer == "" { + cfg.Inbound.Issuer = base + slog.Info("derived issuer from keycloak_url + keycloak_realm", + "issuer", cfg.Inbound.Issuer) + } +} + +// deriveJWKSURL derives the JWKS endpoint from TOKEN_URL using Keycloak's convention: +// .../protocol/openid-connect/token → .../protocol/openid-connect/certs +func deriveJWKSURL(cfg *Config) { + if cfg.Inbound.JWKSURL != "" || cfg.Outbound.TokenURL == "" { + return + } + jwksURL := strings.Replace(cfg.Outbound.TokenURL, "/token", "/certs", 1) + if jwksURL != cfg.Outbound.TokenURL { + cfg.Inbound.JWKSURL = jwksURL + slog.Info("derived jwks_url from token_url", "jwks_url", jwksURL) + } +} + +// resolveCredentialFiles waits for and reads credential files when configured. +// This handles the startup race with client-registration and spiffe-helper. +func resolveCredentialFiles(cfg *Config) error { + if cfg.Identity.ClientIDFile != "" { + if err := waitAndReadFile(cfg.Identity.ClientIDFile, &cfg.Identity.ClientID, 60*time.Second); err != nil { + slog.Warn("client_id_file not available, using client_id from config", "error", err) + } + } + if cfg.Identity.ClientSecretFile != "" { + if err := waitAndReadFile(cfg.Identity.ClientSecretFile, &cfg.Identity.ClientSecret, 60*time.Second); err != nil { + slog.Warn("client_secret_file not available, using client_secret from config", "error", err) + } + } + // Wait for JWT-SVID file if SPIFFE identity is configured + if cfg.Identity.Type == "spiffe" && cfg.Identity.JWTSVIDPath != "" { + if err := waitForFile(cfg.Identity.JWTSVIDPath, 60*time.Second); err != nil { + slog.Warn("jwt_svid_path not available at startup, will be read on each exchange", "error", err) + } + } + return nil +} + +// waitAndReadFile polls for a file to exist, then reads its content into dest. +func waitAndReadFile(path string, dest *string, timeout time.Duration) error { + if err := waitForFile(path, timeout); err != nil { + return err + } + content, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("reading %s: %w", path, err) + } + *dest = strings.TrimSpace(string(content)) + slog.Info("loaded credential from file", "path", path) + return nil +} + +// waitForFile polls for a file to exist, returning nil when found or error on timeout. +func waitForFile(path string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if info, err := os.Stat(path); err == nil && info.Size() > 0 { + return nil + } + time.Sleep(2 * time.Second) + } + return fmt.Errorf("timeout waiting for %s (%v)", path, timeout) +} + func resolveClientAuth(cfg *Config) (exchange.ClientAuth, error) { switch cfg.Identity.Type { case "spiffe": diff --git a/authbridge/authlib/config/validate.go b/authbridge/authlib/config/validate.go index e8edb7d4e..3d25566e3 100644 --- a/authbridge/authlib/config/validate.go +++ b/authbridge/authlib/config/validate.go @@ -27,7 +27,8 @@ func Validate(cfg *Config) error { return fmt.Errorf("inbound.jwks_url is required") } if cfg.Outbound.TokenURL == "" { - return fmt.Errorf("outbound.token_url is required") + // token_url may have been derived from keycloak_url + keycloak_realm in Resolve() + return fmt.Errorf("outbound.token_url is required (or set keycloak_url + keycloak_realm)") } // Identity validation @@ -52,15 +53,15 @@ func validateIdentity(cfg *Config) error { if cfg.Identity.SocketPath == "" && cfg.Identity.JWTSVIDPath == "" { return fmt.Errorf("identity.type=spiffe requires socket_path or jwt_svid_path") } - if cfg.Identity.ClientID == "" { - return fmt.Errorf("identity.type=spiffe requires client_id") + if cfg.Identity.ClientID == "" && cfg.Identity.ClientIDFile == "" { + return fmt.Errorf("identity.type=spiffe requires client_id or client_id_file") } case "client-secret": - if cfg.Identity.ClientID == "" { - return fmt.Errorf("identity.type=client-secret requires client_id") + if cfg.Identity.ClientID == "" && cfg.Identity.ClientIDFile == "" { + return fmt.Errorf("identity.type=client-secret requires client_id or client_id_file") } - if cfg.Identity.ClientSecret == "" { - return fmt.Errorf("identity.type=client-secret requires client_secret") + if cfg.Identity.ClientSecret == "" && cfg.Identity.ClientSecretFile == "" { + return fmt.Errorf("identity.type=client-secret requires client_secret or client_secret_file") } case "k8s-sa": // Future: validate service account token path From 0eaf70cdb85eed8efa9c8dcbff67df6a5c073a9f Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Tue, 14 Apr 2026 21:07:02 -0400 Subject: [PATCH 06/10] Fix: client_credentials audience and waypoint audience derivation Two feature parity findings from comprehensive review: 1. ClientCredentials grant now passes audience to Keycloak (matching old go-processor behavior). Without this, Keycloak may issue tokens with wrong audience that target services reject. 2. Waypoint mode now auto-derives audience from destination hostname via AudienceDeriver callback (serviceNameFromHost). This eliminates the need for explicit routes in waypoint mode, matching the old waypoint behavior where audience = first DNS label of host. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/authlib/auth/auth.go | 15 ++++++++++- authbridge/authlib/config/resolve.go | 30 ++++++++++++++++++++-- authbridge/authlib/exchange/client.go | 7 ++++- authbridge/authlib/exchange/client_test.go | 5 +++- 4 files changed, 52 insertions(+), 5 deletions(-) diff --git a/authbridge/authlib/auth/auth.go b/authbridge/authlib/auth/auth.go index 9db41dcc1..823fb3ac4 100644 --- a/authbridge/authlib/auth/auth.go +++ b/authbridge/authlib/auth/auth.go @@ -25,6 +25,11 @@ type IdentityConfig struct { // Returns ("", nil) when no actor token is available. type ActorTokenSource func(ctx context.Context) (string, error) +// AudienceDeriver derives a target audience from a request host. +// Used by waypoint mode to auto-derive audience from the destination service name. +// Returns "" if no derivation is possible (falls back to route config). +type AudienceDeriver func(host string) string + // Config holds the resolved dependencies for the auth layer. type Config struct { Verifier validation.Verifier @@ -35,6 +40,7 @@ type Config struct { Identity IdentityConfig NoTokenPolicy string // NoTokenClientCredentials, NoTokenAllow, or NoTokenDeny ActorTokenSource ActorTokenSource // optional, for act claim chaining + AudienceDeriver AudienceDeriver // optional, derives audience from host (waypoint mode) Logger *slog.Logger } @@ -48,6 +54,7 @@ type Auth struct { identity IdentityConfig noTokenPolicy string actorTokenSource ActorTokenSource + audienceDeriver AudienceDeriver log *slog.Logger } @@ -66,6 +73,7 @@ func New(cfg Config) *Auth { identity: cfg.Identity, noTokenPolicy: cfg.NoTokenPolicy, actorTokenSource: cfg.ActorTokenSource, + audienceDeriver: cfg.AudienceDeriver, log: logger, } } @@ -145,6 +153,11 @@ func (a *Auth) HandleOutbound(ctx context.Context, authHeader, host string) *Out audience := resolved.Audience scopes := resolved.Scopes + // If no audience from route and deriver is set, derive from host (waypoint pattern) + if audience == "" && a.audienceDeriver != nil { + audience = a.audienceDeriver(host) + } + // 4. Extract bearer token subjectToken := extractBearer(authHeader) @@ -218,7 +231,7 @@ func (a *Auth) handleNoToken(ctx context.Context, audience, scopes string) *Outb } } a.log.Debug("no token, falling back to client_credentials") - resp, err := a.exchanger.ClientCredentials(ctx, scopes) + resp, err := a.exchanger.ClientCredentials(ctx, audience, scopes) if err != nil { a.log.Info("client credentials grant failed", "error", err) return &OutboundResult{ diff --git a/authbridge/authlib/config/resolve.go b/authbridge/authlib/config/resolve.go index 10b23e418..4e1778c52 100644 --- a/authbridge/authlib/config/resolve.go +++ b/authbridge/authlib/config/resolve.go @@ -66,7 +66,7 @@ func Resolve(ctx context.Context, cfg *Config) (*auth.Config, error) { return nil, fmt.Errorf("router: %w", err) } - return &auth.Config{ + result := &auth.Config{ Verifier: verifier, Exchanger: exchanger, Cache: cache.New(), @@ -77,7 +77,14 @@ func Resolve(ctx context.Context, cfg *Config) (*auth.Config, error) { Audience: cfg.Identity.ClientID, // inbound audience defaults to client ID }, NoTokenPolicy: NoTokenPolicyForMode(cfg.Mode), - }, nil + } + + // Waypoint mode: derive audience from destination hostname when no route matches + if cfg.Mode == ModeWaypoint { + result.AudienceDeriver = serviceNameFromHost + } + + return result, nil } // deriveKeycloakURLs derives TOKEN_URL and ISSUER from KEYCLOAK_URL + KEYCLOAK_REALM. @@ -162,6 +169,25 @@ func waitForFile(path string, timeout time.Duration) error { return fmt.Errorf("timeout waiting for %s (%v)", path, timeout) } +// serviceNameFromHost extracts the service name from a Kubernetes host. +// "auth-target-service.team1.svc.cluster.local:8081" → "auth-target-service" +func serviceNameFromHost(host string) string { + // Strip port + for i, c := range host { + if c == ':' { + host = host[:i] + break + } + } + // Take first DNS label + for i, c := range host { + if c == '.' { + return host[:i] + } + } + return host +} + func resolveClientAuth(cfg *Config) (exchange.ClientAuth, error) { switch cfg.Identity.Type { case "spiffe": diff --git a/authbridge/authlib/exchange/client.go b/authbridge/authlib/exchange/client.go index e55dc52eb..5f0131a04 100644 --- a/authbridge/authlib/exchange/client.go +++ b/authbridge/authlib/exchange/client.go @@ -90,10 +90,15 @@ func (c *Client) Exchange(ctx context.Context, req *ExchangeRequest) (*ExchangeR } // ClientCredentials performs a client credentials grant. -func (c *Client) ClientCredentials(ctx context.Context, scopes string) (*ExchangeResponse, error) { +// audience is included as a form parameter so Keycloak issues a token +// scoped to the target service (matching the old go-processor behavior). +func (c *Client) ClientCredentials(ctx context.Context, audience, scopes string) (*ExchangeResponse, error) { form := url.Values{ "grant_type": {"client_credentials"}, } + if audience != "" { + form.Set("audience", audience) + } if scopes != "" { form.Set("scope", scopes) } diff --git a/authbridge/authlib/exchange/client_test.go b/authbridge/authlib/exchange/client_test.go index 319b1f691..a658875bd 100644 --- a/authbridge/authlib/exchange/client_test.go +++ b/authbridge/authlib/exchange/client_test.go @@ -96,6 +96,9 @@ func TestClientCredentials_Success(t *testing.T) { if got := r.FormValue("grant_type"); got != "client_credentials" { t.Errorf("grant_type = %q", got) } + if got := r.FormValue("audience"); got != "target-aud" { + t.Errorf("audience = %q, want target-aud", got) + } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "access_token": "cc-token", @@ -106,7 +109,7 @@ func TestClientCredentials_Success(t *testing.T) { defer srv.Close() client := NewClient(srv.URL, &ClientSecretAuth{ClientID: "c", ClientSecret: "s"}) - resp, err := client.ClientCredentials(context.Background(), "openid") + resp, err := client.ClientCredentials(context.Background(), "target-aud", "openid") if err != nil { t.Fatalf("unexpected error: %v", err) } From 8cf07a1affe19f70024f1a76e25ccc5b254e0715 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Wed, 15 Apr 2026 07:50:15 -0400 Subject: [PATCH 07/10] 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/auth/actor_cache.go | 8 +++----- authbridge/authlib/auth/auth_test.go | 6 ++++-- authbridge/authlib/config/resolve.go | 8 ++++---- authbridge/authlib/exchange/client_test.go | 12 ++++++++---- 4 files changed, 19 insertions(+), 15 deletions(-) diff --git a/authbridge/authlib/auth/actor_cache.go b/authbridge/authlib/auth/actor_cache.go index 9d9fa3516..3da9836ab 100644 --- a/authbridge/authlib/auth/actor_cache.go +++ b/authbridge/authlib/auth/actor_cache.go @@ -48,11 +48,9 @@ func (c *CachedActorTokenSource) FetchToken(ctx context.Context) (string, error) return "", err } c.token = token - // Default TTL of 5 minutes if the source doesn't provide one. - // The actual TTL is set via SetTTL after the client-credentials grant. - if c.expiresAt.IsZero() { - c.expiresAt = time.Now().Add(5 * time.Minute) - } + // Always reset expiry on refresh. Default TTL of 5 minutes; + // SetTTL can override with the actual expires_in from the grant. + c.expiresAt = time.Now().Add(5 * time.Minute) return token, nil } diff --git a/authbridge/authlib/auth/auth_test.go b/authbridge/authlib/auth/auth_test.go index 8ed1df16d..d93326ee1 100644 --- a/authbridge/authlib/auth/auth_test.go +++ b/authbridge/authlib/auth/auth_test.go @@ -138,7 +138,8 @@ func newTestExchangeServer(t *testing.T) *httptest.Server { t.Helper() return 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 } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ @@ -301,7 +302,8 @@ func TestHandleOutbound_PerRouteTokenEndpoint(t *testing.T) { func TestHandleOutbound_ActorToken(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 actor-jwt", got) diff --git a/authbridge/authlib/config/resolve.go b/authbridge/authlib/config/resolve.go index 4e1778c52..a85b07223 100644 --- a/authbridge/authlib/config/resolve.go +++ b/authbridge/authlib/config/resolve.go @@ -110,14 +110,14 @@ func deriveKeycloakURLs(cfg *Config) { // deriveJWKSURL derives the JWKS endpoint from TOKEN_URL using Keycloak's convention: // .../protocol/openid-connect/token → .../protocol/openid-connect/certs +// Uses suffix-based replacement to avoid modifying hostnames containing "token". func deriveJWKSURL(cfg *Config) { if cfg.Inbound.JWKSURL != "" || cfg.Outbound.TokenURL == "" { return } - jwksURL := strings.Replace(cfg.Outbound.TokenURL, "/token", "/certs", 1) - if jwksURL != cfg.Outbound.TokenURL { - cfg.Inbound.JWKSURL = jwksURL - slog.Info("derived jwks_url from token_url", "jwks_url", jwksURL) + if strings.HasSuffix(cfg.Outbound.TokenURL, "/token") { + cfg.Inbound.JWKSURL = strings.TrimSuffix(cfg.Outbound.TokenURL, "/token") + "/certs" + slog.Info("derived jwks_url from token_url", "jwks_url", cfg.Inbound.JWKSURL) } } diff --git a/authbridge/authlib/exchange/client_test.go b/authbridge/authlib/exchange/client_test.go index a658875bd..b9a0ba6b5 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) @@ -121,7 +123,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") @@ -203,7 +206,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) From faf9a83da1cf38f3603d6e0d264436a0b2a90d87 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Wed, 15 Apr 2026 07:56:23 -0400 Subject: [PATCH 08/10] Move ServiceNameFromHost to routing/, add TODO for SetTTL Address review from @pdettori: - Extract ServiceNameFromHost to routing/hostutil.go (shared by config/resolve.go and extauthz listener, avoids duplication) - Add TODO comment on SetTTL (unused, clarifies intent) Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/authlib/auth/actor_cache.go | 3 ++- authbridge/authlib/config/resolve.go | 21 +------------------- authbridge/authlib/routing/hostutil.go | 20 +++++++++++++++++++ authbridge/authlib/routing/hostutil_test.go | 22 +++++++++++++++++++++ 4 files changed, 45 insertions(+), 21 deletions(-) create mode 100644 authbridge/authlib/routing/hostutil.go create mode 100644 authbridge/authlib/routing/hostutil_test.go diff --git a/authbridge/authlib/auth/actor_cache.go b/authbridge/authlib/auth/actor_cache.go index 3da9836ab..4aa5da9d4 100644 --- a/authbridge/authlib/auth/actor_cache.go +++ b/authbridge/authlib/auth/actor_cache.go @@ -55,7 +55,8 @@ func (c *CachedActorTokenSource) FetchToken(ctx context.Context) (string, error) } // SetTTL updates the cache expiry based on the token's expires_in value. -// Called by the auth layer after a successful client-credentials grant. +// TODO: Wire from client-credentials response in HandleOutbound when actor token +// caching is enabled. Currently unused — the 5-minute default applies. func (c *CachedActorTokenSource) SetTTL(expiresIn time.Duration) { c.mu.Lock() defer c.mu.Unlock() diff --git a/authbridge/authlib/config/resolve.go b/authbridge/authlib/config/resolve.go index a85b07223..f1ac209e4 100644 --- a/authbridge/authlib/config/resolve.go +++ b/authbridge/authlib/config/resolve.go @@ -81,7 +81,7 @@ func Resolve(ctx context.Context, cfg *Config) (*auth.Config, error) { // Waypoint mode: derive audience from destination hostname when no route matches if cfg.Mode == ModeWaypoint { - result.AudienceDeriver = serviceNameFromHost + result.AudienceDeriver = routing.ServiceNameFromHost } return result, nil @@ -169,25 +169,6 @@ func waitForFile(path string, timeout time.Duration) error { return fmt.Errorf("timeout waiting for %s (%v)", path, timeout) } -// serviceNameFromHost extracts the service name from a Kubernetes host. -// "auth-target-service.team1.svc.cluster.local:8081" → "auth-target-service" -func serviceNameFromHost(host string) string { - // Strip port - for i, c := range host { - if c == ':' { - host = host[:i] - break - } - } - // Take first DNS label - for i, c := range host { - if c == '.' { - return host[:i] - } - } - return host -} - func resolveClientAuth(cfg *Config) (exchange.ClientAuth, error) { switch cfg.Identity.Type { case "spiffe": diff --git a/authbridge/authlib/routing/hostutil.go b/authbridge/authlib/routing/hostutil.go new file mode 100644 index 000000000..7dde7cfbc --- /dev/null +++ b/authbridge/authlib/routing/hostutil.go @@ -0,0 +1,20 @@ +package routing + +// ServiceNameFromHost extracts the service name (first DNS label) from a Kubernetes host. +// "auth-target-service.team1.svc.cluster.local:8081" becomes "auth-target-service". +func ServiceNameFromHost(host string) string { + // Strip port + for i, c := range host { + if c == ':' { + host = host[:i] + break + } + } + // Take first DNS label + for i, c := range host { + if c == '.' { + return host[:i] + } + } + return host +} diff --git a/authbridge/authlib/routing/hostutil_test.go b/authbridge/authlib/routing/hostutil_test.go new file mode 100644 index 000000000..4f2740230 --- /dev/null +++ b/authbridge/authlib/routing/hostutil_test.go @@ -0,0 +1,22 @@ +package routing + +import "testing" + +func TestServiceNameFromHost(t *testing.T) { + tests := []struct { + host string + want string + }{ + {"auth-target-service.authbridge.svc.cluster.local:8081", "auth-target-service"}, + {"auth-target-service.authbridge.svc.cluster.local", "auth-target-service"}, + {"auth-target-service:8081", "auth-target-service"}, + {"auth-target-service", "auth-target-service"}, + {"simple", "simple"}, + {"", ""}, + } + for _, tt := range tests { + if got := ServiceNameFromHost(tt.host); got != tt.want { + t.Errorf("ServiceNameFromHost(%q) = %q, want %q", tt.host, got, tt.want) + } + } +} From 398f110e6fd38e45da4f2378b3ac8f899cd53b7a Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Wed, 15 Apr 2026 08:06:27 -0400 Subject: [PATCH 09/10] Return generic JWT error to client, log detail server-side JWT validation errors can reveal issuer URL, audience, or algorithm details. Return 'token validation failed' to the client and log the full error server-side at INFO level. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/authlib/auth/auth.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/authbridge/authlib/auth/auth.go b/authbridge/authlib/auth/auth.go index 823fb3ac4..1513707fc 100644 --- a/authbridge/authlib/auth/auth.go +++ b/authbridge/authlib/auth/auth.go @@ -2,7 +2,6 @@ package auth import ( "context" - "fmt" "log/slog" "net/http" "strings" @@ -119,11 +118,13 @@ func (a *Auth) HandleInbound(ctx context.Context, authHeader, path, audience str } claims, err := a.verifier.Verify(ctx, token, audience) if err != nil { + // Log full error server-side; return generic message to client + // to avoid leaking issuer URL, audience, or algorithm details. a.log.Info("JWT validation failed", "error", err) return &InboundResult{ Action: ActionDeny, DenyStatus: http.StatusUnauthorized, - DenyReason: fmt.Sprintf("token validation failed: %v", err), + DenyReason: "token validation failed", } } From b2ca966b0c1bb93ed912a282fe7b9936acf13cec Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Wed, 15 Apr 2026 08:32:14 -0400 Subject: [PATCH 10/10] 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 5f0131a04..a662995cb 100644 --- a/authbridge/authlib/exchange/client.go +++ b/authbridge/authlib/exchange/client.go @@ -61,6 +61,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"}, @@ -78,7 +81,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) } @@ -103,7 +106,7 @@ func (c *Client) ClientCredentials(ctx context.Context, audience, scopes string) 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") + } +}