Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
d7052a0
feat(pipeline): Add Auth extension, SessionDenied phase, plugin-event…
huang195 May 8, 2026
332f3ed
feat(auth): Surface machine-stable DenyReasonCode and CacheHit on res…
huang195 May 8, 2026
8adee12
feat(plugins): jwt-validation populates Auth extension with allow/den…
huang195 May 8, 2026
802b80a
feat(plugins): token-exchange populates Auth extension for exchange/deny
huang195 May 8, 2026
d55524b
feat(listener): Record Auth + Plugins extensions, emit SessionDenied …
huang195 May 8, 2026
0eab409
test(sessionapi): Cover Auth + Plugins event fields on /v1/sessions
huang195 May 8, 2026
1778403
docs: Session event extension contract + Plugins escape hatch
huang195 May 8, 2026
e427571
feat(abctl): Render Auth decisions and denied events in sessions view
huang195 May 8, 2026
03d8698
fix(listener): Widen inbound-response recording gate to match request…
huang195 May 8, 2026
75013db
feat(auth): Surface request Path on InboundAuth entries
huang195 May 8, 2026
2457df0
feat(abctl): Rename PROTO column to PLUGIN; name the plugin attribute…
huang195 May 8, 2026
d3e17d7
feat(plugins): token-exchange populates Auth.Outbound on passthrough
huang195 May 8, 2026
63d662d
feat(pipeline): Add 5-value InvocationAction + Invocation type
huang195 May 8, 2026
1361236
feat(plugins): Gate plugins emit Invocation with 5-value actions
huang195 May 8, 2026
6135f93
feat(plugins): Parsers emit Invocation with observe/skip
huang195 May 8, 2026
9947ddf
feat(listener): Record Invocations on session events
huang195 May 8, 2026
9f0dcb1
feat(abctl): One row per plugin invocation; ACTION column with 5-valu…
huang195 May 8, 2026
969f440
docs: Invocation contract and 5-value action vocabulary
huang195 May 8, 2026
bc62977
feat(pipeline): Tag Invocations with request/response phase
huang195 May 8, 2026
38f1aae
feat(plugins): Tag parser Invocations with phase; drop type-mismatch …
huang195 May 8, 2026
ab3b60d
feat(abctl): Event-level # column with pair IDs and method-aware pairing
huang195 May 8, 2026
330d379
feat(plugins): Open plugin registry via RegisterPlugin + init()
huang195 May 8, 2026
5d9ae64
docs: Plugin registration pattern in CONVENTIONS.md
huang195 May 8, 2026
0584e27
feat(pipeline): pctx.Record helpers for ergonomic invocation emission
huang195 May 8, 2026
50912c8
docs: Document pctx.Record family in CONVENTIONS.md
huang195 May 8, 2026
c222f74
docs: Plugin developer guide (GUIDE.md)
huang195 May 8, 2026
f63f5f5
docs: Reorganize plugin/pipeline docs; trim duplicates
huang195 May 8, 2026
8e8ecee
docs: Consolidate plugin/pipeline docs into authbridge/docs/
huang195 May 8, 2026
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
36 changes: 33 additions & 3 deletions authbridge/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ with protocol-specific listeners in `cmd/authbridge/listener/`:

