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 +}