From b7f79e49c72829d9c862f23a74150e24b1839c72 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Tue, 14 Apr 2026 17:05:10 -0400 Subject: [PATCH 01/15] =?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/15] =?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/15] =?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/15] =?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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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") + } +} From 02a74934c66c0224e39d761754a2236660f0547e Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Tue, 14 Apr 2026 19:04:37 -0400 Subject: [PATCH 11/15] =?UTF-8?q?=E2=9C=A8=20Add=20listeners=20and=20singl?= =?UTF-8?q?e=20binary=20=E2=80=94=20four=20modes,=20one=20codebase=20(Phas?= =?UTF-8?q?e=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add four protocol listeners and a unified cmd/authbridge binary that replaces three separate codebases (go-processor, waypoint, klaviger). Listeners (thin protocol translators, 50-170 lines each): - listener/extproc/ — Envoy ext_proc gRPC streaming (envoy-sidecar mode) - listener/extauthz/ — Envoy ext_authz gRPC unary (waypoint mode) - listener/forwardproxy/ — HTTP forward proxy (waypoint + proxy-sidecar) - listener/reverseproxy/ — HTTP reverse proxy (proxy-sidecar mode) cmd/authbridge/main.go: - --mode (envoy-sidecar|waypoint|proxy-sidecar) + --config flags - Mode selects which listeners start - Graceful shutdown with 15s timeout on SIGTERM - gRPC health checks on all gRPC servers Infrastructure: - go.work at authbridge/ level for multi-module development - Separate Go module (cmd/authbridge) keeps gRPC/Envoy deps out of authlib - Dockerfile: multi-stage build producing distroless image 17 listener tests + 56 authlib tests = 73 total, all passing. Ref: kagenti/kagenti-extensions#279 Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/cmd/authbridge/Dockerfile | 25 ++ authbridge/cmd/authbridge/go.mod | 36 ++ authbridge/cmd/authbridge/go.sum | 83 +++++ .../authbridge/listener/extauthz/server.go | 130 +++++++ .../listener/extauthz/server_test.go | 150 ++++++++ .../cmd/authbridge/listener/extproc/server.go | 174 +++++++++ .../listener/extproc/server_test.go | 336 ++++++++++++++++++ .../listener/forwardproxy/server.go | 87 +++++ .../listener/forwardproxy/server_test.go | 128 +++++++ .../listener/reverseproxy/server.go | 52 +++ .../listener/reverseproxy/server_test.go | 95 +++++ authbridge/cmd/authbridge/main.go | 153 ++++++++ authbridge/go.work | 6 + 13 files changed, 1455 insertions(+) create mode 100644 authbridge/cmd/authbridge/Dockerfile create mode 100644 authbridge/cmd/authbridge/go.mod create mode 100644 authbridge/cmd/authbridge/go.sum create mode 100644 authbridge/cmd/authbridge/listener/extauthz/server.go create mode 100644 authbridge/cmd/authbridge/listener/extauthz/server_test.go create mode 100644 authbridge/cmd/authbridge/listener/extproc/server.go create mode 100644 authbridge/cmd/authbridge/listener/extproc/server_test.go create mode 100644 authbridge/cmd/authbridge/listener/forwardproxy/server.go create mode 100644 authbridge/cmd/authbridge/listener/forwardproxy/server_test.go create mode 100644 authbridge/cmd/authbridge/listener/reverseproxy/server.go create mode 100644 authbridge/cmd/authbridge/listener/reverseproxy/server_test.go create mode 100644 authbridge/cmd/authbridge/main.go create mode 100644 authbridge/go.work diff --git a/authbridge/cmd/authbridge/Dockerfile b/authbridge/cmd/authbridge/Dockerfile new file mode 100644 index 000000000..b11b11da1 --- /dev/null +++ b/authbridge/cmd/authbridge/Dockerfile @@ -0,0 +1,25 @@ +# AuthBridge unified binary — single binary, three modes. +# Build context: ./authbridge (needs access to both authlib/ and cmd/authbridge/) + +FROM golang:1.24-alpine AS builder + +RUN apk add --no-cache git + +WORKDIR /app + +# Copy go.work and both modules +COPY go.work . +COPY authlib/ authlib/ +COPY cmd/authbridge/ cmd/authbridge/ + +# Build +RUN cd cmd/authbridge && CGO_ENABLED=0 GOOS=linux go build -o /authbridge . + +# Runtime +FROM gcr.io/distroless/static-debian12 + +COPY --from=builder /authbridge /authbridge + +USER 65534 + +ENTRYPOINT ["/authbridge"] diff --git a/authbridge/cmd/authbridge/go.mod b/authbridge/cmd/authbridge/go.mod new file mode 100644 index 000000000..20f23cce6 --- /dev/null +++ b/authbridge/cmd/authbridge/go.mod @@ -0,0 +1,36 @@ +module github.com/kagenti/kagenti-extensions/authbridge/cmd/authbridge + +go 1.24.0 + +toolchain go1.24.5 + +require ( + github.com/envoyproxy/go-control-plane/envoy v1.37.0 + github.com/kagenti/kagenti-extensions/authbridge/authlib v0.0.0-00010101000000-000000000000 + google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 + google.golang.org/grpc v1.80.0 +) + +require ( + github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect + github.com/gobwas/glob v0.2.3 // 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/jwx/v2 v2.1.6 // indirect + github.com/lestrrat-go/option v1.0.1 // indirect + github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + github.com/segmentio/asm v1.2.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/kagenti/kagenti-extensions/authbridge/authlib => ../../authlib diff --git a/authbridge/cmd/authbridge/go.sum b/authbridge/cmd/authbridge/go.sum new file mode 100644 index 000000000..127901f96 --- /dev/null +++ b/authbridge/cmd/authbridge/go.sum @@ -0,0 +1,83 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= +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/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= +github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= +github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +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/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +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/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +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= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +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/cmd/authbridge/listener/extauthz/server.go b/authbridge/cmd/authbridge/listener/extauthz/server.go new file mode 100644 index 000000000..2fa328c63 --- /dev/null +++ b/authbridge/cmd/authbridge/listener/extauthz/server.go @@ -0,0 +1,130 @@ +// Package extauthz implements an Envoy ext_authz gRPC unary listener. +// Used by waypoint mode where both inbound validation and outbound exchange +// happen in a single Check() call. +package extauthz + +import ( + "context" + "encoding/json" + + corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" + authv3 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v3" + typev3 "github.com/envoyproxy/go-control-plane/envoy/type/v3" + "google.golang.org/grpc/codes" + + rpcstatus "google.golang.org/genproto/googleapis/rpc/status" + + authpkg "github.com/kagenti/kagenti-extensions/authbridge/authlib/auth" +) + +// Server implements the Envoy ext_authz Authorization gRPC service. +type Server struct { + authv3.UnimplementedAuthorizationServer + Auth *authpkg.Auth +} + +// Check handles a single ext_authz authorization request. +func (s *Server) Check(ctx context.Context, req *authv3.CheckRequest) (*authv3.CheckResponse, error) { + httpReq := req.GetAttributes().GetRequest().GetHttp() + if httpReq == nil { + return denied(codes.InvalidArgument, 400, "missing HTTP request attributes"), nil + } + + headers := httpReq.GetHeaders() + host := headers[":authority"] + if host == "" { + host = headers["host"] + } + authHeader := headers["authorization"] + path := httpReq.GetPath() + + // Derive audience from destination host (waypoint pattern) + audience := ServiceNameFromHost(host) + + // Inbound validation + inResult := s.Auth.HandleInbound(ctx, authHeader, path, audience) + if inResult.Action == authpkg.ActionDeny { + return denied(codes.Unauthenticated, inResult.DenyStatus, inResult.DenyReason), nil + } + + // Outbound exchange + outResult := s.Auth.HandleOutbound(ctx, authHeader, host) + switch outResult.Action { + case authpkg.ActionReplaceToken: + return allowedWithToken(outResult.Token), nil + case authpkg.ActionDeny: + return denied(codes.PermissionDenied, outResult.DenyStatus, outResult.DenyReason), nil + default: + return allowed(), nil + } +} + +// ServiceNameFromHost extracts the service name from a Kubernetes host. +// "auth-target-service.authbridge.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 denied(code codes.Code, httpStatus int, msg string) *authv3.CheckResponse { + body, _ := json.Marshal(map[string]string{"error": msg}) + return &authv3.CheckResponse{ + Status: &rpcstatus.Status{ + Code: int32(code), + Message: msg, + }, + HttpResponse: &authv3.CheckResponse_DeniedResponse{ + DeniedResponse: &authv3.DeniedHttpResponse{ + Status: &typev3.HttpStatus{ + Code: typev3.StatusCode(httpStatus), + }, + Body: string(body), + Headers: []*corev3.HeaderValueOption{ + { + Header: &corev3.HeaderValue{ + Key: "Content-Type", + Value: "application/json", + }, + }, + }, + }, + }, + } +} + +func allowed() *authv3.CheckResponse { + return &authv3.CheckResponse{ + Status: &rpcstatus.Status{Code: int32(codes.OK)}, + HttpResponse: &authv3.CheckResponse_OkResponse{OkResponse: &authv3.OkHttpResponse{}}, + } +} + +func allowedWithToken(token string) *authv3.CheckResponse { + return &authv3.CheckResponse{ + Status: &rpcstatus.Status{Code: int32(codes.OK)}, + HttpResponse: &authv3.CheckResponse_OkResponse{ + OkResponse: &authv3.OkHttpResponse{ + Headers: []*corev3.HeaderValueOption{ + { + Header: &corev3.HeaderValue{ + Key: "authorization", + Value: "Bearer " + token, + }, + }, + }, + }, + }, + } +} diff --git a/authbridge/cmd/authbridge/listener/extauthz/server_test.go b/authbridge/cmd/authbridge/listener/extauthz/server_test.go new file mode 100644 index 000000000..3ec9a5dea --- /dev/null +++ b/authbridge/cmd/authbridge/listener/extauthz/server_test.go @@ -0,0 +1,150 @@ +package extauthz + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + authv3 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v3" + "google.golang.org/grpc/codes" + + authpkg "github.com/kagenti/kagenti-extensions/authbridge/authlib/auth" + "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" +) + +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 checkRequest(host, path, authHeader string) *authv3.CheckRequest { + headers := map[string]string{ + ":authority": host, + ":path": path, + } + if authHeader != "" { + headers["authorization"] = authHeader + } + return &authv3.CheckRequest{ + Attributes: &authv3.AttributeContext{ + Request: &authv3.AttributeContext_Request{ + Http: &authv3.AttributeContext_HttpRequest{ + Headers: headers, + Path: path, + }, + }, + }, + } +} + +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) + } + } +} + +func TestCheck_ValidToken_Exchange(t *testing.T) { + exchangeSrv := 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": "exchanged", + "token_type": "Bearer", + "expires_in": 300, + }) + })) + defer exchangeSrv.Close() + + mv := &mockVerifier{claims: &validation.Claims{ + Subject: "user", Audience: []string{"caller-agent"}, + }} + router, _ := routing.NewRouter("exchange", []routing.Route{}) + exchanger := exchange.NewClient(exchangeSrv.URL, &exchange.ClientSecretAuth{ + ClientID: "svc", ClientSecret: "secret", + }) + a := authpkg.New(authpkg.Config{ + Verifier: mv, + Router: router, + Exchanger: exchanger, + Cache: cache.New(), + Identity: authpkg.IdentityConfig{Audience: "default-aud"}, + }) + srv := &Server{Auth: a} + + resp, err := srv.Check(context.Background(), + checkRequest("auth-target-service.authbridge.svc:8081", "/api/test", "Bearer user-token")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Audience should be derived from host + if mv.lastAudience != "auth-target-service" { + t.Errorf("audience = %q, want auth-target-service", mv.lastAudience) + } + + // Should be OK with token replacement + ok := resp.GetOkResponse() + if ok == nil { + t.Fatal("expected OkResponse") + } + if len(ok.Headers) == 0 { + t.Fatal("expected Authorization header override") + } + if ok.Headers[0].Header.Value != "Bearer exchanged" { + t.Errorf("token = %q, want Bearer exchanged", ok.Headers[0].Header.Value) + } +} + +func TestCheck_InvalidToken(t *testing.T) { + a := authpkg.New(authpkg.Config{ + Verifier: &mockVerifier{err: fmt.Errorf("bad token")}, + Identity: authpkg.IdentityConfig{Audience: "aud"}, + }) + srv := &Server{Auth: a} + + resp, _ := srv.Check(context.Background(), + checkRequest("svc", "/api", "Bearer bad")) + + denied := resp.GetDeniedResponse() + if denied == nil { + t.Fatal("expected DeniedResponse") + } + if resp.Status.Code != int32(codes.Unauthenticated) { + t.Errorf("code = %d, want %d", resp.Status.Code, codes.Unauthenticated) + } +} + +func TestCheck_MissingHTTPAttributes(t *testing.T) { + a := authpkg.New(authpkg.Config{}) + srv := &Server{Auth: a} + + resp, _ := srv.Check(context.Background(), &authv3.CheckRequest{}) + + denied := resp.GetDeniedResponse() + if denied == nil { + t.Fatal("expected DeniedResponse for missing attributes") + } +} diff --git a/authbridge/cmd/authbridge/listener/extproc/server.go b/authbridge/cmd/authbridge/listener/extproc/server.go new file mode 100644 index 000000000..2774bfd4a --- /dev/null +++ b/authbridge/cmd/authbridge/listener/extproc/server.go @@ -0,0 +1,174 @@ +// Package extproc implements an Envoy ext_proc gRPC streaming listener. +// It translates ext_proc ProcessingRequests into auth.HandleInbound/HandleOutbound +// calls and maps the results back to ProcessingResponses. +package extproc + +import ( + "context" + "encoding/json" + "strings" + + corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" + extprocv3 "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" + typev3 "github.com/envoyproxy/go-control-plane/envoy/type/v3" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/auth" +) + +// Server implements the Envoy ext_proc ExternalProcessor gRPC service. +type Server struct { + extprocv3.UnimplementedExternalProcessorServer + Auth *auth.Auth +} + +// Process handles the bidirectional ext_proc stream. +func (s *Server) Process(stream extprocv3.ExternalProcessor_ProcessServer) error { + ctx := stream.Context() + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + req, err := stream.Recv() + if err != nil { + return status.Errorf(codes.Unknown, "cannot receive stream request: %v", err) + } + + var resp *extprocv3.ProcessingResponse + + switch r := req.Request.(type) { + case *extprocv3.ProcessingRequest_RequestHeaders: + headers := r.RequestHeaders.Headers + direction := getHeader(headers, "x-authbridge-direction") + + if direction == "inbound" { + resp = s.handleInbound(ctx, headers) + } else { + resp = s.handleOutbound(ctx, headers) + } + + case *extprocv3.ProcessingRequest_ResponseHeaders: + resp = &extprocv3.ProcessingResponse{ + Response: &extprocv3.ProcessingResponse_ResponseHeaders{ + ResponseHeaders: &extprocv3.HeadersResponse{}, + }, + } + + default: + resp = &extprocv3.ProcessingResponse{} + } + + if err := stream.Send(resp); err != nil { + return status.Errorf(codes.Unknown, "cannot send stream response: %v", err) + } + } +} + +func (s *Server) handleInbound(ctx context.Context, headers *corev3.HeaderMap) *extprocv3.ProcessingResponse { + authHeader := getHeader(headers, "authorization") + path := getHeader(headers, ":path") + + result := s.Auth.HandleInbound(ctx, authHeader, path, "") + + if result.Action == auth.ActionDeny { + return denyResponse(typev3.StatusCode_Unauthorized, + jsonError("unauthorized", result.DenyReason)) + } + + return allowResponse() +} + +func (s *Server) handleOutbound(ctx context.Context, headers *corev3.HeaderMap) *extprocv3.ProcessingResponse { + authHeader := getHeader(headers, "authorization") + host := getHeader(headers, ":authority") + if host == "" { + host = getHeader(headers, "host") + } + + result := s.Auth.HandleOutbound(ctx, authHeader, host) + + switch result.Action { + case auth.ActionReplaceToken: + return replaceTokenResponse(result.Token) + case auth.ActionDeny: + return denyResponse(typev3.StatusCode_ServiceUnavailable, + jsonError("token_acquisition_failed", result.DenyReason)) + default: + return passResponse() + } +} + +func allowResponse() *extprocv3.ProcessingResponse { + return &extprocv3.ProcessingResponse{ + Response: &extprocv3.ProcessingResponse_RequestHeaders{ + RequestHeaders: &extprocv3.HeadersResponse{ + Response: &extprocv3.CommonResponse{ + HeaderMutation: &extprocv3.HeaderMutation{ + RemoveHeaders: []string{"x-authbridge-direction"}, + }, + }, + }, + }, + } +} + +func passResponse() *extprocv3.ProcessingResponse { + return &extprocv3.ProcessingResponse{ + Response: &extprocv3.ProcessingResponse_RequestHeaders{ + RequestHeaders: &extprocv3.HeadersResponse{}, + }, + } +} + +func replaceTokenResponse(token string) *extprocv3.ProcessingResponse { + return &extprocv3.ProcessingResponse{ + Response: &extprocv3.ProcessingResponse_RequestHeaders{ + RequestHeaders: &extprocv3.HeadersResponse{ + Response: &extprocv3.CommonResponse{ + HeaderMutation: &extprocv3.HeaderMutation{ + SetHeaders: []*corev3.HeaderValueOption{ + { + Header: &corev3.HeaderValue{ + Key: "authorization", + RawValue: []byte("Bearer " + token), + }, + }, + }, + }, + }, + }, + }, + } +} + +func denyResponse(code typev3.StatusCode, body string) *extprocv3.ProcessingResponse { + return &extprocv3.ProcessingResponse{ + Response: &extprocv3.ProcessingResponse_ImmediateResponse{ + ImmediateResponse: &extprocv3.ImmediateResponse{ + Status: &typev3.HttpStatus{Code: code}, + Body: []byte(body), + }, + }, + } +} + +func jsonError(errorCode, message string) string { + b, _ := json.Marshal(map[string]string{"error": errorCode, "message": message}) + return string(b) +} + +func getHeader(headers *corev3.HeaderMap, key string) string { + if headers == nil { + return "" + } + for _, h := range headers.Headers { + if strings.EqualFold(h.Key, key) { + return string(h.RawValue) + } + } + return "" +} diff --git a/authbridge/cmd/authbridge/listener/extproc/server_test.go b/authbridge/cmd/authbridge/listener/extproc/server_test.go new file mode 100644 index 000000000..baadb6f65 --- /dev/null +++ b/authbridge/cmd/authbridge/listener/extproc/server_test.go @@ -0,0 +1,336 @@ +package extproc + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" + extprocv3 "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" + "google.golang.org/grpc/metadata" + + "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/validation" +) + +// mockStream implements ExternalProcessor_ProcessServer for testing. +type mockStream struct { + extprocv3.ExternalProcessor_ProcessServer + ctx context.Context + requests []*extprocv3.ProcessingRequest + responses []*extprocv3.ProcessingResponse + recvIdx int +} + +func (m *mockStream) Context() context.Context { return m.ctx } +func (m *mockStream) Send(resp *extprocv3.ProcessingResponse) error { + m.responses = append(m.responses, resp) + return nil +} +func (m *mockStream) Recv() (*extprocv3.ProcessingRequest, error) { + if m.recvIdx >= len(m.requests) { + return nil, fmt.Errorf("EOF") + } + req := m.requests[m.recvIdx] + m.recvIdx++ + return req, nil +} +func (m *mockStream) SetHeader(metadata.MD) error { return nil } +func (m *mockStream) SendHeader(metadata.MD) error { return nil } +func (m *mockStream) SetTrailer(metadata.MD) {} +func (m *mockStream) SendMsg(any) error { return nil } +func (m *mockStream) RecvMsg(any) error { return nil } + +type mockVerifier struct { + claims *validation.Claims + err error +} + +func (v *mockVerifier) Verify(_ context.Context, _ string, _ string) (*validation.Claims, error) { + return v.claims, v.err +} + +func makeHeaders(kvs ...string) *corev3.HeaderMap { + hm := &corev3.HeaderMap{} + for i := 0; i < len(kvs); i += 2 { + hm.Headers = append(hm.Headers, &corev3.HeaderValue{ + Key: kvs[i], + RawValue: []byte(kvs[i+1]), + }) + } + return hm +} + +func inboundRequest(headers *corev3.HeaderMap) *extprocv3.ProcessingRequest { + return &extprocv3.ProcessingRequest{ + Request: &extprocv3.ProcessingRequest_RequestHeaders{ + RequestHeaders: &extprocv3.HttpHeaders{Headers: headers}, + }, + } +} + +func outboundRequest(headers *corev3.HeaderMap) *extprocv3.ProcessingRequest { + return &extprocv3.ProcessingRequest{ + Request: &extprocv3.ProcessingRequest_RequestHeaders{ + RequestHeaders: &extprocv3.HttpHeaders{Headers: headers}, + }, + } +} + +// --- Inbound Tests --- + +func TestExtProc_Inbound_ValidJWT(t *testing.T) { + a := auth.New(auth.Config{ + Verifier: &mockVerifier{claims: &validation.Claims{Subject: "user-1"}}, + Identity: auth.IdentityConfig{Audience: "my-agent"}, + }) + srv := &Server{Auth: a} + + stream := &mockStream{ + ctx: context.Background(), + requests: []*extprocv3.ProcessingRequest{ + inboundRequest(makeHeaders( + "x-authbridge-direction", "inbound", + "authorization", "Bearer valid-token", + ":path", "/api/test", + )), + }, + } + + _ = srv.Process(stream) // returns error on EOF from Recv, expected + + if len(stream.responses) != 1 { + t.Fatalf("expected 1 response, got %d", len(stream.responses)) + } + resp := stream.responses[0] + // Should be allow (HeadersResponse, not ImmediateResponse) + rh := resp.GetRequestHeaders() + if rh == nil { + t.Fatal("expected RequestHeaders response (allow), got ImmediateResponse") + } + // Should remove x-authbridge-direction header + if rh.Response == nil || rh.Response.HeaderMutation == nil { + t.Fatal("expected header mutation to remove direction header") + } + found := false + for _, h := range rh.Response.HeaderMutation.RemoveHeaders { + if h == "x-authbridge-direction" { + found = true + } + } + if !found { + t.Error("expected x-authbridge-direction in RemoveHeaders") + } +} + +func TestExtProc_Inbound_InvalidJWT(t *testing.T) { + a := auth.New(auth.Config{ + Verifier: &mockVerifier{err: fmt.Errorf("token expired")}, + Identity: auth.IdentityConfig{Audience: "my-agent"}, + }) + srv := &Server{Auth: a} + + stream := &mockStream{ + ctx: context.Background(), + requests: []*extprocv3.ProcessingRequest{ + inboundRequest(makeHeaders( + "x-authbridge-direction", "inbound", + "authorization", "Bearer bad-token", + ":path", "/api/test", + )), + }, + } + + _ = srv.Process(stream) + + if len(stream.responses) != 1 { + t.Fatalf("expected 1 response, got %d", len(stream.responses)) + } + ir := stream.responses[0].GetImmediateResponse() + if ir == nil { + t.Fatal("expected ImmediateResponse (deny)") + } + if ir.Status.Code != 401 { + t.Errorf("status = %d, want 401", ir.Status.Code) + } +} + +func TestExtProc_Inbound_BypassPath(t *testing.T) { + matcher, _ := bypass.NewMatcher(bypass.DefaultPatterns) + a := auth.New(auth.Config{ + Verifier: &mockVerifier{err: fmt.Errorf("should not be called")}, + Bypass: matcher, + }) + srv := &Server{Auth: a} + + stream := &mockStream{ + ctx: context.Background(), + requests: []*extprocv3.ProcessingRequest{ + inboundRequest(makeHeaders( + "x-authbridge-direction", "inbound", + ":path", "/healthz", + )), + }, + } + + _ = srv.Process(stream) + + if len(stream.responses) != 1 { + t.Fatalf("expected 1 response, got %d", len(stream.responses)) + } + rh := stream.responses[0].GetRequestHeaders() + if rh == nil { + t.Fatal("expected allow for bypass path") + } +} + +// --- Outbound Tests --- + +func TestExtProc_Outbound_Exchange(t *testing.T) { + exchangeSrv := 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": "exchanged-token", + "token_type": "Bearer", + "expires_in": 300, + }) + })) + defer exchangeSrv.Close() + + router, _ := routing.NewRouter("exchange", []routing.Route{}) + exchanger := exchange.NewClient(exchangeSrv.URL, &exchange.ClientSecretAuth{ + ClientID: "agent", ClientSecret: "secret", + }) + a := auth.New(auth.Config{ + Router: router, + Exchanger: exchanger, + Cache: cache.New(), + }) + srv := &Server{Auth: a} + + stream := &mockStream{ + ctx: context.Background(), + requests: []*extprocv3.ProcessingRequest{ + outboundRequest(makeHeaders( + ":authority", "target-svc", + "authorization", "Bearer user-token", + )), + }, + } + + _ = srv.Process(stream) + + if len(stream.responses) != 1 { + t.Fatalf("expected 1 response, got %d", len(stream.responses)) + } + rh := stream.responses[0].GetRequestHeaders() + if rh == nil || rh.Response == nil || rh.Response.HeaderMutation == nil { + t.Fatal("expected HeadersResponse with token replacement") + } + if len(rh.Response.HeaderMutation.SetHeaders) == 0 { + t.Fatal("expected SetHeaders with new token") + } + got := string(rh.Response.HeaderMutation.SetHeaders[0].Header.RawValue) + if got != "Bearer exchanged-token" { + t.Errorf("token = %q, want Bearer exchanged-token", got) + } +} + +func TestExtProc_Outbound_Passthrough(t *testing.T) { + router, _ := routing.NewRouter("passthrough", []routing.Route{}) + a := auth.New(auth.Config{Router: router}) + srv := &Server{Auth: a} + + stream := &mockStream{ + ctx: context.Background(), + requests: []*extprocv3.ProcessingRequest{ + outboundRequest(makeHeaders( + ":authority", "unknown-svc", + "authorization", "Bearer token", + )), + }, + } + + _ = srv.Process(stream) + + if len(stream.responses) != 1 { + t.Fatalf("expected 1 response, got %d", len(stream.responses)) + } + rh := stream.responses[0].GetRequestHeaders() + if rh == nil { + t.Fatal("expected passthrough (HeadersResponse)") + } + // Passthrough should have no header mutations + if rh.Response != nil && rh.Response.HeaderMutation != nil && len(rh.Response.HeaderMutation.SetHeaders) > 0 { + t.Error("passthrough should not set headers") + } +} + +func TestExtProc_Outbound_Deny(t *testing.T) { + router, _ := routing.NewRouter("exchange", []routing.Route{}) + a := auth.New(auth.Config{ + Router: router, + NoTokenPolicy: auth.NoTokenPolicyDeny, + }) + srv := &Server{Auth: a} + + stream := &mockStream{ + ctx: context.Background(), + requests: []*extprocv3.ProcessingRequest{ + outboundRequest(makeHeaders( + ":authority", "target-svc", + // No authorization header + )), + }, + } + + _ = srv.Process(stream) + + if len(stream.responses) != 1 { + t.Fatalf("expected 1 response, got %d", len(stream.responses)) + } + ir := stream.responses[0].GetImmediateResponse() + if ir == nil { + t.Fatal("expected ImmediateResponse (deny)") + } + if ir.Status.Code != 503 { + t.Errorf("status = %d, want 503", ir.Status.Code) + } +} + +// --- Response Headers --- + +func TestExtProc_ResponseHeaders(t *testing.T) { + a := auth.New(auth.Config{}) + srv := &Server{Auth: a} + + stream := &mockStream{ + ctx: context.Background(), + requests: []*extprocv3.ProcessingRequest{ + { + Request: &extprocv3.ProcessingRequest_ResponseHeaders{ + ResponseHeaders: &extprocv3.HttpHeaders{ + Headers: makeHeaders("content-type", "application/json"), + }, + }, + }, + }, + } + + _ = srv.Process(stream) + + if len(stream.responses) != 1 { + t.Fatalf("expected 1 response, got %d", len(stream.responses)) + } + rh := stream.responses[0].GetResponseHeaders() + if rh == nil { + t.Fatal("expected ResponseHeaders passthrough") + } +} diff --git a/authbridge/cmd/authbridge/listener/forwardproxy/server.go b/authbridge/cmd/authbridge/listener/forwardproxy/server.go new file mode 100644 index 000000000..88aa3c838 --- /dev/null +++ b/authbridge/cmd/authbridge/listener/forwardproxy/server.go @@ -0,0 +1,87 @@ +// Package forwardproxy implements an HTTP forward proxy listener. +// Agents set HTTP_PROXY to route outbound traffic through this proxy +// for transparent token exchange. +package forwardproxy + +import ( + "encoding/json" + "io" + "net/http" + "time" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/auth" +) + +// Server is an HTTP forward proxy that performs token exchange on outbound requests. +type Server struct { + Auth *auth.Auth + Client *http.Client +} + +// NewServer creates a forward proxy server with a default HTTP client. +func NewServer(a *auth.Auth) *Server { + return &Server{ + Auth: a, + Client: &http.Client{ + Timeout: 30 * time.Second, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + }, + } +} + +// Handler returns the HTTP handler for the forward proxy. +func (s *Server) Handler() http.Handler { + return http.HandlerFunc(s.handleRequest) +} + +func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request) { + // Reject CONNECT (HTTPS tunneling) — only handle plain HTTP + if r.Method == http.MethodConnect { + http.Error(w, `{"error":"HTTPS CONNECT not supported — only HTTP proxy"}`, http.StatusMethodNotAllowed) + return + } + + result := s.Auth.HandleOutbound(r.Context(), r.Header.Get("Authorization"), r.Host) + + switch result.Action { + case auth.ActionDeny: + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(result.DenyStatus) + body, _ := json.Marshal(map[string]string{"error": result.DenyReason}) + w.Write(body) + return + case auth.ActionReplaceToken: + r.Header.Set("Authorization", "Bearer "+result.Token) + } + + // Remove hop-by-hop headers + r.Header.Del("Connection") + r.Header.Del("Keep-Alive") + r.Header.Del("Proxy-Authenticate") + r.Header.Del("Proxy-Authorization") + r.Header.Del("Proxy-Connection") + r.Header.Del("TE") + r.Header.Del("Trailer") + r.Header.Del("Transfer-Encoding") + r.Header.Del("Upgrade") + + // Clear RequestURI — set by the server but must be empty for client requests + r.RequestURI = "" + + resp, err := s.Client.Do(r) + if err != nil { + http.Error(w, `{"error":"bad gateway"}`, http.StatusBadGateway) + return + } + defer resp.Body.Close() + + for key, values := range resp.Header { + for _, value := range values { + w.Header().Add(key, value) + } + } + w.WriteHeader(resp.StatusCode) + io.Copy(w, resp.Body) +} diff --git a/authbridge/cmd/authbridge/listener/forwardproxy/server_test.go b/authbridge/cmd/authbridge/listener/forwardproxy/server_test.go new file mode 100644 index 000000000..704c73d88 --- /dev/null +++ b/authbridge/cmd/authbridge/listener/forwardproxy/server_test.go @@ -0,0 +1,128 @@ +package forwardproxy + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/auth" + "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" +) + +type mockVerifier struct { + claims *validation.Claims + err error +} + +func (m *mockVerifier) Verify(_ context.Context, _ string, _ string) (*validation.Claims, error) { + return m.claims, m.err +} + +func TestForwardProxy_Exchange(t *testing.T) { + // Token exchange server + exchangeSrv := 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": "exchanged-token", + "token_type": "Bearer", + "expires_in": 300, + }) + })) + defer exchangeSrv.Close() + + // Backend server that the proxy forwards to + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got := r.Header.Get("Authorization") + if got != "Bearer exchanged-token" { + t.Errorf("backend got Authorization = %q, want Bearer exchanged-token", got) + } + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) + })) + defer backend.Close() + + router, _ := routing.NewRouter("exchange", []routing.Route{}) + exchanger := exchange.NewClient(exchangeSrv.URL, &exchange.ClientSecretAuth{ + ClientID: "agent", ClientSecret: "secret", + }) + a := auth.New(auth.Config{ + Router: router, + Exchanger: exchanger, + Cache: cache.New(), + }) + + srv := &Server{Auth: a, Client: http.DefaultClient} + proxy := httptest.NewServer(srv.Handler()) + defer proxy.Close() + + // Forward proxy: request URL is the full backend URL (as a proxy would receive) + req, _ := http.NewRequest("GET", backend.URL+"/test", nil) + req.Header.Set("Authorization", "Bearer user-token") + + // Route through the proxy by sending the request to proxy address + // but with the backend URL as the target (simulates HTTP_PROXY behavior) + proxyClient := &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyURL(mustParseURL(proxy.URL)), + }, + } + resp, err := proxyClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + if resp.StatusCode != http.StatusOK { + t.Errorf("status = %d, want 200", resp.StatusCode) + } +} + +func TestForwardProxy_CONNECT_Rejected(t *testing.T) { + a := auth.New(auth.Config{}) + srv := NewServer(a) + proxy := httptest.NewServer(srv.Handler()) + defer proxy.Close() + + req, _ := http.NewRequest("CONNECT", proxy.URL, nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Errorf("status = %d, want 405", resp.StatusCode) + } +} + +func TestForwardProxy_Deny(t *testing.T) { + router, _ := routing.NewRouter("exchange", []routing.Route{}) + a := auth.New(auth.Config{ + Router: router, + NoTokenPolicy: auth.NoTokenPolicyDeny, + }) + + srv := NewServer(a) + proxy := httptest.NewServer(srv.Handler()) + defer proxy.Close() + + req, _ := http.NewRequest("GET", proxy.URL+"/test", nil) + // No Authorization header — should be denied + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", resp.StatusCode) + } +} + +func mustParseURL(rawURL string) *url.URL { + u, err := url.Parse(rawURL) + if err != nil { + panic(err) + } + return u +} diff --git a/authbridge/cmd/authbridge/listener/reverseproxy/server.go b/authbridge/cmd/authbridge/listener/reverseproxy/server.go new file mode 100644 index 000000000..dbe782b71 --- /dev/null +++ b/authbridge/cmd/authbridge/listener/reverseproxy/server.go @@ -0,0 +1,52 @@ +// Package reverseproxy implements an HTTP reverse proxy listener. +// Inbound requests are validated via auth.HandleInbound before being +// forwarded to a fixed backend. +package reverseproxy + +import ( + "encoding/json" + "net/http" + "net/http/httputil" + "net/url" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/auth" +) + +// Server is an HTTP reverse proxy with inbound JWT validation. +type Server struct { + Auth *auth.Auth + proxy *httputil.ReverseProxy + backend string +} + +// NewServer creates a reverse proxy that forwards to the given backend URL. +func NewServer(a *auth.Auth, backendURL string) (*Server, error) { + target, err := url.Parse(backendURL) + if err != nil { + return nil, err + } + return &Server{ + Auth: a, + proxy: httputil.NewSingleHostReverseProxy(target), + backend: backendURL, + }, nil +} + +// Handler returns the HTTP handler for the reverse proxy. +func (s *Server) Handler() http.Handler { + return http.HandlerFunc(s.handleRequest) +} + +func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request) { + result := s.Auth.HandleInbound(r.Context(), r.Header.Get("Authorization"), r.URL.Path, "") + + if result.Action == auth.ActionDeny { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(result.DenyStatus) + body, _ := json.Marshal(map[string]string{"error": result.DenyReason}) + w.Write(body) + return + } + + s.proxy.ServeHTTP(w, r) +} diff --git a/authbridge/cmd/authbridge/listener/reverseproxy/server_test.go b/authbridge/cmd/authbridge/listener/reverseproxy/server_test.go new file mode 100644 index 000000000..b7f71a43e --- /dev/null +++ b/authbridge/cmd/authbridge/listener/reverseproxy/server_test.go @@ -0,0 +1,95 @@ +package reverseproxy + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/auth" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/bypass" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/validation" +) + +type mockVerifier struct { + claims *validation.Claims + err error +} + +func (m *mockVerifier) Verify(_ context.Context, _ string, _ string) (*validation.Claims, error) { + return m.claims, m.err +} + +func TestReverseProxy_AllowedRequest(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("backend-ok")) + })) + defer backend.Close() + + a := auth.New(auth.Config{ + Verifier: &mockVerifier{claims: &validation.Claims{Subject: "user"}}, + Identity: auth.IdentityConfig{Audience: "my-app"}, + }) + srv, err := NewServer(a, backend.URL) + if err != nil { + t.Fatal(err) + } + + proxy := httptest.NewServer(srv.Handler()) + defer proxy.Close() + + req, _ := http.NewRequest("GET", proxy.URL+"/api/data", nil) + req.Header.Set("Authorization", "Bearer valid-token") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusOK { + t.Errorf("status = %d, want 200", resp.StatusCode) + } +} + +func TestReverseProxy_DeniedRequest(t *testing.T) { + a := auth.New(auth.Config{ + Verifier: &mockVerifier{err: fmt.Errorf("invalid token")}, + Identity: auth.IdentityConfig{Audience: "my-app"}, + }) + srv, _ := NewServer(a, "http://localhost:9999") + + proxy := httptest.NewServer(srv.Handler()) + defer proxy.Close() + + req, _ := http.NewRequest("GET", proxy.URL+"/api/data", nil) + req.Header.Set("Authorization", "Bearer bad-token") + resp, _ := http.DefaultClient.Do(req) + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", resp.StatusCode) + } +} + +func TestReverseProxy_BypassPath(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("agent-card")) + })) + defer backend.Close() + + matcher, _ := bypass.NewMatcher(bypass.DefaultPatterns) + a := auth.New(auth.Config{ + Verifier: &mockVerifier{err: fmt.Errorf("should not be called")}, + Bypass: matcher, + }) + srv, _ := NewServer(a, backend.URL) + + proxy := httptest.NewServer(srv.Handler()) + defer proxy.Close() + + // No auth header, but bypass path should be allowed + req, _ := http.NewRequest("GET", proxy.URL+"/.well-known/agent.json", nil) + resp, _ := http.DefaultClient.Do(req) + if resp.StatusCode != http.StatusOK { + t.Errorf("status = %d, want 200 for bypass path", resp.StatusCode) + } +} diff --git a/authbridge/cmd/authbridge/main.go b/authbridge/cmd/authbridge/main.go new file mode 100644 index 000000000..123212d85 --- /dev/null +++ b/authbridge/cmd/authbridge/main.go @@ -0,0 +1,153 @@ +// Command authbridge is a unified auth proxy supporting three deployment modes: +// envoy-sidecar (ext_proc), waypoint (ext_authz + forward proxy), and +// proxy-sidecar (reverse proxy + forward proxy). +package main + +import ( + "context" + "flag" + "log" + "log/slog" + "net" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + authv3 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v3" + extprocv3 "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" + "google.golang.org/grpc" + "google.golang.org/grpc/health" + healthpb "google.golang.org/grpc/health/grpc_health_v1" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/auth" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/config" + "github.com/kagenti/kagenti-extensions/authbridge/cmd/authbridge/listener/extauthz" + "github.com/kagenti/kagenti-extensions/authbridge/cmd/authbridge/listener/extproc" + "github.com/kagenti/kagenti-extensions/authbridge/cmd/authbridge/listener/forwardproxy" + "github.com/kagenti/kagenti-extensions/authbridge/cmd/authbridge/listener/reverseproxy" +) + +func main() { + mode := flag.String("mode", "", "deployment mode: envoy-sidecar, waypoint, proxy-sidecar") + configPath := flag.String("config", "", "path to config YAML file") + flag.Parse() + + if *configPath == "" { + log.Fatal("--config is required") + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Load config + cfg, err := config.Load(*configPath) + if err != nil { + log.Fatalf("loading config: %v", err) + } + if *mode != "" { + cfg.Mode = *mode // flag overrides YAML + } + + // Resolve config into auth dependencies + resolved, err := config.Resolve(ctx, cfg) + if err != nil { + log.Fatalf("resolving config: %v", err) + } + handler := auth.New(*resolved) + + slog.Info("authbridge starting", "mode", cfg.Mode) + + // Track servers for graceful shutdown + var grpcServers []*grpc.Server + var httpServers []*http.Server + + // Start listeners based on mode + switch cfg.Mode { + case config.ModeEnvoySidecar: + grpcServers = append(grpcServers, startGRPCExtProc(handler, cfg.Listener.ExtProcAddr)) + + case config.ModeWaypoint: + grpcServers = append(grpcServers, startGRPCExtAuthz(handler, cfg.Listener.ExtAuthzAddr)) + httpServers = append(httpServers, startHTTPServer("forward-proxy", forwardproxy.NewServer(handler).Handler(), cfg.Listener.ForwardProxyAddr)) + + case config.ModeProxySidecar: + rpSrv, err := reverseproxy.NewServer(handler, cfg.Listener.ReverseProxyBackend) + if err != nil { + log.Fatalf("creating reverse proxy: %v", err) + } + httpServers = append(httpServers, startHTTPServer("reverse-proxy", rpSrv.Handler(), cfg.Listener.ReverseProxyAddr)) + httpServers = append(httpServers, startHTTPServer("forward-proxy", forwardproxy.NewServer(handler).Handler(), cfg.Listener.ForwardProxyAddr)) + } + + // Wait for shutdown signal + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT) + sig := <-sigCh + slog.Info("shutting down", "signal", sig) + + // Graceful shutdown + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second) + defer shutdownCancel() + + for _, srv := range grpcServers { + srv.GracefulStop() + } + for _, srv := range httpServers { + srv.Shutdown(shutdownCtx) + } +} + +func startGRPCExtProc(handler *auth.Auth, addr string) *grpc.Server { + srv := grpc.NewServer() + extprocv3.RegisterExternalProcessorServer(srv, &extproc.Server{Auth: handler}) + registerHealth(srv) + + go func() { + lis, err := net.Listen("tcp", addr) + if err != nil { + log.Fatalf("ext_proc listen %s: %v", addr, err) + } + slog.Info("ext_proc gRPC listening", "addr", addr) + if err := srv.Serve(lis); err != nil { + log.Fatalf("ext_proc serve: %v", err) + } + }() + return srv +} + +func startGRPCExtAuthz(handler *auth.Auth, addr string) *grpc.Server { + srv := grpc.NewServer() + authv3.RegisterAuthorizationServer(srv, &extauthz.Server{Auth: handler}) + registerHealth(srv) + + go func() { + lis, err := net.Listen("tcp", addr) + if err != nil { + log.Fatalf("ext_authz listen %s: %v", addr, err) + } + slog.Info("ext_authz gRPC listening", "addr", addr) + if err := srv.Serve(lis); err != nil { + log.Fatalf("ext_authz serve: %v", err) + } + }() + return srv +} + +func startHTTPServer(name string, handler http.Handler, addr string) *http.Server { + srv := &http.Server{Addr: addr, Handler: handler} + go func() { + slog.Info("HTTP server listening", "name", name, "addr", addr) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("%s serve: %v", name, err) + } + }() + return srv +} + +func registerHealth(srv *grpc.Server) { + healthSrv := health.NewServer() + healthpb.RegisterHealthServer(srv, healthSrv) + healthSrv.SetServingStatus("", healthpb.HealthCheckResponse_SERVING) +} diff --git a/authbridge/go.work b/authbridge/go.work new file mode 100644 index 000000000..1b1b0458a --- /dev/null +++ b/authbridge/go.work @@ -0,0 +1,6 @@ +go 1.24.0 + +use ( + ./authlib + ./cmd/authbridge +) From 9c876c2ff9679f7c2887974ddfd2b231539fbbb3 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Tue, 14 Apr 2026 19:18:08 -0400 Subject: [PATCH 12/15] =?UTF-8?q?=F0=9F=90=9B=20Add=20authproxy=20to=20go.?= =?UTF-8?q?work=20and=20fix=20import=20ordering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The go.work file was missing the authproxy module, causing go fmt/vet to fail in CI with "directory prefix . does not contain modules listed in go.work". Also fixes import ordering in go-processor/main_test.go (go fmt). Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/authproxy/go-processor/main_test.go | 2 +- authbridge/go.work | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/authbridge/authproxy/go-processor/main_test.go b/authbridge/authproxy/go-processor/main_test.go index 2de647854..ae90faf6d 100644 --- a/authbridge/authproxy/go-processor/main_test.go +++ b/authbridge/authproxy/go-processor/main_test.go @@ -20,10 +20,10 @@ import ( core "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" v3 "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" typev3 "github.com/envoyproxy/go-control-plane/envoy/type/v3" + "github.com/kagenti/kagenti-extensions/authbridge/authproxy/go-processor/internal/resolver" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jwk" "github.com/lestrrat-go/jwx/v2/jwt" - "github.com/kagenti/kagenti-extensions/authbridge/authproxy/go-processor/internal/resolver" ) func TestMatchBypassPath(t *testing.T) { diff --git a/authbridge/go.work b/authbridge/go.work index 1b1b0458a..5cbd75afe 100644 --- a/authbridge/go.work +++ b/authbridge/go.work @@ -2,5 +2,6 @@ go 1.24.0 use ( ./authlib + ./authproxy ./cmd/authbridge ) From 957d5bc966fec4aa1dbbd459d78c50d56e574a94 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Tue, 14 Apr 2026 20:35:08 -0400 Subject: [PATCH 13/15] Combine Envoy + authbridge in one container image The Dockerfile now produces a single image containing both Envoy and the authbridge binary, a true drop-in replacement for envoy-with-processor. The entrypoint uses wait -n process supervision (same pattern as PR #308). Key changes: - Multi-stage build: Go builder, Envoy binary, ubi9-minimal runtime - entrypoint.sh starts authbridge then Envoy, monitors both with wait -n - Runs as UID 1337 (required by proxy-init iptables rules) - Same base image (ubi9-minimal) and ports as Dockerfile.envoy Verified E2E on Kind cluster: inbound JWT validation, outbound RFC 8693 token exchange, bypass paths, and process supervision all working. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/cmd/authbridge/Dockerfile | 39 ++++++++++++++++++------ authbridge/cmd/authbridge/entrypoint.sh | 40 +++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 9 deletions(-) create mode 100644 authbridge/cmd/authbridge/entrypoint.sh diff --git a/authbridge/cmd/authbridge/Dockerfile b/authbridge/cmd/authbridge/Dockerfile index b11b11da1..8adfd1445 100644 --- a/authbridge/cmd/authbridge/Dockerfile +++ b/authbridge/cmd/authbridge/Dockerfile @@ -1,25 +1,46 @@ -# AuthBridge unified binary — single binary, three modes. +# AuthBridge unified image — Envoy + authbridge binary in one container. +# Drop-in replacement for envoy-with-processor (Dockerfile.envoy). +# # Build context: ./authbridge (needs access to both authlib/ and cmd/authbridge/) +# Stage 1: Build authbridge binary FROM golang:1.24-alpine AS builder RUN apk add --no-cache git WORKDIR /app -# Copy go.work and both modules -COPY go.work . +# Copy both modules with Docker-compatible layout COPY authlib/ authlib/ COPY cmd/authbridge/ cmd/authbridge/ -# Build +# The go.mod has: replace authlib => ../../authlib +# In Docker the layout is /app/authlib and /app/cmd/authbridge, so the +# relative path ../../authlib resolves to /app/authlib. Correct. +ENV GOWORK=off RUN cd cmd/authbridge && CGO_ENABLED=0 GOOS=linux go build -o /authbridge . -# Runtime -FROM gcr.io/distroless/static-debian12 +# Stage 2: Get Envoy binary +FROM docker.io/envoyproxy/envoy:v1.37.1 AS envoy-source -COPY --from=builder /authbridge /authbridge +# Stage 3: Runtime — same base as Dockerfile.envoy +FROM registry.access.redhat.com/ubi9/ubi-minimal@sha256:83006d535923fcf1345067873524a3980316f51794f01d8655be55d6e9387183 -USER 65534 +RUN microdnf install -y ca-certificates && microdnf clean all -ENTRYPOINT ["/authbridge"] +COPY --from=envoy-source /usr/local/bin/envoy /usr/local/bin/envoy +COPY --from=builder /authbridge /usr/local/bin/authbridge +COPY cmd/authbridge/entrypoint.sh /usr/local/bin/entrypoint.sh + +RUN chmod 755 /usr/local/bin/entrypoint.sh \ + /usr/local/bin/envoy \ + /usr/local/bin/authbridge + +# Envoy writes hot-restart lock files and admin socket here +RUN mkdir -p /tmp/envoy && chmod 775 /tmp/envoy + +USER 1337 + +EXPOSE 15123 15124 9901 9090 + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/authbridge/cmd/authbridge/entrypoint.sh b/authbridge/cmd/authbridge/entrypoint.sh new file mode 100644 index 000000000..77468fac8 --- /dev/null +++ b/authbridge/cmd/authbridge/entrypoint.sh @@ -0,0 +1,40 @@ +#!/bin/bash +set -eu + +# Envoy + authbridge entrypoint with process supervision. +# Both processes run in the background; the shell stays as PID 1. +# If either process exits, the other is killed and the container exits +# non-zero so Kubernetes restarts it. +# +# authbridge args are passed through from the container command/args. +# Envoy config is expected at /etc/envoy/envoy.yaml. + +cleanup() { + echo "[entrypoint] Received signal, shutting down..." + kill "$AUTHBRIDGE_PID" "$ENVOY_PID" 2>/dev/null || true + wait + exit 0 +} +trap cleanup TERM INT + +# Start authbridge (ext_proc gRPC server) in the background +echo "[entrypoint] Starting authbridge..." +/usr/local/bin/authbridge "$@" & +AUTHBRIDGE_PID=$! + +# Give authbridge a moment to start the gRPC listener +sleep 2 + +# Start Envoy in the background +echo "[entrypoint] Starting Envoy..." +/usr/local/bin/envoy -c /etc/envoy/envoy.yaml \ + --service-cluster auth-proxy --service-node auth-proxy & +ENVOY_PID=$! + +# Wait for the first child to exit. If either dies, restart the container. +wait -n "$AUTHBRIDGE_PID" "$ENVOY_PID" +EXIT_CODE=$? +echo "[entrypoint] A process exited unexpectedly (exit code $EXIT_CODE), terminating container" +kill "$AUTHBRIDGE_PID" "$ENVOY_PID" 2>/dev/null || true +wait +exit 1 From 00ff761f2989c280d0df4943ab75bac5a1f7630a Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Wed, 15 Apr 2026 08:01:26 -0400 Subject: [PATCH 14/15] Fix review findings: shared hostutil, default case, graceful stop, Envoy SHA pin Address review from @pdettori on PR #312: 1. Use shared routing.ServiceNameFromHost instead of local duplicate in extauthz listener (avoids drift between two copies) 2. Add default case to mode switch in main.go (fail-fast on unhandled mode instead of silent zero-listener hang) 3. GracefulStop with timeout fallback: spawn goroutine that calls Stop() when shutdownCtx expires, preventing indefinite block on long-lived ext_proc streams 4. Pin Envoy image by SHA digest for supply-chain consistency 5. Log io.Copy error in forward proxy for debuggability Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/cmd/authbridge/Dockerfile | 2 +- .../authbridge/listener/extauthz/server.go | 21 ++----------------- .../listener/extauthz/server_test.go | 18 +--------------- .../listener/forwardproxy/server.go | 5 ++++- authbridge/cmd/authbridge/main.go | 9 ++++++++ 5 files changed, 17 insertions(+), 38 deletions(-) diff --git a/authbridge/cmd/authbridge/Dockerfile b/authbridge/cmd/authbridge/Dockerfile index 8adfd1445..ce62fe4f6 100644 --- a/authbridge/cmd/authbridge/Dockerfile +++ b/authbridge/cmd/authbridge/Dockerfile @@ -21,7 +21,7 @@ ENV GOWORK=off RUN cd cmd/authbridge && CGO_ENABLED=0 GOOS=linux go build -o /authbridge . # Stage 2: Get Envoy binary -FROM docker.io/envoyproxy/envoy:v1.37.1 AS envoy-source +FROM docker.io/envoyproxy/envoy:v1.37.1@sha256:70e7c82d8c82fecdebe3408f7227e9abc3c34b4fdc7f9c1c2842d87bcda1e5ff AS envoy-source # Stage 3: Runtime — same base as Dockerfile.envoy FROM registry.access.redhat.com/ubi9/ubi-minimal@sha256:83006d535923fcf1345067873524a3980316f51794f01d8655be55d6e9387183 diff --git a/authbridge/cmd/authbridge/listener/extauthz/server.go b/authbridge/cmd/authbridge/listener/extauthz/server.go index 2fa328c63..fa77503ea 100644 --- a/authbridge/cmd/authbridge/listener/extauthz/server.go +++ b/authbridge/cmd/authbridge/listener/extauthz/server.go @@ -15,6 +15,7 @@ import ( rpcstatus "google.golang.org/genproto/googleapis/rpc/status" authpkg "github.com/kagenti/kagenti-extensions/authbridge/authlib/auth" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/routing" ) // Server implements the Envoy ext_authz Authorization gRPC service. @@ -39,7 +40,7 @@ func (s *Server) Check(ctx context.Context, req *authv3.CheckRequest) (*authv3.C path := httpReq.GetPath() // Derive audience from destination host (waypoint pattern) - audience := ServiceNameFromHost(host) + audience := routing.ServiceNameFromHost(host) // Inbound validation inResult := s.Auth.HandleInbound(ctx, authHeader, path, audience) @@ -59,24 +60,6 @@ func (s *Server) Check(ctx context.Context, req *authv3.CheckRequest) (*authv3.C } } -// ServiceNameFromHost extracts the service name from a Kubernetes host. -// "auth-target-service.authbridge.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 denied(code codes.Code, httpStatus int, msg string) *authv3.CheckResponse { body, _ := json.Marshal(map[string]string{"error": msg}) diff --git a/authbridge/cmd/authbridge/listener/extauthz/server_test.go b/authbridge/cmd/authbridge/listener/extauthz/server_test.go index 3ec9a5dea..5eb809dbc 100644 --- a/authbridge/cmd/authbridge/listener/extauthz/server_test.go +++ b/authbridge/cmd/authbridge/listener/extauthz/server_test.go @@ -49,23 +49,7 @@ func checkRequest(host, path, authHeader string) *authv3.CheckRequest { } } -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) - } - } -} +// ServiceNameFromHost is tested in routing/hostutil_test.go (shared implementation) func TestCheck_ValidToken_Exchange(t *testing.T) { exchangeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { diff --git a/authbridge/cmd/authbridge/listener/forwardproxy/server.go b/authbridge/cmd/authbridge/listener/forwardproxy/server.go index 88aa3c838..5d59f83a7 100644 --- a/authbridge/cmd/authbridge/listener/forwardproxy/server.go +++ b/authbridge/cmd/authbridge/listener/forwardproxy/server.go @@ -6,6 +6,7 @@ package forwardproxy import ( "encoding/json" "io" + "log/slog" "net/http" "time" @@ -83,5 +84,7 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request) { } } w.WriteHeader(resp.StatusCode) - io.Copy(w, resp.Body) + if _, err := io.Copy(w, resp.Body); err != nil { + slog.Debug("response copy error", "host", r.Host, "error", err) + } } diff --git a/authbridge/cmd/authbridge/main.go b/authbridge/cmd/authbridge/main.go index 123212d85..9837c1910 100644 --- a/authbridge/cmd/authbridge/main.go +++ b/authbridge/cmd/authbridge/main.go @@ -79,6 +79,9 @@ func main() { } httpServers = append(httpServers, startHTTPServer("reverse-proxy", rpSrv.Handler(), cfg.Listener.ReverseProxyAddr)) httpServers = append(httpServers, startHTTPServer("forward-proxy", forwardproxy.NewServer(handler).Handler(), cfg.Listener.ForwardProxyAddr)) + + default: + log.Fatalf("unhandled mode %q", cfg.Mode) } // Wait for shutdown signal @@ -92,6 +95,12 @@ func main() { defer shutdownCancel() for _, srv := range grpcServers { + // GracefulStop blocks until all RPCs complete. If streams are long-lived + // (e.g., ext_proc), fall back to hard Stop after the shutdown timeout. + go func(s *grpc.Server) { + <-shutdownCtx.Done() + s.Stop() + }(srv) srv.GracefulStop() } for _, srv := range httpServers { From 2bd14c826cd7197df837470dc0bd832a4cdd8192 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Wed, 15 Apr 2026 08:08:26 -0400 Subject: [PATCH 15/15] Add ReadHeaderTimeout and pipefail Address review from @pdettori on PR #313: - Add ReadHeaderTimeout: 10s to HTTP servers (slowloris defense) - Add pipefail to entrypoint.sh set flags Findings 1 (JWT error leakage) fixed in PR #311. Findings 2 (GracefulStop) and 4 (Envoy SHA pin) already fixed in PR #312 review round. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/cmd/authbridge/entrypoint.sh | 2 +- authbridge/cmd/authbridge/main.go | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/authbridge/cmd/authbridge/entrypoint.sh b/authbridge/cmd/authbridge/entrypoint.sh index 77468fac8..e74fb6401 100644 --- a/authbridge/cmd/authbridge/entrypoint.sh +++ b/authbridge/cmd/authbridge/entrypoint.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -eu +set -euo pipefail # Envoy + authbridge entrypoint with process supervision. # Both processes run in the background; the shell stays as PID 1. diff --git a/authbridge/cmd/authbridge/main.go b/authbridge/cmd/authbridge/main.go index 9837c1910..1e1edc6ca 100644 --- a/authbridge/cmd/authbridge/main.go +++ b/authbridge/cmd/authbridge/main.go @@ -145,7 +145,11 @@ func startGRPCExtAuthz(handler *auth.Auth, addr string) *grpc.Server { } func startHTTPServer(name string, handler http.Handler, addr string) *http.Server { - srv := &http.Server{Addr: addr, Handler: handler} + srv := &http.Server{ + Addr: addr, + Handler: handler, + ReadHeaderTimeout: 10 * time.Second, + } go func() { slog.Info("HTTP server listening", "name", name, "addr", addr) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {