Skip to content

feat: add On-Behalf-Of flow for browser-based MCP client support - #370

Merged
gossion merged 2 commits into
Azure:mainfrom
Michael-Wilson94:feat/oauth-obo
May 18, 2026
Merged

feat: add On-Behalf-Of flow for browser-based MCP client support#370
gossion merged 2 commits into
Azure:mainfrom
Michael-Wilson94:feat/oauth-obo

Conversation

@Michael-Wilson94

Copy link
Copy Markdown
Contributor

Summary

Browser-based MCP clients such as Claude Web authenticate via OAuth but cannot supply the X-Azure-Token header required by tokenAuthOnly kubectl tools, causing all call_kubectl invocations to fail with X-Azure-Token not found in context. This PR adds an On-Behalf-Of (OBO) token exchange flow that resolves this entirely server-side with no changes required from the client.

Changes

On-Behalf-Of token exchange (--oauth-obo-enabled)

  • After OAuth validates the user's bearer token, the server performs two OBO exchanges via Azure AD: one for an ARM token (https://management.azure.com/user_impersonation) used to authenticate the RunCommand API call, and one for an AKS cluster token (6dae42f8-4368-4678-94ff-3960e28e3630) required by AAD-enabled clusters using Kubernetes RBAC
  • Both tokens are injected into the request context automatically — call_kubectl finds them without any client-side changes
  • Falls back gracefully: if the cluster OBO exchange fails (e.g. Azure RBAC cluster), the ARM token is used as the cluster token

Session caching

  • Browser clients authenticate once and then send only Mcp-Session-Id on follow-up requests with no Authorization header. The middleware now caches OBO tokens per session so these continuation requests succeed
  • OBO tokens are refreshed proactively 5 minutes before expiry using the cached bearer token, keeping sessions alive without prompting the user to reconnect
  • Sessions are bounded by the bearer token's lifetime (up to 24 hours)

Default cluster resource ID (--default-aks-resource-id / AZURE_AKS_RESOURCE_ID)

  • When a default cluster is configured, aks_resource_id becomes optional in the call_kubectl tool schema — MCP clients no longer ask the user to supply the cluster resource ID on every conversation

Helm chart

One new value:

oauth:
  oboEnabled: false          # new — enable OBO flow

Azure AD requirements

The app registration requires the Azure Service Management → user_impersonation delegated permission and a client secret (passed via AZURE_CLIENT_SECRET / azure.clientSecret).

Backwards compatibility

All new behaviour is gated behind --oauth-obo-enabled (default: false). Existing deployments using workload identity, local az login, or manual X-Azure-Token headers are completely unaffected.

@Michael-Wilson94

Copy link
Copy Markdown
Contributor Author

@microsoft-github-policy-service agree

@Michael-Wilson94
Michael-Wilson94 marked this pull request as ready for review May 11, 2026 13:20
@codecov-commenter

codecov-commenter commented May 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 69.93865% with 49 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (main@7f9d26e). Learn more about missing BASE report.
⚠️ Report is 231 commits behind head on main.

Files with missing lines Patch % Lines
internal/auth/oauth/middleware.go 72.52% 19 Missing and 6 partials ⚠️
internal/auth/oauth/provider.go 74.35% 5 Missing and 5 partials ⚠️
internal/config/config.go 10.00% 8 Missing and 1 partial ⚠️
internal/k8s/runcommand_executor.go 58.33% 5 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main     #370   +/-   ##
=======================================
  Coverage        ?   41.73%           
=======================================
  Files           ?       73           
  Lines           ?     6886           
  Branches        ?        0           
=======================================
  Hits            ?     2874           
  Misses          ?     3819           
  Partials        ?      193           

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@gossion gossion 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.

Thanks for this PR! The OBO approach is the right solution for browser-based clients. A few correctness and safety issues worth addressing before merge.

}

se.azureToken = armToken
se.oboExpiresAt = time.Now().Add(55 * time.Minute)

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.

Race condition on sessionEntry fields

refreshSessionOBO mutates se.azureToken, se.oboExpiresAt, and se.clusterToken in-place while concurrent requests for the same session can be reading those same fields. sync.Map only protects map-level operations, not struct field access.

Suggest adding a sync.Mutex to sessionEntry:

