Azure / Azure/kars

Slice 4d.5: OAuth On-Behalf-Of (OBO) for MCP outbound auth

Open
#319 0 comments 0 reactions 0 assignees View on GitHub
enhancement
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.