**Configuration loading:**
- YAML config with `${ENV_VAR}` expansion, mode presets, and startup validation.
- Plugin settings are local to each plugin under `pipeline.*.plugins[].config`; the runtime YAML itself only carries `mode`, `listener`, `session`, `stats`, and the pipeline composition. See [`cmd/authbridge/README.md`](cmd/authbridge/README.md) for the per-mode YAML shape and [`authlib/plugins/CONVENTIONS.md`](authlib/plugins/CONVENTIONS.md) for the per-plugin decode pattern.
- Plugin settings are local to each plugin under `pipeline.*.plugins[].config`; the runtime YAML itself only carries `mode`, `listener`, `session`, `stats`, and the pipeline composition. See [`cmd/authbridge/README.md`](cmd/authbridge/README.md) for the per-mode YAML shape and [`docs/plugin-reference.md`](docs/plugin-reference.md) for the per-plugin decode pattern.
- The operator-supplied env vars (`KEYCLOAK_URL`, `KEYCLOAK_REALM`, `TOKEN_URL`, `ISSUER`, `DEFAULT_OUTBOUND_POLICY`, `CLIENT_ID`) are consumed by the default `authbridge-combined.yaml` via `${VAR}` expansion — they land inside the appropriate plugin's `config:` block rather than a top-level section.
- `jwt-validation` derives `jwks_url` from `issuer` when omitted (appends `/protocol/openid-connect/certs`).
- `token-exchange` derives `token_url` from `keycloak_url + keycloak_realm` when omitted (Keycloak convention).
Expand All @@ -127,8 +127,8 @@ with protocol-specific listeners in `cmd/authbridge/listener/`:
- `authlib/routing/` -- Host-to-audience route resolver (used internally by `token-exchange` plugin)
- `authlib/auth/` -- `HandleInbound` + `HandleOutbound` composition; each plugin instance constructs its own `auth.Auth` from its own local config
- `authlib/config/` -- Mode presets, YAML config loader, credential-file waiters, top-level (mode + listener + session) validation
- `authlib/pipeline/` -- Plugin interface + lifecycle (`Configurable`, `Initializer`, `Shutdowner`); see [`authlib/pipeline/README.md`](authlib/pipeline/README.md)
- `authlib/plugins/` -- The concrete plugins + registry; see [`authlib/plugins/CONVENTIONS.md`](authlib/plugins/CONVENTIONS.md) for the per-plugin config convention
- `authlib/pipeline/` -- Plugin interface + lifecycle (`Configurable`, `Initializer`, `Shutdowner`); see [`docs/framework-architecture.md`](docs/framework-architecture.md)
- `authlib/plugins/` -- The concrete plugins + registry; see [`docs/plugin-reference.md`](docs/plugin-reference.md) for the per-plugin config convention

### init-iptables.sh

