Skip to content

feat: add client_credentials fallback for outbound requests without Authorization header#171

Merged
mrsabath merged 7 commits into
rossoctl:mainfrom
mrsabath:feat/client-credentials-fallback
Mar 6, 2026
Merged

feat: add client_credentials fallback for outbound requests without Authorization header#171
mrsabath merged 7 commits into
rossoctl:mainfrom
mrsabath:feat/client-credentials-fallback

Conversation

@mrsabath

@mrsabath mrsabath commented Mar 6, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Add clientCredentialsGrant() function to go-processor for OAuth 2.0 client_credentials grant
  • When an outbound request has no Authorization header, fall back to client_credentials using the agent's shared credentials (/shared/client-id.txt + /shared/client-secret.txt)
  • Token exchange (RFC 8693) is still preferred when an Authorization header IS present (preserves user identity)
  • Logged as [Client Credentials] to distinguish from [Token Exchange]

Context

Many agent frameworks (including the A2A SDK) do not propagate the inbound Bearer token to outbound tool calls. The agent code assumes AuthBridge handles outbound auth transparently, but the ext_proc previously required an existing token to exchange. Without this fallback, the tool receives no Authorization header and returns 401.

Files changed

File Changes
AuthBridge/AuthProxy/go-processor/main.go Add clientCredentialsGrant(), add fallback in handleOutbound()

Closes #170

Test plan

  • Build envoy-with-processor image and deploy to cluster
  • Deploy agent with AuthBridge (no GITHUB_TOKEN set)
  • Verify outbound requests get [Client Credentials] token injection
  • Verify tool receives valid Bearer token and responds successfully
  • Verify token exchange still works when agent forwards Authorization header

mrsabath added 6 commits March 4, 2026 23:01
Migrate all GitHub Issue Agent demo files from the Keycloak "demo" realm
to the shared "kagenti" realm. This aligns the demo with the platform-wide
realm migration (kagenti#764) so that UI-issued tokens and demo tokens
share the same issuer, enabling UI chat with AuthBridge-protected agents.

Updated files:
- k8s/configmaps.yaml: KEYCLOAK_REALM, TOKEN_URL, ISSUER
- k8s/github-tool-deployment.yaml: ISSUER, JWKS_URL
- k8s/git-issue-agent-deployment.yaml: JWKS_URI
- setup_keycloak.py: default KEYCLOAK_REALM
- demo-manual.md: all realm references in docs and commands

Part of: rossoctl/rossoctl#767

Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>
Made-with: Cursor
Update all Keycloak realm references in demo-ui.md from "demo" to
"kagenti" to align with the platform-wide realm migration (kagenti#764).

With the UI and demos now on the same realm, the UI chat with
AuthBridge-protected agents works end-to-end. The Step 8 known-limitation
workaround (kagenti-extensions#147) is replaced with instructions for
direct UI chat.

Part of: rossoctl/rossoctl#767

Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>
Made-with: Cursor
The CLI test steps were obtaining the Keycloak admin token from the
master realm. Now that the admin user exists in the kagenti realm
with realm-admin permissions, use the kagenti realm consistently
throughout the demo to avoid cross-realm dependencies.

The wrong-issuer test and troubleshooting sections intentionally
retain master realm references.

Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>
Made-with: Cursor
The Kagenti installer (rossoctl/rossoctl#851) now creates correct default
ConfigMaps for all AuthBridge components:
- authbridge-config with templatized kagenti realm
- envoy-config with 300s timeouts and Lua filter for inbound direction

Remove environments, spiffe-helper-config, and envoy-config from the
demo's configmaps.yaml — only authbridge-config remains, overriding
TARGET_AUDIENCE (github-tool), TARGET_SCOPES, and EXPECTED_AUDIENCE
with demo-specific values.

Update demo-ui.md Step 2 and demo-manual.md Step 3 to explain that
only authbridge-config is overridden.  Update troubleshooting sections
to reference the installer's envoy-config defaults.

Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>
Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Keycloak only auto-assigns realm default scopes to newly created
clients. The UI kagenti client was created during install before
the agent audience scope existed, so it does not inherit it. Without
this scope, the UI token lacks the agent SPIFFE ID in the audience
claim, causing AuthBridge to reject UI chat requests with invalid
audience.

Explicitly add the agent-ns-sa-aud scope to the kagenti UI client
after creating it as a realm default. This is a temporary workaround
until the client-registration sidecar handles this automatically
(rossoctl#169).

Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>
Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
…uthorization header

When an outbound request has no Authorization header, the ext_proc
now falls back to a client_credentials grant using the agent's shared
credentials (/shared/client-id.txt + /shared/client-secret.txt).
This handles agent frameworks that do not propagate the inbound
Bearer token to outbound tool calls.

The token exchange path (RFC 8693) is still preferred when an
Authorization header IS present, as it preserves the end user's
identity (sub claim). The client_credentials fallback uses the
agent's own identity instead.

Closes rossoctl#170

Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>
Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
@huang195

huang195 commented Mar 6, 2026

Copy link
Copy Markdown
Member

@mrsabath need some clarifications on this PR. Our original assumption was that the (A2A) agent would take the inbound token (i.e., from the UI or CLI) that has user info, and passes that token when it makes tools calls or calls other agents. However, it seems that we cannot make that assumption and we would need to rely on static credentials embedded in the agent to make the token exchange. This begs the question if the agent is shared between multiple users (e.g., Alice and Bob), how would authbridge do the correct token exchange on behalf of Alice or Bob as the credential it uses for the token exchange is tied to the agent itself, and nothing to do with Alice or Bob.

@mrsabath

mrsabath commented Mar 6, 2026

Copy link
Copy Markdown
Contributor Author

Great question, Hai. You're right that this is a key trade-off. Here's my thinking:

The core problem: Current agent frameworks (A2A SDK, LangChain, etc.) don't propagate the inbound Bearer token to outbound HTTP calls. The agent receives Alice's token on inbound, processes the prompt with the LLM, and makes a fresh HTTP call to the tool — without any Authorization header. Without this PR, the tool gets a 401.

This PR is a pragmatic fallback, not a replacement for token exchange. The code still prefers token exchange when an Authorization header IS present (lines 511-535). Client_credentials only kicks in when there's no token to exchange. If/when agent frameworks start propagating the caller's token, the token exchange path activates automatically — no code changes needed.

What we lose: With client_credentials, the tool sees the agent's SPIFFE ID, not the end user. Per-user authorization at the tool level isn't possible from the token alone.

What still works: Inbound validation still identifies the user (Alice vs Bob). Scope-based access (public vs privileged PAT) is configured at the agent level and still works. The tool just can't distinguish which user triggered a particular call.

The industry is actively working on this gap. Two IETF drafts from late 2025 define "Transaction Tokens for Agents" — tokens carrying both an actor (the agent) and a principal (the human). See draft-oauth-transaction-tokens-for-agents. The A2A protocol roadmap also lists standardized token delegation as active work for 2026.

Proposed path forward:

  1. Now: Merge client_credentials fallback so the demo works end-to-end
  2. Short-term: Investigate whether the A2A SDK can be extended to propagate inbound tokens via request context (this would automatically activate the existing token exchange path)
  3. Long-term: Adopt Transaction Tokens when the IETF spec matures — tokens that carry both agent and user identity

I'll file an issue to track the token propagation investigation.

@mrsabath

mrsabath commented Mar 6, 2026

Copy link
Copy Markdown
Contributor Author

You're right, I need to correct that claim. With client_credentials, the agent requests TARGET_SCOPES (which includes github-full-access) using its own identity — there's no user context, so Keycloak grants all requested scopes to the agent's client. Both Alice and Bob's requests end up with github-full-access, meaning the tool always uses PRIVILEGED_ACCESS_PAT. Per-user scope differentiation is lost.

With token exchange, Keycloak evaluates the subject token (Alice's or Bob's) and can restrict the exchanged token's scopes based on what the subject actually has. That's the mechanism that makes Alice → public access, Bob → privileged access work.

So to be precise about what the client_credentials fallback provides:

Capability Token Exchange Client Credentials
Tool gets a valid token Yes Yes
Tool knows the audience Yes Yes
Per-user identity (sub) Yes (user's) No (agent's)
Per-user scope differentiation Yes No — always gets full scopes
Demo works end-to-end Yes (if agent propagates token) Yes

The client_credentials path is a "get it working" fallback that trades per-user access control for functionality. The right long-term fix is agent-side token propagation (#174), which would activate the existing token exchange path automatically.

@mrsabath

mrsabath commented Mar 6, 2026

Copy link
Copy Markdown
Contributor Author

Good idea — since the ext_proc is a single Go process that handles both inbound and outbound, it can maintain in-memory state. The question is: what key do we use to correlate an outbound request back to the inbound user?

The architectural challenge:

Inbound (port 15124)           Outbound (port 15123)
─────────────────────          ──────────────────────
Alice → Envoy → ext_proc      Agent → Envoy → ext_proc → Tool
         │  validates JWT                │  needs to find Alice's token
         │  stores token?                │  but how?
         ▼                               ▼
   gRPC stream A                   gRPC stream B
   (separate connection)           (separate connection)

Inbound and outbound are on separate Envoy listeners with separate gRPC streams to the ext_proc. There's no shared Envoy context (no connection ID, no session ID) that links them.

Why correlation headers don't help:
We could inject a header on inbound (e.g., X-AuthBridge-Correlation-ID: <uuid>) and cache uuid → user_token. But for the ext_proc to read it back on outbound, the agent would need to propagate that header to its outbound HTTP calls — which is the same problem as propagating the Authorization header itself.

The "last token" approach — works for simple agents:
For agents that process one request at a time (no concurrency), we could simply cache the most recent inbound token and use it for the next outbound call. This is simple to implement:

var lastInboundToken atomic.Value  // set on inbound, read on outbound

But this is unsafe for concurrent multi-user agents — Alice's outbound could pick up Bob's cached token, causing privilege escalation. We'd need to document this clearly and gate it behind a feature flag (e.g., AUTHBRIDGE_CACHE_INBOUND_TOKEN=true).

Summary of approaches:

Approach Per-user identity Concurrent-safe Agent changes needed
Token exchange (original) Yes Yes Agent must forward token
Client credentials (#171) No Yes None
Last-token cache Yes No — race condition risk None
Correlation header Yes Yes Agent must forward header
Transaction Tokens (IETF) Yes (actor+principal) Yes Spec adoption

I think the "last-token cache" is worth exploring as an opt-in middle ground for single-user/non-concurrent agents. It solves the per-user problem for simple deployments without requiring agent framework changes, at the cost of concurrency safety. Want me to prototype it behind a feature flag?

@huang195 huang195 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Summary

This PR adds a valuable OAuth2 client_credentials fallback for agent frameworks that don't propagate inbound Bearer tokens, plus significant cleanup migrating from the demo to kagenti realm and slimming ConfigMaps. The main concern is that the Go fallback path silently continues without auth when token acquisition fails — this should return an error response to prevent unauthenticated requests reaching downstream services. The ConfigMap reduction and Keycloak setup improvements are well done.

Areas reviewed: Go, Python, YAML/K8s, Docs, Commit conventions, Security
Commits: 6 commits, all signed-off (DCO passing)
CI status: passing

Great cleanup on configmaps.yaml — reducing from 237 to 55 lines by leveraging installer defaults eliminates config drift. The setup_keycloak.py workaround for scope inheritance is well-documented with a clear pointer to #169 for the permanent fix.

Comment thread AuthBridge/AuthProxy/go-processor/main.go
Comment thread AuthBridge/AuthProxy/go-processor/main.go
Comment thread AuthBridge/AuthProxy/go-processor/main.go
When clientCredentialsGrant() or exchangeToken() fails, the ext_proc
was falling through and sending the outbound request without any
Authorization header. This caused confusing downstream 401 errors
far from the root cause.

Now return 503 Service Unavailable with a clear error message so
callers get an immediate signal that token acquisition failed.

Addresses review feedback from @huang195 on PR rossoctl#171.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>

@huang195 huang195 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: go-processor should fall back to client_credentials grant when outbound request has no Authorization header

2 participants