type sessionEntry struct {
    mu           sync.Mutex // protects azureToken, clusterToken, oboExpiresAt
    tokenInfo    *auth.TokenInfo
    bearerToken  string
    azureToken   string
    clusterToken string
    oboExpiresAt time.Time
    expiresAt    time.Time
}

Alternatively, make sessionEntry immutable and replace the whole entry atomically with sync.Map.Store after refresh — this avoids the mutex entirely and makes the concurrent-read path lock-free.

type AuthMiddleware struct {
provider *AzureOAuthProvider
serverURL string
sessions sync.Map // Mcp-Session-Id → *sessionEntry

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.

No session eviction — potential memory leak

Expired sessions are only removed lazily when a request happens to arrive after expiry. Browser sessions that close without reconnecting accumulate forever in the sync.Map.

Consider a background cleanup goroutine started in NewAuthMiddleware:

go func() {
    ticker := time.NewTicker(30 * time.Minute)
    defer ticker.Stop()
    for range ticker.C {
        now := time.Now()
        m.sessions.Range(func(k, v any) bool {
            if now.After(v.(*sessionEntry).expiresAt) {
                m.sessions.Delete(k)
            }
            return true
        })
    }
}()

A 30–60 minute sweep interval is sufficient given the 24 h max session lifetime.

next.ServeHTTP(w, r)
// Wrap the ResponseWriter to capture the Mcp-Session-Id mcp-go assigns on the
// response, then cache the full auth result (including bearer token for OBO refresh).
oboExpiry := time.Now().Add(55 * time.Minute) // slightly under the 1h ARM token lifetime

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.

OBO token expiry is hardcoded to 55 minutes

The OBO token response includes an expires_in field. If tenant policy issues shorter-lived tokens, this hardcoded estimate may cause the server to serve an already-expired token until the proactive refresh kicks in.

Consider returning the actual expires_in from ExchangeOBO and using it to compute oboExpiresAt, rather than assuming a fixed 55-minute window.

// Extract aks_resource_id from params, falling back to the configured default
aksResourceID, _ := params["aks_resource_id"].(string)
if aksResourceID == "" {
aksResourceID = os.Getenv("AZURE_AKS_RESOURCE_ID")

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.

extractRequestContext reads AZURE_AKS_RESOURCE_ID directly — bypasses the config layer

config.go already reads this env var and stores it in cfg.DefaultAKSResourceID, which is then passed through RegisterKubectlToolscreateCallKubectlTool. Having extractRequestContext also call os.Getenv directly creates a second, independent code path for the same setting.

This means any future changes to validation or override precedence would need to be applied in two places. Suggest injecting the default into RunCommandExecutor at construction time:

type RunCommandExecutor struct {
    defaultAKSResourceID string
}

func NewRunCommandExecutor(defaultAKSResourceID string) *RunCommandExecutor {
    return &RunCommandExecutor{defaultAKSResourceID: defaultAKSResourceID}
}

Then use e.defaultAKSResourceID as the fallback here instead of os.Getenv.

@gossion

gossion commented May 12, 2026

Copy link
Copy Markdown
Member

README.md: new flags and env var are not documented

The CLI flags reference block in README.md is missing the three new flags added by this PR. Suggest adding them to the flags list:

      --token-auth-only           Execute kubectl via Azure AKS RunCommand API using user-provided tokens instead of a local kubeconfig. Required for browser-based MCP clients (e.g. Claude Web). Incompatible with stdio transport. (default false)
      --oauth-obo-enabled         Enable On-Behalf-Of token exchange: exchanges the user's MCP bearer token for ARM and AKS cluster tokens server-side (requires AZURE_CLIENT_SECRET). (default false)
      --default-aks-resource-id   Default AKS cluster resource ID when aks_resource_id is not supplied by the caller. Falls back to AZURE_AKS_RESOURCE_ID env var.

And to the Environment variables section:

- `AZURE_AKS_RESOURCE_ID`: Default AKS cluster resource ID, used as a fallback when `aks_resource_id` is not provided by the MCP client (equivalent to `--default-aks-resource-id`).

@gossion
gossion added this pull request to the merge queue May 18, 2026
Merged via the queue into Azure:main with commit 3310aa0 May 18, 2026
9 checks passed
achaikaJH pushed a commit to achaikaJH/aks-mcp that referenced this pull request May 18, 2026
…re#370)

* feat: add On-Behalf-Of flow for browser-based MCP client support

* fix: suppress false positives
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.

4 participants