Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 18 additions & 9 deletions authbridge/authlib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,30 @@ A pure Go library providing reusable building blocks for JWT validation, OAuth 2

## Packages

**Shared building blocks** (used by ≥2 plugins or the framework):

| Package | Purpose |
|---------|---------|
| `validation/` | JWKS-backed JWT verifier (`lestrrat-go/jwx`) with required audience parameter |
| `exchange/` | RFC 8693 token exchange + client credentials grant with pluggable auth |
| `cache/` | SHA-256 keyed token cache with TTL eviction |
| `bypass/` | Path pattern matcher for public endpoints (health, agent card) |
| `spiffe/` | SPIFFE credential sources (file-based JWT-SVID) |
| `routing/` | Host-to-audience router with glob pattern matching |
| `auth/` | Composition layer: `HandleInbound` + `HandleOutbound` — used internally by `jwt-validation` and `token-exchange` plugins |
| `bypass/` | Path pattern matcher for public endpoints (health, agent card). Any inbound gate plugin (jwt-validation, SAML, mTLS) can use it. |
| `routing/` | Host-to-audience router with glob pattern matching. Used by token-exchange; future routed plugins are expected to reuse it. |
| `auth/` | Composition layer: `HandleInbound` + `HandleOutbound` — used internally by the `jwt-validation` and `token-exchange` plugins. Lingering in authlib/ for now; plugin-internal in practice. |
| `config/` | YAML config loader, mode presets, credential-file waiters, top-level validation |
| `pipeline/` | Plugin pipeline + lifecycle (`Configurable`, `Initializer`, `Shutdowner`) — see [docs/framework-architecture.md](../docs/framework-architecture.md) |
| `session/` | In-memory session store + `SessionSummary` aggregation, backing the `:9094` API |
| `sessionapi/` | HTTP API (`/v1/sessions`, `/v1/events` SSE, `/v1/pipeline`) exposing the session store |
| `plugins/` | Built-in plugins: `jwt-validation`, `token-exchange`, `a2a-parser`, `mcp-parser`, `inference-parser`. See [docs/plugin-reference.md](../docs/plugin-reference.md) for the per-plugin config convention |
| `observe/` | OTEL + metrics helpers |
| `reloader/` | fsnotify-based config hot-reload — atomic pipeline swap on ConfigMap change |
| `observe/` | `/stats`, `/config`, `/reload/status` HTTP server |

**Plugins and their internals**:

| Package | Purpose |
|---------|---------|
| `plugins/` | Registry + parser plugins (a2a-parser, mcp-parser, inference-parser) + shared `Build` + `StatsSource` contract |
| `plugins/jwtvalidation/` | The `jwt-validation` plugin. Owns `plugins/jwtvalidation/validation/` (JWKS-backed JWT verifier). |
| `plugins/tokenexchange/` | The `token-exchange` plugin. Owns `plugins/tokenexchange/exchange/` (RFC 8693 client), `plugins/tokenexchange/cache/` (token TTL cache), `plugins/tokenexchange/spiffe/` (JWT-SVID file source). |
| `plugins/plugintesting/` | Test helpers — stubs of jwt-validation / token-exchange that skip file IO, for listener-level tests. |

Packages that used to live at `authlib/validation`, `authlib/exchange`, `authlib/cache`, `authlib/spiffe` moved under their owning plugin. They had no reuse outside that plugin; keeping them at `authlib/` top-level implied wider usefulness than reality. New plugins should follow the same pattern: if the package is plugin-internal, colocate it under `plugins/<plugin>/`.

## Usage

Expand Down
6 changes: 3 additions & 3 deletions authbridge/authlib/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ import (
"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/plugins/tokenexchange/cache"
"github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/tokenexchange/exchange"
"github.com/kagenti/kagenti-extensions/authbridge/authlib/routing"
"github.com/kagenti/kagenti-extensions/authbridge/authlib/validation"
"github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/jwtvalidation/validation"
)

// IdentityConfig holds the agent's identity for audience validation and token exchange.
Expand Down
6 changes: 3 additions & 3 deletions authbridge/authlib/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ import (
"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/plugins/tokenexchange/cache"
"github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/tokenexchange/exchange"
"github.com/kagenti/kagenti-extensions/authbridge/authlib/routing"
"github.com/kagenti/kagenti-extensions/authbridge/authlib/validation"
"github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/jwtvalidation/validation"
)

// mockVerifier captures the audience arg and returns configured claims/error.
Expand Down
2 changes: 1 addition & 1 deletion authbridge/authlib/auth/result.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
package auth

import (
"github.com/kagenti/kagenti-extensions/authbridge/authlib/validation"
"github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/jwtvalidation/validation"
)

// Inbound actions.
Expand Down
26 changes: 19 additions & 7 deletions authbridge/authlib/pipeline/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,24 @@ import (
"log/slog"
"net/http"
"time"

"github.com/kagenti/kagenti-extensions/authbridge/authlib/routing"
"github.com/kagenti/kagenti-extensions/authbridge/authlib/validation"
)

// Identity carries the subject identity established by whichever auth
// plugin ran — jwt-validation, SAML, mTLS, custom. Populated by the
// auth plugin via pctx.Identity = <adapter>. Listener reads via these
// methods — not via concrete-type assertion — so plugins can contribute
// any identity shape without the framework caring.
//
// Returning empty-string / nil from any method is valid (e.g., a
// SPIFFE-SVID authenticator may have no "ClientID" concept). Consumers
// that need plugin-specific fields type-assert to a richer interface
// or to the plugin's known concrete type.
type Identity interface {
Subject() string // stable subject ID (sub claim / SPIFFE ID / email)
ClientID() string // registering-client ID, if applicable
Scopes() []string // granted scopes / roles
}

// Direction indicates whether a request is inbound (caller → this agent) or
// outbound (this agent → target service).
type Direction int
Expand Down Expand Up @@ -77,10 +90,9 @@ type Context struct {
// compute SessionEvent.Duration without walking the event history.
StartedAt time.Time

Agent *AgentIdentity
Claims *validation.Claims // nil before jwt-validation runs
Route *routing.ResolvedRoute
Session *SessionView // nil unless session tracking is enabled
Agent *AgentIdentity
Identity Identity // nil before an auth plugin runs
Session *SessionView // nil unless session tracking is enabled

// Response-phase fields (populated by listener before RunResponse).
// ResponseBody may be nil even during response phase if no plugin declared BodyAccess.
Expand Down
30 changes: 16 additions & 14 deletions authbridge/authlib/pipeline/extensions.go
Original file line number Diff line number Diff line change
Expand Up @@ -279,20 +279,22 @@ type Invocation struct {
// when the plugin has no path context.
Path string `json:"path,omitempty"`

// Auth-gate context, populated when applicable.
ExpectedIssuer string `json:"expectedIssuer,omitempty"`
ExpectedAudience string `json:"expectedAudience,omitempty"`
TokenSubject string `json:"tokenSubject,omitempty"`
TokenAudience []string `json:"tokenAudience,omitempty"`
TokenScopes []string `json:"tokenScopes,omitempty"`

// Outbound routing context. RouteMatched=true means a route rule
// explicitly applied; false means the default policy caught it.
RouteMatched bool `json:"routeMatched,omitempty"`
RouteHost string `json:"routeHost,omitempty"`
TargetAudience string `json:"targetAudience,omitempty"`
RequestedScopes []string `json:"requestedScopes,omitempty"`
CacheHit bool `json:"cacheHit,omitempty"`
// Details carries plugin-specific context as a flat string→string
// map. Opaque to the framework; abctl renders it as key=value rows
// in the invocation detail pane. Suggested convention: snake_case
// keys scoped to the plugin's semantic domain. Built-in plugins
// use keys like expected_issuer, token_subject, route_host,
// target_audience, cache_hit. Third-party plugins define their own.
//
// Stringify booleans as "true"/"false" and []string as space-
// joined (matching OAuth scope conventions). Keep values short
// enough for a detail pane — bulky diagnostics belong in the
// Extensions.Custom escape-hatch event.
//
// NEVER put raw tokens, signatures, or client credentials here.
// The session API has no auth on it — only safe-to-log data
// belongs in Invocation.Details.
Details map[string]string `json:"details,omitempty"`
}

// DelegationExtension tracks the token delegation chain across hops.
Expand Down
21 changes: 6 additions & 15 deletions authbridge/authlib/pipeline/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,12 +125,6 @@ type SessionEvent struct {
// landed on. Empty when the listener didn't populate pctx.Host.
Host string

// TargetAudience is the OAuth audience the outbound request was routed
// to, or empty when no route matched (passthrough) or the event is
// inbound. Useful for policy plugins that care which target scope a
// call was made against, independent of host (which can be a glob).
TargetAudience string

// Duration is the wall-clock time from request entry into the listener
// to response recording. Zero on request-phase events. On response
// events it's computed as now - matching-request.At.
Expand Down Expand Up @@ -158,9 +152,8 @@ type sessionEventWire struct {
Identity *EventIdentity `json:"identity,omitempty"`
StatusCode int `json:"statusCode,omitempty"`
Error *EventError `json:"error,omitempty"`
Host string `json:"host,omitempty"`
TargetAudience string `json:"targetAudience,omitempty"`
DurationMs int64 `json:"durationMs,omitempty"`
Host string `json:"host,omitempty"`
DurationMs int64 `json:"durationMs,omitempty"`
}

func (e SessionEvent) MarshalJSON() ([]byte, error) {
Expand All @@ -177,9 +170,8 @@ func (e SessionEvent) MarshalJSON() ([]byte, error) {
Identity: e.Identity,
StatusCode: e.StatusCode,
Error: e.Error,
Host: e.Host,
TargetAudience: e.TargetAudience,
DurationMs: e.Duration.Milliseconds(),
Host: e.Host,
DurationMs: e.Duration.Milliseconds(),
})
}

Expand All @@ -204,9 +196,8 @@ func (e *SessionEvent) UnmarshalJSON(data []byte) error {
Identity: w.Identity,
StatusCode: w.StatusCode,
Error: w.Error,
Host: w.Host,
TargetAudience: w.TargetAudience,
Duration: time.Duration(w.DurationMs) * time.Millisecond,
Host: w.Host,
Duration: time.Duration(w.DurationMs) * time.Millisecond,
}
return nil
}
Expand Down
39 changes: 21 additions & 18 deletions authbridge/authlib/pipeline/session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,9 @@ func TestSessionEvent_JSONRoundTrip(t *testing.T) {
ToolCalls: []InferenceToolCall{{ID: "c1", Name: "get_weather", Arguments: `{"city":"NYC"}`}},
Completion: "Hello, world!", FinishReason: "stop", TotalTokens: 17,
},
Identity: &EventIdentity{Subject: "alice", ClientID: "agent-1"},
StatusCode: 200,
Error: &EventError{Kind: "upstream", Message: "timeout"},
TargetAudience: "github-tool",
Identity: &EventIdentity{Subject: "alice", ClientID: "agent-1"},
StatusCode: 200,
Error: &EventError{Kind: "upstream", Message: "timeout"},
}

first, err := json.Marshal(orig)
Expand Down Expand Up @@ -107,7 +106,7 @@ func TestSessionEvent_MarshalJSON_OmitsEmpty(t *testing.T) {
}
s := string(data)

for _, field := range []string{"a2a", "mcp", "inference", "auth", "plugins", "identity", "statusCode", "error", "host", "targetAudience", "durationMs"} {
for _, field := range []string{"a2a", "mcp", "inference", "auth", "plugins", "identity", "statusCode", "error", "host", "durationMs"} {
if strings.Contains(s, `"`+field+`":`) {
t.Errorf("expected %q omitted when zero: %s", field, s)
}
Expand Down Expand Up @@ -150,21 +149,25 @@ func TestSessionEvent_Invocations_JSONRoundTrip(t *testing.T) {
Phase: SessionDenied,
Invocations: &Invocations{
Inbound: []Invocation{{
Plugin: "jwt-validation",
Action: ActionDeny,
Reason: "jwt_failed",
ExpectedIssuer: "http://keycloak.localtest.me:8080/realms/kagenti",
ExpectedAudience: "spiffe://localtest.me/ns/team1/sa/weather-tool",
Plugin: "jwt-validation",
Action: ActionDeny,
Reason: "jwt_failed",
Details: map[string]string{
"expected_issuer": "http://keycloak.localtest.me:8080/realms/kagenti",
"expected_audience": "spiffe://localtest.me/ns/team1/sa/weather-tool",
},
}},
Outbound: []Invocation{{
Plugin: "token-exchange",
Action: ActionModify,
Reason: "cache_hit",
RouteMatched: true,
RouteHost: "weather-tool-mcp",
TargetAudience: "spiffe://localtest.me/ns/team1/sa/weather-tool",
RequestedScopes: []string{"openid", "weather-aud"},
CacheHit: true,
Plugin: "token-exchange",
Action: ActionModify,
Reason: "cache_hit",
Details: map[string]string{
"route_matched": "true",
"route_host": "weather-tool-mcp",
"target_audience": "spiffe://localtest.me/ns/team1/sa/weather-tool",
"requested_scopes": "openid weather-aud",
"cache_hit": "true",
},
}},
},
}
Expand Down
34 changes: 34 additions & 0 deletions authbridge/authlib/plugins/jwtvalidation/identity.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package jwtvalidation

import "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/jwtvalidation/validation"

// claimsIdentity adapts a *validation.Claims to the pipeline.Identity
// interface that Context exposes. Kept in the plugin so the pipeline
// package doesn't import any validation-specific types.
//
// A nil *validation.Claims would cause NPEs on the accessor methods,
// so jwt-validation only wraps non-nil Claims.
type claimsIdentity struct {
c *validation.Claims
}

func (i claimsIdentity) Subject() string {
if i.c == nil {
return ""
}
return i.c.Subject
}

func (i claimsIdentity) ClientID() string {
if i.c == nil {
return ""
}
return i.c.ClientID
}

func (i claimsIdentity) Scopes() []string {
if i.c == nil {
return nil
}
return i.c.Scopes
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package plugins
package jwtvalidation

import (
"bytes"
Expand All @@ -14,8 +14,9 @@ import (
"github.com/kagenti/kagenti-extensions/authbridge/authlib/bypass"
"github.com/kagenti/kagenti-extensions/authbridge/authlib/config"
"github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline"
"github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins"
"github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/jwtvalidation/validation"
"github.com/kagenti/kagenti-extensions/authbridge/authlib/routing"
"github.com/kagenti/kagenti-extensions/authbridge/authlib/validation"
)

// jwtValidationConfig is the plugin's local config schema. See
Expand Down Expand Up @@ -161,7 +162,7 @@ type JWTValidation struct {
func NewJWTValidation() *JWTValidation { return &JWTValidation{} }

func init() {
RegisterPlugin("jwt-validation", func() pipeline.Plugin { return NewJWTValidation() })
plugins.RegisterPlugin("jwt-validation", func() pipeline.Plugin { return NewJWTValidation() })
}

func (p *JWTValidation) Name() string { return "jwt-validation" }
Expand Down Expand Up @@ -306,10 +307,12 @@ func (p *JWTValidation) OnRequest(ctx context.Context, pctx *pipeline.Context) p
// ExpectedIssuer / ExpectedAudience diagnostic fields that the
// one-liner DenyAndRecord doesn't accept.
pctx.Record(pipeline.Invocation{
Action: pipeline.ActionDeny,
Reason: result.DenyReasonCode.String(),
ExpectedIssuer: p.cfg.Issuer,
ExpectedAudience: audience,
Action: pipeline.ActionDeny,
Reason: result.DenyReasonCode.String(),
Details: map[string]string{
"expected_issuer": p.cfg.Issuer,
"expected_audience": audience,
},
})
// result.DenyReason carries the specific failure (missing header,
// audience mismatch, expired, etc.). Pick a code whose default
Expand All @@ -333,15 +336,18 @@ func (p *JWTValidation) OnRequest(ctx context.Context, pctx *pipeline.Context) p
}

// ActionAllow with Claims = authorized. Surface what the plugin
// VERIFIED in the token — diverges from the top-level Identity
// snapshot if later plugins re-annotate pctx.Claims.
pctx.Claims = result.Claims
// VERIFIED in the token via the pipeline.Identity interface.
// Plugins that don't run jwt-validation (SAML, mTLS, custom)
// publish their own adapter; listeners read through the interface.
pctx.Identity = claimsIdentity{c: result.Claims}
pctx.Record(pipeline.Invocation{
Action: pipeline.ActionAllow,
Reason: auth.APPROVE_AUTHORIZED.String(),
TokenSubject: result.Claims.Subject,
TokenAudience: result.Claims.Audience,
TokenScopes: result.Claims.Scopes,
Action: pipeline.ActionAllow,
Reason: auth.APPROVE_AUTHORIZED.String(),
Details: map[string]string{
"token_subject": result.Claims.Subject,
"token_audience": strings.Join(result.Claims.Audience, ","),
"token_scopes": strings.Join(result.Claims.Scopes, " "),
},
})
return pipeline.Action{Type: pipeline.Continue}
}
Expand Down Expand Up @@ -382,5 +388,5 @@ var (
_ pipeline.Initializer = (*JWTValidation)(nil)
_ pipeline.Shutdowner = (*JWTValidation)(nil)
_ pipeline.Readier = (*JWTValidation)(nil)
_ StatsSource = (*JWTValidation)(nil)
_ plugins.StatsSource = (*JWTValidation)(nil)
)
Loading
Loading