Back to Mcpproxy Go

Per-User Auth Broker

docs/features/auth-broker.md

0.38.15.6 KB
Original Source

Per-User Auth Broker

:::info Server edition only The auth broker is part of the server edition (//go:build server). It is opt-in per upstream — servers without an auth_broker block behave exactly as before. Brokering applies only to HTTP-family upstreams (http, sse, streamable-http); configuring it on a stdio upstream is rejected at config validation in this phase. :::

The auth broker lets the gateway acquire an upstream credential on behalf of the calling user instead of sharing a single static token. Each upstream server can declare how its credential is obtained via an auth_broker block on its server config (spec 074).

Modes

The auth_broker.mode field selects the credential-acquisition strategy:

ModeDescription
token_exchangeRFC 8693 OAuth 2.0 Token Exchange — swaps the caller's IdP token for an upstream-scoped token at the IdP token endpoint.
entra_oboMicrosoft Entra On-Behalf-Of flow.
oauth_connectPath B — a per-user authorization-code + PKCE connect flow against an upstream authorization server that does not support token exchange. The user is redirected to the upstream's consent screen once; the resulting per-user credential is persisted encrypted and refreshed transparently.

Configuration

The block lives under a server entry in the config file:

json
{
  "mcpServers": [
    {
      "name": "github-enterprise",
      "url": "https://ghe.example.com/mcp",
      "protocol": "streamable-http",
      "auth_broker": {
        "mode": "oauth_connect",
        "authorization_endpoint": "https://ghe.example.com/login/oauth/authorize",
        "token_endpoint": "https://ghe.example.com/login/oauth/access_token",
        "client_id": "Iv1.0123456789abcdef",
        "client_secret": "GHE-secret",
        "scopes": ["repo", "read:user"],
        "resource": "https://ghe.example.com/mcp"
      }
    }
  ]
}

Fields

KeyRequiredDescription
modeyesOne of token_exchange, entra_obo, oauth_connect.
token_endpointyesIdP/upstream token endpoint used to mint (and refresh) the upstream credential.
authorization_endpointonly for oauth_connectUpstream authorization-server authorize URL the user is redirected to for consent. Required when mode is oauth_connect; ignored by token_exchange and entra_obo.
resourcenoRFC 8707 audience the resulting token is scoped to.
scopesnoScopes requested for the upstream credential.
client_idno¹Identifies the gateway to the token/authorization endpoint.
client_secretnoAuthenticates a confidential client. A public client may omit it — PKCE still protects the oauth_connect code exchange.
headernoOutbound header the resolved credential is injected into (default Authorization).
header_formatnoValue template; {token} is replaced with the resolved credential (default Bearer {token}).

¹ client_id is required at runtime for the oauth_connect flow (the connector rejects an empty client ID); it is validated when the connect flow is assembled.

:::warning authorization_endpoint is mandatory for oauth_connect Config validation fails with auth_broker.authorization_endpoint is required for mode "oauth_connect" if the key is missing while mode is oauth_connect. The other two modes never read it. :::

The oauth_connect flow (Path B)

  1. The gateway builds an authorize URL from authorization_endpoint with a per-user opaque state and a PKCE S256 challenge, and redirects the user there.
  2. On the upstream's callback, state is validated as a known, unexpired, single-use pending flow (10-minute TTL) bound to the initiating user — confused-deputy / replay hardening.
  3. The authorization code is exchanged at token_endpoint using the bound PKCE verifier; the resulting credential is stored encrypted, per user, tagged ObtainedVia=connect_flow.
  4. Tokens are refreshed transparently from the stored refresh token; a non-rotating authorization server keeps its prior refresh token.

A denied consent (error=access_denied) clears the pending flow and stores nothing.

Credential resolution

On each proxied request the broker resolves the per-user credential to inject, in a strict per-user-only order. There is no shared or static fallback — a request that cannot produce a per-user credential fails rather than borrowing another identity:

  1. A valid cached per-user credential is injected directly; if it is within the near-expiry window it is refreshed first (re-minted for token_exchange/entra_obo, or renewed from the stored refresh token for oauth_connect).
  2. Otherwise, for token_exchange/entra_obo, a credential is minted from the user's stored IdP subject token.
  3. Otherwise, for oauth_connect upstreams the user has not connected — or whose stored credential expired and could not be refreshed — the request fails with an actionable error carrying the connect URL, so the user is told to (re)connect rather than being silently denied.
  4. Otherwise the request fails with "no per-user credential available".

Concurrent requests for the same (user, upstream) are coalesced (single-flight) so a burst does not trigger duplicate upstream token flows. A policy-decision hook is evaluated per call immediately before the credential is returned; no policy engine ships yet, so it permits every injection by default.

See also

  • OAuth Authentication — upstream OAuth for the personal edition.
  • Server multi-user authentication is covered in the project CLAUDE.md (Spec 024).