diff --git a/authbridge/authlib/auth/actor_cache.go b/authbridge/authlib/auth/actor_cache.go new file mode 100644 index 000000000..4aa5da9d4 --- /dev/null +++ b/authbridge/authlib/auth/actor_cache.go @@ -0,0 +1,67 @@ +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 + // 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 +} + +// SetTTL updates the cache expiry based on the token's expires_in value. +// 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() + if expiresIn < 60*time.Second { + expiresIn = 60 * time.Second + } + c.expiresAt = time.Now().Add(expiresIn) +} diff --git a/authbridge/authlib/auth/auth.go b/authbridge/authlib/auth/auth.go new file mode 100644 index 000000000..1513707fc --- /dev/null +++ b/authbridge/authlib/auth/auth.go @@ -0,0 +1,261 @@ +package auth + +import ( + "context" + "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) + +// 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 + 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 + AudienceDeriver AudienceDeriver // optional, derives audience from host (waypoint mode) + 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 + audienceDeriver AudienceDeriver + 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, + audienceDeriver: cfg.AudienceDeriver, + 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 { + // 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: "token validation failed", + } + } + + // 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 + + // 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) + + 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, audience, 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..d93326ee1 --- /dev/null +++ b/authbridge/authlib/auth/auth_test.go @@ -0,0 +1,336 @@ +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 { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + 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 { + 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) + } + 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/bypass/matcher.go b/authbridge/authlib/bypass/matcher.go new file mode 100644 index 000000000..7957d0316 --- /dev/null +++ b/authbridge/authlib/bypass/matcher.go @@ -0,0 +1,47 @@ +// 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/*"). +// 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 +} + +// 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..07ac8f52b --- /dev/null +++ b/authbridge/authlib/cache/cache.go @@ -0,0 +1,103 @@ +// 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. 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) { + 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 { + // 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) + } + } + 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/config/config.go b/authbridge/authlib/config/config.go new file mode 100644 index 000000000..a2325a05d --- /dev/null +++ b/authbridge/authlib/config/config.go @@ -0,0 +1,106 @@ +// 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"` + 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"` + 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. +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..c7fd04a44 --- /dev/null +++ b/authbridge/authlib/config/config_test.go @@ -0,0 +1,338 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +// --- 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) + } +} + +// --- 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 { + 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..f1ac209e4 --- /dev/null +++ b/authbridge/authlib/config/resolve.go @@ -0,0 +1,224 @@ +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" + "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) + + // 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) + } + 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) + } + + result := &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), + } + + // Waypoint mode: derive audience from destination hostname when no route matches + if cfg.Mode == ModeWaypoint { + result.AudienceDeriver = routing.ServiceNameFromHost + } + + return result, 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 +// Uses suffix-based replacement to avoid modifying hostnames containing "token". +func deriveJWKSURL(cfg *Config) { + if cfg.Inbound.JWKSURL != "" || cfg.Outbound.TokenURL == "" { + return + } + 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) + } +} + +// 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": + 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..3d25566e3 --- /dev/null +++ b/authbridge/authlib/config/validate.go @@ -0,0 +1,130 @@ +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 == "" { + // 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 + 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 == "" && cfg.Identity.ClientIDFile == "" { + return fmt.Errorf("identity.type=spiffe requires client_id or client_id_file") + } + case "client-secret": + 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 == "" && 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 + 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/auth.go b/authbridge/authlib/exchange/auth.go new file mode 100644 index 000000000..7eecf6937 --- /dev/null +++ b/authbridge/authlib/exchange/auth.go @@ -0,0 +1,43 @@ +package exchange + +import ( + "context" + "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) 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) 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) 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..a662995cb --- /dev/null +++ b/authbridge/authlib/exchange/client.go @@ -0,0 +1,167 @@ +// 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" + "strings" + "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 + TokenEndpoint string // optional per-request override of the client's tokenURL +} + +// 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) { + 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"}, + "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); err != nil { + return nil, fmt.Errorf("applying client auth: %w", err) + } + + tokenURL := c.tokenURL + if req.TokenEndpoint != "" { + tokenURL = req.TokenEndpoint + } + return c.doTokenRequest(ctx, tokenURL, form) +} + +// ClientCredentials performs a client credentials grant. +// 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) + } + + if err := c.auth.Apply(ctx, form); err != nil { + return nil, fmt.Errorf("applying client auth: %w", err) + } + + return c.doTokenRequest(ctx, c.tokenURL, form) +} + +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) + } + 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() + + // 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) + } + + 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..b9a0ba6b5 --- /dev/null +++ b/authbridge/authlib/exchange/client_test.go @@ -0,0 +1,244 @@ +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 { + 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) + } + 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 { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + 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", + "token_type": "Bearer", + "expires_in": 3600, + }) + })) + defer srv.Close() + + client := NewClient(srv.URL, &ClientSecretAuth{ClientID: "c", ClientSecret: "s"}) + resp, err := client.ClientCredentials(context.Background(), "target-aud", "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 { + 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") + } + 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 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 { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + 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..d59b9aa8e --- /dev/null +++ b/authbridge/authlib/go.mod @@ -0,0 +1,24 @@ +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 + 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.48.0 // indirect + golang.org/x/sys v0.41.0 // indirect +) diff --git a/authbridge/authlib/go.sum b/authbridge/authlib/go.sum new file mode 100644 index 000000000..5f280e3f9 --- /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.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= +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..5248fc67b --- /dev/null +++ b/authbridge/authlib/routing/config.go @@ -0,0 +1,26 @@ +package routing + +import ( + "errors" + "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) { + 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 + 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/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) + } + } +} diff --git a/authbridge/authlib/routing/router.go b/authbridge/authlib/routing/router.go new file mode 100644 index 000000000..980e6d17c --- /dev/null +++ b/authbridge/authlib/routing/router.go @@ -0,0 +1,92 @@ +// 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 { + Matched bool // true if a configured route matched; false for default action fallback + 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{ + Matched: true, + Audience: entry.route.Audience, + Scopes: entry.route.Scopes, + TokenEndpoint: entry.route.TokenEndpoint, + Passthrough: action == "passthrough", + } + } + } + if r.defaultAction == "exchange" { + return &ResolvedRoute{Matched: false} + } + return nil +} diff --git a/authbridge/authlib/routing/router_test.go b/authbridge/authlib/routing/router_test.go new file mode 100644 index 000000000..ca5747b4f --- /dev/null +++ b/authbridge/authlib/routing/router_test.go @@ -0,0 +1,141 @@ +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.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") + } + 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.Matched { + t.Error("expected Matched=false for default action fallback") + } + 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..8ea66f93f --- /dev/null +++ b/authbridge/authlib/validation/jwks.go @@ -0,0 +1,111 @@ +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) + } + + 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), + 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..e62f30de8 --- /dev/null +++ b/authbridge/authlib/validation/jwks_test.go @@ -0,0 +1,233 @@ +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) { + 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") + } +} + +// --- 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") + } +} 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 +} 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/cmd/authbridge/Dockerfile b/authbridge/cmd/authbridge/Dockerfile new file mode 100644 index 000000000..ce62fe4f6 --- /dev/null +++ b/authbridge/cmd/authbridge/Dockerfile @@ -0,0 +1,46 @@ +# 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 both modules with Docker-compatible layout +COPY authlib/ authlib/ +COPY cmd/authbridge/ cmd/authbridge/ + +# 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 . + +# Stage 2: Get Envoy binary +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 + +RUN microdnf install -y ca-certificates && microdnf clean all + +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..e74fb6401 --- /dev/null +++ b/authbridge/cmd/authbridge/entrypoint.sh @@ -0,0 +1,40 @@ +#!/bin/bash +set -euo pipefail + +# 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 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..fa77503ea --- /dev/null +++ b/authbridge/cmd/authbridge/listener/extauthz/server.go @@ -0,0 +1,113 @@ +// 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" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/routing" +) + +// 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 := routing.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 + } +} + + +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..5eb809dbc --- /dev/null +++ b/authbridge/cmd/authbridge/listener/extauthz/server_test.go @@ -0,0 +1,134 @@ +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, + }, + }, + }, + } +} + +// 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) { + 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..5d59f83a7 --- /dev/null +++ b/authbridge/cmd/authbridge/listener/forwardproxy/server.go @@ -0,0 +1,90 @@ +// 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" + "log/slog" + "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) + 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/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..1e1edc6ca --- /dev/null +++ b/authbridge/cmd/authbridge/main.go @@ -0,0 +1,166 @@ +// 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)) + + default: + log.Fatalf("unhandled mode %q", cfg.Mode) + } + + // 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 { + // 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 { + 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, + ReadHeaderTimeout: 10 * time.Second, + } + 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..5cbd75afe --- /dev/null +++ b/authbridge/go.work @@ -0,0 +1,7 @@ +go 1.24.0 + +use ( + ./authlib + ./authproxy + ./cmd/authbridge +)