diff --git a/authbridge/authlib/listener/e2e/placeholder_e2e_test.go b/authbridge/authlib/listener/e2e/placeholder_e2e_test.go new file mode 100644 index 000000000..9c4e88051 --- /dev/null +++ b/authbridge/authlib/listener/e2e/placeholder_e2e_test.go @@ -0,0 +1,281 @@ +// Package e2e contains cross-listener, in-process integration tests that +// must import BOTH the reverseproxy (inbound) and forwardproxy (outbound) +// listeners. It lives in its own test-only package to avoid an import +// cycle between the two listener packages. +package e2e + +import ( + "crypto/rand" + "crypto/rsa" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v2/jwt" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/listener/forwardproxy" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/listener/reverseproxy" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/placeholder" + jwtvalidation "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/jwtvalidation" + tokenexchange "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/tokenexchange" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/shared" +) + +// startJWKS mints an RSA keypair, serves its public half as a JWKS, and +// returns the private key (for signing) and the JWKS server. Mirrors the +// helper in plugins/jwtvalidation/e2e_audiences_test.go. +func startJWKS(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, "e2e-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) + })) + t.Cleanup(srv.Close) + return privKey, srv +} + +// signToken signs a JWT with the given claims using privKey. +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, "e2e-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 mustParseURL(t *testing.T, raw string) *url.URL { + t.Helper() + u, err := url.Parse(raw) + if err != nil { + t.Fatalf("parse url %q: %v", raw, err) + } + return u +} + +func trunc(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} + +// TestPlaceholderSwap_EndToEnd proves, through real in-process HTTP +// round-trips, that: +// +// - The agent backend (what reverseproxy forwards to) only ever sees an +// opaque "abph_" placeholder, never the real user JWT. +// - The real JWT is stored server-side in a shared store, recoverable +// only inside the proxy via the handle. +// - When the agent makes an outbound call through the forwardproxy, the +// placeholder is resolved and exchanged (RFC 8693), so the external +// upstream receives the EXCHANGED token — neither the real JWT nor the +// raw placeholder. +// +// The whole point is a single process with a single shared store wired +// into BOTH listeners. +func TestPlaceholderSwap_EndToEnd(t *testing.T) { + // 1. JWKS + signed JWT -------------------------------------------------- + priv, jwksSrv := startJWKS(t) + issuer := "http://test-issuer" + realJWT := signToken(t, priv, map[string]interface{}{ + "iss": issuer, + "aud": "agent", + "sub": "user@example.com", + "exp": time.Now().Add(5 * time.Minute).Unix(), + "iat": time.Now().Unix(), + }) + + // 2. Token-exchange stub ------------------------------------------------ + exchangeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": "EXCHANGED-TOKEN", + "token_type": "Bearer", + "expires_in": 300, + }) + })) + defer exchangeSrv.Close() + + // 3. Upstream server (external API the agent calls) --------------------- + var upstreamReceivedAuth string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamReceivedAuth = r.Header.Get("Authorization") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("upstream-ok")) + })) + defer upstream.Close() + + // 5. Shared store — ONE store, shared by both listeners. ----------------- + store := shared.New() + + // 7. Outbound stack (built first so the agent backend can route + // through it). token-exchange resolves the placeholder and exchanges. + txPlugin := tokenexchange.NewTokenExchange() + txCfg := []byte(`{ + "token_url":"` + exchangeSrv.URL + `", + "default_policy":"exchange", + "resolve_placeholders":true, + "identity":{"type":"client-secret","client_id":"agent","client_secret":"secret"} + }`) + if err := txPlugin.Configure(txCfg); err != nil { + t.Fatalf("token-exchange Configure: %v", err) + } + txPipe, err := pipeline.New([]pipeline.Plugin{txPlugin}) + if err != nil { + t.Fatalf("outbound pipeline.New: %v", err) + } + fpSrv, err := forwardproxy.NewServer(pipeline.NewHolder(txPipe), nil, nil) + if err != nil { + t.Fatalf("forwardproxy.NewServer: %v", err) + } + fpSrv.Shared = store + forwardProxy := httptest.NewServer(fpSrv.Handler()) + defer forwardProxy.Close() + + // 4. Agent backend (what reverseproxy forwards to). Records the + // Authorization header IT sees, then simulates the agent making an + // outbound call THROUGH the forwardproxy to the upstream, carrying + // exactly the Authorization header the agent received. + var agentReceivedAuth string + agentBackend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + agentReceivedAuth = r.Header.Get("Authorization") + + // Agent's outbound client routes through the forward proxy. + egressClient := &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyURL(mustParseURL(t, forwardProxy.URL)), + }, + } + egressReq, _ := http.NewRequest(http.MethodGet, upstream.URL+"/external", nil) + // The agent forwards whatever Authorization it was handed — the + // opaque placeholder, never the real token (it never had it). + egressReq.Header.Set("Authorization", agentReceivedAuth) + egressResp, err := egressClient.Do(egressReq) + if err != nil { + http.Error(w, "agent egress failed: "+err.Error(), http.StatusBadGateway) + return + } + _, _ = io.Copy(io.Discard, egressResp.Body) + egressResp.Body.Close() + + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("agent-ok")) + })) + defer agentBackend.Close() + + // 6. Inbound stack: jwt-validation in placeholder mode, reverseproxy + // forwarding to the agent backend, sharing the SAME store. + jwtPlugin := jwtvalidation.NewJWTValidation() + jwtCfg := []byte(`{ + "issuer":"` + issuer + `", + "jwks_url":"` + jwksSrv.URL + `", + "audience":"agent", + "placeholder_mode":true + }`) + if err := jwtPlugin.Configure(jwtCfg); err != nil { + t.Fatalf("jwt-validation Configure: %v", err) + } + if !jwtPlugin.Ready() { + t.Fatal("jwt-validation not Ready after Configure") + } + inboundPipe, err := pipeline.New([]pipeline.Plugin{jwtPlugin}) + if err != nil { + t.Fatalf("inbound pipeline.New: %v", err) + } + rpSrv, err := reverseproxy.NewServer(pipeline.NewHolder(inboundPipe), nil, agentBackend.URL, nil) + if err != nil { + t.Fatalf("reverseproxy.NewServer: %v", err) + } + rpSrv.Shared = store + reverseProxy := httptest.NewServer(rpSrv.Handler()) + defer reverseProxy.Close() + + // 8. Drive it: client → reverseproxy with the REAL JWT. ------------------ + req, _ := http.NewRequest(http.MethodGet, reverseProxy.URL+"/work", nil) + req.Header.Set("Authorization", "Bearer "+realJWT) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("client.Do: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("reverseproxy status = %d, want 200 (body: %s)", resp.StatusCode, body) + } + + // --- Observability for the human --------------------------------------- + agentBearer := strings.TrimPrefix(agentReceivedAuth, "Bearer ") + t.Logf("real JWT (caller sent): Bearer %s", trunc(realJWT, 25)) + t.Logf("agent received: %s <-- opaque handle, NOT the real token", agentReceivedAuth) + if storedRaw, ok := store.Get(placeholder.Key(agentBearer)); ok { + t.Logf("store[%s] holds: Bearer %s <-- real token kept server-side", trunc(agentBearer, 12), trunc(storedRaw.(string), 25)) + } + t.Logf("upstream received: %s <-- exchanged token, not the real JWT and not the handle", upstreamReceivedAuth) + + // --- Assertions -------------------------------------------------------- + + // Agent saw an opaque placeholder. + if !strings.HasPrefix(agentReceivedAuth, "Bearer "+placeholder.Prefix) { + t.Fatalf("agent Authorization = %q, want prefix %q", agentReceivedAuth, "Bearer "+placeholder.Prefix) + } + if !placeholder.IsPlaceholder(agentBearer) { + t.Fatalf("agent bearer %q is not a placeholder", agentBearer) + } + + // The real token NEVER reached the agent. + if strings.Contains(agentReceivedAuth, realJWT) { + t.Fatalf("LEAK: agent Authorization %q contains the real JWT", agentReceivedAuth) + } + + // The shared store, keyed by the handle the agent got, returns the real JWT. + stored, ok := store.Get(placeholder.Key(agentBearer)) + if !ok { + t.Fatalf("shared store has no entry for handle %q", agentBearer) + } + if stored.(string) != realJWT { + t.Fatalf("store holds %q, want the real JWT", trunc(stored.(string), 25)) + } + + // The external upstream saw the EXCHANGED token — not the real JWT, + // not the raw placeholder handle. + if upstreamReceivedAuth != "Bearer EXCHANGED-TOKEN" { + t.Fatalf("upstream Authorization = %q, want Bearer EXCHANGED-TOKEN", upstreamReceivedAuth) + } + if strings.Contains(upstreamReceivedAuth, realJWT) { + t.Fatalf("LEAK: upstream Authorization %q contains the real JWT", upstreamReceivedAuth) + } + if strings.Contains(upstreamReceivedAuth, placeholder.Prefix) { + t.Fatalf("LEAK: upstream Authorization %q still contains the placeholder handle", upstreamReceivedAuth) + } +} diff --git a/authbridge/authlib/listener/extproc/placeholder_test.go b/authbridge/authlib/listener/extproc/placeholder_test.go new file mode 100644 index 000000000..9cde4ace6 --- /dev/null +++ b/authbridge/authlib/listener/extproc/placeholder_test.go @@ -0,0 +1,162 @@ +package extproc + +import ( + "context" + "testing" + + extprocv3 "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" +) + +// mintPlugin rewrites the inbound Authorization header to a minted +// credential. Used to assert handleInbound emits a SetHeaders mutation +// (via replaceTokenResponse) carrying the new value so Envoy rewrites the +// request to the agent. +type mintPlugin struct{} + +func (mintPlugin) Name() string { return "mint" } +func (mintPlugin) Capabilities() pipeline.PluginCapabilities { + return pipeline.PluginCapabilities{} +} +func (mintPlugin) OnRequest(_ context.Context, pctx *pipeline.Context) pipeline.Action { + pctx.Headers.Set("Authorization", "Bearer abph_minted") + return pipeline.Action{Type: pipeline.Continue} +} +func (mintPlugin) OnResponse(_ context.Context, _ *pipeline.Context) pipeline.Action { + return pipeline.Action{Type: pipeline.Continue} +} + +// setHeaderValue extracts the value for the named SetHeaders key from a +// RequestHeaders ProcessingResponse. replaceTokenResponse stores the value +// in RawValue; fall back to Value for robustness. Returns ("", false) when +// the key is absent. +func setHeaderValue(resp *extprocv3.ProcessingResponse, key string) (string, bool) { + rh := resp.GetRequestHeaders() + if rh == nil || rh.GetResponse() == nil || rh.GetResponse().GetHeaderMutation() == nil { + return "", false + } + for _, h := range rh.GetResponse().GetHeaderMutation().GetSetHeaders() { + hv := h.GetHeader() + if hv == nil || hv.GetKey() != key { + continue + } + if rv := hv.GetRawValue(); len(rv) > 0 { + return string(rv), true + } + return hv.GetValue(), true + } + return "", false +} + +// TestExtProc_Inbound_AuthorizationMutation: a plugin that rewrites the +// inbound Authorization header must cause handleInbound to emit a SetHeaders +// mutation carrying the new value, so Envoy rewrites the request to the +// agent. Without the inbound emission path this returns a plain allow with +// no auth mutation. +func TestExtProc_Inbound_AuthorizationMutation(t *testing.T) { + p, err := pipeline.New([]pipeline.Plugin{mintPlugin{}}) + if err != nil { + t.Fatalf("building pipeline: %v", err) + } + srv := &Server{InboundPipeline: pipeline.NewHolder(p)} + + stream := &mockStream{ctx: context.Background()} + headers := makeHeaders( + "x-authbridge-direction", "inbound", + "authorization", "Bearer real-user-token", + ":path", "/api/test", + ) + + resp, _ := srv.handleInbound(stream, headers, nil) + + got, ok := setHeaderValue(resp, "authorization") + if !ok { + t.Fatalf("expected SetHeaders mutation for authorization, got %+v", resp) + } + if got != "Bearer abph_minted" { + t.Errorf("authorization = %q, want %q", got, "Bearer abph_minted") + } + + // The internal direction header must be stripped on the mint path too, + // otherwise Envoy forwards x-authbridge-direction to the agent. + if !headerRemoved(resp.GetRequestHeaders().GetResponse(), "x-authbridge-direction") { + t.Errorf("expected RemoveHeaders to contain x-authbridge-direction, got %+v", resp) + } +} + +// headerRemoved reports whether the CommonResponse's HeaderMutation removes +// the named header. Shared by the header- and body-path mint tests. +func headerRemoved(cr *extprocv3.CommonResponse, key string) bool { + if cr == nil || cr.GetHeaderMutation() == nil { + return false + } + for _, h := range cr.GetHeaderMutation().GetRemoveHeaders() { + if h == key { + return true + } + } + return false +} + +// bodyHeaderValue extracts the value for the named SetHeaders key from a +// RequestBody ProcessingResponse. The body path (replaceTokenBodyResponse +// wrapped by withBodyMutation) nests the SetHeaders mutation inside the +// RequestBody's CommonResponse rather than the RequestHeaders response that +// setHeaderValue reads, so it needs its own accessor. replaceTokenBodyResponse +// stores the value in RawValue; fall back to Value for robustness. Returns +// ("", false) when the key is absent. +func bodyHeaderValue(resp *extprocv3.ProcessingResponse, key string) (string, bool) { + rb := resp.GetRequestBody() + if rb == nil || rb.GetResponse() == nil || rb.GetResponse().GetHeaderMutation() == nil { + return "", false + } + for _, h := range rb.GetResponse().GetHeaderMutation().GetSetHeaders() { + hv := h.GetHeader() + if hv == nil || hv.GetKey() != key { + continue + } + if rv := hv.GetRawValue(); len(rv) > 0 { + return string(rv), true + } + return hv.GetValue(), true + } + return "", false +} + +// TestExtProc_InboundBody_AuthorizationMutation mirrors +// TestExtProc_Inbound_AuthorizationMutation but exercises the body path +// (handleInboundBody) instead of the header path. A plugin that rewrites the +// inbound Authorization header must cause handleInboundBody to emit a +// SetHeaders mutation carrying the new value — nested in the RequestBody +// response via replaceTokenBodyResponse/withBodyMutation — so Envoy rewrites +// the request to the agent on the body phase too. +func TestExtProc_InboundBody_AuthorizationMutation(t *testing.T) { + p, err := pipeline.New([]pipeline.Plugin{mintPlugin{}}) + if err != nil { + t.Fatalf("building pipeline: %v", err) + } + srv := &Server{InboundPipeline: pipeline.NewHolder(p)} + + stream := &mockStream{ctx: context.Background()} + headers := makeHeaders( + "x-authbridge-direction", "inbound", + "authorization", "Bearer real-user-token", + ":path", "/api/test", + ) + + resp, _ := srv.handleInboundBody(stream, headers, []byte("{}")) + + got, ok := bodyHeaderValue(resp, "authorization") + if !ok { + t.Fatalf("expected SetHeaders mutation for authorization in body response, got %+v", resp) + } + if got != "Bearer abph_minted" { + t.Errorf("authorization = %q, want %q", got, "Bearer abph_minted") + } + + // The internal direction header must be stripped on the body mint path too. + if !headerRemoved(resp.GetRequestBody().GetResponse(), "x-authbridge-direction") { + t.Errorf("expected RemoveHeaders to contain x-authbridge-direction, got %+v", resp) + } +} diff --git a/authbridge/authlib/listener/extproc/server.go b/authbridge/authlib/listener/extproc/server.go index a857e3ad8..7ad533b25 100644 --- a/authbridge/authlib/listener/extproc/server.go +++ b/authbridge/authlib/listener/extproc/server.go @@ -36,7 +36,8 @@ type Server struct { extprocv3.UnimplementedExternalProcessorServer InboundPipeline *pipeline.Holder OutboundPipeline *pipeline.Holder - Sessions *session.Store // nil when session tracking is disabled + Sessions *session.Store // nil when session tracking is disabled + Shared pipeline.SharedStore // process-scoped store; set by main, may be nil } // Process handles the bidirectional ext_proc stream. @@ -149,9 +150,11 @@ func (s *Server) handleInbound(stream extprocv3.ExternalProcessor_ProcessServer, Path: getHeader(headers, ":path"), Headers: headerMapToHTTP(headers), Body: body, + Shared: s.Shared, StartedAt: time.Now(), } + originalAuth := pctx.Headers.Get("Authorization") action := s.InboundPipeline.Run(ctx, pctx) if action.Type == pipeline.Reject { s.recordInboundReject(pctx, action) @@ -159,6 +162,9 @@ func (s *Server) handleInbound(stream extprocv3.ExternalProcessor_ProcessServer, } s.recordInboundSession(pctx) + if newAuth := pctx.Headers.Get("Authorization"); newAuth != originalAuth { + return replaceTokenResponse(auth.ExtractBearer(newAuth)), pctx + } return allowResponse(), pctx } @@ -171,9 +177,11 @@ func (s *Server) handleInboundBody(stream extprocv3.ExternalProcessor_ProcessSer Path: getHeader(headers, ":path"), Headers: headerMapToHTTP(headers), Body: body, + Shared: s.Shared, StartedAt: time.Now(), } + originalAuth := pctx.Headers.Get("Authorization") action := s.InboundPipeline.Run(ctx, pctx) if action.Type == pipeline.Reject { s.recordInboundReject(pctx, action) @@ -181,6 +189,9 @@ func (s *Server) handleInboundBody(stream extprocv3.ExternalProcessor_ProcessSer } s.recordInboundSession(pctx) + if newAuth := pctx.Headers.Get("Authorization"); newAuth != originalAuth { + return withBodyMutation(replaceTokenBodyResponse(auth.ExtractBearer(newAuth)), pctx), pctx + } return withBodyMutation(allowBodyResponse(), pctx), pctx } @@ -325,8 +336,6 @@ func (s *Server) recordOutboundReject(pctx *pipeline.Context, action pipeline.Ac s.Sessions.Append(sid, ev) } - - // recordInboundResponseSession appends a Phase:SessionResponse event for the // inbound direction. Called after RunResponse completes so the event carries // the updated SessionID (from the response body's contextId, when an A2A @@ -398,9 +407,6 @@ func (s *Server) recordOutboundResponseSession(pctx *pipeline.Context) { } } - - - // rekeyInboundSession renames the DefaultSessionID bucket to the // server-assigned A2A contextId when the response reveals one, so events // from the first turn (recorded under "default" during the request phase) @@ -441,9 +447,6 @@ func (s *Server) recordOutboundSession(pctx *pipeline.Context) { } } - - - func (s *Server) handleOutbound(stream extprocv3.ExternalProcessor_ProcessServer, headers *corev3.HeaderMap, body []byte) (*extprocv3.ProcessingResponse, *pipeline.Context) { ctx := stream.Context() pctx := &pipeline.Context{ @@ -454,6 +457,7 @@ func (s *Server) handleOutbound(stream extprocv3.ExternalProcessor_ProcessServer Path: getHeader(headers, ":path"), Headers: headerMapToHTTP(headers), Body: body, + Shared: s.Shared, StartedAt: time.Now(), } if pctx.Host == "" { @@ -492,6 +496,7 @@ func (s *Server) handleOutboundBody(stream extprocv3.ExternalProcessor_ProcessSe Path: getHeader(headers, ":path"), Headers: headerMapToHTTP(headers), Body: body, + Shared: s.Shared, StartedAt: time.Now(), } if pctx.Host == "" { @@ -645,7 +650,6 @@ func headerMapToHTTP(headers *corev3.HeaderMap) http.Header { return h } - func requestBodyResponse() *extprocv3.ProcessingResponse { return &extprocv3.ProcessingResponse{ Response: &extprocv3.ProcessingResponse_RequestHeaders{ @@ -747,6 +751,10 @@ func replaceTokenBodyResponse(token string) *extprocv3.ProcessingResponse { }, }, }, + // Strip the internal direction header before forwarding, + // matching allowResponse/allowBodyResponse — otherwise + // Envoy leaks x-authbridge-direction to the agent/target. + RemoveHeaders: []string{"x-authbridge-direction"}, }, }, }, @@ -768,6 +776,10 @@ func replaceTokenResponse(token string) *extprocv3.ProcessingResponse { }, }, }, + // Strip the internal direction header before forwarding, + // matching allowResponse/allowBodyResponse — otherwise + // Envoy leaks x-authbridge-direction to the agent/target. + RemoveHeaders: []string{"x-authbridge-direction"}, }, }, }, diff --git a/authbridge/authlib/listener/forwardproxy/server.go b/authbridge/authlib/listener/forwardproxy/server.go index 45768cfb5..0933cd72b 100644 --- a/authbridge/authlib/listener/forwardproxy/server.go +++ b/authbridge/authlib/listener/forwardproxy/server.go @@ -31,7 +31,8 @@ const maxBodySize = 1 << 20 // 1MB — matches Envoy's default per_stream_buffer // in-flight requests finish on the pipeline they started with. type Server struct { OutboundPipeline *pipeline.Holder - Sessions *session.Store // nil when session tracking is disabled + Sessions *session.Store // nil when session tracking is disabled + Shared pipeline.SharedStore // process-scoped store; set by main, may be nil Client *http.Client } @@ -163,6 +164,7 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request) { Host: r.Host, Path: r.URL.Path, Headers: r.Header.Clone(), + Shared: s.Shared, StartedAt: time.Now(), } diff --git a/authbridge/authlib/listener/reverseproxy/placeholder_test.go b/authbridge/authlib/listener/reverseproxy/placeholder_test.go new file mode 100644 index 000000000..0ca6ec0c2 --- /dev/null +++ b/authbridge/authlib/listener/reverseproxy/placeholder_test.go @@ -0,0 +1,56 @@ +package reverseproxy + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" +) + +// rewritePlugin sets a new Authorization on the inbound context, simulating +// jwt-validation mint mode. +type rewritePlugin struct{} + +func (rewritePlugin) Name() string { return "rewrite" } +func (rewritePlugin) Capabilities() pipeline.PluginCapabilities { return pipeline.PluginCapabilities{} } +func (rewritePlugin) OnRequest(_ context.Context, pctx *pipeline.Context) pipeline.Action { + pctx.Headers.Set("Authorization", "Bearer abph_minted") + return pipeline.Action{Type: pipeline.Continue} +} +func (rewritePlugin) OnResponse(_ context.Context, _ *pipeline.Context) pipeline.Action { + return pipeline.Action{Type: pipeline.Continue} +} + +func TestInboundPropagation_RewrittenAuthReachesBackend(t *testing.T) { + var seen string + backend := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + seen = r.Header.Get("Authorization") + })) + defer backend.Close() + + p, err := pipeline.New([]pipeline.Plugin{rewritePlugin{}}) + if err != nil { + t.Fatalf("pipeline.New: %v", err) + } + srv, err := NewServer(pipeline.NewHolder(p), nil, backend.URL, nil) + if err != nil { + t.Fatalf("NewServer: %v", err) + } + + proxy := httptest.NewServer(srv.Handler()) + defer proxy.Close() + + req, _ := http.NewRequest(http.MethodGet, proxy.URL+"/work", nil) + req.Header.Set("Authorization", "Bearer real-user-token") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("client.Do: %v", err) + } + resp.Body.Close() + + if seen != "Bearer abph_minted" { + t.Fatalf("backend saw Authorization=%q, want Bearer abph_minted", seen) + } +} diff --git a/authbridge/authlib/listener/reverseproxy/server.go b/authbridge/authlib/listener/reverseproxy/server.go index 6fe07109c..247ac869c 100644 --- a/authbridge/authlib/listener/reverseproxy/server.go +++ b/authbridge/authlib/listener/reverseproxy/server.go @@ -50,7 +50,8 @@ func (e *responseRejectedError) Error() string { // in-flight requests finish on the pipeline they started with. type Server struct { InboundPipeline *pipeline.Holder - Sessions *session.Store // nil when session tracking is disabled + Sessions *session.Store // nil when session tracking is disabled + Shared pipeline.SharedStore // process-scoped store; set by main, may be nil proxy *httputil.ReverseProxy backend string @@ -169,6 +170,7 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request) { Host: r.Host, Path: r.URL.Path, Headers: r.Header.Clone(), + Shared: s.Shared, StartedAt: time.Now(), } @@ -207,6 +209,7 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request) { slog.Debug("reverse-proxy: buffered request body", "host", r.Host, "bodyLen", len(body)) } + originalAuth := pctx.Headers.Get("Authorization") action := s.InboundPipeline.Run(r.Context(), pctx) if action.Type == pipeline.Reject { s.recordInboundReject(pctx, action) @@ -224,6 +227,15 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request) { r.Header.Del("Content-Encoding") } + // Propagate an inbound Authorization mutation to the forwarded + // request. A plugin (e.g. jwt-validation in mint mode) may have + // replaced the caller's token on pctx.Headers; the proxy forwards + // r.Header, so without this the backend would still see the original + // token. Only rewrite when the value actually changed. + if newAuth := pctx.Headers.Get("Authorization"); newAuth != originalAuth { + r.Header.Set("Authorization", newAuth) + } + // Inbound recording is gated on A2A by design: reverseproxy is the // A2A-only listener (its session keying and rekey logic are A2A-specific // — see modifyResponse). Forwardproxy widens the analogous gate to diff --git a/authbridge/authlib/pipeline/context.go b/authbridge/authlib/pipeline/context.go index 25aa5b710..014dd9e29 100644 --- a/authbridge/authlib/pipeline/context.go +++ b/authbridge/authlib/pipeline/context.go @@ -29,6 +29,15 @@ type Identity interface { Scopes() []string // granted scopes / roles } +// SharedStore is a process-scoped key→value store with TTL, injected by the +// listener so plugins can share state across the inbound→outbound request +// boundary (e.g. credential placeholders). Implemented by authlib/shared.Store. +type SharedStore interface { + Put(key string, val any, ttl time.Duration) + Get(key string) (any, bool) + Delete(key string) +} + // Direction indicates whether a request is inbound (caller → this agent) or // outbound (this agent → target service). type Direction int @@ -123,6 +132,10 @@ type Context struct { // SessionEvent.TLS for the observability surface. TLS *tls.ConnectionState + // Shared is the process-scoped store the listener injects. May be nil + // when no store is wired; plugins that require it must fail closed. + Shared SharedStore + // Response-phase fields (populated by listener before RunResponse). // ResponseBody may be nil even during response phase if no plugin declared BodyAccess. StatusCode int diff --git a/authbridge/authlib/pipeline/sharedstore_test.go b/authbridge/authlib/pipeline/sharedstore_test.go new file mode 100644 index 000000000..839eaf9db --- /dev/null +++ b/authbridge/authlib/pipeline/sharedstore_test.go @@ -0,0 +1,21 @@ +package pipeline_test + +import ( + "testing" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/shared" +) + +// shared.Store must satisfy pipeline.SharedStore so listeners can inject it. +func TestSharedStore_StoreSatisfiesInterface(t *testing.T) { + var _ pipeline.SharedStore = shared.New() +} + +// Context must expose a Shared field of the interface type. +func TestSharedStore_ContextField(t *testing.T) { + pctx := &pipeline.Context{Shared: shared.New()} + if pctx.Shared == nil { + t.Fatal("Context.Shared not assignable") + } +} diff --git a/authbridge/authlib/placeholder/placeholder.go b/authbridge/authlib/placeholder/placeholder.go new file mode 100644 index 000000000..6a8025df5 --- /dev/null +++ b/authbridge/authlib/placeholder/placeholder.go @@ -0,0 +1,39 @@ +// Package placeholder defines the convention for opaque credential +// handles: a random "abph_" token the agent receives in place of the real +// Authorization value, plus the namespaced key used to store the real +// token in a shared store. The generic store itself (authlib/shared) holds +// no credential semantics. +package placeholder + +import ( + "crypto/rand" + "encoding/base64" + "strings" +) + +// Prefix marks a value as a credential placeholder. token-exchange uses it +// as a cheap fast-path before attempting a store lookup. +const Prefix = "abph_" + +// keyNamespace prefixes shared-store keys to avoid collisions with other +// shared-store consumers. +const keyNamespace = "placeholder/" + +// New returns a fresh, unguessable handle (Prefix + 256 bits base64url). +func New() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return Prefix + base64.RawURLEncoding.EncodeToString(b), nil +} + +// IsPlaceholder reports whether token is a placeholder handle. +func IsPlaceholder(token string) bool { + return strings.HasPrefix(token, Prefix) +} + +// Key returns the shared-store key for a handle. +func Key(handle string) string { + return keyNamespace + handle +} diff --git a/authbridge/authlib/placeholder/placeholder_test.go b/authbridge/authlib/placeholder/placeholder_test.go new file mode 100644 index 000000000..53b5c6728 --- /dev/null +++ b/authbridge/authlib/placeholder/placeholder_test.go @@ -0,0 +1,39 @@ +package placeholder + +import ( + "strings" + "testing" +) + +func TestNew_HasPrefixAndIsUnique(t *testing.T) { + a, err := New() + if err != nil { + t.Fatalf("New: %v", err) + } + if !strings.HasPrefix(a, Prefix) { + t.Fatalf("handle %q missing prefix %q", a, Prefix) + } + if len(a) <= len(Prefix)+10 { + t.Fatalf("handle %q too short", a) + } + b, _ := New() + if a == b { + t.Fatal("two handles collided") + } +} + +func TestIsPlaceholder(t *testing.T) { + h, _ := New() + if !IsPlaceholder(h) { + t.Fatalf("IsPlaceholder(%q) = false", h) + } + if IsPlaceholder("eyJhbGci-real-jwt") { + t.Fatal("real token misclassified as placeholder") + } +} + +func TestKey_NamespacesHandle(t *testing.T) { + if got := Key("abph_xyz"); got != "placeholder/abph_xyz" { + t.Fatalf("Key = %q", got) + } +} diff --git a/authbridge/authlib/plugins/jwtvalidation/placeholder_test.go b/authbridge/authlib/plugins/jwtvalidation/placeholder_test.go new file mode 100644 index 000000000..5f55d0563 --- /dev/null +++ b/authbridge/authlib/plugins/jwtvalidation/placeholder_test.go @@ -0,0 +1,82 @@ +package jwtvalidation + +import ( + "net/http" + "testing" + "time" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/placeholder" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/shared" +) + +func mintTestContext(store pipeline.SharedStore) *pipeline.Context { + pctx := &pipeline.Context{ + Direction: pipeline.Inbound, + Path: "/work", + Headers: http.Header{"Authorization": []string{"Bearer real-user-token"}}, + Shared: store, + } + pctx.SetCurrentPlugin("jwt-validation", pipeline.InvocationPhaseRequest) + return pctx +} + +func TestMint_ReplacesAuthAndStoresToken(t *testing.T) { + st := shared.New() + p := &JWTValidation{ + cfg: jwtValidationConfig{PlaceholderMode: true}, + placeholderTTL: time.Hour, + } + pctx := mintTestContext(st) + + handle, real, ok := p.mint(pctx) + if !ok { + t.Fatal("mint returned not-ok") + } + if !placeholder.IsPlaceholder(handle) { + t.Fatalf("handle %q not a placeholder", handle) + } + if real != "real-user-token" { + t.Fatalf("stored token = %q", real) + } + if pctx.Headers.Get("Authorization") != "Bearer "+handle { + t.Fatalf("header = %q, want Bearer %s", pctx.Headers.Get("Authorization"), handle) + } + got, present := st.Get(placeholder.Key(handle)) + if !present || got.(string) != "real-user-token" { + t.Fatalf("store[%s] = %v, %v", handle, got, present) + } +} + +func TestMint_NilStoreFailsClosed(t *testing.T) { + p := &JWTValidation{cfg: jwtValidationConfig{PlaceholderMode: true}, placeholderTTL: time.Hour} + pctx := mintTestContext(nil) + if _, _, ok := p.mint(pctx); ok { + t.Fatal("mint must fail when Shared is nil") + } +} + +func TestConfigure_ParsesPlaceholderTTL(t *testing.T) { + p := NewJWTValidation() + raw := []byte(`{"issuer":"https://kc/realms/x","jwks_url":"https://kc/jwks","audience":"agent","placeholder_mode":true,"placeholder_ttl":"15m"}`) + if err := p.Configure(raw); err != nil { + t.Fatalf("Configure: %v", err) + } + if !p.cfg.PlaceholderMode { + t.Fatal("placeholder_mode not parsed") + } + if p.placeholderTTL != 15*time.Minute { + t.Fatalf("ttl = %v, want 15m", p.placeholderTTL) + } +} + +func TestConfigure_DefaultPlaceholderTTL(t *testing.T) { + p := NewJWTValidation() + raw := []byte(`{"issuer":"https://kc/realms/x","jwks_url":"https://kc/jwks","audience":"agent","placeholder_mode":true}`) + if err := p.Configure(raw); err != nil { + t.Fatalf("Configure: %v", err) + } + if p.placeholderTTL != time.Hour { + t.Fatalf("default ttl = %v, want 1h", p.placeholderTTL) + } +} diff --git a/authbridge/authlib/plugins/jwtvalidation/plugin.go b/authbridge/authlib/plugins/jwtvalidation/plugin.go index f422c2f22..6848b014f 100644 --- a/authbridge/authlib/plugins/jwtvalidation/plugin.go +++ b/authbridge/authlib/plugins/jwtvalidation/plugin.go @@ -9,11 +9,13 @@ import ( "log/slog" "strings" "sync/atomic" + "time" "github.com/kagenti/kagenti-extensions/authbridge/authlib/auth" "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/placeholder" "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" @@ -99,6 +101,9 @@ type jwtValidationConfig struct { // BypassPaths are URL path globs (see authlib/bypass) that skip // validation entirely. BypassPaths []string `json:"bypass_paths" description:"Path globs (path.Match) skipped without JWT validation. Defaults: /healthz /readyz /livez /.well-known/*."` + + PlaceholderMode bool `json:"placeholder_mode" default:"false" description:"After validating the inbound token, replace it with an opaque placeholder before forwarding to the agent; the real token is held in the shared store for the outbound path to resolve. Requires a shared store and token-exchange resolve_placeholders downstream."` + PlaceholderTTL string `json:"placeholder_ttl" default:"1h" description:"How long the real token is retained for outbound resolution (Go duration, e.g. 30m). Default 1h."` } func (c *jwtValidationConfig) applyDefaults() { @@ -201,6 +206,8 @@ type JWTValidation struct { // so the lock-free guarantee is future-proofing rather than a // correctness fix for current callers. bgCancel atomic.Pointer[context.CancelFunc] + + placeholderTTL time.Duration } // NewJWTValidation constructs an unconfigured plugin. Configure must be @@ -290,6 +297,15 @@ func (p *JWTValidation) Configure(raw json.RawMessage) error { Bypass: matcher, Identity: auth.IdentityConfig{Audiences: audiences, Issuer: c.Issuer}, }) + + p.placeholderTTL = time.Hour + if c.PlaceholderTTL != "" { + d, err := time.ParseDuration(c.PlaceholderTTL) + if err != nil { + return fmt.Errorf("jwt-validation: invalid placeholder_ttl %q: %w", c.PlaceholderTTL, err) + } + p.placeholderTTL = d + } return nil } @@ -346,6 +362,26 @@ func (p *JWTValidation) Shutdown(_ context.Context) error { return nil } +// mint replaces the validated Authorization header with an opaque placeholder +// and stores the real bearer token in the shared store. Returns the handle, +// the stored token, and ok=false (fail closed) when no store is wired. +func (p *JWTValidation) mint(pctx *pipeline.Context) (handle, real string, ok bool) { + if pctx.Shared == nil { + return "", "", false + } + real = auth.ExtractBearer(pctx.Headers.Get("Authorization")) + if real == "" { + return "", "", false + } + h, err := placeholder.New() + if err != nil { + return "", "", false + } + pctx.Shared.Put(placeholder.Key(h), real, p.placeholderTTL) + pctx.Headers.Set("Authorization", "Bearer "+h) + return h, real, true +} + func (p *JWTValidation) OnRequest(ctx context.Context, pctx *pipeline.Context) pipeline.Action { if p.inner == nil { return pipeline.DenyStatus(503, "upstream.unreachable", "jwt-validation not configured") @@ -410,6 +446,21 @@ func (p *JWTValidation) OnRequest(ctx context.Context, pctx *pipeline.Context) p "token_scopes": strings.Join(result.Claims.Scopes, " "), }, }) + if p.cfg.PlaceholderMode { + handle, _, ok := p.mint(pctx) + if !ok { + pctx.Record(pipeline.Invocation{ + Action: pipeline.ActionDeny, + Reason: "placeholder_mint_failed", + }) + return pipeline.DenyStatus(503, "upstream.unreachable", "placeholder_mode requires a shared store") + } + pctx.Record(pipeline.Invocation{ + Action: pipeline.ActionModify, + Reason: "placeholder_minted", + Details: map[string]string{"handle_prefix": handle[:len(placeholder.Prefix)+6]}, + }) + } return pipeline.Action{Type: pipeline.Continue} } diff --git a/authbridge/authlib/plugins/tokenexchange/placeholder_test.go b/authbridge/authlib/plugins/tokenexchange/placeholder_test.go new file mode 100644 index 000000000..f15d19c89 --- /dev/null +++ b/authbridge/authlib/plugins/tokenexchange/placeholder_test.go @@ -0,0 +1,133 @@ +package tokenexchange + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/placeholder" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/shared" +) + +func resolveTestPlugin(t *testing.T, exchangeURL string) *TokenExchange { + t.Helper() + p := NewTokenExchange() + raw := []byte(`{ + "token_url":"` + exchangeURL + `", + "default_policy":"exchange", + "resolve_placeholders":true, + "identity":{"type":"client-secret","client_id":"agent","client_secret":"secret"} + }`) + if err := p.Configure(raw); err != nil { + t.Fatalf("Configure: %v", err) + } + return p +} + +func exchangeStub(t *testing.T) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": "exchanged-token", "token_type": "Bearer", "expires_in": 300, + }) + })) +} + +func TestResolve_MatchedRouteExchanges(t *testing.T) { + srv := exchangeStub(t) + defer srv.Close() + p := resolveTestPlugin(t, srv.URL) + + st := shared.New() + handle, _ := placeholder.New() + st.Put(placeholder.Key(handle), "real-user-token", time.Hour) + + pctx := &pipeline.Context{ + Direction: pipeline.Outbound, + Host: "target-svc", + Headers: http.Header{"Authorization": []string{"Bearer " + handle}}, + Shared: st, + } + action := invokeOnRequest(p, pctx) + if action.Type != pipeline.Continue { + t.Fatalf("action = %v, want Continue", action.Type) + } + if pctx.Headers.Get("Authorization") != "Bearer exchanged-token" { + t.Fatalf("auth = %q, want Bearer exchanged-token", pctx.Headers.Get("Authorization")) + } +} + +func TestResolve_MissDenies(t *testing.T) { + srv := exchangeStub(t) + defer srv.Close() + p := resolveTestPlugin(t, srv.URL) + + pctx := &pipeline.Context{ + Direction: pipeline.Outbound, + Host: "target-svc", + Headers: http.Header{"Authorization": []string{"Bearer abph_unknownhandle"}}, + Shared: shared.New(), + } + action := invokeOnRequest(p, pctx) + if action.Type != pipeline.Reject { + t.Fatalf("action = %v, want Reject", action.Type) + } +} + +func TestResolve_NonPlaceholderPassThrough(t *testing.T) { + srv := exchangeStub(t) + defer srv.Close() + p := resolveTestPlugin(t, srv.URL) + + pctx := &pipeline.Context{ + Direction: pipeline.Outbound, + Host: "target-svc", + Headers: http.Header{"Authorization": []string{"Bearer real-jwt"}}, + Shared: shared.New(), + } + action := invokeOnRequest(p, pctx) + if action.Type != pipeline.Continue { + t.Fatalf("action = %v, want Continue", action.Type) + } + if pctx.Headers.Get("Authorization") != "Bearer exchanged-token" { + t.Fatalf("auth = %q, want Bearer exchanged-token (normal exchange)", pctx.Headers.Get("Authorization")) + } +} + +func TestResolve_UnmatchedRoutePlaceholderNotLeaked(t *testing.T) { + p := NewTokenExchange() + // default_policy defaults to passthrough; with no routes, every host is + // passthrough — token-exchange must NOT write the resolved real token to + // the header. The agent forwards the opaque handle off-route; the real + // token stays in-process. + raw := []byte(`{ + "token_url":"http://unused.local", + "resolve_placeholders":true, + "identity":{"type":"client-secret","client_id":"agent","client_secret":"secret"} + }`) + if err := p.Configure(raw); err != nil { + t.Fatalf("Configure: %v", err) + } + + st := shared.New() + handle, _ := placeholder.New() + st.Put(placeholder.Key(handle), "real-user-token", time.Hour) + + pctx := &pipeline.Context{ + Direction: pipeline.Outbound, + Host: "some-passthrough-host", + Headers: http.Header{"Authorization": []string{"Bearer " + handle}}, + Shared: st, + } + action := invokeOnRequest(p, pctx) + if action.Type != pipeline.Continue { + t.Fatalf("action = %v, want Continue", action.Type) + } + got := pctx.Headers.Get("Authorization") + if got != "Bearer "+handle { + t.Fatalf("header = %q, want unchanged \"Bearer %s\" — real token must not leak off-route", got, handle) + } +} diff --git a/authbridge/authlib/plugins/tokenexchange/plugin.go b/authbridge/authlib/plugins/tokenexchange/plugin.go index f992ce615..2fa315cb1 100644 --- a/authbridge/authlib/plugins/tokenexchange/plugin.go +++ b/authbridge/authlib/plugins/tokenexchange/plugin.go @@ -14,6 +14,7 @@ import ( "github.com/kagenti/kagenti-extensions/authbridge/authlib/auth" "github.com/kagenti/kagenti-extensions/authbridge/authlib/config" "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/placeholder" "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins" "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/tokenexchange/cache" "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/tokenexchange/exchange" @@ -65,6 +66,8 @@ type tokenExchangeConfig struct { // routing.ServiceNameFromHost(host) as the target audience. Used in // waypoint mode. AudienceFromHost bool `json:"audience_from_host" description:"When true, derive audience from host for unrouted requests (waypoint mode)." default:"false"` + + ResolvePlaceholders bool `json:"resolve_placeholders" default:"false" description:"Resolve an inbound bearer carrying the placeholder prefix from the shared store to the real token before exchange. Unresolvable placeholders are denied (fail closed)."` } type tokenExchangeIdentity struct { @@ -566,6 +569,14 @@ func (p *TokenExchange) OnRequest(ctx context.Context, pctx *pipeline.Context) p authHeader := pctx.Headers.Get("Authorization") host := pctx.Host + if p.cfg.ResolvePlaceholders && placeholder.IsPlaceholder(auth.ExtractBearer(authHeader)) { + real, ok := resolvePlaceholder(pctx, auth.ExtractBearer(authHeader)) + if !ok { + return pctx.DenyAndRecord("placeholder_unresolved", "auth.unauthorized", "unresolvable credential placeholder") + } + authHeader = "Bearer " + real + } + result := p.inner.HandleOutbound(ctx, authHeader, host) // Record an Auth.Outbound entry on every branch so operators have // full outbound audit in the session stream — matches the inbound @@ -633,6 +644,20 @@ func (p *TokenExchange) OnRequest(ctx context.Context, pctx *pipeline.Context) p return pipeline.Action{Type: pipeline.Continue} } +// resolvePlaceholder looks a handle up in the shared store. Returns ok=false +// (fail closed) when no store is wired or the handle is unknown/expired. +func resolvePlaceholder(pctx *pipeline.Context, handle string) (string, bool) { + if pctx.Shared == nil { + return "", false + } + v, ok := pctx.Shared.Get(placeholder.Key(handle)) + if !ok { + return "", false + } + s, ok := v.(string) + return s, ok +} + // boolStr renders a boolean as "true" / "false" for Invocation.Details. // Kept as a small helper rather than inlining so both the deny and // modify branches use the same string form and abctl's filter diff --git a/authbridge/authlib/shared/store.go b/authbridge/authlib/shared/store.go new file mode 100644 index 000000000..8ae66b694 --- /dev/null +++ b/authbridge/authlib/shared/store.go @@ -0,0 +1,101 @@ +// Package shared provides a generic, process-scoped, TTL key→value store +// that plugins reach via pipeline.Context.Shared. It is intentionally +// semantics-free — feature-specific conventions (e.g. credential +// placeholders) live in their own packages and namespace their keys. +package shared + +import ( + "sync" + "time" +) + +const defaultSweepInterval = time.Minute + +type entry struct { + val any + expires time.Time +} + +// Store is a thread-safe TTL map. The zero value is not usable; call New. +type Store struct { + mu sync.RWMutex + items map[string]entry + now func() time.Time // injectable for tests + stop chan struct{} + closeOnce sync.Once +} + +// New returns an empty Store with a background janitor running. Call Close +// to stop the janitor when the store is no longer needed. +func New() *Store { + s := &Store{items: make(map[string]entry), now: time.Now, stop: make(chan struct{})} + go s.janitor(defaultSweepInterval) + return s +} + +// Put stores val under key with the given time-to-live. +func (s *Store) Put(key string, val any, ttl time.Duration) { + s.mu.Lock() + defer s.mu.Unlock() + s.items[key] = entry{val: val, expires: s.now().Add(ttl)} +} + +// Get returns the value for key if present and unexpired. Expired entries +// are evicted lazily. +func (s *Store) Get(key string) (any, bool) { + s.mu.RLock() + e, ok := s.items[key] + s.mu.RUnlock() + if !ok { + return nil, false + } + if s.now().After(e.expires) { + s.mu.Lock() + // Re-check under the write lock so a concurrent Put that refreshed + // this key isn't clobbered by a stale eviction. + if cur, present := s.items[key]; present && s.now().After(cur.expires) { + delete(s.items, key) + } + s.mu.Unlock() + return nil, false + } + return e.val, true +} + +// Delete removes key. +func (s *Store) Delete(key string) { + s.mu.Lock() + delete(s.items, key) + s.mu.Unlock() +} + +// janitor periodically reclaims expired entries until Close is called. +func (s *Store) janitor(interval time.Duration) { + t := time.NewTicker(interval) + defer t.Stop() + for { + select { + case <-t.C: + s.sweep() + case <-s.stop: + return + } + } +} + +// sweep deletes all expired entries. +func (s *Store) sweep() { + s.mu.Lock() + defer s.mu.Unlock() + now := s.now() + for k, e := range s.items { + if now.After(e.expires) { + delete(s.items, k) + } + } +} + +// Close stops the background janitor. Safe to call multiple times. +func (s *Store) Close() { + s.closeOnce.Do(func() { close(s.stop) }) +} diff --git a/authbridge/authlib/shared/store_test.go b/authbridge/authlib/shared/store_test.go new file mode 100644 index 000000000..0a6a42180 --- /dev/null +++ b/authbridge/authlib/shared/store_test.go @@ -0,0 +1,110 @@ +package shared + +import ( + "sync" + "testing" + "time" +) + +func TestStore_PutGet(t *testing.T) { + s := New() + defer s.Close() + s.Put("k", "v", time.Minute) + got, ok := s.Get("k") + if !ok || got.(string) != "v" { + t.Fatalf("Get = %v, %v; want v, true", got, ok) + } +} + +func TestStore_GetMissing(t *testing.T) { + s := New() + defer s.Close() + if _, ok := s.Get("nope"); ok { + t.Fatal("expected miss") + } +} + +func TestStore_Expiry(t *testing.T) { + s := New() + defer s.Close() + now := time.Unix(1000, 0) + s.now = func() time.Time { return now } + s.Put("k", "v", time.Minute) + now = now.Add(30 * time.Second) + if _, ok := s.Get("k"); !ok { + t.Fatal("should still be live at 30s") + } + now = now.Add(31 * time.Second) + if _, ok := s.Get("k"); ok { + t.Fatal("should be expired past 60s") + } +} + +func TestStore_Delete(t *testing.T) { + s := New() + defer s.Close() + s.Put("k", "v", time.Minute) + s.Delete("k") + if _, ok := s.Get("k"); ok { + t.Fatal("expected deleted") + } +} + +func TestStore_ConcurrentAccess(t *testing.T) { + s := New() + defer s.Close() + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + key := string(rune('a' + n%26)) + s.Put(key, n, time.Minute) + s.Get(key) + }(i) + } + wg.Wait() +} + +// TestStore_SweepEvictsExpired verifies sweep() reclaims expired entries and +// leaves unexpired ones intact. Uses an injected clock and calls sweep() +// directly so the test is deterministic — it does not rely on the janitor's +// timer. +func TestStore_SweepEvictsExpired(t *testing.T) { + s := New() + defer s.Close() + now := time.Unix(1000, 0) + s.now = func() time.Time { return now } + + s.Put("a", 1, time.Second) + s.Put("b", 2, time.Second) + s.Put("live", 3, time.Hour) + + now = now.Add(2 * time.Second) // a and b expired; live still valid + + s.sweep() + + s.mu.RLock() + _, aPresent := s.items["a"] + _, bPresent := s.items["b"] + _, livePresent := s.items["live"] + s.mu.RUnlock() + + if aPresent || bPresent { + t.Errorf("sweep should have evicted expired keys: a=%v b=%v", aPresent, bPresent) + } + if !livePresent { + t.Error("sweep should have kept the unexpired key 'live'") + } + if _, ok := s.Get("live"); !ok { + t.Error("Get('live') should still succeed after sweep") + } +} + +// TestStore_CloseIsIdempotent verifies Close can be called multiple times +// without panicking. +func TestStore_CloseIsIdempotent(t *testing.T) { + s := New() + s.Close() + s.Close() +} diff --git a/authbridge/cmd/authbridge-envoy/main.go b/authbridge/cmd/authbridge-envoy/main.go index ccf6c42bf..7b995675c 100644 --- a/authbridge/cmd/authbridge-envoy/main.go +++ b/authbridge/cmd/authbridge-envoy/main.go @@ -39,6 +39,7 @@ import ( "github.com/kagenti/kagenti-extensions/authbridge/authlib/reloader" "github.com/kagenti/kagenti-extensions/authbridge/authlib/session" "github.com/kagenti/kagenti-extensions/authbridge/authlib/sessionapi" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/shared" "github.com/kagenti/kagenti-extensions/authbridge/authlib/spiffe" // Only the ext_proc listener is compiled in (no ext_authz, no @@ -212,8 +213,11 @@ func main() { slog.Info("session tracking disabled") } + store := shared.New() + defer store.Close() // stop the TTL janitor on normal main return + var grpcServers []*grpc.Server - grpcServers = append(grpcServers, startGRPCExtProc(inboundH, outboundH, sessions, cfg.Listener.ExtProcAddr)) + grpcServers = append(grpcServers, startGRPCExtProc(inboundH, outboundH, sessions, store, cfg.Listener.ExtProcAddr)) statsProvider := func() *auth.Stats { sources := plugins.CollectStats(inboundH.Load()) @@ -296,12 +300,13 @@ func main() { } } -func startGRPCExtProc(inbound, outbound *pipeline.Holder, sessions *session.Store, addr string) *grpc.Server { +func startGRPCExtProc(inbound, outbound *pipeline.Holder, sessions *session.Store, store pipeline.SharedStore, addr string) *grpc.Server { srv := grpc.NewServer() extprocv3.RegisterExternalProcessorServer(srv, &extproc.Server{ InboundPipeline: inbound, OutboundPipeline: outbound, Sessions: sessions, + Shared: store, }) registerHealth(srv) reflection.Register(srv) diff --git a/authbridge/cmd/authbridge-lite/main.go b/authbridge/cmd/authbridge-lite/main.go index 7ac6f1424..948fc3de3 100644 --- a/authbridge/cmd/authbridge-lite/main.go +++ b/authbridge/cmd/authbridge-lite/main.go @@ -30,6 +30,7 @@ import ( "github.com/kagenti/kagenti-extensions/authbridge/authlib/reloader" "github.com/kagenti/kagenti-extensions/authbridge/authlib/session" "github.com/kagenti/kagenti-extensions/authbridge/authlib/sessionapi" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/shared" "github.com/kagenti/kagenti-extensions/authbridge/authlib/spiffe" authtls "github.com/kagenti/kagenti-extensions/authbridge/authlib/tls" @@ -237,6 +238,10 @@ func main() { if err != nil { log.Fatalf("creating forward proxy: %v", err) } + sharedStore := shared.New() + defer sharedStore.Close() // stop the TTL janitor on normal main return + rpSrv.Shared = sharedStore + fpSrv.Shared = sharedStore httpServers = append(httpServers, startReverseProxyServer("reverse-proxy", rpSrv, cfg.Listener.ReverseProxyAddr)) httpServers = append(httpServers, startHTTPServer("forward-proxy", fpSrv.Handler(), cfg.Listener.ForwardProxyAddr)) _ = mtlsMetrics // TODO Phase 2: surface metrics through /stats diff --git a/authbridge/cmd/authbridge-proxy/main.go b/authbridge/cmd/authbridge-proxy/main.go index 340493305..21d7b6865 100644 --- a/authbridge/cmd/authbridge-proxy/main.go +++ b/authbridge/cmd/authbridge-proxy/main.go @@ -30,6 +30,7 @@ import ( "github.com/kagenti/kagenti-extensions/authbridge/authlib/reloader" "github.com/kagenti/kagenti-extensions/authbridge/authlib/session" "github.com/kagenti/kagenti-extensions/authbridge/authlib/sessionapi" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/shared" "github.com/kagenti/kagenti-extensions/authbridge/authlib/spiffe" authtls "github.com/kagenti/kagenti-extensions/authbridge/authlib/tls" @@ -251,6 +252,10 @@ func main() { if err != nil { log.Fatalf("creating forward proxy: %v", err) } + sharedStore := shared.New() + defer sharedStore.Close() // stop the TTL janitor on normal main return + rpSrv.Shared = sharedStore + fpSrv.Shared = sharedStore httpServers = append(httpServers, startReverseProxyServer("reverse-proxy", rpSrv, cfg.Listener.ReverseProxyAddr)) httpServers = append(httpServers, startHTTPServer("forward-proxy", fpSrv.Handler(), cfg.Listener.ForwardProxyAddr)) _ = mtlsMetrics // TODO Phase 2: surface metrics through /stats diff --git a/authbridge/demos/echo/.gitignore b/authbridge/demos/echo/.gitignore new file mode 100644 index 000000000..ab6901c56 --- /dev/null +++ b/authbridge/demos/echo/.gitignore @@ -0,0 +1 @@ +.stamps/ diff --git a/authbridge/demos/echo/Makefile b/authbridge/demos/echo/Makefile new file mode 100644 index 000000000..ac8e12664 --- /dev/null +++ b/authbridge/demos/echo/Makefile @@ -0,0 +1,293 @@ +# Echo demo Makefile — credential placeholder-swap, kagenti-UI flow. +# +# End-to-end: +# +# make demo-echo # build sidecar + demo images, deploy, keycloak, patch, banner +# make show-result # tail upstream logs (ground-truth echoed credential) +# make undeploy # remove the demo's resources from team1 +# +# Unlike the IBAC demo, this demo needs a freshly-built authbridge sidecar +# image: credential placeholder swap is a new authbridge capability, so we +# build the proxy from the repo root and point the kagenti platform config +# at the local tag before deploying the agent. +# +# Prerequisites: +# - kagenti installed on a kind cluster (this is the operator-flow demo) +# - kubectl, kind, podman/docker, python3 + PyYAML +# - python-keycloak (for setup-keycloak; pip install python-keycloak) + +.PHONY: help preflight build-sidecar load-sidecar override-sidecar-image \ + build-images load-images deploy wait-pods setup-keycloak \ + patch-config demo-echo show-result undeploy \ + logs-agent logs-upstream + +# `make` with no target prints help — easier discoverability than the +# walls-of-comments at the top. +.DEFAULT_GOAL := help + +help: ## Show this menu (default) + @printf "\nEcho demo — credential placeholder-swap, operator-injected agent in team1.\n\n" + @printf "Typical run:\n" + @printf " \033[1mmake demo-echo\033[0m\n" + @printf " ... chat with echo-agent in the kagenti UI ...\n" + @printf " \033[1mmake show-result\033[0m\n" + @printf " \033[1mmake undeploy\033[0m\n\n" + @printf "Targets:\n" + @awk 'BEGIN { FS = ":.*## " } \ + /^[a-zA-Z_-]+:.*## / { printf " \033[36m%-22s\033[0m %s\n", $$1, $$2 }' \ + $(MAKEFILE_LIST) + @printf "\nVariables:\n" + @printf " \033[36m%-22s\033[0m %s\n" "KIND_CLUSTER_NAME" "kind cluster to load images into (default: kagenti)" + @printf " \033[36m%-22s\033[0m %s\n" "NAMESPACE" "agent namespace (default: team1)" + @printf " \033[36m%-22s\033[0m %s\n" "AGENT_NAME" "deployment name (default: echo-agent)" + @printf " \033[36m%-22s\033[0m %s\n" "SIDECAR_IMAGE" "authbridge sidecar tag (default: authbridge:placeholder-dev)" + @printf " \033[36m%-22s\033[0m %s\n" "CONTAINER_RUNTIME" "podman or docker (auto-detected)" + @printf "\nPrerequisites: kagenti install on a kind cluster, kubectl/kind/podman,\n" + @printf "python3 + PyYAML, and python-keycloak for setup-keycloak.\n\n" + +KIND_CLUSTER_NAME ?= kagenti +KIND_NODE ?= $(KIND_CLUSTER_NAME)-control-plane +NAMESPACE ?= team1 +AGENT_NAME ?= echo-agent + +# Authbridge sidecar image built from the repo root (the placeholder-swap +# capability is new, so the demo overrides the platform's default sidecar +# image with this locally-built tag). +SIDECAR_IMAGE ?= authbridge:placeholder-dev + +# Container runtime auto-detect. +CONTAINER_RUNTIME ?= $(shell command -v podman >/dev/null 2>&1 && echo podman || echo docker) + +# Demo-image tags (local-only, not pushed). +AGENT_IMAGE := echo-agent:latest +UPSTREAM_IMAGE := echo-upstream:latest + +# Repo root (demos/echo -> demos -> authbridge). The sidecar Dockerfile +# lives at cmd/authbridge-proxy/Dockerfile and builds with the repo root +# as context. +REPO_ROOT := $(abspath ../..) + +# Per-checkout build/load bookkeeping. `.stamps/.built` is a make-tracked +# stamp file: its mtime is compared against source files so build-images +# rebuilds only when something actually changed. `.stamps/.loaded` +# tracks the last successful kind-load — managed manually because make can't +# stat images inside a kind node. Wiped by `undeploy`. +STAMP_DIR := .stamps +LOAD_MARKER := $(STAMP_DIR)/.loaded-this-run + +# ---------- Pre-flight ---------- + +preflight: ## Verify kagenti is installed + tools are available + @kubectl get ns kagenti-system >/dev/null 2>&1 || { \ + echo "ERROR: namespace 'kagenti-system' not found — kagenti doesn't appear to be installed."; \ + echo " Install kagenti first (see https://github.com/kagenti/kagenti/blob/main/docs/install.md)"; \ + exit 1; \ + } + @kubectl get ns $(NAMESPACE) >/dev/null 2>&1 || { \ + echo "ERROR: namespace '$(NAMESPACE)' not found — kagenti's agent namespace is missing."; \ + echo " Did the kagenti install complete? Try: kubectl get ns | grep team"; \ + exit 1; \ + } + @command -v kind >/dev/null 2>&1 || { echo "ERROR: kind not installed"; exit 1; } + @python3 -c 'import yaml' 2>/dev/null || { \ + echo "ERROR: python3-yaml (PyYAML) is required."; \ + echo " Install: pip3 install --user pyyaml"; \ + exit 1; \ + } + +# ---------- Sidecar (authbridge proxy) ---------- + +build-sidecar: ## Build the authbridge sidecar image from the repo root + @echo "[*] Building $(SIDECAR_IMAGE) from $(REPO_ROOT) ..." + $(CONTAINER_RUNTIME) build -f $(REPO_ROOT)/cmd/authbridge-proxy/Dockerfile -t $(SIDECAR_IMAGE) $(REPO_ROOT) + +load-sidecar: ## Load the sidecar image into the kind cluster + @echo "[*] Loading $(SIDECAR_IMAGE) into kind cluster $(KIND_CLUSTER_NAME) ..." + kind load docker-image $(SIDECAR_IMAGE) --name $(KIND_CLUSTER_NAME) + @# With the podman provider, kind leaves the image on the node as + @# localhost/; the operator references the bare tag (docker.io/library/), + @# so tag it there too or IfNotPresent will try (and fail) to pull. Mirrors load-images. + @$(CONTAINER_RUNTIME) exec $(KIND_NODE) ctr -n k8s.io images tag \ + localhost/$(SIDECAR_IMAGE) docker.io/library/$(SIDECAR_IMAGE) >/dev/null 2>&1 || true + +# Point the kagenti platform config at our locally-built sidecar tag so +# the operator's injecting webhook uses it for echo-agent. We edit the +# `images.authbridge` key inside the config.yaml of the +# `kagenti-platform-config` ConfigMap (kagenti-system), re-apply it, and +# roll the controller so the new value is picked up. +override-sidecar-image: ## Point kagenti's platform config at the local sidecar image + @echo "[*] Overriding images.authbridge -> $(SIDECAR_IMAGE) in kagenti-platform-config ..." + @CURRENT=$$(kubectl -n kagenti-system get configmap kagenti-platform-config \ + -o jsonpath='{.data.config\.yaml}'); \ + if [ -z "$$CURRENT" ]; then \ + echo "ERROR: could not read config.yaml from kagenti-platform-config (kagenti-system)."; \ + echo " Is kagenti installed? kubectl -n kagenti-system get cm kagenti-platform-config"; \ + exit 1; \ + fi; \ + MERGED=$$(printf '%s' "$$CURRENT" | SIDECAR_IMAGE='$(SIDECAR_IMAGE)' python3 -c 'import os, sys, yaml; \ +c = yaml.safe_load(sys.stdin) or {}; \ +c.setdefault("images", {})["authbridge"] = os.environ["SIDECAR_IMAGE"]; \ +sys.stdout.write(yaml.safe_dump(c, default_flow_style=False, sort_keys=False))'); \ + TMP=$$(mktemp); printf '%s' "$$MERGED" >"$$TMP"; \ + kubectl -n kagenti-system create configmap kagenti-platform-config \ + --from-file=config.yaml="$$TMP" --dry-run=client -o yaml | kubectl apply -f -; \ + rm -f "$$TMP" + @echo "[*] Restarting the kagenti controller to pick up the new sidecar image ..." + kubectl -n kagenti-system rollout restart deploy/kagenti-controller-manager + kubectl -n kagenti-system rollout status deploy/kagenti-controller-manager --timeout=120s + +# ---------- Build demo images ---------- + +# Per-service stamp rules. Each `.built` stamp depends on every file +# under that service's source directory plus go.mod (the shared module +# definition every Dockerfile copies in). Make naturally rebuilds only the +# services whose sources actually changed; an unchanged tree turns into +# a millisecond-scale no-op. + +$(STAMP_DIR): + @mkdir -p $@ + +$(STAMP_DIR)/agent.built: $(shell find agent -type f) go.mod | $(STAMP_DIR) + @echo "[*] Building $(AGENT_IMAGE) ..." + $(CONTAINER_RUNTIME) build -f agent/Dockerfile -t $(AGENT_IMAGE) . + @touch $@ + +$(STAMP_DIR)/upstream.built: $(shell find upstream -type f) go.mod | $(STAMP_DIR) + @echo "[*] Building $(UPSTREAM_IMAGE) ..." + $(CONTAINER_RUNTIME) build -f upstream/Dockerfile -t $(UPSTREAM_IMAGE) . + @touch $@ + +build-images: $(STAMP_DIR)/agent.built $(STAMP_DIR)/upstream.built ## Build demo images (rebuilds only when sources change) + +# load-images reloads each image when EITHER: +# - the kind node doesn't have it under docker.io/library/, OR +# - the .built stamp is newer than the .loaded stamp (rebuilt this run +# or in a prior session that didn't get to deploy). +# Both conditions independently fire a reload, which keeps the target +# self-healing across cluster recreation, manual `crictl rmi`, and +# rebuilds — without any flag. +load-images: build-images | $(STAMP_DIR) ## Load demo images into kind (reloads when node is missing the image or sources were rebuilt) + @rm -f $(LOAD_MARKER) + @for spec in agent=$(AGENT_IMAGE) upstream=$(UPSTREAM_IMAGE); do \ + svc=$${spec%%=*}; img=$${spec#*=}; \ + built=$(STAMP_DIR)/$$svc.built; \ + loaded=$(STAMP_DIR)/$$svc.loaded; \ + in_node=no; \ + $(CONTAINER_RUNTIME) exec $(KIND_NODE) ctr -n k8s.io images list -q 2>/dev/null \ + | grep -qx "docker.io/library/$$img" && in_node=yes; \ + if [ "$$in_node" = yes ] && [ -f "$$loaded" ] && [ ! "$$loaded" -ot "$$built" ]; then \ + echo "[*] $$img already in node and up-to-date — skip"; \ + continue; \ + fi; \ + echo "[*] Loading $$img into kind"; \ + kind load docker-image "$$img" --name $(KIND_CLUSTER_NAME); \ + $(CONTAINER_RUNTIME) exec $(KIND_NODE) ctr -n k8s.io images tag \ + localhost/$$img docker.io/library/$$img >/dev/null 2>&1 || true; \ + touch "$$loaded" $(LOAD_MARKER); \ + done + +# ---------- Deploy + patch ---------- + +deploy: ## Apply manifests; rollout-restart only if load-images actually loaded something + kubectl apply -f k8s/agent.yaml + kubectl apply -f k8s/upstream.yaml + @if [ -f $(LOAD_MARKER) ]; then \ + echo "[*] Image content changed — rolling restart"; \ + kubectl -n $(NAMESPACE) rollout restart deploy/echo-agent deploy/echo-upstream; \ + rm -f $(LOAD_MARKER); \ + else \ + echo "[*] No image changes this run — skip rollout restart."; \ + fi + $(MAKE) wait-pods + +wait-pods: ## Wait for pods + operator-rendered authbridge ConfigMap + @echo "[*] Waiting for echo-agent and echo-upstream ..." + kubectl -n $(NAMESPACE) wait --for=condition=Available --timeout=120s \ + deploy/echo-agent deploy/echo-upstream + @echo "[*] Waiting for the operator to admit the agent pod and create the authbridge ConfigMap ..." + @for i in $$(seq 1 60); do \ + if kubectl -n $(NAMESPACE) get configmap authbridge-config-$(AGENT_NAME) >/dev/null 2>&1; then \ + echo " ✓ authbridge-config-$(AGENT_NAME) present"; \ + break; \ + fi; \ + sleep 2; \ + done + @kubectl -n $(NAMESPACE) get configmap authbridge-config-$(AGENT_NAME) >/dev/null 2>&1 || { \ + echo "ERROR: operator did not create authbridge-config-$(AGENT_NAME) within 120s"; \ + echo " Check the agent pod state:"; \ + kubectl -n $(NAMESPACE) get pods -l app.kubernetes.io/name=$(AGENT_NAME); \ + exit 1; \ + } + +# setup-keycloak configures the realm clients/scopes/users for the demo. +# It talks to Keycloak over HTTP; on a kind install the in-cluster service +# is reachable at http://keycloak.localtest.me:8080 (default). If you +# can't reach that, port-forward in another terminal: +# kubectl port-forward service/keycloak-service -n keycloak 8080:8080 +# and set KEYCLOAK_URL=http://localhost:8080. +# Requires the python-keycloak package: pip install python-keycloak +setup-keycloak: ## Configure Keycloak (echo-upstream client, scopes, demo users) + @python3 -c 'import keycloak' 2>/dev/null || { \ + echo "ERROR: python-keycloak is required for setup-keycloak."; \ + echo " Install: pip install python-keycloak"; \ + exit 1; \ + } + python3 scripts/setup_keycloak.py --namespace $(NAMESPACE) --service-account $(AGENT_NAME) + +patch-config: ## Enable placeholder-swap + echo-upstream route in the rendered authbridge ConfigMap (and wait for hot-reload) + bash scripts/patch-echo-config.sh $(NAMESPACE) $(AGENT_NAME) + +# ---------- Top-level demo + result rendering ---------- + +demo-echo: preflight build-sidecar load-sidecar override-sidecar-image build-images load-images deploy wait-pods setup-keycloak patch-config ## Build, load, deploy the demo end-to-end and print next-steps banner + @echo + @echo "╔══════════════════════════════════════════════════════════╗" + @echo "║ ✅ Echo placeholder-swap demo agent ready ║" + @echo "║ ║" + @echo "║ Open the kagenti UI: ║" + @echo "║ http://kagenti-ui.localtest.me:8080 ║" + @echo "║ ║" + @echo "║ Log in as alice / alice123 (log out + back in if you ║" + @echo "║ were already signed in — new audience scope). ║" + @echo "║ ║" + @echo "║ Pick echo-agent from the Agents list, then chat: ║" + @echo "║ ║" + @echo "║ \"echo\" ║" + @echo "║ ║" + @echo "║ The reply shows two lines: ║" + @echo "║ - inbound : an abph_... placeholder (what the agent ║" + @echo "║ received from jwt-validation) ║" + @echo "║ - outbound : the exchanged token echo-upstream saw ║" + @echo "║ (aud=echo-upstream) ║" + @echo "║ ║" + @echo "║ Ground-truth check: ║" + @echo "║ make show-result ║" + @echo "╚══════════════════════════════════════════════════════════╝" + +show-result: ## Tail echo-upstream logs (the exchanged credential it received) + @echo "--- echo-upstream (last 50 lines) ---" + kubectl -n $(NAMESPACE) logs deploy/echo-upstream --tail=50 + +# ---------- Cleanup ---------- + +undeploy: ## Remove demo resources from team1 (kagenti install untouched) + -kubectl -n $(NAMESPACE) delete -f k8s/agent.yaml --ignore-not-found + -kubectl -n $(NAMESPACE) delete -f k8s/upstream.yaml --ignore-not-found + -kubectl -n $(NAMESPACE) delete agentruntime echo-agent --ignore-not-found + @echo "[*] Note: the operator-created ConfigMap authbridge-config-$(AGENT_NAME)" + @echo " may linger after the agent is gone. Remove manually if needed:" + @echo " kubectl -n $(NAMESPACE) delete configmap authbridge-config-$(AGENT_NAME)" + @rm -rf $(STAMP_DIR) + @echo "[*] Demo resources removed from $(NAMESPACE)." + @echo " The kagenti install + the namespace itself are untouched." + +# ---------- Diagnostics ---------- + +logs-agent: ## Tail agent + authbridge sidecar logs + @echo "--- agent ---" + kubectl -n $(NAMESPACE) logs deploy/$(AGENT_NAME) -c agent --tail=100 + @echo "--- authbridge ---" + kubectl -n $(NAMESPACE) logs deploy/$(AGENT_NAME) -c authbridge-proxy --tail=100 + +logs-upstream: ## Tail echo-upstream logs + kubectl -n $(NAMESPACE) logs deploy/echo-upstream --tail=100 diff --git a/authbridge/demos/echo/README.md b/authbridge/demos/echo/README.md new file mode 100644 index 000000000..08bea21df --- /dev/null +++ b/authbridge/demos/echo/README.md @@ -0,0 +1,152 @@ +# Echo demo — credential placeholder swap + +A minimal, UI-testable demo of AuthBridge's **credential placeholder swap**: +the user's real token never reaches the agent, yet the agent's outbound call +still arrives at the upstream carrying a correctly **exchanged** token. + +## What it proves + +When you chat `echo` with the agent in the kagenti UI, the reply shows two +lines: + +``` +inbound: abph_3f0c… <- what the AGENT received +outbound: eyJhbGciOi… <- what ECHO-UPSTREAM received (aud=echo-upstream) +``` + +- **inbound** is an opaque `abph_…` placeholder. The injected AuthBridge + sidecar's inbound `jwt-validation` runs in `placeholder_mode`: it validates + the user's real token, stashes it in the shared store, and forwards only the + placeholder to the agent. The agent never sees the user's bearer. +- **outbound** is a real JWT. On the agent's outbound call to `echo-upstream`, + the sidecar's `token-exchange` plugin resolves the placeholder back to the + real token (`resolve_placeholders: true`) and exchanges it for one with + `aud=echo-upstream`. `echo-upstream` simply returns the `Authorization` + header it received, giving you ground truth. + +`echo-upstream` carries **no** `kagenti.io/*` labels, so the operator's webhook +leaves it un-injected — what it logs is exactly what left the agent's sidecar. + +## Prerequisites + +- A running kagenti **kind** cluster (`kagenti-system` + `team1` namespaces). + See the [install guide](https://github.com/kagenti/kagenti/blob/main/docs/install.md). +- `kubectl`, `kind`, `podman` or `docker`. +- `python3` + PyYAML (config merge) and `python-keycloak` (Keycloak setup): + `pip install pyyaml python-keycloak`. + +## Run it + +```bash +cd authbridge/demos/echo +make demo-echo +``` + +`demo-echo` runs, in order: + +``` +preflight → build-sidecar → load-sidecar → override-sidecar-image + → build-images → load-images → deploy → wait-pods + → setup-keycloak → patch-config +``` + +The placeholder-swap capability is new, so the demo builds the AuthBridge +sidecar from the repo root (`build-sidecar`), loads it into kind, and points +the kagenti platform config's `images.authbridge` at the local tag +(`override-sidecar-image`) before deploying the agent. + +## Use it from the UI + +1. Open . +2. Log in as **alice / alice123**. If you were already signed in, **log out + and back in** — the demo adds a new audience scope to the UI client and + tokens only pick it up on a fresh login. +3. Pick **echo-agent** from the Agents list. +4. Chat: `echo`. +5. Confirm the reply's `inbound:` line is an `abph_…` placeholder and the + `outbound:` line is a JWT. + +## Ground-truth check + +```bash +kubectl -n team1 logs deploy/echo-upstream +# or: +make show-result +``` + +`echo-upstream` logs the `Authorization` header it received. Decode the JWT +(e.g. paste into jwt.io) and confirm `aud` is `echo-upstream` — proof the +exchange happened on the outbound leg, not the inbound one. + +## Testing in envoy-sidecar mode + +The demo defaults to **proxy-sidecar** mode. The placeholder feature is +mode-agnostic — it also works under **envoy-sidecar** mode (Envoy data plane + +ext_proc + proxy-init iptables). To exercise it: + +1. Build + load the ext_proc image and point the platform at it (mirrors + `build-sidecar`/`override-sidecar-image`, but for `images.envoyProxy`): + ```bash + podman build -f ../../cmd/authbridge-envoy/Dockerfile -t authbridge-envoy:placeholder-dev ../.. + kind load docker-image authbridge-envoy:placeholder-dev --name kagenti + podman exec kagenti-control-plane ctr -n k8s.io images tag \ + localhost/authbridge-envoy:placeholder-dev docker.io/library/authbridge-envoy:placeholder-dev + # set images.envoyProxy in kagenti-system/kagenti-platform-config to + # authbridge-envoy:placeholder-dev, then restart the operator: + kubectl -n kagenti-system rollout restart deploy/kagenti-controller-manager + ``` +2. Put echo-agent in envoy mode and restart so the operator re-injects Envoy + + ext_proc + proxy-init: + ```bash + kubectl -n team1 patch agentruntime echo-agent --type=merge \ + -p '{"spec":{"authBridgeMode":"envoy-sidecar"}}' + kubectl -n team1 rollout restart deploy/echo-agent + ``` +3. Re-run `make patch-config`, then chat from the UI as above. Both the inbound + `abph_…` placeholder and the outbound exchanged JWT behave identically to + proxy-sidecar mode. + +**Why the upstream listens on `:8888`, not `:8080`.** In envoy mode the +operator's proxy-init **excludes the agent's own Service port (8080) from +outbound iptables interception**. An upstream on 8080 would bypass +Envoy/ext_proc and the placeholder would reach it unresolved. echo-upstream +therefore uses `:8888`, which is intercepted. (Proxy-sidecar mode routes egress +via `HTTP_PROXY` and works on any port — so this choice is harmless there.) + +## Known live-cluster gotchas + +1. **Operator pull policy.** `override-sidecar-image` only helps if the + injected sidecar uses `imagePullPolicy: IfNotPresent` (so kind's locally + loaded image is used instead of pulling `:latest` from the registry). If + the agent pod shows `ErrImagePull` / `ImagePullBackOff` for the sidecar, + the platform is configured to always pull — load the tag and confirm the + pull policy, or push the sidecar image somewhere reachable. +2. **Re-run `setup-keycloak` after the agent registers.** The agent's own + Keycloak client is created dynamically when the agent pod starts. If + `setup-keycloak` ran before the client existed, the `echo-upstream-aud` + optional scope won't be attached to the agent client and the outbound + exchange fails. Re-run `make setup-keycloak` once the agent is up. +3. **The route must match.** The outbound exchange only fires for the host in + the `echo-patch.yaml` route (`echo-upstream`). The agent's `UPSTREAM_URL` + must resolve to that host. If the upstream sees the placeholder instead of + an exchanged token, the host didn't match the route — check + `k8s/echo-patch.yaml` against the agent's `UPSTREAM_URL`. + +## Clean up + +```bash +make undeploy +``` + +This removes the agent, upstream, and AgentRuntime from `team1`. The +operator-created `authbridge-config-echo-agent` ConfigMap may linger; remove it +manually if needed: + +```bash +kubectl -n team1 delete configmap authbridge-config-echo-agent +``` + +## Design + +See the design spec: +[`docs/superpowers/specs/2026-06-02-credential-placeholder-swap-design.md`](../../docs/superpowers/specs/2026-06-02-credential-placeholder-swap-design.md). diff --git a/authbridge/demos/echo/agent/Dockerfile b/authbridge/demos/echo/agent/Dockerfile new file mode 100644 index 000000000..c5d10b498 --- /dev/null +++ b/authbridge/demos/echo/agent/Dockerfile @@ -0,0 +1,17 @@ +# Multi-stage build for the echo demo agent. +# The agent has zero non-stdlib dependencies, so the build context is +# just main.go + go.mod. + +FROM golang:1.24-alpine AS build +WORKDIR /src +COPY go.mod ./ +COPY agent/main.go ./agent/main.go +RUN CGO_ENABLED=0 go build -o /out/agent ./agent + +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=build /out/agent /agent +# The agent listens on whatever PORT env says, defaulting to 8080. +# In the demo Pod we set PORT=8000 to leave 8080 for the authbridge +# sidecar's reverse proxy. EXPOSE is documentation-only; leaving it +# off avoids implying a port the operator hasn't picked. +ENTRYPOINT ["/agent"] diff --git a/authbridge/demos/echo/agent/main.go b/authbridge/demos/echo/agent/main.go new file mode 100644 index 000000000..dc2a95140 --- /dev/null +++ b/authbridge/demos/echo/agent/main.go @@ -0,0 +1,398 @@ +// Demo echo agent for the credential placeholder-swap walkthrough. +// +// Modeled on the IBAC demo agent (authbridge/demos/ibac/agent/main.go), +// keeping all the A2A / agent-card / Task scaffolding verbatim so the +// agent stays discoverable and chat-able in the kagenti UI. The IBAC +// demo's Ollama/LLM/tool/email machinery is removed — this agent does +// exactly one thing on each message/send: +// +// 1. Read the inbound Authorization header off the incoming HTTP +// request. With authbridge's credential placeholder mode on, this +// is a "Bearer abph_" placeholder, NOT the real token. +// 2. Make ONE outbound HTTP GET through the proxied client (HTTP_PROXY, +// i.e. the authbridge sidecar's forward proxy) to UPSTREAM_URL/echo, +// forwarding that same Authorization header value unchanged. On the +// way out, the sidecar's token-exchange plugin resolves the +// placeholder back to the real credential and exchanges it for an +// echo-upstream-audience token. +// 3. Read echo-upstream's plaintext reply (the Authorization it saw). +// 4. Report both values back in the A2A response so the placeholder +// swap is visible end-to-end. +package main + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "time" +) + +// proxiedClient is the HTTP client used for the outbound echo call that +// MUST flow through the authbridge proxy-sidecar (so the token-exchange +// plugin can resolve the credential placeholder). Built once in main() +// with an explicit http.ProxyURL transport — http.DefaultClient honors +// HTTP_PROXY in theory, but Go's ProxyFromEnvironment caches its +// decision per process and has subtle no-proxy rules around in-cluster +// hostnames that have caused silent bypasses in this exact deployment +// shape. Hard-coding http.ProxyURL is unambiguous: every call goes +// through the proxy unconditionally. +var proxiedClient *http.Client + +func buildProxiedClient() *http.Client { + proxyEnv := os.Getenv("HTTP_PROXY") + if proxyEnv == "" { + log.Printf("[Agent] HTTP_PROXY unset — outbound HTTP is direct. proxy-sidecar mode would set HTTP_PROXY; envoy-sidecar mode leaves it unset and proxy-init iptables transparently routes egress through Envoy/ext_proc, so the placeholder is still resolved.") + return &http.Client{} + } + u, err := url.Parse(proxyEnv) + if err != nil { + log.Printf("[Agent] HTTP_PROXY=%q is not a valid URL (%v) — falling back to direct", proxyEnv, err) + return &http.Client{} + } + log.Printf("[Agent] All outbound HTTP via explicit proxy: %s", u) + return &http.Client{ + Transport: &http.Transport{Proxy: http.ProxyURL(u)}, + } +} + +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +// callUpstreamEcho makes the single outbound GET to UPSTREAM_URL/echo +// through the proxied client, forwarding the inbound Authorization +// header value unchanged. Returns the upstream's plaintext body (the +// Authorization echo-upstream actually saw, after the sidecar's +// outbound resolve + token-exchange). +func callUpstreamEcho(inboundAuth string) (string, error) { + upstreamURL := envOr("UPSTREAM_URL", "http://echo-upstream.team1.svc.cluster.local:8080") + req, err := http.NewRequest(http.MethodGet, upstreamURL+"/echo", nil) + if err != nil { + return "", fmt.Errorf("creating upstream request: %w", err) + } + // Forward the inbound Authorization value verbatim. With placeholder + // mode on this is "Bearer abph_"; the sidecar swaps it on + // the way out so echo-upstream never sees the placeholder. + if inboundAuth != "" { + req.Header.Set("Authorization", inboundAuth) + } + resp, err := proxiedClient.Do(req) + if err != nil { + return "", fmt.Errorf("calling upstream: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("reading upstream response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("upstream returned %d: %s", resp.StatusCode, string(body)) + } + return string(body), nil +} + +// --- A2A (JSON-RPC 2.0) endpoint --- +// +// Wire shape: +// +// POST / HTTP/1.1 +// Content-Type: application/json +// +// { +// "jsonrpc": "2.0", +// "id": "1", +// "method": "message/send", +// "params": { +// "message": { +// "role": "user", +// "parts": [{"kind": "text", "text": "echo my auth"}], +// "contextId": "demo-session-1" +// } +// } +// } + +type jsonRPCRequest struct { + JSONRPC string `json:"jsonrpc"` + ID any `json:"id"` + Method string `json:"method"` + Params jsonRPCMessage `json:"params"` +} + +type jsonRPCMessage struct { + Message struct { + Role string `json:"role"` + Parts []a2aPart `json:"parts"` + ContextID string `json:"contextId,omitempty"` + } `json:"message"` +} + +type a2aPart struct { + Kind string `json:"kind"` + Text string `json:"text,omitempty"` +} + +type jsonRPCResponse struct { + JSONRPC string `json:"jsonrpc"` + ID any `json:"id"` + Result *a2aTask `json:"result,omitempty"` + Error *jsonRPCError `json:"error,omitempty"` +} + +// a2aTask is the A2A v0.3.0 Task response shape. We use this rather +// than the simpler Message shape (role+parts directly under result) +// because the authbridge a2a-parser's response-side artifact +// extraction (extractSendResponse in plugin.go) keys off +// `result.status.state` and `result.artifacts[].parts[].text`. Without +// the Task shape, abctl and the session-event JSON show only the +// REQUEST text on response events, which makes the agent's reply +// invisible in the platform observability layer. +// +// kagenti's backend chat handler accepts both shapes (chat.py:211 +// handles Task, chat.py:219 handles Message), so emitting a Task +// here doesn't break the UI. +type a2aTask struct { + ID string `json:"id"` + ContextID string `json:"contextId,omitempty"` + Kind string `json:"kind"` + Status a2aStatus `json:"status"` + Artifacts []a2aArtifact `json:"artifacts,omitempty"` +} + +type a2aStatus struct { + State string `json:"state"` + Message *a2aMessage `json:"message,omitempty"` +} + +type a2aMessage struct { + Role string `json:"role"` + Parts []a2aPart `json:"parts"` +} + +type a2aArtifact struct { + ArtifactID string `json:"artifactId"` + Name string `json:"name,omitempty"` + Parts []a2aPart `json:"parts"` +} + +type jsonRPCError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// handleAgentCard serves the A2A agent card at +// /.well-known/agent-card.json. The kagenti operator's +// AgentCardReconciler fetches this URL through the agent's Service +// and stuffs the result into an AgentCard CR; the kagenti UI's agent +// detail page renders that. Without the endpoint the UI shows +// "Agent card not available." +// +// jwt-validation's bypass list includes /.well-known/* by default +// (bypass.DefaultPatterns at authlib/bypass), so the operator's +// reconciler can hit this without a Bearer token. +func handleAgentCard(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + // AGENT_PUBLIC_URL is what the UI displays as the agent's + // callable address. Defaults to the in-cluster Service URL, + // which is what kagenti UI actually uses for the chat call. + publicURL := envOr("AGENT_PUBLIC_URL", "http://echo-agent.team1.svc.cluster.local:8080/") + card := map[string]any{ + "name": "Echo", + "description": "Echoes the Authorization header it received, then calls echo-upstream and reports the Authorization the upstream saw", + "protocolVersion": "0.3.0", + "version": "0.0.1", + "url": publicURL, + "preferredTransport": "JSONRPC", + "defaultInputModes": []string{"text"}, + "defaultOutputModes": []string{"text"}, + "capabilities": map[string]any{ + "streaming": false, + }, + "skills": []map[string]any{ + { + "id": "echo_authorization", + "name": "Echo Authorization", + "description": "Reports the inbound Authorization header it received, then makes one outbound call to echo-upstream and reports the Authorization the upstream saw — making the credential placeholder swap visible end-to-end.", + "tags": []string{"demo", "echo", "placeholder-swap"}, + "examples": []string{ + "Echo my authorization.", + }, + }, + }, + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(card); err != nil { + log.Printf("[Agent] failed to encode agent card: %v", err) + } +} + +func handleA2A(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + // Capture the inbound Authorization header BEFORE reading the body. + // With authbridge placeholder mode on, jwt-validation rewrites this + // to "Bearer abph_" before the request reaches the agent. + inboundAuth := r.Header.Get("Authorization") + + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "failed to read body", http.StatusBadRequest) + return + } + var req jsonRPCRequest + if err := json.Unmarshal(body, &req); err != nil { + writeRPCError(w, nil, -32700, "parse error: "+err.Error()) + return + } + if req.Method != "message/send" { + writeRPCError(w, req.ID, -32601, "method not found: "+req.Method) + return + } + + // First text-kind part wins. Real A2A clients pack one text part + // per user turn; multiple parts are reserved for multi-modal. + var query string + for _, p := range req.Params.Message.Parts { + if p.Kind == "text" && p.Text != "" { + query = p.Text + break + } + } + if query == "" { + writeRPCError(w, req.ID, -32602, "no text part in message") + return + } + + sessionID := req.Params.Message.ContextID + if sessionID == "" { + // Best-effort fallback: many A2A clients also pass an explicit + // session header. Keep this for parity with the legacy endpoint. + sessionID = r.Header.Get("X-Session-Id") + } + if sessionID == "" { + // A2A spec §6.6: when the client omits a contextId, the server + // SHOULD assign one and return it. The kagenti UI currently + // sends bare message/send calls without any contextId field; + // returning a fresh UUID restores per-conversation bucketing + // while staying backward-compatible. + sessionID = newUUID() + } + log.Printf("[Agent] A2A query (session=%s): %s", sessionID, query) + + result := runEcho(inboundAuth) + + writeRPCSuccess(w, req.ID, sessionID, result) +} + +// runEcho implements the demo's single behavior: report the inbound +// Authorization header, then forward it on one outbound GET to +// echo-upstream and report what the upstream saw. Errors are handled +// gracefully — if the outbound call fails we still report the inbound +// header plus the error text. +func runEcho(inboundAuth string) string { + inboundDisplay := inboundAuth + if inboundDisplay == "" { + inboundDisplay = "(no Authorization header)" + } + + upstreamBody, err := callUpstreamEcho(inboundAuth) + if err != nil { + log.Printf("[Agent] upstream echo call failed: %v", err) + upstreamBody = fmt.Sprintf("(outbound call failed: %v)", err) + } + + return fmt.Sprintf( + "Inbound Authorization I received: %s\n\nAuthorization echo-upstream received (after outbound resolve+exchange): %s", + inboundDisplay, upstreamBody, + ) +} + +// writeRPCSuccess emits an A2A v0.3.0 Task response. Both +// status.message AND artifacts carry the agent's reply: the kagenti +// backend's chat handler reads from status.message (chat.py:211), +// while the authbridge a2a-parser's response-side artifact extractor +// reads from artifacts[].parts[].text (plugin.go:188-195). Carrying +// the text in both keeps the kagenti UI working AND gets the reply +// into the session-event JSON for abctl / show-result. +func writeRPCSuccess(w http.ResponseWriter, id any, sessionID, text string) { + taskID := newUUID() + parts := []a2aPart{{Kind: "text", Text: text}} + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(jsonRPCResponse{ + JSONRPC: "2.0", + ID: id, + Result: &a2aTask{ + ID: taskID, + ContextID: sessionID, + Kind: "task", + Status: a2aStatus{ + State: "completed", + Message: &a2aMessage{ + Role: "agent", + Parts: parts, + }, + }, + Artifacts: []a2aArtifact{ + { + ArtifactID: newUUID(), + Name: "reply", + Parts: parts, + }, + }, + }, + }) +} + +// newUUID returns a hex-encoded random ID. Good enough for A2A +// task / artifact IDs in this demo — we don't need RFC 4122 UUIDs. +func newUUID() string { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return fmt.Sprintf("%d", time.Now().UnixNano()) + } + return hex.EncodeToString(b[:]) +} + +func writeRPCError(w http.ResponseWriter, id any, code int, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) // JSON-RPC errors are HTTP 200 with body.error + json.NewEncoder(w).Encode(jsonRPCResponse{ + JSONRPC: "2.0", + ID: id, + Error: &jsonRPCError{Code: code, Message: message}, + }) +} + +func main() { + proxiedClient = buildProxiedClient() + + http.HandleFunc("/", handleA2A) + http.HandleFunc("/.well-known/agent-card.json", handleAgentCard) + + // Honor PORT env so the demo's Pod manifest can land the agent + // on a port that doesn't collide with the authbridge sidecar's + // reverse proxy (the operator port-steals — see agent.yaml). + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + addr := ":" + port + log.Printf("[Agent] Starting on %s (A2A at /)", addr) + if err := http.ListenAndServe(addr, nil); err != nil { + log.Fatalf("failed to start server: %v", err) + } +} diff --git a/authbridge/demos/echo/go.mod b/authbridge/demos/echo/go.mod new file mode 100644 index 000000000..2e714edc1 --- /dev/null +++ b/authbridge/demos/echo/go.mod @@ -0,0 +1,3 @@ +module github.com/kagenti/kagenti-extensions/authbridge/demos/echo + +go 1.24 diff --git a/authbridge/demos/echo/k8s/agent.yaml b/authbridge/demos/echo/k8s/agent.yaml new file mode 100644 index 000000000..4eba26e17 --- /dev/null +++ b/authbridge/demos/echo/k8s/agent.yaml @@ -0,0 +1,152 @@ +# Echo demo agent — operator-injected, kagenti-UI-discoverable. +# +# Required labels for this agent to show up in the kagenti UI: +# kagenti.io/type: agent — UI lists agents by this label +# kagenti.io/inject: enabled — operator's webhook injects authbridge +# kagenti.io/spire: disabled — demo doesn't depend on SPIRE identity +# protocol.kagenti.io/a2a: "" — declares A2A protocol for the chat flow +# +# The operator's webhook port-steals: it sees containerPort 8000 here, +# rewrites the agent's port to 8001, and sets PORT=8001 + HTTP_PROXY so +# the agent's outbound flows through the injected authbridge sidecar's +# forward proxy on :8081. Inbound traffic from the kagenti backend +# hits the Service on :8080, which the operator points at the sidecar's +# reverse proxy on :8000, which forwards to the agent on :8001. +# +# We do NOT ship a per-agent authbridge ConfigMap here — the operator +# creates `authbridge-config-echo-agent` automatically. The demo's +# scripts/patch-echo-config.sh runs after deploy to enable credential +# placeholder swap (inbound jwt-validation placeholder_mode, outbound +# token-exchange resolve_placeholders) and register the echo-upstream +# token-exchange route. + +apiVersion: v1 +kind: ServiceAccount +metadata: + name: echo-agent + namespace: team1 + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: echo-agent + namespace: team1 + labels: + app.kubernetes.io/name: echo-agent + app.kubernetes.io/part-of: echo-demo + # Operator's AgentCardReconciler reads these from the workload + # (not the Pod template) to know what protocol to fetch the card + # under and to confirm the workload is an agent. + kagenti.io/type: agent + protocol.kagenti.io/a2a: "" +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: echo-agent + template: + metadata: + labels: + app.kubernetes.io/name: echo-agent + app.kubernetes.io/part-of: echo-demo + kagenti.io/type: agent + kagenti.io/inject: enabled + kagenti.io/spire: disabled + protocol.kagenti.io/a2a: "" + spec: + serviceAccountName: echo-agent + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + containers: + - name: agent + image: echo-agent:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 8000 + name: http + env: + - name: PORT + value: "8000" + - name: UPSTREAM_URL + value: "http://echo-upstream.team1.svc.cluster.local:8888" + # AGENT_PUBLIC_URL is what the agent advertises in its + # /.well-known/agent-card.json. The kagenti UI uses it + # for the agent card; default below works for the + # standard team1 install. + - name: AGENT_PUBLIC_URL + value: "http://echo-agent.team1.svc.cluster.local:8080/" + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + resources: + limits: + cpu: 500m + memory: 256Mi + requests: + cpu: 50m + memory: 64Mi + # /shared volume mount is required by the operator-injected + # authbridge sidecar (jwt-validation reads /shared/client-id.txt + # written by the operator's Keycloak client-registration). The + # agent itself doesn't read these files; the volume just has + # to exist on the Pod for the sidecar to mount it. + volumeMounts: + - name: shared-data + mountPath: /shared + volumes: + - name: shared-data + emptyDir: {} + +--- +apiVersion: v1 +kind: Service +metadata: + name: echo-agent + namespace: team1 + labels: + app.kubernetes.io/name: echo-agent + app.kubernetes.io/part-of: echo-demo +spec: + selector: + app.kubernetes.io/name: echo-agent + # Port 8080 → targetPort 8000 mirrors the github-issue-agent demo + # shape. The kagenti backend's /chat endpoint hits the agent's + # Service:8080, which the operator wires to the authbridge sidecar's + # reverse proxy on :8000. + ports: + - port: 8080 + targetPort: 8000 + protocol: TCP + name: http + type: ClusterIP + +--- +# AgentRuntime triggers operator-managed Keycloak client registration +# (creates kagenti-keycloak-client-credentials- Secret with +# client-id.txt and client-secret.txt that the authbridge sidecar +# mounts at /shared/). Without this CR, jwt-validation has no +# audience to validate against and inbound auth falls over. +apiVersion: agent.kagenti.dev/v1alpha1 +kind: AgentRuntime +metadata: + name: echo-agent + namespace: team1 +spec: + type: agent + targetRef: + apiVersion: apps/v1 + kind: Deployment + name: echo-agent + +# We intentionally do NOT define an AgentCard CR here — the operator +# auto-creates `-deployment-card` for any Deployment with +# `kagenti.io/type=agent`. Defining our own collides with the +# admission webhook ("an AgentCard already targets Deployment X"). +# The operator's CR points at the same Deployment, fetches our +# `/.well-known/agent-card.json` endpoint, and stuffs the result +# into the CR's status — which the kagenti UI then displays. diff --git a/authbridge/demos/echo/k8s/echo-patch.yaml b/authbridge/demos/echo/k8s/echo-patch.yaml new file mode 100644 index 000000000..8d80b789c --- /dev/null +++ b/authbridge/demos/echo/k8s/echo-patch.yaml @@ -0,0 +1,16 @@ +# Enables credential placeholder swap on the operator-created +# authbridge-config-echo-agent ConfigMap, and registers the echo-upstream +# token-exchange route. Applied by scripts/patch-echo-config.sh (added separately). +inbound_plugin_config: + jwt-validation: + placeholder_mode: true + placeholder_ttl: "1h" +outbound_plugin_config: + token-exchange: + resolve_placeholders: true +# Host must match what the agent dials (router compiles the glob with '.' as a +# separator, so '*' does not cross dots — use the full in-cluster FQDN). +routes: + - host: "echo-upstream.team1.svc.cluster.local" + target_audience: "echo-upstream" + token_scopes: "openid echo-upstream-aud" diff --git a/authbridge/demos/echo/k8s/upstream.yaml b/authbridge/demos/echo/k8s/upstream.yaml new file mode 100644 index 000000000..5ab50fb1f --- /dev/null +++ b/authbridge/demos/echo/k8s/upstream.yaml @@ -0,0 +1,76 @@ +# Echo upstream for the credential placeholder-swap demo. +# +# Returns the request's Authorization header on GET /echo. Plain HTTP +# service in team1. +# +# CRITICAL: this workload carries NO `kagenti.io/*` labels. That keeps +# the operator's injecting webhook from touching it — echo-upstream must +# stay a plain, un-injected "external" upstream so the demo can show the +# credential the agent's authbridge sidecar produced reaching it +# unmodified by any upstream-side authbridge. + +apiVersion: apps/v1 +kind: Deployment +metadata: + name: echo-upstream + namespace: team1 + labels: + app.kubernetes.io/name: echo-upstream + app.kubernetes.io/part-of: echo-demo +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: echo-upstream + template: + metadata: + labels: + app.kubernetes.io/name: echo-upstream + app.kubernetes.io/part-of: echo-demo + spec: + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + containers: + - name: upstream + image: echo-upstream:latest + imagePullPolicy: IfNotPresent + # Port 8888 (NOT 8080) on purpose: in envoy-sidecar mode the + # operator's proxy-init excludes the agent's own Service port (8080) + # from outbound iptables interception, so an upstream on 8080 would + # bypass Envoy/ext_proc and the placeholder would never be resolved. + # A distinct port is captured and routed through ext_proc. (Proxy- + # sidecar mode uses HTTP_PROXY and works on any port.) + env: + - name: PORT + value: "8888" + ports: + - containerPort: 8888 + name: http + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + resources: + limits: + cpu: 100m + memory: 64Mi + requests: + cpu: 10m + memory: 16Mi +--- +apiVersion: v1 +kind: Service +metadata: + name: echo-upstream + namespace: team1 +spec: + selector: + app.kubernetes.io/name: echo-upstream + ports: + - port: 8888 + targetPort: 8888 + protocol: TCP + type: ClusterIP diff --git a/authbridge/demos/echo/scripts/echo-merge.py b/authbridge/demos/echo/scripts/echo-merge.py new file mode 100644 index 000000000..14e3fb09b --- /dev/null +++ b/authbridge/demos/echo/scripts/echo-merge.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Merge credential placeholder-swap settings into the operator-rendered +authbridge config.yaml. + +Reads: + - argv[1]: path to k8s/echo-patch.yaml (the additions fragment) + - stdin: the operator's current config.yaml content + +Writes the merged YAML to stdout. + +Unlike ibac-merge.py (which APPENDS whole plugins to the pipeline), this +script EDITS the existing plugin configs in place: + + * inbound jwt-validation : set placeholder_mode + placeholder_ttl + * outbound token-exchange : set resolve_placeholders + add the + echo-upstream route under + config.routes.rules + +The route key shape is dictated by the token-exchange plugin's Go config +struct (authlib/plugins/tokenexchange/plugin.go): + + tokenExchangeConfig.Routes json:"routes" -> config.routes + tokenExchangeRoutes.Rules json:"rules" -> config.routes.rules + tokenExchangeRoute.Host json:"host" + tokenExchangeRoute.TargetAudience json:"target_audience" + tokenExchangeRoute.TokenScopes json:"token_scopes" + +So an inline route lives at: + token-exchange.config.routes.rules: [ {host, target_audience, token_scopes} ] + +Idempotent: re-running with already-merged input is a no-op (flags that +are already set stay set; routes already present by `host` aren't +duplicated). Output uses safe_dump(..., sort_keys=False) so the +hot-reload SHA comparison in patch-echo-config.sh is deterministic. +""" + +import sys +import yaml + + +def merge_inbound(plugins, jwt_cfg): + """Set placeholder_mode / placeholder_ttl on every jwt-validation + plugin entry's config. Creates the config map if missing.""" + for p in plugins: + if p.get("name") != "jwt-validation": + continue + cfg = p.setdefault("config", {}) + if "placeholder_mode" in jwt_cfg: + cfg["placeholder_mode"] = jwt_cfg["placeholder_mode"] + if "placeholder_ttl" in jwt_cfg: + cfg["placeholder_ttl"] = jwt_cfg["placeholder_ttl"] + + +def merge_outbound(plugins, te_cfg, routes): + """Set resolve_placeholders on every token-exchange plugin entry and + append the patch's routes into config.routes.rules (by `host`).""" + for p in plugins: + if p.get("name") != "token-exchange": + continue + cfg = p.setdefault("config", {}) + if "resolve_placeholders" in te_cfg: + cfg["resolve_placeholders"] = te_cfg["resolve_placeholders"] + + # Merge inline routes into config.routes.rules. The plugin decodes + # routes at config.routes.rules[] (see module docstring), so we + # build that nesting explicitly rather than placing a bare list. + routes_node = cfg.setdefault("routes", {}) + rules = routes_node.setdefault("rules", []) + existing_hosts = {r.get("host") for r in rules if isinstance(r, dict)} + for route in routes: + host = route.get("host") + if host in existing_hosts: + continue + rules.append( + { + "host": host, + "target_audience": route.get("target_audience"), + "token_scopes": route.get("token_scopes"), + } + ) + existing_hosts.add(host) + + +def main() -> int: + if len(sys.argv) != 2: + sys.stderr.write("usage: echo-merge.py \n") + return 2 + + patch_path = sys.argv[1] + + operator = yaml.safe_load(sys.stdin) or {} + with open(patch_path) as f: + patch = yaml.safe_load(f) or {} + + pipeline = operator.setdefault("pipeline", {}) + inbound = pipeline.setdefault("inbound", {}) + outbound = pipeline.setdefault("outbound", {}) + in_plugins = inbound.setdefault("plugins", []) + out_plugins = outbound.setdefault("plugins", []) + + jwt_cfg = (patch.get("inbound_plugin_config") or {}).get("jwt-validation") or {} + te_cfg = (patch.get("outbound_plugin_config") or {}).get("token-exchange") or {} + routes = patch.get("routes") or [] + + merge_inbound(in_plugins, jwt_cfg) + merge_outbound(out_plugins, te_cfg, routes) + + sys.stdout.write(yaml.safe_dump(operator, default_flow_style=False, sort_keys=False)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/authbridge/demos/echo/scripts/patch-echo-config.sh b/authbridge/demos/echo/scripts/patch-echo-config.sh new file mode 100755 index 000000000..5ace97fcc --- /dev/null +++ b/authbridge/demos/echo/scripts/patch-echo-config.sh @@ -0,0 +1,187 @@ +#!/bin/bash +# Patch the operator-rendered authbridge ConfigMap to enable credential +# placeholder swap (inbound jwt-validation placeholder_mode, outbound +# token-exchange resolve_placeholders) and register the echo-upstream +# token-exchange route. +# +# Usage: patch-echo-config.sh +# +# The kagenti operator creates `authbridge-config-` when the +# agent's pod is admitted (server-side apply, line 682 of pod_mutator.go). +# Its default pipeline has only `jwt-validation` inbound and +# `token-exchange` outbound. This script: +# +# 1. Extracts the operator's config.yaml from the ConfigMap +# 2. Reads our additions from k8s/echo-patch.yaml +# 3. Merges them via python3 + PyYAML (PyYAML is widely available; +# we error out with an actionable hint if it's missing) +# 4. Replaces the ConfigMap's data.config.yaml with the merged version +# +# Authbridge's filesystem-watch hot-reload picks up the change without +# a Pod restart (the operator-injected sidecar doesn't set +# readOnlyRootFilesystem so fsnotify can see the symlink swap). On a +# re-run where the CM is already patched (e.g. previous demo run + +# fresh rollout-restart that booted with the patched content), the +# script short-circuits — no apply, no reload-wait. + +set -euo pipefail + +NAMESPACE=${1:-team1} +AGENT_NAME=${2:-echo-agent} +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd) +DEMO_DIR=$(dirname "$SCRIPT_DIR") +PATCH_FILE="$DEMO_DIR/k8s/echo-patch.yaml" +CM_NAME="authbridge-config-$AGENT_NAME" + +if ! python3 -c 'import yaml' 2>/dev/null; then + cat <<'EOF' >&2 +ERROR: python3-yaml (PyYAML) is required. + Install with one of: + pip3 install --user pyyaml + brew install libyaml && pip3 install pyyaml # macOS + sudo apt install python3-yaml # Debian/Ubuntu +EOF + exit 1 +fi + +if ! kubectl -n "$NAMESPACE" get configmap "$CM_NAME" >/dev/null 2>&1; then + echo "ERROR: ConfigMap $NAMESPACE/$CM_NAME not found." >&2 + echo " The operator should create this when the agent pod is admitted." >&2 + echo " Check: kubectl -n $NAMESPACE get pods -l app.kubernetes.io/name=$AGENT_NAME" >&2 + exit 1 +fi + +# The merge runs in scripts/echo-merge.py: reads the patch from a +# file (argv[1]), reads the operator's current config.yaml from stdin, +# emits merged YAML to stdout. Idempotent: re-running is a no-op once +# the placeholder flags + echo-upstream route are present. +# +# Implemented as a separate .py file rather than an inline heredoc +# because heredoc + piped stdin clash — python3 with `<&2 + exit 1 +fi + +# No-op short-circuit: if the patch wouldn't change anything (typical +# on a re-run where a previous demo invocation already patched the CM +# and the agent pod booted with that content baked in), skip the apply +# AND skip the reload wait. Otherwise we block forever waiting for a +# swap event that will never fire — kubectl apply is a no-op, kubelet +# has nothing to sync, the reloader sees no fs event. +if [[ "$CURRENT_YAML" == "$MERGED_YAML" ]]; then + echo "[*] $CM_NAME already has placeholder-swap config — nothing to patch." + echo "[*] Active plugins:" + printf '%s' "$CURRENT_YAML" | python3 -c ' +import yaml, sys +c = yaml.safe_load(sys.stdin) +for d in ("inbound", "outbound"): + names = [p["name"] for p in c.get("pipeline", {}).get(d, {}).get("plugins", [])] + print(f" {d}: {names}") +' + exit 0 +fi + +# Apply the patched ConfigMap. Using `kubectl create configmap +# --from-file --dry-run=client -o yaml | kubectl apply` is the +# conflict-free pattern: it sidesteps the resource-version mismatch +# you get when piping the existing CM through edits and re-applying. +echo "[*] Applying patched ConfigMap ..." +TMP_CONFIG=$(mktemp) +trap 'rm -f "$TMP_CONFIG"' EXIT +printf '%s' "$MERGED_YAML" >"$TMP_CONFIG" + +kubectl -n "$NAMESPACE" create configmap "$CM_NAME" \ + --from-file=config.yaml="$TMP_CONFIG" \ + --dry-run=client -o yaml \ + | kubectl apply -f - + +echo "[*] Patched. Active plugins now:" +kubectl -n "$NAMESPACE" get configmap "$CM_NAME" \ + -o jsonpath='{.data.config\.yaml}' \ + | python3 -c ' +import yaml, sys +c = yaml.safe_load(sys.stdin) +for d in ("inbound", "outbound"): + names = [p["name"] for p in c.get("pipeline", {}).get(d, {}).get("plugins", [])] + print(f" {d}: {names}") +' + +# Block until the running authbridge process is using a config whose +# SHA-256 matches what we just applied. The sidecar exposes its +# active config's SHA at :9093/reload/status (`active_config_sha256`); +# we compare it to the SHA of the merged YAML. This handles both +# convergence pathways uniformly: +# +# - Hot-reload: same pod, the reloader detects the projected-volume +# symlink swap (kubelet syncs every ~60s) and rebuilds pipelines; +# active_config_sha256 advances on swap completion. +# - Pod-roll: a fresh pod (e.g. operator's reconciler restarted the +# deployment) boots with the patched ConfigMap mounted from the +# start, so its initial active_config_sha256 already matches. +# +# Tailing logs for "reloader: pipelines swapped" only catches the +# hot-reload path and misses the pod-roll path entirely (the new +# pod's startup never logs a "swap" — it loaded the right config at +# boot). The SHA check is correct in both cases. +WANT_SHA=$(printf '%s' "$MERGED_YAML" | sha256sum | awk '{print $1}') +TIMEOUT=${RELOAD_TIMEOUT:-180} +DEADLINE=$(( $(date +%s) + TIMEOUT )) +echo "[*] Waiting for authbridge to load the patched config (timeout ${TIMEOUT}s)" +echo " target SHA: $WANT_SHA" + +# The sidecar container name differs by authbridge mode: "authbridge-proxy" +# for proxy-sidecar mode, "envoy-proxy" for envoy-sidecar mode. Both serve the +# same :9093/reload/status. Detect it so the reload-wait works in either mode. +SIDECAR=$(kubectl -n "$NAMESPACE" get pod -l app.kubernetes.io/name="$AGENT_NAME" \ + -o jsonpath='{.items[0].spec.containers[*].name}' 2>/dev/null \ + | tr ' ' '\n' | grep -E '^(authbridge-proxy|envoy-proxy)$' | head -1) +SIDECAR=${SIDECAR:-authbridge-proxy} +echo " sidecar container: $SIDECAR" + +ACTIVE_SHA="" +while [[ $(date +%s) -lt $DEADLINE ]]; do + ACTIVE_SHA=$(kubectl -n "$NAMESPACE" exec deploy/"$AGENT_NAME" -c "$SIDECAR" -- \ + wget -q -O - http://localhost:9093/reload/status 2>/dev/null | \ + python3 -c 'import json, sys +try: + print(json.load(sys.stdin).get("active_config_sha256", "")) +except Exception: + pass' 2>/dev/null || true) + # The envoy-sidecar image ships no wget, so the exec above yields nothing in + # envoy mode. Fall back to the reloader's own log line (it prints the swapped + # sha256 prefix) so the wait works in both proxy- and envoy-sidecar modes. + if [[ "$ACTIVE_SHA" != "$WANT_SHA" ]] && \ + kubectl -n "$NAMESPACE" logs deploy/"$AGENT_NAME" -c "$SIDECAR" --tail=200 2>/dev/null \ + | grep -q "pipelines swapped sha256=${WANT_SHA:0:12}"; then + ACTIVE_SHA="$WANT_SHA" + fi + if [[ "$ACTIVE_SHA" == "$WANT_SHA" ]]; then + echo "[*] Active config SHA matches — patch is live." + exit 0 + fi + sleep 3 +done + +echo "ERROR: authbridge active config did not match patched SHA within ${TIMEOUT}s." >&2 +echo " want: $WANT_SHA" >&2 +echo " last active: ${ACTIVE_SHA:-}" >&2 +echo " Last 20 lines of the authbridge container:" >&2 +kubectl -n "$NAMESPACE" logs deploy/"$AGENT_NAME" -c "${SIDECAR:-authbridge-proxy}" --tail=20 >&2 || true +echo >&2 +echo " Likely causes:" >&2 +echo " - ConfigMap parse error (look for 'reload failed' above)" >&2 +echo " - kubelet sync slow (retry: RELOAD_TIMEOUT=300 make patch-config)" >&2 +echo " - operator reconciler reverted the patch (re-run patch-config)" >&2 +exit 1 diff --git a/authbridge/demos/echo/scripts/setup_keycloak.py b/authbridge/demos/echo/scripts/setup_keycloak.py new file mode 100644 index 000000000..925539ce6 --- /dev/null +++ b/authbridge/demos/echo/scripts/setup_keycloak.py @@ -0,0 +1,460 @@ +""" +setup_keycloak.py - Keycloak Setup for the Echo (credential placeholder-swap) Demo + +This script configures Keycloak for running the echo demo with AuthBridge's +credential placeholder swap + transparent token exchange. + +Architecture: + UI (user) → gets token (aud: Agent's SPIFFE ID) → sends to Agent + ↓ + Agent Pod (echo-agent + AuthBridge sidecar) + | inbound jwt-validation runs in placeholder_mode: it validates + | the user's token, stashes the real token in the shared store, + | and forwards an opaque `abph_...` placeholder to the agent. + | + | Agent echoes the placeholder back via the upstream call. + v + AuthProxy (forward proxy) - intercepts outbound, resolves the + placeholder back to the real token, then exchanges it. + | + | Token Exchange → audience "echo-upstream" + v + echo-upstream (plain HTTP) - returns whatever Authorization header it + received (the exchanged token), proving the swap end to end. + +Clients created: +- echo-upstream: Target audience for token exchange (the echo upstream) + +Client Scopes created: +- agent---aud: Adds Agent's SPIFFE ID to token audience (realm DEFAULT) +- echo-upstream-aud: Adds "echo-upstream" to exchanged tokens (realm OPTIONAL) + +Demo Users created: +- alice: alice123 +- bob: bob123 + (both get the realm "admin" role so the kagenti UI lists agents/tools) + +Usage: + python setup_keycloak.py + python setup_keycloak.py --namespace myns --service-account mysa + +Security Note: +- This script uses default Keycloak admin credentials (username: "admin", password: "admin") + for demo and local development only. These credentials are insecure and MUST NOT be used + in any production or internet-exposed environment. +""" + +import argparse +import os +import sys + +from keycloak import KeycloakAdmin, KeycloakGetError, KeycloakPostError + +# Default configuration +KEYCLOAK_URL = os.environ.get("KEYCLOAK_URL", "http://keycloak.localtest.me:8080") +KEYCLOAK_REALM = os.environ.get("KEYCLOAK_REALM", "kagenti") +KEYCLOAK_ADMIN_USERNAME = os.environ.get("KEYCLOAK_ADMIN_USERNAME", "admin") +KEYCLOAK_ADMIN_PASSWORD = os.environ.get("KEYCLOAK_ADMIN_PASSWORD", "admin") + +if KEYCLOAK_ADMIN_USERNAME == "admin" and KEYCLOAK_ADMIN_PASSWORD == "admin": + print( + "WARNING: Using default Keycloak admin credentials 'admin'/'admin'. " + "These credentials are INSECURE and must NOT be used in production.", + file=sys.stderr, + ) + +DEFAULT_NAMESPACE = "team1" +DEFAULT_SERVICE_ACCOUNT = "echo-agent" +SPIFFE_TRUST_DOMAIN = "localtest.me" +UI_CLIENT_ID = os.environ.get("UI_CLIENT_ID", "kagenti") + +# The token-exchange target audience (the echo upstream). Mirrors the +# `target_audience` / `token_scopes` in k8s/echo-patch.yaml's route. +ECHO_UPSTREAM_CLIENT_ID = "echo-upstream" +ECHO_UPSTREAM_SCOPE = "echo-upstream-aud" + +DEMO_USERS = [ + { + "username": "alice", + "email": "alice@example.com", + "firstName": "Alice", + "lastName": "Demo", + "password": "alice123", + "description": "Regular demo user", + }, + { + "username": "bob", + "email": "bob@example.com", + "firstName": "Bob", + "lastName": "Demo", + "password": "bob123", + "description": "Second demo user", + }, +] + + +def get_spiffe_id(namespace: str, service_account: str) -> str: + return f"spiffe://{SPIFFE_TRUST_DOMAIN}/ns/{namespace}/sa/{service_account}" + + +def get_or_create_realm(keycloak_admin, realm_name): + try: + realms = keycloak_admin.get_realms() + for realm in realms: + if realm["realm"] == realm_name: + print(f"Realm '{realm_name}' already exists.") + return + keycloak_admin.create_realm({"realm": realm_name, "enabled": True, "displayName": realm_name}) + print(f"Created realm '{realm_name}'.") + except Exception as e: + print(f"Error checking/creating realm: {e}", file=sys.stderr) + raise + + +def get_or_create_client(keycloak_admin, client_payload): + client_id = client_payload["clientId"] + existing_client_id = keycloak_admin.get_client_id(client_id) + if existing_client_id: + print(f"Client '{client_id}' already exists.") + return existing_client_id + internal_id = keycloak_admin.create_client(client_payload) + print(f"Created client '{client_id}'.") + return internal_id + + +def get_or_create_client_scope(keycloak_admin, scope_payload): + scope_name = scope_payload.get("name") + scopes = keycloak_admin.get_client_scopes() + for scope in scopes: + if scope["name"] == scope_name: + print(f"Client scope '{scope_name}' already exists with ID: {scope['id']}") + return scope["id"] + try: + scope_id = keycloak_admin.create_client_scope(scope_payload) + print(f"Created client scope '{scope_name}': {scope_id}") + return scope_id + except KeycloakPostError as e: + print(f"Could not create client scope '{scope_name}': {e}") + raise + + +def add_audience_mapper(keycloak_admin, scope_id, mapper_name, audience): + mapper_payload = { + "name": mapper_name, + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": False, + "config": { + "included.custom.audience": audience, + "id.token.claim": "false", + "access.token.claim": "true", + "userinfo.token.claim": "false", + }, + } + try: + keycloak_admin.add_mapper_to_client_scope(scope_id, mapper_payload) + print(f"Added audience mapper '{mapper_name}' for audience '{audience}'") + except Exception as e: + print(f"Note: Could not add mapper '{mapper_name}' (might already exist): {e}") + + +def get_or_create_user(keycloak_admin, user_config): + username = user_config["username"] + users = keycloak_admin.get_users({"username": username}) + existing_user = next( + (user for user in users if user.get("username") == username), + None, + ) + if existing_user: + print(f"User '{username}' already exists.") + return existing_user["id"] + try: + user_id = keycloak_admin.create_user( + { + "username": username, + "email": user_config["email"], + "firstName": user_config["firstName"], + "lastName": user_config["lastName"], + "enabled": True, + "emailVerified": True, + "credentials": [ + { + "type": "password", + "value": user_config["password"], + "temporary": False, + } + ], + } + ) + print(f"Created user '{username}' with ID: {user_id}") + return user_id + except KeycloakPostError as e: + print(f"Could not create user '{username}': {e}") + raise + + +def main(): + parser = argparse.ArgumentParser(description="Setup Keycloak for the Echo (credential placeholder-swap) demo") + parser.add_argument( + "--namespace", + "-n", + default=DEFAULT_NAMESPACE, + help=f"Kubernetes namespace (default: {DEFAULT_NAMESPACE})", + ) + parser.add_argument( + "--service-account", + "-s", + default=DEFAULT_SERVICE_ACCOUNT, + help=f"Service account name (default: {DEFAULT_SERVICE_ACCOUNT})", + ) + args = parser.parse_args() + + namespace = args.namespace + service_account = args.service_account + agent_spiffe_id = get_spiffe_id(namespace, service_account) + + print("=" * 70) + print("Echo (credential placeholder-swap) + AuthBridge - Keycloak Setup") + print("=" * 70) + print(f"\nNamespace: {namespace}") + print(f"Service Account: {service_account}") + print(f"SPIFFE ID: {agent_spiffe_id}") + + # Connect to Keycloak + print(f"\nConnecting to Keycloak at {KEYCLOAK_URL}...") + try: + master_admin = KeycloakAdmin( + server_url=KEYCLOAK_URL, + username=KEYCLOAK_ADMIN_USERNAME, + password=KEYCLOAK_ADMIN_PASSWORD, + realm_name="master", + user_realm_name="master", + ) + except Exception as e: + print(f"Failed to connect to Keycloak: {e}") + print("\nMake sure Keycloak is running and accessible at:") + print(f" {KEYCLOAK_URL}") + print("\nIf using port-forward, run:") + print(" kubectl port-forward service/keycloak-service -n keycloak 8080:8080") + sys.exit(1) + + # Create realm + print(f"\n--- Setting up realm: {KEYCLOAK_REALM} ---") + get_or_create_realm(master_admin, KEYCLOAK_REALM) + + # Switch to target realm + keycloak_admin = KeycloakAdmin( + server_url=KEYCLOAK_URL, + username=KEYCLOAK_ADMIN_USERNAME, + password=KEYCLOAK_ADMIN_PASSWORD, + realm_name=KEYCLOAK_REALM, + user_realm_name="master", + ) + + # --------------------------------------------------------------- + # Create echo-upstream client (target audience for token exchange) + # --------------------------------------------------------------- + print("\n--- Creating echo-upstream client ---") + print("This client represents the echo upstream as a token exchange target") + get_or_create_client( + keycloak_admin, + { + "clientId": ECHO_UPSTREAM_CLIENT_ID, + "name": "Echo Upstream", + "enabled": True, + "publicClient": False, + "standardFlowEnabled": False, + "serviceAccountsEnabled": True, + "attributes": {"standard.token.exchange.enabled": "true"}, + }, + ) + + # --------------------------------------------------------------- + # Create client scopes + # --------------------------------------------------------------- + print("\n--- Creating client scopes ---") + + # 1. agent-spiffe-aud scope: adds Agent's SPIFFE ID to all tokens (realm default). + # Inbound jwt-validation checks the audience against the agent's SPIFFE ID, + # so the UI's tokens must carry it or chat requests are rejected. + scope_name = f"agent-{namespace}-{service_account}-aud" + print(f"\nCreating scope for Agent's SPIFFE ID audience: {scope_name}") + agent_spiffe_scope_id = get_or_create_client_scope( + keycloak_admin, + { + "name": scope_name, + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + }, + }, + ) + add_audience_mapper(keycloak_admin, agent_spiffe_scope_id, scope_name, agent_spiffe_id) + + # 2. echo-upstream-aud scope: adds "echo-upstream" to exchanged tokens (optional). + # This is the target_audience referenced by the echo-patch.yaml route's + # token_scopes ("openid echo-upstream-aud"). + print("\nCreating scope for echo-upstream audience...") + echo_upstream_scope_id = get_or_create_client_scope( + keycloak_admin, + { + "name": ECHO_UPSTREAM_SCOPE, + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + }, + }, + ) + add_audience_mapper(keycloak_admin, echo_upstream_scope_id, ECHO_UPSTREAM_SCOPE, ECHO_UPSTREAM_CLIENT_ID) + + # --------------------------------------------------------------- + # Assign scopes at realm level + # --------------------------------------------------------------- + print("\n--- Assigning scopes ---") + + # agent-spiffe-aud as realm default (all tokens get Agent's SPIFFE ID in audience) + try: + keycloak_admin.add_default_default_client_scope(agent_spiffe_scope_id) + print(f"Added '{scope_name}' as realm default scope.") + except Exception as e: + print(f"Note: Could not add '{scope_name}' as realm default: {e}") + + # echo-upstream-aud as realm optional (requested during token exchange) + try: + keycloak_admin.add_default_optional_client_scope(echo_upstream_scope_id) + print(f"Added '{ECHO_UPSTREAM_SCOPE}' as realm OPTIONAL scope.") + except Exception as e: + print(f"Note: Could not add '{ECHO_UPSTREAM_SCOPE}' as optional: {e}") + + # --------------------------------------------------------------- + # Add agent audience scope to the Kagenti UI client + # --------------------------------------------------------------- + # Keycloak only auto-assigns realm default scopes to NEW clients. + # The UI client was created during install (before this scope existed), + # so we must add it explicitly. Without this, the UI's tokens won't + # include the agent's SPIFFE ID in the audience, and AuthBridge will + # reject UI chat requests with "invalid audience". + # + # TODO: Remove this workaround once the client-registration sidecar + # handles this automatically (kagenti/kagenti-extensions#169). + print(f"\n--- Adding agent audience scope to UI client '{UI_CLIENT_ID}' ---") + ui_client_internal_id = keycloak_admin.get_client_id(UI_CLIENT_ID) + if ui_client_internal_id: + try: + keycloak_admin.add_client_default_client_scope(ui_client_internal_id, agent_spiffe_scope_id, {}) + print(f"Added '{scope_name}' as default scope on client '{UI_CLIENT_ID}'.") + print(" → UI tokens will now include the agent's SPIFFE ID in audience.") + print(" → Users must log out and back in for the new scope to take effect.") + except Exception as e: + print(f"Note: Could not add scope to '{UI_CLIENT_ID}' client: {e}") + else: + print( + f"Warning: UI client '{UI_CLIENT_ID}' not found in realm " + f"'{KEYCLOAK_REALM}'. UI chat with this agent will require " + f"manually adding the '{scope_name}' scope to the UI client." + ) + + # --------------------------------------------------------------- + # Add token exchange scopes to the agent's client (if it exists) + # --------------------------------------------------------------- + # The agent's Keycloak client is created dynamically by the + # client-registration sidecar when the agent pod starts. If the + # client already exists (from a prior deployment), realm-level + # optional scopes added after client creation won't be inherited. + # Explicitly add the scopes so the outbound exchange to + # audience=echo-upstream (scope=openid echo-upstream-aud) succeeds. + print("\n--- Adding scopes to agent client (if registered) ---") + agent_internal_id = keycloak_admin.get_client_id(agent_spiffe_id) + if agent_internal_id: + # Add the agent's own audience scope as a default so tokens issued + # via the exchange include the agent's SPIFFE ID in `aud`. + try: + keycloak_admin.add_client_default_client_scope(agent_internal_id, agent_spiffe_scope_id, {}) + print(f"Added '{scope_name}' as default scope on agent client.") + except Exception as e: + print(f"Note: Could not add '{scope_name}' to agent client: {e}") + + # Add the echo-upstream-aud scope as optional so the token exchange + # request with scope=echo-upstream-aud succeeds. + try: + keycloak_admin.add_client_optional_client_scope(agent_internal_id, echo_upstream_scope_id, {}) + print(f"Added '{ECHO_UPSTREAM_SCOPE}' as optional scope on agent client.") + except Exception as e: + print(f"Note: Could not add '{ECHO_UPSTREAM_SCOPE}' to agent client: {e}") + else: + print( + f"Agent client '{agent_spiffe_id}' not yet registered.\n" + f" The scopes are realm-level defaults/optionals and will be inherited\n" + f" when client-registration creates the client. If the agent was deployed\n" + f" before this script, re-run it after the agent is running." + ) + + # --------------------------------------------------------------- + # Create demo users and assign the admin realm role + # --------------------------------------------------------------- + print("\n--- Creating demo users ---") + for user in DEMO_USERS: + print(f"\n {user['username']}: {user['description']}") + get_or_create_user(keycloak_admin, user) + + # The Kagenti backend uses the "admin" realm role for RBAC. Without + # it, users can log in but see no agents or tools in the UI. + print("\n--- Assigning 'admin' realm role to demo users ---") + try: + admin_role = keycloak_admin.get_realm_role("admin") + except KeycloakGetError: + admin_role = None + if admin_role: + for user in DEMO_USERS: + user_id = keycloak_admin.get_user_id(user["username"]) + try: + keycloak_admin.assign_realm_roles(user_id, [admin_role]) + print(f"Assigned 'admin' role to '{user['username']}'.") + except Exception as e: + print(f"Note: Could not assign 'admin' role to '{user['username']}' (might already have it): {e}") + else: + print( + "Warning: 'admin' realm role not found. Demo users will not " + "be able to see agents/tools in the UI. Ensure the Kagenti " + "platform is installed before running this script." + ) + + # --------------------------------------------------------------- + # Summary + # --------------------------------------------------------------- + print("\n" + "=" * 70) + print("SETUP COMPLETE") + print("=" * 70) + print( + f""" +Keycloak is configured for the Echo credential placeholder-swap demo. + +Created: + Realm: {KEYCLOAK_REALM} + Clients: {ECHO_UPSTREAM_CLIENT_ID} (target audience for token exchange) + Scopes: {scope_name} (realm DEFAULT - auto-adds Agent's SPIFFE ID to aud) + {ECHO_UPSTREAM_SCOPE} (realm OPTIONAL - for exchanged tokens) + Users: alice (alice123), bob (bob123) — both with admin role + +Token flow: + 1. UI gets token for user (aud includes Agent's SPIFFE ID via default scope) + 2. UI sends request to Agent with token + 3. AuthBridge inbound jwt-validation validates the token, stashes the real + token in the shared store, and forwards an `abph_...` placeholder + 4. Agent echoes the placeholder out to echo-upstream + 5. AuthBridge outbound token-exchange resolves the placeholder back to the + real token, then exchanges it: aud={agent_spiffe_id} → aud=echo-upstream + 6. echo-upstream returns the exchanged token in its response body + +Note (gotcha): the agent's Keycloak client is registered dynamically when the +agent pod starts. If you ran this script BEFORE the agent client existed, +re-run it after the agent is up so the optional scopes attach to the agent +client (see "Adding scopes to agent client" above). +""" + ) + + +if __name__ == "__main__": + main() diff --git a/authbridge/demos/echo/upstream/Dockerfile b/authbridge/demos/echo/upstream/Dockerfile new file mode 100644 index 000000000..e3d25683d --- /dev/null +++ b/authbridge/demos/echo/upstream/Dockerfile @@ -0,0 +1,10 @@ +FROM golang:1.24-alpine AS build +WORKDIR /src +COPY go.mod ./ +COPY upstream/main.go ./upstream/main.go +RUN CGO_ENABLED=0 go build -o /out/upstream ./upstream + +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=build /out/upstream /upstream +EXPOSE 8080 +ENTRYPOINT ["/upstream"] diff --git a/authbridge/demos/echo/upstream/main.go b/authbridge/demos/echo/upstream/main.go new file mode 100644 index 000000000..d6eeb48f6 --- /dev/null +++ b/authbridge/demos/echo/upstream/main.go @@ -0,0 +1,43 @@ +// Trivial echo upstream for the credential placeholder-swap demo. +// +// Plain HTTP server with one route: GET /echo returns the +// Authorization header the request arrived with, as text/plain. In the +// demo this is the credential the authbridge sidecar produced AFTER +// resolving the agent's placeholder and exchanging it for an +// echo-upstream-audience token — so the body lets you see exactly what +// the upstream received, in contrast to the placeholder the agent held. +// +// Modeled on the IBAC demo's email-server skeleton, minus all the MCP / +// poisoned-email machinery. Reads PORT (default 8080). +package main + +import ( + "log" + "net/http" + "os" +) + +func handleEcho(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if auth == "" { + auth = "(no Authorization header)" + } + log.Printf("[Upstream] /echo saw Authorization: %s", auth) + w.Header().Set("Content-Type", "text/plain") + if _, err := w.Write([]byte(auth)); err != nil { + log.Printf("[Upstream] write response: %v", err) + } +} + +func main() { + http.HandleFunc("/echo", handleEcho) + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + addr := ":" + port + log.Printf("[Upstream] Echo upstream starting on %s/echo", addr) + if err := http.ListenAndServe(addr, nil); err != nil { + log.Fatalf("failed to start upstream server: %v", err) + } +} diff --git a/authbridge/docs/plugin-reference.md b/authbridge/docs/plugin-reference.md index 88838cffe..14d535f2b 100644 --- a/authbridge/docs/plugin-reference.md +++ b/authbridge/docs/plugin-reference.md @@ -84,6 +84,25 @@ and `expected_audience_host` (waypoint per-request derived audience, may be empty). Update saved queries and dashboards that filtered on the old key. +### `jwt-validation`: `placeholder_mode` / `placeholder_ttl` (optional, off by default) + +Two fields enable the **mint** half of [credential placeholder +swap](#credential-placeholder-swap) (read that section for the full +model — these are the field-level reference). + +- **`placeholder_mode`** — bool, default `false`. After validating the + inbound token, replace it with an opaque `abph_`-prefixed placeholder + before forwarding to the agent. The real token is held in the + process-scoped shared store for the outbound path to resolve. Requires + `token-exchange` with `resolve_placeholders: true` on the outbound + chain to be coherent (see the matched-pair note below). +- **`placeholder_ttl`** — Go duration string (e.g. `30m`, `1h`), default + `1h`. How long the real token is retained for outbound resolution. + +Mint requires `pctx.Shared` to be wired by the listener; if it is nil +when `placeholder_mode` is on, the plugin fails fast at init (a deploy +error) rather than silently forwarding the real token. + ## `on_error` policy > **Naming caveat.** Despite the name, `on_error` controls how the @@ -1064,6 +1083,82 @@ Plugins keep ownership of: The IBAC plugin (`authlib/plugins/ibac/judge.go`) is the in-tree reference; copy its shape when adding a new LLM-using plugin. For what IBAC actually does end-to-end (threat model, configuration, deny-reason vocabulary), see [`ibac-plugin.md`](ibac-plugin.md). +## Credential placeholder swap + +An opt-in mode that keeps the **real** user token out of the agent. With +it on, `jwt-validation` validates the inbound token and then replaces it +with an opaque `abph_` placeholder before forwarding to the agent; the +real token is held in a process-scoped shared store. On the way out, +`token-exchange` resolves the placeholder back to the real token (on a +matched route) before its normal RFC 8693 exchange. The agent thus holds +only an opaque handle, never a usable credential. + +For the design rationale, data flow, and branch table, see +[`docs/superpowers/specs/2026-06-02-credential-placeholder-swap-design.md`](./superpowers/specs/2026-06-02-credential-placeholder-swap-design.md). + +### The two flags are a matched pair + +| Flag | Plugin | Chain | Role | +|---|---|---|---| +| `placeholder_mode` | `jwt-validation` | inbound | **mint** — swap real token → `abph_` handle | +| `resolve_placeholders` | `token-exchange` | outbound | **resolve** — swap `abph_` handle → real token, then exchange | + +Both must be on to be coherent: + +- **Mint on, resolve off** → the outbound side sees an `abph_` subject it + can't use and **fails closed (deny)**. Safe and visible, but broken. +- **Resolve on, mint off** → no `abph_` tokens are ever produced, so + resolve is a **no-op**; normal exchange is unaffected. + +The pairing can't be expressed via `Requires` (that checks plugin +*name*, not config *state*), so v1 relies on the fail-closed deny plus +this documentation rather than build-time cross-validation. + +### `token-exchange`: `resolve_placeholders` + +Bool, default `false`. When the outbound bearer carries the `abph_` +prefix, resolve it from the shared store to the real token before the +normal exchange. An unresolvable placeholder (unknown or expired — +e.g. after a sidecar restart) is **denied, fail-closed**; the opaque +string is never sent to an upstream. A non-placeholder bearer skips +resolve and runs the normal exchange (backward compatible). Resolve is +gated by the same route match as the exchange — the handle is never +resolved before a route is confirmed, so a leaked handle can't pull the +real token into a header bound for an unmatched host. + +### Passthrough hosts receive the placeholder, not the real token + +With mint on, the agent never holds the real token, so any non-exchange +(passthrough) egress forwards the opaque `abph_` handle as-is. It is +useless off-box. Any host that needs a real credential MUST be +configured as a `token-exchange` route — passthrough egress cannot +produce one. + +### The store is in-memory and process-scoped + +The handle→token store lives in memory and is shared only within a +single process. The mode therefore works only where inbound mint and +outbound resolve run in the **same** process: + +- the reverse+forward proxy sidecar (`authbridge-proxy` / + `authbridge-lite`); +- a **single-replica** extproc/extauthz (`authbridge-envoy`). + +Multi-replica (HA) or shared/scaled Istio ambient waypoint deployments +can land mint and resolve on different processes, which the in-memory +store cannot bridge. Those need an external store behind the same +interface — a **current limitation**, tracked as a future enhancement. + +### Security + +- The handle is a random `abph_`-prefixed token (CSPRNG ≥256-bit), + meaningless outside the minting process's store. +- The real token never reaches the agent and never persists to disk + (in-memory store only). +- Neither the real token nor the handle is logged in cleartext — + records carry a hash or the prefix only (see the "NEVER put raw + tokens" rules under [Emitting session events](#emitting-session-events)). + ## Cross-references - `authbridge/authlib/pipeline/configurable.go` — the interface. diff --git a/authbridge/docs/superpowers/plans/2026-06-02-credential-placeholder-swap.md b/authbridge/docs/superpowers/plans/2026-06-02-credential-placeholder-swap.md new file mode 100644 index 000000000..e579ff76f --- /dev/null +++ b/authbridge/docs/superpowers/plans/2026-06-02-credential-placeholder-swap.md @@ -0,0 +1,1199 @@ +# Credential Placeholder Swap Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Keep the real user token out of the agent — `jwt-validation` mints an opaque placeholder on inbound, `token-exchange` resolves it back to the real token on egress before exchange. + +**Architecture:** A generic process-scoped TTL store (`authlib/shared`) is injected into the listeners and exposed on `pipeline.Context.Shared`. A tiny `authlib/placeholder` package holds the handle convention. `jwt-validation` (mint mode) stores the real token under a random handle and forwards the handle to the agent; the reverseproxy/extproc inbound paths propagate that header change to the agent. `token-exchange` (resolve mode) looks the handle up and substitutes the real token before its normal RFC 8693 exchange. + +**Tech Stack:** Go (module root `authbridge/`), `crypto/rand`, `encoding/base64`, standard `testing`. + +**Spec:** `authbridge/docs/superpowers/specs/2026-06-02-credential-placeholder-swap-design.md` + +**All commands run from** the repository's `authbridge/` module root (the directory containing `go.work`). + +**Scope (v1):** `authbridge-proxy` / `authbridge-lite` (reverseproxy + forwardproxy) and `authbridge-envoy` (extproc), all single-replica. `extauthz` (waypoint) and an external store are deferred — the waypoint topology needs the external store anyway (see spec compatibility table). + +**Commit discipline:** DCO sign-off on every commit (`git commit -s`). Use the `Assisted-By: Claude (Anthropic AI) ` trailer, never `Co-Authored-By`. Run `golangci-lint run` before the final push. + +--- + +## File Structure + +| File | Responsibility | Create/Modify | +|------|----------------|---------------| +| `authlib/shared/store.go` | Generic process-scoped TTL key→value store (`any` values) | Create | +| `authlib/shared/store_test.go` | Store unit tests | Create | +| `authlib/placeholder/placeholder.go` | Handle convention: `Prefix`, `New()`, `Key()` | Create | +| `authlib/placeholder/placeholder_test.go` | Placeholder helper tests | Create | +| `authlib/pipeline/context.go` | Add `SharedStore` interface + `Context.Shared` field | Modify | +| `authlib/pipeline/sharedstore_test.go` | Interface-satisfaction assertion | Create | +| `authlib/plugins/jwtvalidation/plugin.go` | `placeholder_mode`/`placeholder_ttl` config + mint logic | Modify | +| `authlib/plugins/jwtvalidation/placeholder_test.go` | Mint-mode tests | Create | +| `authlib/plugins/tokenexchange/plugin.go` | `resolve_placeholders` config + resolve logic | Modify | +| `authlib/plugins/tokenexchange/placeholder_test.go` | Resolve tests | Create | +| `authlib/listener/reverseproxy/server.go` | `Shared` field + inbound Authorization propagation | Modify | +| `authlib/listener/reverseproxy/placeholder_test.go` | Inbound propagation test | Create | +| `authlib/listener/forwardproxy/server.go` | `Shared` field set on pctx | Modify | +| `authlib/listener/extproc/server.go` | `Shared` field + inbound propagation in `handleInbound`/`handleInboundBody` | Modify | +| `authlib/listener/extproc/placeholder_test.go` | Inbound propagation test | Create | +| `cmd/authbridge-proxy/main.go` | Create store, set on both servers | Modify | +| `cmd/authbridge-lite/main.go` | Create store, set on both servers | Modify | +| `cmd/authbridge-envoy/main.go` | Create store, set in extproc.Server literal | Modify | +| `authbridge/docs/plugin-reference.md` | Document the new mode | Modify | + +--- + +## Design notes the implementer must respect + +1. **Resolve happens in `token-exchange.OnRequest` BEFORE `p.inner.HandleOutbound`.** Reason: route matching is inside `HandleOutbound`, but the plugin only ever writes the `Authorization` header on `ActionReplaceToken` (a matched exchange route). So substituting the real token as the *subject* before `HandleOutbound` cannot leak it to an unmatched host — on passthrough/no-route the plugin leaves the header untouched (still the placeholder). Resolving first is also correct for the token cache, which keys on the real subject token. +2. **Passthrough hosts receive the placeholder, not the real token.** With mint on, the agent never holds the real token, so any non-exchange (passthrough) egress forwards the opaque handle. Hosts that need a real credential must be configured as exchange routes. Document this; consider `default_policy: exchange` or a deny default when mint is on. +3. **Fail-closed on lookup miss.** `resolve_placeholders` on + handle prefix present + store miss (expired/forged/restart) → deny. Never hand a placeholder to Keycloak as a subject token. +4. **Multi-use handle.** `Get` never deletes; the same handle resolves for every outbound call until its TTL expires. +5. **Listener wiring uses an exported `Shared` field set after construction** — not a `NewServer` signature change — to avoid churning every call site and test. + +--- + +## Task 1: Generic shared store + +**Files:** +- Create: `authlib/shared/store.go` +- Test: `authlib/shared/store_test.go` + +- [ ] **Step 1: Write the failing tests** + +Create `authlib/shared/store_test.go`: + +```go +package shared + +import ( + "sync" + "testing" + "time" +) + +func TestStore_PutGet(t *testing.T) { + s := New() + s.Put("k", "v", time.Minute) + got, ok := s.Get("k") + if !ok || got.(string) != "v" { + t.Fatalf("Get = %v, %v; want v, true", got, ok) + } +} + +func TestStore_GetMissing(t *testing.T) { + s := New() + if _, ok := s.Get("nope"); ok { + t.Fatal("expected miss") + } +} + +func TestStore_Expiry(t *testing.T) { + s := New() + now := time.Unix(1000, 0) + s.now = func() time.Time { return now } + s.Put("k", "v", time.Minute) + now = now.Add(30 * time.Second) + if _, ok := s.Get("k"); !ok { + t.Fatal("should still be live at 30s") + } + now = now.Add(31 * time.Second) + if _, ok := s.Get("k"); ok { + t.Fatal("should be expired past 60s") + } +} + +func TestStore_Delete(t *testing.T) { + s := New() + s.Put("k", "v", time.Minute) + s.Delete("k") + if _, ok := s.Get("k"); ok { + t.Fatal("expected deleted") + } +} + +func TestStore_ConcurrentAccess(t *testing.T) { + s := New() + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + key := string(rune('a' + n%26)) + s.Put(key, n, time.Minute) + s.Get(key) + }(i) + } + wg.Wait() +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./authlib/shared/ -v` +Expected: FAIL — `undefined: New`. + +- [ ] **Step 3: Write the store** + +Create `authlib/shared/store.go`: + +```go +// Package shared provides a generic, process-scoped, TTL key→value store +// that plugins reach via pipeline.Context.Shared. It is intentionally +// semantics-free — feature-specific conventions (e.g. credential +// placeholders) live in their own packages and namespace their keys. +package shared + +import ( + "sync" + "time" +) + +type entry struct { + val any + expires time.Time +} + +// Store is a thread-safe TTL map. The zero value is not usable; call New. +type Store struct { + mu sync.RWMutex + items map[string]entry + now func() time.Time // injectable for tests +} + +// New returns an empty Store. +func New() *Store { + return &Store{items: make(map[string]entry), now: time.Now} +} + +// Put stores val under key with the given time-to-live. +func (s *Store) Put(key string, val any, ttl time.Duration) { + s.mu.Lock() + defer s.mu.Unlock() + s.items[key] = entry{val: val, expires: s.now().Add(ttl)} +} + +// Get returns the value for key if present and unexpired. Expired entries +// are evicted lazily. +func (s *Store) Get(key string) (any, bool) { + s.mu.RLock() + e, ok := s.items[key] + s.mu.RUnlock() + if !ok { + return nil, false + } + if s.now().After(e.expires) { + s.Delete(key) + return nil, false + } + return e.val, true +} + +// Delete removes key. +func (s *Store) Delete(key string) { + s.mu.Lock() + delete(s.items, key) + s.mu.Unlock() +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./authlib/shared/ -race -v` +Expected: PASS (all 5 tests, no data races). + +- [ ] **Step 5: Commit** + +```bash +git add authlib/shared/ +git commit -s -m "feat(authbridge): add generic process-scoped TTL shared store" +``` + +--- + +## Task 2: Placeholder convention package + +**Files:** +- Create: `authlib/placeholder/placeholder.go` +- Test: `authlib/placeholder/placeholder_test.go` + +- [ ] **Step 1: Write the failing tests** + +Create `authlib/placeholder/placeholder_test.go`: + +```go +package placeholder + +import ( + "strings" + "testing" +) + +func TestNew_HasPrefixAndIsUnique(t *testing.T) { + a, err := New() + if err != nil { + t.Fatalf("New: %v", err) + } + if !strings.HasPrefix(a, Prefix) { + t.Fatalf("handle %q missing prefix %q", a, Prefix) + } + if len(a) <= len(Prefix)+10 { + t.Fatalf("handle %q too short", a) + } + b, _ := New() + if a == b { + t.Fatal("two handles collided") + } +} + +func TestIsPlaceholder(t *testing.T) { + h, _ := New() + if !IsPlaceholder(h) { + t.Fatalf("IsPlaceholder(%q) = false", h) + } + if IsPlaceholder("eyJhbGci-real-jwt") { + t.Fatal("real token misclassified as placeholder") + } +} + +func TestKey_NamespacesHandle(t *testing.T) { + if got := Key("abph_xyz"); got != "placeholder/abph_xyz" { + t.Fatalf("Key = %q", got) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./authlib/placeholder/ -v` +Expected: FAIL — `undefined: New`. + +- [ ] **Step 3: Write the package** + +Create `authlib/placeholder/placeholder.go`: + +```go +// Package placeholder defines the convention for opaque credential +// handles: a random "abph_" token the agent receives in place of the real +// Authorization value, plus the namespaced key used to store the real +// token in a shared store. The generic store itself (authlib/shared) holds +// no credential semantics. +package placeholder + +import ( + "crypto/rand" + "encoding/base64" + "strings" +) + +// Prefix marks a value as a credential placeholder. token-exchange uses it +// as a cheap fast-path before attempting a store lookup. +const Prefix = "abph_" + +// keyNamespace prefixes shared-store keys to avoid collisions with other +// shared-store consumers. +const keyNamespace = "placeholder/" + +// New returns a fresh, unguessable handle (Prefix + 256 bits base64url). +func New() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return Prefix + base64.RawURLEncoding.EncodeToString(b), nil +} + +// IsPlaceholder reports whether token is a placeholder handle. +func IsPlaceholder(token string) bool { + return strings.HasPrefix(token, Prefix) +} + +// Key returns the shared-store key for a handle. +func Key(handle string) string { + return keyNamespace + handle +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./authlib/placeholder/ -v` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add authlib/placeholder/ +git commit -s -m "feat(authbridge): add placeholder handle convention package" +``` + +--- + +## Task 3: SharedStore interface on the pipeline Context + +**Files:** +- Modify: `authlib/pipeline/context.go` (struct at lines 82-132; add interface near the `Identity` interface ~line 26) +- Test: `authlib/pipeline/sharedstore_test.go` + +- [ ] **Step 1: Write the failing test** + +Create `authlib/pipeline/sharedstore_test.go`: + +```go +package pipeline_test + +import ( + "testing" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/shared" +) + +// shared.Store must satisfy pipeline.SharedStore so listeners can inject it. +func TestSharedStore_StoreSatisfiesInterface(t *testing.T) { + var _ pipeline.SharedStore = shared.New() +} + +// Context must expose a Shared field of the interface type. +func TestSharedStore_ContextField(t *testing.T) { + pctx := &pipeline.Context{Shared: shared.New()} + if pctx.Shared == nil { + t.Fatal("Context.Shared not assignable") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./authlib/pipeline/ -run TestSharedStore -v` +Expected: FAIL — `undefined: pipeline.SharedStore` / unknown field `Shared`. + +- [ ] **Step 3: Add the interface and field** + +In `authlib/pipeline/context.go`, add the interface immediately after the `Identity` interface block (after line 30): + +```go +// SharedStore is a process-scoped key→value store with TTL, injected by the +// listener so plugins can share state across the inbound→outbound request +// boundary (e.g. credential placeholders). Implemented by authlib/shared.Store. +type SharedStore interface { + Put(key string, val any, ttl time.Duration) + Get(key string) (any, bool) + Delete(key string) +} +``` + +In the `Context` struct, add the field after the `TLS *tls.ConnectionState` field (around line 124): + +```go + // Shared is the process-scoped store the listener injects. May be nil + // when no store is wired; plugins that require it must fail closed. + Shared SharedStore +``` + +(`time` is already imported in context.go.) + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./authlib/pipeline/ -run TestSharedStore -v` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add authlib/pipeline/context.go authlib/pipeline/sharedstore_test.go +git commit -s -m "feat(authbridge): expose SharedStore on the pipeline Context" +``` + +--- + +## Task 4: jwt-validation mint mode + +**Files:** +- Modify: `authlib/plugins/jwtvalidation/plugin.go` (config struct 30-102; `JWTValidation` struct 187-208; `Configure` 233-294; `OnRequest` 349-414) +- Test: `authlib/plugins/jwtvalidation/placeholder_test.go` + +- [ ] **Step 1: Write the failing tests** + +Create `authlib/plugins/jwtvalidation/placeholder_test.go`: + +```go +package jwtvalidation + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/placeholder" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/shared" +) + +// fakeAuth lets us drive OnRequest's allow path without real JWKS. +// The real plugin uses p.inner; for these tests we configure a bypass so +// validation is skipped, then assert mint behavior on the allow path. +func mintTestContext(store pipeline.SharedStore) *pipeline.Context { + pctx := &pipeline.Context{ + Direction: pipeline.Inbound, + Path: "/work", + Headers: http.Header{"Authorization": []string{"Bearer real-user-token"}}, + Shared: store, + } + pctx.SetCurrentPlugin("jwt-validation", pipeline.InvocationPhaseRequest) + return pctx +} + +func TestMint_ReplacesAuthAndStoresToken(t *testing.T) { + st := shared.New() + p := &JWTValidation{ + cfg: jwtValidationConfig{PlaceholderMode: true}, + placeholderTTL: time.Hour, + } + pctx := mintTestContext(st) + + handle, real, ok := p.mint(pctx) + if !ok { + t.Fatal("mint returned not-ok") + } + if !placeholder.IsPlaceholder(handle) { + t.Fatalf("handle %q not a placeholder", handle) + } + if real != "real-user-token" { + t.Fatalf("stored token = %q", real) + } + if pctx.Headers.Get("Authorization") != "Bearer "+handle { + t.Fatalf("header = %q, want Bearer %s", pctx.Headers.Get("Authorization"), handle) + } + got, present := st.Get(placeholder.Key(handle)) + if !present || got.(string) != "real-user-token" { + t.Fatalf("store[%s] = %v, %v", handle, got, present) + } +} + +func TestMint_NilStoreFailsClosed(t *testing.T) { + p := &JWTValidation{cfg: jwtValidationConfig{PlaceholderMode: true}, placeholderTTL: time.Hour} + pctx := mintTestContext(nil) + if _, _, ok := p.mint(pctx); ok { + t.Fatal("mint must fail when Shared is nil") + } +} + +func TestConfigure_ParsesPlaceholderTTL(t *testing.T) { + p := NewJWTValidation() + raw := []byte(`{"issuer":"https://kc/realms/x","jwks_url":"https://kc/jwks","audience":"agent","placeholder_mode":true,"placeholder_ttl":"15m"}`) + if err := p.Configure(raw); err != nil { + t.Fatalf("Configure: %v", err) + } + if !p.cfg.PlaceholderMode { + t.Fatal("placeholder_mode not parsed") + } + if p.placeholderTTL != 15*time.Minute { + t.Fatalf("ttl = %v, want 15m", p.placeholderTTL) + } +} + +func TestConfigure_DefaultPlaceholderTTL(t *testing.T) { + p := NewJWTValidation() + raw := []byte(`{"issuer":"https://kc/realms/x","jwks_url":"https://kc/jwks","audience":"agent","placeholder_mode":true}`) + if err := p.Configure(raw); err != nil { + t.Fatalf("Configure: %v", err) + } + if p.placeholderTTL != time.Hour { + t.Fatalf("default ttl = %v, want 1h", p.placeholderTTL) + } +} + +func _ctx() context.Context { return context.Background() } +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./authlib/plugins/jwtvalidation/ -run 'Mint|Placeholder' -v` +Expected: FAIL — unknown field `PlaceholderMode`, `placeholderTTL`, undefined `p.mint`. + +- [ ] **Step 3: Add config fields** + +In `authlib/plugins/jwtvalidation/plugin.go`, add to `jwtValidationConfig` (after the `BypassPaths` field, ~line 101): + +```go + PlaceholderMode bool `json:"placeholder_mode" default:"false" description:"After validating the inbound token, replace it with an opaque placeholder before forwarding to the agent; the real token is held in the shared store for the outbound path to resolve. Requires a shared store and token-exchange resolve_placeholders downstream."` + PlaceholderTTL string `json:"placeholder_ttl" default:"1h" description:"How long the real token is retained for outbound resolution (Go duration, e.g. 30m). Default 1h."` +``` + +- [ ] **Step 4: Add the struct field and TTL parsing** + +In the `JWTValidation` struct (lines 187-208), add a field: + +```go + placeholderTTL time.Duration +``` + +At the end of `Configure`, immediately before `return nil` (line 293), add: + +```go + p.placeholderTTL = time.Hour + if c.PlaceholderTTL != "" { + d, err := time.ParseDuration(c.PlaceholderTTL) + if err != nil { + return fmt.Errorf("jwt-validation: invalid placeholder_ttl %q: %w", c.PlaceholderTTL, err) + } + p.placeholderTTL = d + } +``` + +Ensure `time` is imported in plugin.go (it is used elsewhere; if not, add to the import block). Add `"github.com/kagenti/kagenti-extensions/authbridge/authlib/placeholder"` and confirm `"github.com/kagenti/kagenti-extensions/authbridge/authlib/auth"` are imported (auth is already used for `p.inner`). + +- [ ] **Step 5: Add the `mint` helper** + +Add this method near `OnRequest` in plugin.go: + +```go +// mint replaces the validated Authorization header with an opaque placeholder +// and stores the real bearer token in the shared store. Returns the handle, +// the stored token, and ok=false (fail closed) when no store is wired. +func (p *JWTValidation) mint(pctx *pipeline.Context) (handle, real string, ok bool) { + if pctx.Shared == nil { + return "", "", false + } + real = auth.ExtractBearer(pctx.Headers.Get("Authorization")) + if real == "" { + return "", "", false + } + h, err := placeholder.New() + if err != nil { + return "", "", false + } + pctx.Shared.Put(placeholder.Key(h), real, p.placeholderTTL) + pctx.Headers.Set("Authorization", "Bearer "+h) + return h, real, true +} +``` + +- [ ] **Step 6: Call `mint` from the allow path of `OnRequest`** + +In `OnRequest`, after `pctx.Identity = claimsIdentity{c: result.Claims}` and the existing `pctx.Record(... ActionAllow ...)` block, but before `return pipeline.Action{Type: pipeline.Continue}` (after line 412), add: + +```go + if p.cfg.PlaceholderMode { + handle, _, ok := p.mint(pctx) + if !ok { + pctx.Record(pipeline.Invocation{ + Action: pipeline.ActionDeny, + Reason: "placeholder_mint_failed", + }) + return pipeline.DenyStatus(503, "upstream.unreachable", "placeholder_mode requires a shared store") + } + pctx.Record(pipeline.Invocation{ + Action: pipeline.ActionModify, + Reason: "placeholder_minted", + Details: map[string]string{"handle_prefix": handle[:len(placeholder.Prefix)+6]}, + }) + } +``` + +(The `handle_prefix` records only the prefix plus 6 chars — never the full handle or token, per the security requirement.) + +- [ ] **Step 7: Run tests to verify they pass** + +Run: `go test ./authlib/plugins/jwtvalidation/ -v` +Expected: PASS (existing tests + new mint tests). + +- [ ] **Step 8: Commit** + +```bash +git add authlib/plugins/jwtvalidation/ +git commit -s -m "feat(authbridge): add placeholder mint mode to jwt-validation" +``` + +--- + +## Task 5: token-exchange resolve step + +**Files:** +- Modify: `authlib/plugins/tokenexchange/plugin.go` (config struct ~30-67; `OnRequest` 562-634) +- Test: `authlib/plugins/tokenexchange/placeholder_test.go` + +- [ ] **Step 1: Write the failing tests** + +Create `authlib/plugins/tokenexchange/placeholder_test.go`: + +```go +package tokenexchange + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/placeholder" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/shared" +) + +func resolveTestPlugin(t *testing.T, exchangeURL string) *TokenExchange { + t.Helper() + p := NewTokenExchange() + raw := []byte(`{ + "token_url":"` + exchangeURL + `", + "default_policy":"exchange", + "resolve_placeholders":true, + "identity":{"type":"client-secret","client_id":"agent","client_secret":"secret"} + }`) + if err := p.Configure(raw); err != nil { + t.Fatalf("Configure: %v", err) + } + return p +} + +func exchangeStub(t *testing.T) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": "exchanged-token", "token_type": "Bearer", "expires_in": 300, + }) + })) +} + +// A valid handle on a matched route → resolved to the real token, exchanged. +func TestResolve_MatchedRouteExchanges(t *testing.T) { + srv := exchangeStub(t) + defer srv.Close() + p := resolveTestPlugin(t, srv.URL) + + st := shared.New() + handle, _ := placeholder.New() + st.Put(placeholder.Key(handle), "real-user-token", time.Hour) + + pctx := &pipeline.Context{ + Direction: pipeline.Outbound, + Host: "target-svc", + Headers: http.Header{"Authorization": []string{"Bearer " + handle}}, + Shared: st, + } + pctx.SetCurrentPlugin("token-exchange", pipeline.InvocationPhaseRequest) + action := p.OnRequest(_ctx(), pctx) + if action.Type != pipeline.Continue { + t.Fatalf("action = %v, want Continue", action.Type) + } + if pctx.Headers.Get("Authorization") != "Bearer exchanged-token" { + t.Fatalf("auth = %q, want Bearer exchanged-token", pctx.Headers.Get("Authorization")) + } +} + +// Unknown/expired handle on a matched route → deny (fail closed). +func TestResolve_MissDenies(t *testing.T) { + srv := exchangeStub(t) + defer srv.Close() + p := resolveTestPlugin(t, srv.URL) + + pctx := &pipeline.Context{ + Direction: pipeline.Outbound, + Host: "target-svc", + Headers: http.Header{"Authorization": []string{"Bearer abph_unknownhandle"}}, + Shared: shared.New(), + } + pctx.SetCurrentPlugin("token-exchange", pipeline.InvocationPhaseRequest) + action := p.OnRequest(_ctx(), pctx) + if action.Type != pipeline.Reject { + t.Fatalf("action = %v, want Reject", action.Type) + } +} + +// A normal (non-placeholder) token is unaffected by resolve mode. +func TestResolve_NonPlaceholderPassThrough(t *testing.T) { + srv := exchangeStub(t) + defer srv.Close() + p := resolveTestPlugin(t, srv.URL) + + pctx := &pipeline.Context{ + Direction: pipeline.Outbound, + Host: "target-svc", + Headers: http.Header{"Authorization": []string{"Bearer real-jwt"}}, + Shared: shared.New(), + } + pctx.SetCurrentPlugin("token-exchange", pipeline.InvocationPhaseRequest) + action := p.OnRequest(_ctx(), pctx) + if action.Type != pipeline.Continue { + t.Fatalf("action = %v, want Continue", action.Type) + } + if pctx.Headers.Get("Authorization") != "Bearer exchanged-token" { + t.Fatalf("auth = %q, want Bearer exchanged-token (normal exchange)", pctx.Headers.Get("Authorization")) + } +} + +func _ctx() context.Context { return context.Background() } +``` + +Add `"context"` to the import block of this test file (and remove the duplicate `_ctx` if the package already defines one — if `go vet` reports a redeclaration, delete the local `_ctx` here and rely on the existing helper). + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./authlib/plugins/tokenexchange/ -run TestResolve -v` +Expected: FAIL — unknown field `resolve_placeholders`. + +- [ ] **Step 3: Add config field** + +In `authlib/plugins/tokenexchange/plugin.go`, add to `tokenExchangeConfig` (after `AudienceFromHost`, ~line 67): + +```go + ResolvePlaceholders bool `json:"resolve_placeholders" default:"false" description:"Resolve an inbound bearer carrying the placeholder prefix from the shared store to the real token before exchange. Unresolvable placeholders are denied (fail closed)."` +``` + +- [ ] **Step 4: Add the resolve step to `OnRequest`** + +Add the placeholder import: `"github.com/kagenti/kagenti-extensions/authbridge/authlib/placeholder"`. + +In `OnRequest`, replace the opening lines: + +```go + authHeader := pctx.Headers.Get("Authorization") + host := pctx.Host + + result := p.inner.HandleOutbound(ctx, authHeader, host) +``` + +with: + +```go + authHeader := pctx.Headers.Get("Authorization") + host := pctx.Host + + if p.cfg.ResolvePlaceholders && placeholder.IsPlaceholder(auth.ExtractBearer(authHeader)) { + real, ok := resolvePlaceholder(pctx, auth.ExtractBearer(authHeader)) + if !ok { + return pctx.DenyAndRecord("placeholder_unresolved", "auth.unauthorized", "unresolvable credential placeholder") + } + authHeader = "Bearer " + real + } + + result := p.inner.HandleOutbound(ctx, authHeader, host) +``` + +Add the helper near `OnRequest`: + +```go +// resolvePlaceholder looks a handle up in the shared store. Returns ok=false +// (fail closed) when no store is wired or the handle is unknown/expired. +func resolvePlaceholder(pctx *pipeline.Context, handle string) (string, bool) { + if pctx.Shared == nil { + return "", false + } + v, ok := pctx.Shared.Get(placeholder.Key(handle)) + if !ok { + return "", false + } + s, ok := v.(string) + return s, ok +} +``` + +(`auth` is already imported — `auth.ExtractBearer` is used in the listeners and `p.inner` is `*auth.Auth`. Confirm the import is present in plugin.go; if not, add `"github.com/kagenti/kagenti-extensions/authbridge/authlib/auth"`.) + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test ./authlib/plugins/tokenexchange/ -v` +Expected: PASS (existing tests + 3 new resolve tests). + +- [ ] **Step 6: Commit** + +```bash +git add authlib/plugins/tokenexchange/ +git commit -s -m "feat(authbridge): add placeholder resolve step to token-exchange" +``` + +--- + +## Task 6: reverseproxy inbound Authorization propagation + +**Files:** +- Modify: `authlib/listener/reverseproxy/server.go` (`Server` struct 51-64; `handleRequest` 164-254) +- Test: `authlib/listener/reverseproxy/placeholder_test.go` + +- [ ] **Step 1: Write the failing test** + +Create `authlib/listener/reverseproxy/placeholder_test.go`: + +```go +package reverseproxy + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" +) + +// rewritePlugin sets a new Authorization on the inbound context, simulating +// jwt-validation mint mode. +type rewritePlugin struct{} + +func (rewritePlugin) Name() string { return "rewrite" } +func (rewritePlugin) Capabilities() pipeline.PluginCapabilities { return pipeline.PluginCapabilities{} } +func (rewritePlugin) OnRequest(_ context.Context, pctx *pipeline.Context) pipeline.Action { + pctx.Headers.Set("Authorization", "Bearer abph_minted") + return pipeline.Action{Type: pipeline.Continue} +} +func (rewritePlugin) OnResponse(_ context.Context, pctx *pipeline.Context) pipeline.Action { + return pipeline.Action{Type: pipeline.Continue} +} + +func TestInboundPropagation_RewrittenAuthReachesBackend(t *testing.T) { + var seen string + backend := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + seen = r.Header.Get("Authorization") + })) + defer backend.Close() + + holder := pipeline.NewHolder([]pipeline.Plugin{rewritePlugin{}}) + srv, err := NewServer(holder, nil, backend.URL, nil) + if err != nil { + t.Fatalf("NewServer: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "http://agent.local/work", nil) + req.Header.Set("Authorization", "Bearer real-user-token") + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + if seen != "Bearer abph_minted" { + t.Fatalf("backend saw Authorization=%q, want Bearer abph_minted", seen) + } +} +``` + +Note: confirm the exact constructor for a `*pipeline.Holder` from a plugin slice (look for `NewHolder` or equivalent in `authlib/pipeline/`); if the name differs, use the existing helper the other listener tests use. Confirm `srv.ServeHTTP` is the handler entry (or use `srv.Handler()` / the method the other reverseproxy tests call). + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./authlib/listener/reverseproxy/ -run TestInboundPropagation -v` +Expected: FAIL — backend sees `Bearer real-user-token` (mutation dropped). + +- [ ] **Step 3: Add the `Shared` field** + +In the `Server` struct (lines 51-64), add: + +```go + Shared pipeline.SharedStore // process-scoped store; set by main, may be nil +``` + +In the `pctx` literal at the top of `handleRequest` (lines 165-173), add the field: + +```go + Shared: s.Shared, +``` + +- [ ] **Step 4: Propagate the inbound Authorization change** + +In `handleRequest`, capture the original Authorization just before `action := s.InboundPipeline.Run(...)` (line 210): + +```go + originalAuth := pctx.Headers.Get("Authorization") + action := s.InboundPipeline.Run(r.Context(), pctx) +``` + +Then, after the `Reject` handling and the `pctx.BodyMutated()` block (after line 225), before the session-recording block, add: + +```go + if newAuth := pctx.Headers.Get("Authorization"); newAuth != originalAuth { + r.Header.Set("Authorization", newAuth) + } +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `go test ./authlib/listener/reverseproxy/ -v` +Expected: PASS (existing + new propagation test). + +- [ ] **Step 6: Commit** + +```bash +git add authlib/listener/reverseproxy/ +git commit -s -m "feat(authbridge): propagate inbound Authorization mutation in reverseproxy" +``` + +--- + +## Task 7: forwardproxy Shared field + +**Files:** +- Modify: `authlib/listener/forwardproxy/server.go` (`Server` struct 32-36; pctx build ~159) + +- [ ] **Step 1: Add the field and set it on pctx** + +In the `Server` struct (lines 32-36), add: + +```go + Shared pipeline.SharedStore // process-scoped store; set by main, may be nil +``` + +In the `pctx` literal in `handleRequest` (lines 159-167), add: + +```go + Shared: s.Shared, +``` + +- [ ] **Step 2: Verify the package still builds and tests pass** + +Run: `go test ./authlib/listener/forwardproxy/ -v` +Expected: PASS (no behavior change; the field is now available to outbound plugins). + +- [ ] **Step 3: Commit** + +```bash +git add authlib/listener/forwardproxy/ +git commit -s -m "feat(authbridge): expose shared store on forwardproxy outbound context" +``` + +--- + +## Task 8: extproc Shared field + inbound propagation + +**Files:** +- Modify: `authlib/listener/extproc/server.go` (`Server` 35-40; `handleInbound` 143-163; `handleInboundBody` 165-185; `handleOutbound` 447-483 and `handleOutboundBody` 485-521 pctx literals) +- Test: `authlib/listener/extproc/placeholder_test.go` + +- [ ] **Step 1: Write the failing test** + +Create `authlib/listener/extproc/placeholder_test.go`: + +```go +package extproc + +import ( + "context" + "testing" + + corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" +) + +type mintPlugin struct{} + +func (mintPlugin) Name() string { return "mint" } +func (mintPlugin) Capabilities() pipeline.PluginCapabilities { return pipeline.PluginCapabilities{} } +func (mintPlugin) OnRequest(_ context.Context, pctx *pipeline.Context) pipeline.Action { + pctx.Headers.Set("Authorization", "Bearer abph_minted") + return pipeline.Action{Type: pipeline.Continue} +} +func (mintPlugin) OnResponse(_ context.Context, pctx *pipeline.Context) pipeline.Action { + return pipeline.Action{Type: pipeline.Continue} +} + +// On the inbound path, a rewritten Authorization must be emitted to Envoy as a +// header mutation so it reaches the agent. +func TestExtprocInbound_EmitsAuthMutation(t *testing.T) { + s := &Server{InboundPipeline: pipeline.NewHolder([]pipeline.Plugin{mintPlugin{}})} + headers := &corev3.HeaderMap{Headers: []*corev3.HeaderValue{ + {Key: ":path", Value: "/work"}, + {Key: "authorization", Value: "Bearer real-user-token"}, + }} + resp, _ := s.handleInbound(fakeStream(t), headers, nil) + + got := authMutationValue(t, resp) + if got != "Bearer abph_minted" { + t.Fatalf("emitted authorization = %q, want Bearer abph_minted", got) + } +} +``` + +Implement the two test helpers (`fakeStream` returning a minimal `extprocv3.ExternalProcessor_ProcessServer` whose `Context()` returns `context.Background()`, and `authMutationValue` which walks `resp.GetRequestHeaders().GetResponse().GetHeaderMutation().GetSetHeaders()` for the `authorization` key) by copying the pattern from the existing extproc tests — check `authlib/listener/extproc/server_test.go` for an existing fake stream and mutation-reading helper and reuse them rather than reinventing. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./authlib/listener/extproc/ -run TestExtprocInbound -v` +Expected: FAIL — `handleInbound` returns `allowResponse()` with no auth mutation. + +- [ ] **Step 3: Add the `Shared` field** + +In the `Server` struct (lines 35-40), add: + +```go + Shared pipeline.SharedStore // process-scoped store; set by main, may be nil +``` + +Add `Shared: s.Shared,` to the `pctx` literal in all four handlers: `handleInbound` (line 145), `handleInboundBody` (line 167), `handleOutbound` (line 449), `handleOutboundBody` (line 487). + +- [ ] **Step 4: Emit the inbound auth mutation** + +In `handleInbound`, capture the original Authorization before `action := s.InboundPipeline.Run(...)` (line 155) and emit on change before the existing `return allowResponse(), pctx`: + +```go + originalAuth := pctx.Headers.Get("Authorization") + action := s.InboundPipeline.Run(ctx, pctx) + if action.Type == pipeline.Reject { + s.recordInboundReject(pctx, action) + return rejectFromAction(action), nil + } + + s.recordInboundSession(pctx) + + if newAuth := pctx.Headers.Get("Authorization"); newAuth != originalAuth { + return replaceTokenResponse(auth.ExtractBearer(newAuth)), pctx + } + return allowResponse(), pctx +``` + +Apply the same change in `handleInboundBody`, but emit via the body variant to preserve body handling: + +```go + originalAuth := pctx.Headers.Get("Authorization") + action := s.InboundPipeline.Run(ctx, pctx) + if action.Type == pipeline.Reject { + s.recordInboundReject(pctx, action) + return rejectFromAction(action), nil + } + + s.recordInboundSession(pctx) + + if newAuth := pctx.Headers.Get("Authorization"); newAuth != originalAuth { + return withBodyMutation(replaceTokenBodyResponse(auth.ExtractBearer(newAuth)), pctx), pctx + } + return withBodyMutation(allowBodyResponse(), pctx), pctx +``` + +(`auth`, `replaceTokenResponse`, `replaceTokenBodyResponse`, `withBodyMutation`, `allowBodyResponse` are all already used in this file.) + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test ./authlib/listener/extproc/ -v` +Expected: PASS (existing + new inbound mutation test). + +- [ ] **Step 6: Commit** + +```bash +git add authlib/listener/extproc/ +git commit -s -m "feat(authbridge): propagate inbound Authorization mutation in extproc" +``` + +--- + +## Task 9: Wire the shared store in main + +**Files:** +- Modify: `cmd/authbridge-proxy/main.go` (~lines 246-250), `cmd/authbridge-lite/main.go` (~lines 232-241), `cmd/authbridge-envoy/main.go` (`startGRPCExtProc` 299-320 + call site ~216) + +- [ ] **Step 1: authbridge-proxy — create and inject the store** + +In `cmd/authbridge-proxy/main.go`, immediately after the `rpSrv, err := reverseproxy.NewServer(...)` / `fpSrv, err := forwardproxy.NewServer(...)` blocks (after line 253) and before the `httpServers = append(...)` lines, add: + +```go + sharedStore := shared.New() + rpSrv.Shared = sharedStore + fpSrv.Shared = sharedStore +``` + +Add the import `"github.com/kagenti/kagenti-extensions/authbridge/authlib/shared"`. + +- [ ] **Step 2: authbridge-lite — same injection** + +In `cmd/authbridge-lite/main.go`, after the `rpSrv`/`fpSrv` construction (after line ~239), add the identical three lines and the `shared` import: + +```go + sharedStore := shared.New() + rpSrv.Shared = sharedStore + fpSrv.Shared = sharedStore +``` + +- [ ] **Step 3: authbridge-envoy — inject into the extproc.Server literal** + +In `cmd/authbridge-envoy/main.go`, change `startGRPCExtProc` to take and set the store. Update the signature and the struct literal (lines 299-305): + +```go +func startGRPCExtProc(inbound, outbound *pipeline.Holder, sessions *session.Store, store pipeline.SharedStore, addr string) *grpc.Server { + srv := grpc.NewServer() + extprocv3.RegisterExternalProcessorServer(srv, &extproc.Server{ + InboundPipeline: inbound, + OutboundPipeline: outbound, + Sessions: sessions, + Shared: store, + }) +``` + +Update the call site (line ~216): + +```go + grpcServers = append(grpcServers, startGRPCExtProc(inboundH, outboundH, sessions, shared.New(), cfg.Listener.ExtProcAddr)) +``` + +Add the imports `"github.com/kagenti/kagenti-extensions/authbridge/authlib/shared"` and (if not present) `"github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline"`. + +- [ ] **Step 4: Build everything** + +Run: `go build ./...` +Expected: builds clean, no errors. + +- [ ] **Step 5: Run the full test suite** + +Run: `go test ./... 2>&1 | tail -30` +Expected: all packages PASS. + +- [ ] **Step 6: Commit** + +```bash +git add cmd/authbridge-proxy/main.go cmd/authbridge-lite/main.go cmd/authbridge-envoy/main.go +git commit -s -m "feat(authbridge): wire shared placeholder store into proxy, lite, and envoy" +``` + +--- + +## Task 10: Documentation + +**Files:** +- Modify: `authbridge/docs/plugin-reference.md` + +- [ ] **Step 1: Document the mode** + +In `authbridge/docs/plugin-reference.md`, under the `jwt-validation` and `token-exchange` sections, add the new config fields and a short "Credential placeholder swap" subsection explaining: the two flags are a matched pair (mint without resolve → fail-closed deny; resolve without mint → no-op); passthrough hosts receive the placeholder, so hosts needing a real credential must be exchange routes; the store is in-memory and single-process (sidecar / single-replica extproc), with the external store as the multi-replica/waypoint follow-on. Link to the design spec `docs/superpowers/specs/2026-06-02-credential-placeholder-swap-design.md`. + +- [ ] **Step 2: Commit** + +```bash +git add authbridge/docs/plugin-reference.md +git commit -s -m "docs(authbridge): document credential placeholder swap mode" +``` + +--- + +## Final verification + +- [ ] **Run the full suite with race detection** + +Run: `go test ./... -race 2>&1 | tail -30` +Expected: all PASS, no races. + +- [ ] **Lint** + +Run: `golangci-lint run` +Expected: no new findings. + +- [ ] **Manual smoke (optional, once deployed):** with `placeholder_mode: true` (jwt-validation) and `resolve_placeholders: true` (token-exchange), confirm via session events / logs that (a) the agent receives `Bearer abph_…`, (b) an exchange-route upstream receives the exchanged token, (c) an unknown/expired handle on an exchange route is denied. + +--- + +## Spec coverage check + +| Spec section | Task | +|--------------|------| +| Shared store (Layer 1) | 1 | +| Placeholder semantics (Layer 2) | 2 | +| `SharedStore` interface + `Context.Shared` | 3 | +| jwt-validation mint + config | 4 | +| token-exchange resolve + config | 5 | +| Inbound propagation: reverseproxy | 6 | +| forwardproxy store exposure | 7 | +| Inbound propagation: extproc | 8 | +| `main` wiring (proxy/lite/envoy) | 9 | +| Docs | 10 | +| Fail-closed branches (D, E) | 4 (mint nil-store), 5 (resolve miss) | +| Route-gating safety | 5 (design note: header only written on ActionReplaceToken) | +| External store / extauthz / waypoint | Out of scope (noted) | + +## Attribution + +Assisted-By: Claude (Anthropic AI) diff --git a/authbridge/docs/superpowers/specs/2026-06-02-credential-placeholder-swap-design.md b/authbridge/docs/superpowers/specs/2026-06-02-credential-placeholder-swap-design.md new file mode 100644 index 000000000..f0af9882c --- /dev/null +++ b/authbridge/docs/superpowers/specs/2026-06-02-credential-placeholder-swap-design.md @@ -0,0 +1,309 @@ +# Design: Credential placeholder swap (hide real tokens from the agent) + +**Date:** 2026-06-02 +**Status:** Draft (design approved, pending spec review) +**Scope (v1):** authbridge — `jwt-validation` plugin, `token-exchange` plugin, a new shared store, and inbound-header propagation in the **`reverseproxy` and `extproc`** listeners (the two single-process topologies that ship today). `extauthz`/waypoint and an external store are **deferred** — see "Out of scope". This scope statement is authoritative; the per-listener and files-touched sections below mark `extauthz` as deferred to match. + +## Problem + +Today the agent workload sees real credentials. On the inbound path, `reverseproxy` +forwards the user's `Authorization: Bearer ` straight through to the agent +(it never strips it). The agent then either forwards that real token outbound (where +`token-exchange` swaps it for a downstream-scoped token) or sends nothing and relies +on client-credentials injection. + +This means a compromised or prompt-injected agent holds the real user token — exactly +the secret we'd like to keep out of its reach. + +**Goal:** the agent never receives the real `Authorization` value. Instead it receives +an opaque random **placeholder**. When the agent forwards that placeholder on an +outbound call, authbridge swaps it back to the real token (then runs its normal token +exchange) before the request leaves the sidecar. + +This escapes the current either/or: it preserves user-delegated identity (unlike +client-credentials injection) **and** keeps the secret away from the agent (unlike +forwarding the real token). + +## Why not the existing config? + +`jwt-validation` is a validator (it can't mint/strip), and `token-exchange` treats the +inbound bearer as an RFC 8693 *subject token* — a random placeholder is not a valid JWT, +so Keycloak would reject it. The placeholder pattern needs a server-side +handle→token store, a resolver step, and inbound mint/strip — none of which exist. +See the conversation history for the full walk-through. + +## Approach + +Two existing plugins gain an opt-in mode; one listener gains a small propagation step; +one new tiny store is added. **No new plugin, no new framework abstraction.** + +``` +User → [reverseproxy: inbound] → Agent → [forwardproxy: outbound] → Upstream + jwt-validation (mint) token-exchange (resolve + exchange) + \ / + \ / + shared.Store (process-scoped, keyed by handle) +``` + +### Decisions (resolved during brainstorming) + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Store topology | **Sidecar-only, in-memory, TTL** | Agent + sidecar share one process; no new infra; extend to external store only when waypoint/shared-Envoy needs it | +| Handle format | **Opaque random `abph_` token** (CSPRNG ≥256-bit) | Simplest; prefix gives the resolver a cheap fast-path | +| Redemption scope | **Existing token-exchange routes** (no handle-level audience binding in v1) | Route-gating already prevents off-route leakage; multi-configured-upstream blast radius equals today's; per-session least-privilege is a future hardening | +| State sharing | **`pctx.Shared` injected by the listener** (not a package global) | Mirrors the existing `sessions` store wiring; explicit, testable, no global mutable state | + +## Components + +### Layer 1 — shared store (reusable) + +A generic, semantics-free, process-scoped TTL key→value store. It is **not** +credential-aware; the placeholder logic lives in the plugins. + +New package `authlib/shared`: + +```go +package shared + +type entry struct{ val any; expires time.Time } + +type Store struct { + mu sync.RWMutex + items map[string]entry + now func() time.Time // injectable for tests +} + +func New() *Store { return &Store{items: map[string]entry{}, now: time.Now} } +func (s *Store) Put(key string, val any, ttl time.Duration) // lock; set expires +func (s *Store) Get(key string) (any, bool) // RLock; lazy-evict if expired +func (s *Store) Delete(key string) // lock; delete +``` + +- Eviction: lazy on `Get` (v1). A periodic background sweep to reclaim + minted-but-never-resolved handles is a future enhancement; for a sidecar with + bounded handle volume, lazy eviction plus TTL is sufficient. The store mirrors + `tokenexchange/cache`'s thread-safe TTL shape. +- Correctness under concurrency comes from the unique, unguessable handle key — + concurrent users never collide. +- Justified as reusable (not speculative) because `tokenexchange/cache` is already a + sibling TTL `string→token` map. Future consumers (idempotency keys, counters, other + brokers) call `Put`/`Get` with namespaced keys; no new infra. +- **Guardrail:** to avoid the "junk drawer" problem (`session.Store` carries a comment + warning of exactly this), keep the API to three methods and namespace keys by feature + (e.g. `placeholder/`). + +The pipeline depends only on a small interface (defined in `pipeline`, so no import +cycle and tests can inject a fake): + +```go +// authlib/pipeline/context.go +type SharedStore interface { + Put(key string, val any, ttl time.Duration) + Get(key string) (any, bool) + Delete(key string) +} +type Context struct { + // ... + Shared SharedStore // process-scoped; set by the listener; may be nil +} +``` + +### Layer 2 — placeholder logic (in the plugins) + +Mint, the `abph_` prefix convention, fail-closed resolve, and the configured +`placeholder_ttl` (default `1h`) live in the two plugins — not in the store. + +### Wiring (mirrors the existing `sessions` injection) + +In `cmd/authbridge-proxy/main.go`, both listeners are already built in one `main()` and +already share a process-scoped store (`sessions`, `config.go`/`main.go:199,246,250`). +The shared store follows the same pattern: + +```go +sh := shared.New() // next to sessions +rpSrv, _ := reverseproxy.NewServer(inboundH, sessions, sh, backend, rpMTLS) +fpSrv, _ := forwardproxy.NewServer(outboundH, sessions, sh, fpMTLS) +``` + +Each server stores it and sets `pctx.Shared` when building the context (reverseproxy +`server.go:~170`, forwardproxy `handleRequest:~159`). For the **extproc/extauthz** +single-server topologies (`cmd/authbridge-envoy`), one server owns the store and sets it +on both inbound and outbound contexts. + +### Inbound header propagation (per-listener — the one real blocker) + +Mint requires the minted placeholder to actually reach the agent, i.e. the **inbound** +pipeline's `Authorization` mutation must be propagated to the request forwarded to the +agent. Before this change no listener did this — they propagated only the *outbound* +`Authorization` swap. **v1 implements the inbound mirror for `reverseproxy` and `extproc`**; +`extauthz` is deferred (see Out of scope). Each needs it in its own idiom: + +| Listener | v1 | Today | Change | +|----------|----|-------|--------| +| `reverseproxy` | ✅ done | clones headers into `pctx`, copies only **body** mutations back (`server.go:171,220-225,253`); inbound header mutations dropped | after `Run`, if `pctx.Headers.Get("Authorization")` changed, copy it to `r.Header` before `ServeHTTP` | +| `extproc` | ✅ done | `handleOutbound` emits `replaceTokenResponse` on auth change; `handleInbound`/`handleInboundBody` emit **no** auth mutation | capture `originalAuth`/`newAuth` in the inbound handlers and emit the `replaceTokenResponse` HeaderMutation (with `RemoveHeaders: x-authbridge-direction`) when changed | +| `extauthz` | ⛔ deferred | inbound `Check` validates but returns no request-header injection for the agent | (future) add the placeholder header to the inbound `OkResponse` (request-header mutation) | + +This generalizes cleanly to "inbound plugins may rewrite the request to the agent," a +capability the listeners arguably should have regardless. + +## Configuration + +Plugins decode config via `Configure(json.RawMessage)`; each pipeline `PluginEntry` +(`config.go:199`) carries an optional `config:` subtree. New modes are new struct +fields, **off by default** (per the feature-flag mandate). + +`jwt-validation` config additions: + +```go +PlaceholderMode bool `json:"placeholder_mode" default:"false" description:"After validating the inbound token, replace it with an opaque placeholder before forwarding to the agent; the real token is held in the shared store for the outbound path to resolve."` +PlaceholderTTL string `json:"placeholder_ttl" default:"1h" description:"How long the real token is retained for outbound resolution (Go duration). Default 1h. Binding the TTL to the token's own exp is a future enhancement."` +``` + +`token-exchange` config addition: + +```go +ResolvePlaceholders bool `json:"resolve_placeholders" default:"false" description:"Resolve an inbound bearer carrying the placeholder prefix from the shared store to the real token before exchange. Unresolvable placeholders are denied."` +``` + +Operator YAML: + +```yaml +pipeline: + inbound: + plugins: + - name: jwt-validation + config: + issuer: https://keycloak/... + audience: agent + placeholder_mode: true # NEW + outbound: + plugins: + - name: token-exchange + config: + keycloak_url: https://keycloak + routes: { ... } + resolve_placeholders: true # NEW +``` + +Free wins: abctl forms pick up the new fields automatically via `SchemaOf` reflection; +the framework `on_error: observe` wrapper gives a shadow-mode rollout. + +**Matched pair caveat:** the two flags must both be on to be coherent. Mint on + resolve +off → outbound gets an `abph_` subject → fail-closed deny (safe, visible). Resolve on + +mint off → no `abph_` tokens appear → no-op. We can't express "requires token-exchange +with resolve on" via the `Requires` relationship (that's by plugin *name*, not config +*state*), so v1 relies on the fail-closed deny + documentation rather than new +build-time cross-validation. + +## Data flow + +**Happy path** + +1. User → reverseproxy with `Authorization: Bearer T`. +2. jwt-validation validates T → `pctx.Identity`. Mint: `H = "abph_"+CSPRNG`, + `pctx.Shared.Put("placeholder/"+H, T, placeholder_ttl)`, swap header to `Bearer H`, + record `Modify{reason:"placeholder_minted"}` (hash only, never cleartext). +3. reverseproxy copies the mutated `Authorization` back to `r.Header`, forwards to agent. +4. Agent holds only `Bearer H`; makes an outbound call forwarding `Bearer H`. +5. token-exchange: on a **matched route**, prefix `abph_` + `Get` → T; exchange T→DT; + set `Bearer DT`. +6. forwardproxy forwards `Bearer DT` upstream. Agent never saw T or DT. + +**Branches** + +| # | Condition | Behavior | +|---|-----------|----------| +| A | T invalid | jwt-validation denies (existing); no mint | +| B | No inbound token, mint on | nothing to mint; existing no-token handling | +| C | H sent to unmatched host (incl. evil.com) | route miss → no resolve/exchange → `Bearer H` passes through; opaque/useless off-box, **T never leaks** | +| D | H sent to matched route, lookup miss (expired/forged/restart) | **deny, fail-closed**, `Deny{reason:"placeholder_unresolved"}`; never exchange the opaque string | +| E | `pctx.Shared == nil` (store not wired) | mint: fail fast at Init (deploy error); resolve + prefix: deny | +| F | Sidecar restart between mint & resolve | in-memory lost → branch D; user retries → new mint (documented v1 limitation) | +| G | Multiple outbound calls, one inbound | handle is **multi-use** until TTL; each matched call resolves independently | +| H | Resolve on, normal token (no `abph_`) | skip resolve, normal exchange — backward compatible | +| I | CONNECT / end-to-end TLS egress | placeholder unreachable inside TLS → **out of scope**, documented | +| J | Response path | no-op (`token-exchange.OnResponse` already no-op) | + +**The route-gating invariant:** the `H → T` lookup MUST be gated by the same route match +as the exchange — never resolve before confirming a matched route. Otherwise the real +token could end up in a header bound for an unmatched host. + +## Listener / deployment compatibility + +The design holds across listeners **provided inbound mint and outbound resolve run in +the same process** (so the in-memory store bridges them). Each listener also needs the +inbound header-propagation change above. + +| Deployment | Mint + resolve same process? | Works with in-memory store? | +|------------|------------------------------|------------------------------| +| `authbridge-proxy` (reverseproxy + forwardproxy sidecar) | Yes — both built in one `main()` | ✅ | +| `authbridge-envoy` (extproc), per-pod sidecar, 1 replica | Yes — one `extproc.Server` holds both pipelines (`main.go:301-304`) | ✅ | +| extproc/extauthz scaled to >1 replica | No — mint and resolve can hit different replicas | ❌ needs external store | +| Istio **ambient waypoint** (shared/scaled) | Often no — inbound-to-agent and egress-from-agent may be enforced by different waypoint instances | ❌ needs external store | + +So Envoy mode is supported in the **single-process** case (per-pod extproc/extauthz, one +replica); the shared/scaled waypoint case is the external-store follow-on (see Out of +scope). The plugin and store code is identical across all of them — only the per-listener +inbound-propagation idiom and the store backend differ. + +## Security properties + +- The agent never receives T; T never persists to disk (in-memory store). +- H is unguessable (CSPRNG ≥256-bit) and meaningless off-box — only resolvable in the + minting sidecar's store. +- Logs/records never emit T or H in cleartext (hash/prefix only). +- Redemption is bounded by token-exchange's existing routes + Keycloak exchange policy + + per-destination output scoping. A leaked handle cannot be redeemed off-route. +- Fail-closed on every resolve failure (branches D, E, F). + +## Out of scope (v1) / future + +- External/shared store for any deployment where mint and resolve can land in + **different processes** — i.e. multiple authbridge replicas (HA) or a shared waypoint + scaled past one replica. The in-memory store is correct only when both the inbound + mint and the outbound resolve run in the same process (the single-replica sidecar, and + single-replica extproc/extauthz). Swap in an external store behind the same + `SharedStore` interface when that no longer holds. +- Per-session least-privilege redemption (handle bound to the token's scopes / an + allowlist) — add only when a "subset of configured upstreams per session" requirement + appears. +- CONNECT / end-to-end-TLS egress. +- Build-time cross-validation of the mint/resolve flag pair. + +## Testing + +- `shared.Store` unit tests: `Put`/`Get`/`Delete`, TTL expiry with injected clock, + concurrent access under `-race`. +- jwt-validation mint: success → header swapped to `abph_`, store entry keyed by handle + with T and `ttl≈placeholder_ttl`; validation failure → no mint, deny; no inbound token → no mint; + `nil` Shared → Init/Configure error. +- **Inbound propagation** (riskiest change, per listener): inbound plugin sets + `pctx.Headers` Authorization → assert it reaches the agent. reverseproxy: forwarded + `r.Header` carries it. extproc: `handleInbound`/`handleInboundBody` emit a + `replaceTokenResponse` HeaderMutation. extauthz: inbound `OkResponse` carries the + request-header injection. +- token-exchange resolve: seeded store → matched route resolves + exchanges; unmatched + route passes through; lookup miss denies; non-placeholder bearer → normal exchange + (regression). +- One end-to-end test sharing a real store: agent sees only H, upstream sees DT. + +## Files touched + +| File | Change | +|------|--------| +| `authlib/shared/store.go` (new) | generic process-scoped TTL store | +| `authlib/pipeline/context.go` | add `SharedStore` interface + `Context.Shared` field | +| `authlib/listener/reverseproxy/server.go` | inbound Authorization propagation (copy `pctx.Headers` → `r.Header`); accept + set shared store | +| `authlib/listener/forwardproxy/server.go` | accept + set shared store on pctx (outbound resolve already propagates) | +| `authlib/listener/extproc/server.go` | inbound Authorization propagation in `handleInbound`/`handleInboundBody` (emit `replaceTokenResponse` on change); accept + set shared store on both pctx | +| `authlib/listener/extauthz/server.go` | ⛔ **deferred (not in v1)** — future inbound request-header injection in `Check` `OkResponse` | +| `authlib/plugins/jwtvalidation/plugin.go` | `placeholder_mode` / `placeholder_ttl`; mint logic | +| `authlib/plugins/tokenexchange/plugin.go` | `resolve_placeholders`; route-gated resolve step | +| `cmd/authbridge-proxy/main.go`, `cmd/authbridge-lite/main.go`, `cmd/authbridge-envoy/main.go` | create store, inject into listeners, `Close()` on shutdown | +| docs | plugin-reference updates for the new mode | + +## Attribution + +Assisted-By: Claude (Anthropic AI)