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/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..65f5b3ef5 --- /dev/null +++ b/authbridge/authlib/exchange/client.go @@ -0,0 +1,157 @@ +// 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 +} + +// 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) + } + + return c.doTokenRequest(ctx, form) +} + +// ClientCredentials performs a client credentials grant. +func (c *Client) ClientCredentials(ctx context.Context, scopes string) (*ExchangeResponse, error) { + form := url.Values{ + "grant_type": {"client_credentials"}, + } + if scopes != "" { + form.Set("scope", scopes) + } + + if err := c.auth.Apply(ctx, form); err != nil { + return nil, fmt.Errorf("applying client auth: %w", err) + } + + return c.doTokenRequest(ctx, form) +} + +func (c *Client) doTokenRequest(ctx context.Context, form url.Values) (*ExchangeResponse, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.tokenURL, + strings.NewReader(form.Encode())) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("token request: %w", err) + } + defer resp.Body.Close() + + // 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..d4f4d14a6 --- /dev/null +++ b/authbridge/authlib/exchange/client_test.go @@ -0,0 +1,241 @@ +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) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "access_token": "cc-token", + "token_type": "Bearer", + "expires_in": 3600, + }) + })) + defer srv.Close() + + client := NewClient(srv.URL, &ClientSecretAuth{ClientID: "c", ClientSecret: "s"}) + resp, err := client.ClientCredentials(context.Background(), "openid") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.AccessToken != "cc-token" { + t.Errorf("access_token = %q, want %q", resp.AccessToken, "cc-token") + } +} + +func TestExchange_WithActorToken(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + 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/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 +}