Expand Down Expand Up @@ -333,6 +333,36 @@ curl -N http://localhost:9094/v1/events
curl -N "http://localhost:9094/v1/events?session=$SID"
```

### Event schema

Every event on `/v1/sessions/{id}` and `/v1/events` carries:

- `at`, `direction`, `phase` — when, which side, what stage. `phase` is one of `"request"`, `"response"`, or `"denied"` (terminal denial from a pipeline plugin — typically a jwt-validation failure).
- `a2a` / `mcp` / `inference` — protocol parser payloads (one at most).
- `invocations` — per-plugin invocation records for every plugin that ran on the pipeline pass. Structured as `{inbound: [...], outbound: [...]}`; each entry carries `plugin`, `action` (one of 5 values — see below), `reason` (machine-stable code), and optional plugin-specific context (expected issuer, target audience, cache-hit flag, path, etc.). abctl renders one row per invocation, so operators see an explicit per-plugin timeline.
- `plugins` — escape-hatch map for plugin-specific observability. Keys are plugin names; values are the raw JSON each plugin emitted. Unknown plugins render as opaque JSON in abctl. See [`docs/plugin-reference.md`](docs/plugin-reference.md#emitting-session-events) for the producer contract.
- `identity`, `host`, `statusCode`, `error`, `durationMs` — request-level context.

### Invocation action vocabulary

Every plugin emits one of these 5 action values per invocation, so operators can scan a timeline without memorizing plugin-specific verbs:

| `action` | Meaning | Example |
|---|---|---|
| `allow` | Gate plugin permitted the request | jwt-validation on valid token |
| `deny` | Gate plugin rejected the request; pipeline stops | jwt-validation on bad token, token-exchange on IdP failure |
| `skip` | Plugin ran but didn't act on this message | jwt-validation on a bypass path; parser whose body didn't match |
| `modify` | Plugin mutated the message | token-exchange replaced the Authorization header |
| `observe` | Plugin attached diagnostic data without changing flow | a2a-parser, mcp-parser, inference-parser when they match |

Use `reason` to discriminate within an action — e.g. `skip/path_bypass` vs `skip/no_matching_route` tell different stories at the detail-pane level but both scan as "skip" in the at-a-glance timeline.

> **Producer-side contract:** the authoritative definition of the 5-value vocabulary, the `Invocation` struct fields, and which diagnostic fields each plugin type populates lives in [`docs/plugin-reference.md`](docs/plugin-reference.md#emitting-session-events). Edit that file when the vocabulary changes; this table is the consumer-side summary.

### Gotcha: denied requests

Rejected requests (401 / 503) land as `phase: "denied"` events in `/v1/sessions` when at least one pipeline plugin appended an Invocation before rejecting. If you're debugging an unauthorized-access pattern, the default-session bucket (`GET /v1/sessions/default`) is where denial events aggregate.

### Disabling

Set `session.enabled: false` in the runtime config to turn off the store (and implicitly the API). Setting `listener.session_api_addr: ""` alone is not currently supported as a selective disable — the preset refills it; if you need store-on-API-off, raise an issue.
Expand Down
6 changes: 3 additions & 3 deletions authbridge/authlib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,15 @@ A pure Go library providing reusable building blocks for JWT validation, OAuth 2
| `routing/` | Host-to-audience router with glob pattern matching |
| `auth/` | Composition layer: `HandleInbound` + `HandleOutbound` — used internally by `jwt-validation` and `token-exchange` plugins |
| `config/` | YAML config loader, mode presets, credential-file waiters, top-level validation |
| `pipeline/` | Plugin pipeline + lifecycle (`Configurable`, `Initializer`, `Shutdowner`) — see [pipeline/README.md](./pipeline/README.md) |
| `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 [plugins/CONVENTIONS.md](./plugins/CONVENTIONS.md) for the per-plugin config convention |
| `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 |

## Usage

Plugins own their own configuration and construct any `auth.Auth` they need internally from their per-plugin config (see [plugins/CONVENTIONS.md](./plugins/CONVENTIONS.md)). Host processes (cmd/authbridge) just load the YAML, call `plugins.Build`, run the pipelines, and let each plugin handle its own dependencies.
Plugins own their own configuration and construct any `auth.Auth` they need internally from their per-plugin config (see [docs/plugin-reference.md](../docs/plugin-reference.md)). Host processes (cmd/authbridge) just load the YAML, call `plugins.Build`, run the pipelines, and let each plugin handle its own dependencies.

```go
import (
Expand Down
83 changes: 54 additions & 29 deletions authbridge/authlib/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -282,39 +282,43 @@ func (a *Auth) HandleInbound(ctx context.Context, authHeader, path, audience str
a.IncInboundDeny(DENY_NO_HEADER)
a.log.Debug("inbound denied: no Authorization header", "path", path)
return &InboundResult{
Action: ActionDeny,
DenyStatus: http.StatusUnauthorized,
DenyReason: "missing Authorization header",
Action: ActionDeny,
DenyStatus: http.StatusUnauthorized,
DenyReason: "missing Authorization header",
DenyReasonCode: DENY_NO_HEADER,
}
}
token := extractBearer(authHeader)
if token == "" {
a.IncInboundDeny(DENY_MALFORMED_HEADER)
a.log.Debug("inbound denied: malformed Authorization header", "path", path)
return &InboundResult{
Action: ActionDeny,
DenyStatus: http.StatusUnauthorized,
DenyReason: "invalid Authorization header format",
Action: ActionDeny,
DenyStatus: http.StatusUnauthorized,
DenyReason: "invalid Authorization header format",
DenyReasonCode: DENY_MALFORMED_HEADER,
}
}

// 3. Validate JWT
if a.verifier == nil {
a.IncInboundDeny(DENY_VALIDATOR_MISSING)
return &InboundResult{
Action: ActionDeny,
DenyStatus: http.StatusUnauthorized,
DenyReason: "inbound validation not configured",
Action: ActionDeny,
DenyStatus: http.StatusUnauthorized,
DenyReason: "inbound validation not configured",
DenyReasonCode: DENY_VALIDATOR_MISSING,
}
}
if audience == "" {
audience = a.identity.Load().Audience
}
if audience == "" {
return &InboundResult{
Action: ActionDeny,
DenyStatus: http.StatusServiceUnavailable,
DenyReason: "identity not yet configured (credentials pending)",
Action: ActionDeny,
DenyStatus: http.StatusServiceUnavailable,
DenyReason: "identity not yet configured (credentials pending)",
DenyReasonCode: DENY_VALIDATOR_MISSING,
}
}
a.log.Debug("validating inbound JWT", "path", path, "expectedAudience", audience)
Expand All @@ -330,9 +334,10 @@ func (a *Auth) HandleInbound(ctx context.Context, authHeader, path, audience str
"expectedIssuer", a.identity.Load().Audience,
"error", err)
return &InboundResult{
Action: ActionDeny,
DenyStatus: http.StatusUnauthorized,
DenyReason: "token validation failed",
Action: ActionDeny,
DenyStatus: http.StatusUnauthorized,
DenyReason: "token validation failed",
DenyReasonCode: DENY_JWT_FAILED,
}
}

