Skip to content

Auth-terminating shim forwards no caller identity to a remote MCP backend (token-exchange gated off by empty access_token prop) #340

Description

@jcox-oxit

Summary

When nanobot run acts as the auth-terminating shim in front of a remote MCP backend (mcpServers.<name>.url), it fully validates the caller (JWKS / trusted-issuer / audiences) but forwards a request to the backend with no representation of the validated caller — the backend sees no Authorization and no subject. This blocks the backend from doing per-caller authorization, audit attribution, or rate limiting.

The interesting part: the forwarding machinery is already present and configured — it's gated off by one condition.

Root cause (v0.0.68 / 0ac2a2d)

  • RFC-8693 token exchange is configured for the shim: pkg/cli/serve.go sets TokenExchangeEndpoint = r.Auth.OAuthTokenURL (from --oauth-token-url / NANOBOT_RUN_OAUTH_TOKEN_URL), threaded into the outbound backend client (pkg/mcp/client.goHTTPClientOptions).
  • pkg/mcp/httpclient.go newRequest() performs the exchange and sets Authorization: Bearer <exchanged> on the backend request iff TokenFromContext(ctx) != "".
  • pkg/auth/auth.go setupContext() (on the request path via auth.Wrap) is where the token would enter the context — but it calls mcp.WithToken(ctx, …) only when info.Props["access_token"] is non-empty:
ctx := types.WithNanobotContext(mcp.WithUser(req.Context(), user), nctx)
if token, _ := info.Props["access_token"].(string); token != "" {
    ctx = mcp.WithToken(ctx, token)
}

In our deployment the OAuth validator does not populate Props["access_token"], so WithToken is skipped, TokenFromContext(ctx) is empty, the exchange never fires, and the backend receives no caller identity.

Proposed fix (additive, one hunk)

When Props["access_token"] is empty, fall back to the validated inbound bearer from the Authorization header before WithToken. No behavior change when the prop is present:

token, _ := info.Props["access_token"].(string)
if token == "" {
    token = strings.TrimPrefix(req.Header.Get("Authorization"), "Bearer ")
}
if token != "" {
    ctx = mcp.WithToken(ctx, token)
}

strings is already imported in that file.

Question for maintainers

Is the outbound backend client's request built from the per-request context, or from a context captured when the backend session was first established? If per-session, a per-request WithToken wouldn't apply and the exchange would need to be invoked where the per-request context is live — happy to adjust the fix accordingly.

Offer

I have this patch applied against v0.0.68 locally and am glad to open a PR (with a test) if the direction looks right, or to adopt whatever config-first surface you'd prefer (e.g. a documented --forward-identity switch or claim-templated mcpServers.<name>.headers).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions