Slice 4d.5: OAuth On-Behalf-Of (OBO) for MCP outbound auth
- Dominant language
- Rust
- Stars
- 42
- Forks
- 10
- Avg merge
- 1d 11h
- Merged PRs (30d)
- 19
Description
## Summary
Design and implement OAuth On-Behalf-Of (OBO) delegation for the MCP CRD lane (Slice 4d.5). This is the proper long-term replacement for the static-bearer shortcut shipped in Slice 4d.4.1 (`spec.bearerFromEnv`).
## Motivation
Slice 4d.4.1 added static-bearer outbound auth (`McpServer.spec.bearerFromEnv`). It works, but has a structural problem:
- **Token reuse**: a single token (typically `COPILOT_GITHUB_TOKEN` — the user's Copilot/PAT) is shared across every agent / sub-agent that talks to the MCP server. There is no per-user attribution upstream.
- **Scope inheritance**: the upstream MCP server sees the *full* scope of the token, even if the agent only needs `repo:read`. Audit logs on the upstream side can't tell which sub-agent triggered which call.
- **Rotation**: rotating the token requires a sandbox restart (env var). No short-lived token story.
- **Multi-tenant story**: in production AKS with many agents/users, a single shared PAT is a blast-radius problem.
## Proposed solution
Per-call OAuth On-Behalf-Of: the inference router exchanges the *agent's* identity (Workload Identity / federated identity / signed JWT) for a short-lived, narrowly-scoped token issued by the configured `oauth.issuer`, then attaches that token on the outbound MCP request.
### CRD shape
Add a delegation mode discriminator to the existing `spec.oauth` block:
```yaml
apiVersion: azureclaw.azure.com/v1alpha1
kind: McpServer
metadata:
name: github
namespace: azureclaw-system
spec:
url: https://api.githubcopilot.com/mcp
oauth:
issuer: https://github.com/login/oauth
delegationMode: on-behalf-of
# Optional: scope down what the router asks the IdP for
requestedScopes:
- repo:read
- public_repo
# Cache exchanged tokens per (agent-identity, server) tuple
tokenCacheTTL: 600s
```
`delegationMode: static-bearer` keeps the 4d.4.1 path. `delegationMode: on-behalf-of` (new) triggers the OBO exchange.
### Router-side flow
1. Sandbox sends `tools/call` to the local router. Router has the agent's identity (Workload Identity token in `/var/run/secrets/azure/tokens/azure-identity-token`, or the AGT-issued agent JWT).
2. Router POSTs to the IdP's token endpoint with `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`, the agent identity assertion, and `scope=`.
3. Receives a short-lived OAuth access token scoped to that specific agent.
4. Caches in-memory keyed by `(agent_principal, mcp_server_name)` with TTL = `tokenCacheTTL`.
5. Attaches `Authorization: Bearer ` on the outbound MCP request.
### Controller-side work
- Reconcile `spec.oauth.delegationMode` into `meta.json` so the router can discover the mode + IdP URL.
- Validate that `delegationMode: on-behalf-of` requires `oauth.issuer` (CEL rule).
- Existing JWKS discovery already handles the *incoming* OAuth path — this is only about *outgoing*.
### Per-IdP adapters
Different IdPs have different OBO flavours:
- **Entra ID** — `urn:ietf:params:oauth:grant-type:jwt-bearer` + Workload Identity federation.
- **GitHub** — no native OBO; need to evaluate whether App-level token exchange or per-user GitHub Apps is the right primitive.
- **Generic OAuth2** — RFC 8693 Token Exchange.
Probably ship Entra ID first (matches our prod target), then evaluate GitHub.
### Telemetry / audit
Every OBO exchange + outbound MCP call is audit-logged with:
- agent principal (sub of the agent JWT)
- requested scopes
- granted scopes (from the IdP response)
- upstream MCP server name
- tools/call method (so we can correlate to AGT policy decisions)
## Out of scope
- Per-user (human) delegation — agents are non-human principals; if a user wants per-human OBO they can wire that at the agent level.
- Token refresh — short-lived tokens cached for a few minutes is enough; if expired, just re-exchange. No refresh-token flow.
- The static-bearer path (Slice 4d.4.1) is NOT removed; it stays as a dev/prototyping shortcut.
## Acceptance criteria
- [ ] CRD field `spec.oauth.delegationMode: on-behalf-of` accepted by admission.
- [ ] Controller propagates the new mode into `meta.json`.
- [ ] Router exchanges agent identity → IdP token → outbound bearer for Entra ID flow.
- [ ] Cache hit/miss metrics emitted.
- [ ] Live test: `palclawe2e` agent talks to a configured upstream MCP server using an OBO-issued token; audit log shows the exchanged token's `sub` matches the agent principal.
- [ ] Static-bearer (4d.4.1) regression suite still green.
- [ ] Doc updated at `docs/governance/mcp-outbound-auth.md`.
## Related
- Slice 4d.4.1 (static-bearer) — shipped 2026-05-15.
- `inference-router/src/mcp/forwarder.rs` — extend with OBO branch.
- `controller/src/mcp_server.rs::McpServerSpec.oauth` — add `delegationMode` field.
## References
- [RFC 8693 — OAuth 2.0 Token Exchange](https://datatracker.ietf.org/doc/html/rfc8693)
- [Microsoft Entra ID OBO flow](https://learn.microsoft.com/azure/active-directory/develop/v2-oauth2-on-behalf-of-flow)
- [GitHub Apps user-to-server tokens](https://docs.github.com/apps/creating-github-apps/authenticating-with-a-github-app/identifying-and-authorizing-users-for-github-apps)
Contributor guide
Research direction
Start by reading inference-router/src/mcp/forwarder.rs and controller/src/mcp_server.rs, including the existing oauth and static-bearer paths. Review docs/governance/mcp-outbound-auth.md and the RFC 8693 and Entra OBO references before deciding the adapter scope. Done means admission accepts the mode, metadata reaches the router, Entra OBO calls and caching work, audit and metrics are emitted, regressions pass, and the documentation is updated.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- kubernetes, rust
- Domain
- authentication, backend-api-design, security
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100