Expand Down Expand Up @@ -396,7 +401,14 @@ func (a *Auth) HandleOutbound(ctx context.Context, authHeader, host string) *Out
if cached, ok := a.cache.Get(subjectToken, audience); ok {
a.IncOutboundReplaceToken(OUTBOUND_ACTION_CACHE_HIT)
a.log.Debug("outbound cache hit", "host", host, "audience", audience)
return &OutboundResult{Action: ActionReplaceToken, Token: cached}
return &OutboundResult{
Action: ActionReplaceToken,
Token: cached,
CacheHit: true,
RouteMatched: true,
TargetAudience: audience,
RequestedScopes: scopes,
}
}
}

Expand Down Expand Up @@ -437,9 +449,13 @@ func (a *Auth) HandleOutbound(ctx context.Context, authHeader, host string) *Out
"tokenEndpoint", resolved.TokenEndpoint,
"error", err)
return &OutboundResult{
Action: ActionDeny,
DenyStatus: http.StatusServiceUnavailable,
DenyReason: "token exchange failed",
Action: ActionDeny,
DenyStatus: http.StatusServiceUnavailable,
DenyReason: "token exchange failed",
DenyReasonCode: OUTBOUND_TOKEN_EXCHANGE_FAILED,
RouteMatched: true,
TargetAudience: audience,
RequestedScopes: scopes,
}
}

Expand All @@ -453,7 +469,13 @@ func (a *Auth) HandleOutbound(ctx context.Context, authHeader, host string) *Out
a.log.Info("outbound token exchanged", "host", host, "audience", audience)
a.log.Debug("outbound exchange details",
"host", host, "audience", audience, "expiresIn", resp.ExpiresIn)
return &OutboundResult{Action: ActionReplaceToken, Token: resp.AccessToken}
return &OutboundResult{
Action: ActionReplaceToken,
Token: resp.AccessToken,
RouteMatched: true,
TargetAudience: audience,
RequestedScopes: scopes,
}
}

func (a *Auth) handleNoToken(ctx context.Context, audience, scopes string) *OutboundResult {
Expand All @@ -469,9 +491,10 @@ func (a *Auth) handleNoToken(ctx context.Context, audience, scopes string) *Outb
a.log.Debug("no token, client_credentials requested but exchanger not configured",
"audience", audience)
return &OutboundResult{
Action: ActionDeny,
DenyStatus: http.StatusServiceUnavailable,
DenyReason: "exchanger not configured for client credentials",
Action: ActionDeny,
DenyStatus: http.StatusServiceUnavailable,
DenyReason: "exchanger not configured for client credentials",
DenyReasonCode: OUTBOUND_CREDS_REQUESTED_NO_EXCHANGER,
}
}
a.log.Debug("no token, falling back to client_credentials",
Expand All @@ -483,9 +506,10 @@ func (a *Auth) handleNoToken(ctx context.Context, audience, scopes string) *Outb
a.log.Debug("client credentials failure details",
"audience", audience, "scopes", scopes, "error", err)
return &OutboundResult{
Action: ActionDeny,
DenyStatus: http.StatusServiceUnavailable,
DenyReason: "client credentials token acquisition failed",
Action: ActionDeny,
DenyStatus: http.StatusServiceUnavailable,
DenyReason: "client credentials token acquisition failed",
DenyReasonCode: OUTBOUND_CREDENTIALS_GRANT_FAILURE,
}
}
a.IncOutboundReplaceToken(OUTBOUND_ACTION_REPLACE_TOKEN)
Expand All @@ -496,9 +520,10 @@ func (a *Auth) handleNoToken(ctx context.Context, audience, scopes string) *Outb
a.log.Debug("no token, policy denies request",
"policy", a.noTokenPolicy, "audience", audience)
return &OutboundResult{
Action: ActionDeny,
DenyStatus: http.StatusUnauthorized,
DenyReason: "missing Authorization header",
Action: ActionDeny,
DenyStatus: http.StatusUnauthorized,
DenyReason: "missing Authorization header",
DenyReasonCode: OUTBOUND_NO_TOKEN,
}
}
}
Expand Down
58 changes: 58 additions & 0 deletions authbridge/authlib/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,64 @@ func TestHandleInbound_NoVerifier_Denies(t *testing.T) {
}
}

// DenyReasonCode must be populated on every ActionDeny InboundResult. The
// listener/plugin layer uses the code (not the free-form DenyReason string)
// to populate SessionEvent.Auth.Inbound[].Reason for machine-stable
// filtering. A drift here would silently leave the event field empty on
// denied requests.
func TestHandleInbound_PopulatesDenyReasonCode(t *testing.T) {
cases := []struct {
name string
cfg Config
authHeader string
audience string
want InboundDenialReason
}{
{
name: "no header",
cfg: Config{Verifier: &mockVerifier{}},
authHeader: "",
want: DENY_NO_HEADER,
},
{
name: "malformed header",
cfg: Config{Verifier: &mockVerifier{}},
authHeader: "Basic abc",
want: DENY_MALFORMED_HEADER,
},
{
name: "validator missing",
cfg: Config{}, // nil Verifier
authHeader: "Bearer tok",
want: DENY_VALIDATOR_MISSING,
},
{
name: "jwt failed",
cfg: Config{
Verifier: &mockVerifier{err: fmt.Errorf("bad signature")},
Identity: IdentityConfig{Audience: "aud"},
},
authHeader: "Bearer tok",
want: DENY_JWT_FAILED,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
a := New(tc.cfg)
res := a.HandleInbound(context.Background(), tc.authHeader, "/api", tc.audience)
if res.Action != ActionDeny {
t.Fatalf("expected deny, got %q", res.Action)
}
if res.DenyReasonCode != tc.want {
t.Errorf("DenyReasonCode = %v, want %v", res.DenyReasonCode, tc.want)
}
if res.DenyReason == "" {
t.Error("DenyReason (human string) should still be populated alongside code")
}
})
}
}

func TestHandleInbound_AudienceOverride(t *testing.T) {
mv := &mockVerifier{claims: validClaims()}
a := New(Config{
Expand Down
28 changes: 20 additions & 8 deletions authbridge/authlib/auth/result.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,28 @@ const (

// 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
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 — safe for logs, response bodies
DenyReasonCode InboundDenialReason // machine-stable enum paired with the /stats counter; use for filtering / indexing session events
}

// 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
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
DenyReasonCode OutboundDenialReason // machine-stable enum paired with the /stats counter
CacheHit bool // true when Token was served from the exchange cache; safe to read on any Action

// Route context populated when the router matched the request. Gives
// observability callers (token-exchange plugin writing a SessionEvent)
// a way to surface which route was chosen without reaching into
// routing.ResolvedRoute directly. RouteMatched=false means the
// request passed through because no route fired.
RouteMatched bool // true when Router.Resolve returned a non-nil route (regardless of action)
TargetAudience string // resolved route's audience (or deriver's output); empty on passthrough
RequestedScopes string // resolved route's scopes, raw space-separated; empty on passthrough
}
6 changes: 3 additions & 3 deletions authbridge/authlib/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ import (
//
// Plugin-specific settings (inbound JWT validation, outbound token
// exchange, identity, bypass paths, routes) live inside their
// respective entries under Pipeline.* now — see the plugin README at
// authbridge/authlib/plugins/CONVENTIONS.md for how each plugin
// respective entries under Pipeline.* now — see the plugin reference at
// authbridge/docs/plugin-reference.md for how each plugin
// declares its own config schema and defaults.
type Config struct {
Mode string `yaml:"mode" json:"mode"` // "envoy-sidecar", "waypoint", "proxy-sidecar"
Expand Down Expand Up @@ -70,7 +70,7 @@ type PipelineStageConfig struct {
// full form ({name, id, config}). The short form keeps existing pipeline
// configs parsing unchanged; the long form is what plugins that
// implement pipeline.Configurable actually need. See
// authbridge/authlib/plugins/CONVENTIONS.md for the convention plugins
// authbridge/docs/plugin-reference.md for the convention plugins
// follow when decoding Config.
//
// Config is captured as a raw subtree via json.RawMessage so the plugin
Expand Down
2 changes: 1 addition & 1 deletion authbridge/authlib/config/presets.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package config

// ApplyPreset fills in mode-specific defaults for listener addresses.
// Plugin-specific defaults live inside each plugin's Configure (see
// authbridge/authlib/plugins/CONVENTIONS.md); the runtime config no
// authbridge/docs/plugin-reference.md); the runtime config no
// longer shapes plugin behavior.
func ApplyPreset(cfg *Config) {
switch cfg.Mode {
Expand Down
Loading
Loading