ConduitMcp.Client.OAuth (ConduitMCP v0.11.0)

Copy Markdown View Source

OAuth 2.1 for the MCP client (the MCP authorization spec, both protocol eras, Streamable HTTP only). Compiled only with the optional :finch dependency.

Wiring

{ConduitMcp.Client,
 name: :docs,
 transport:
   {ConduitMcp.Client.Transport.StreamableHTTP,
    url: "https://mcp.example.com/mcp",
    auth:
      {:oauth,
       redirect_uri: "https://app.example.com/oauth/callback",
       client_metadata_url: "https://app.example.com/oauth/client.json",
       on_authorization_required: &MyApp.MCPAuth.prompt/1}}}

See ConduitMcp.Client.OAuth.Config for every option, and ConduitMcp.Client.OAuth.ClientCredentials for machine-to-machine clients.

What the client does on its own

  • Attaches Authorization: Bearer to every request to the MCP server (including server/discover), and only to it: never to metadata, registration or token requests, never across a redirect.
  • Refreshes a token that expires within refresh_skew seconds before sending it, and after a 401 - single-flight across all processes sharing the token store, persisting rotated refresh tokens.
  • Discovers the authorization server (ConduitMcp.Client.OAuth.Discovery.Fetch) and chooses a client identity (ConduitMcp.Client.OAuth.Registration).

What the host does: interactive authorization

When no usable token exists, the user has to authorize in a browser. The client never blocks on that and never opens a browser:

  1. The request fails with {:error, %ConduitMcp.Client.Error{kind: :auth, reason: :authorization_required, data: %{challenge: challenge}}}, the connection enters :authorizing, and on_authorization_required receives the ConduitMcp.Client.OAuth.Challenge (in a task).
  2. The host calls authorize_url/2 with the client's name and the challenge, stores the returned ConduitMcp.Client.OAuth.Pending (server-side or encrypted: it holds the PKCE verifier and state) and sends the user to the URL.
  3. The authorization server redirects to the redirect URI. The host looks the pending authorization up by state and deletes it (single use), then calls handle_callback/3. The tokens are stored and the connection resumes at once.

A CLI or desktop app can let ConduitMcp.Client.OAuth.Loopback run steps 2-3 with a one-shot server on 127.0.0.1.

While waiting, every request fails fast with :authorization_required. A request also re-reads the store, so tokens stored by other means are picked up; ConduitMcp.Client.authorized/1 does the same explicitly.

Step-up

A 403 with error="insufficient_scope" fails that request with :authorization_required and a challenge for the union of the scopes already requested and the challenged ones (MCP 2026-07-28; used for both eras). The host authorizes again as above. At most max_step_up_attempts (2) step-ups are made per resource and scope set; beyond that the request fails with :step_up_limit (OA-10).

Errors

The functions here return {:error, %ConduitMcp.Client.OAuth.Error{}} (see its reasons), or a ConduitMcp.Client.Error when a running client named by client_or_config cannot be reached.

Summary

Types

A running client's name, an ConduitMcp.Client.OAuth.Config, or OAuth options for one (with :server_url).

Functions

Starts an authorization: discovery (always afresh, so an authorization server the server switched to is found), client registration, PKCE and state. Returns the URL to send the user to and the pending authorization the callback needs.

Completes an authorization: validates the response (state in constant time, the principal, the expiry, then RFC 9207 iss - all before any token request; see ConduitMcp.Client.OAuth.Callback), redeems the code with the same redirect_uri, resource and PKCE verifier, and stores the tokens under {principal, issuer, resource}.

Refreshes the tokens of principal now (single-flight: a caller that waited for another's refresh gets that result). invalid_grant deletes them and returns :authorization_required.

The current tokens for principal, refreshed first (single-flight) when they expire within refresh_skew. {:error, %OAuth.Error{reason: :authorization_required}} when there are none and the user must authorize. A client-credentials configuration requests a token instead.

Types

client_or_config()

@type client_or_config() :: term() | ConduitMcp.Client.OAuth.Config.t() | keyword()

A running client's name, an ConduitMcp.Client.OAuth.Config, or OAuth options for one (with :server_url).

Functions

authorize_url(client_or_config, opts \\ [])

Starts an authorization: discovery (always afresh, so an authorization server the server switched to is found), client registration, PKCE and state. Returns the URL to send the user to and the pending authorization the callback needs.

Options

  • :challenge - the ConduitMcp.Client.OAuth.Challenge the client reported; its resource_metadata and scope are used.
  • :scopes - the scopes to request. Default: the challenge's, else the server's scopes_supported, else the configured :scopes (none ⇒ no scope parameter).
  • :redirect_uri - overrides the configured one.
  • :principal - the identity to bind the flow and its tokens to. Default: the configured :principal.

handle_callback(pending, params, opts)

Completes an authorization: validates the response (state in constant time, the principal, the expiry, then RFC 9207 iss - all before any token request; see ConduitMcp.Client.OAuth.Callback), redeems the code with the same redirect_uri, resource and PKCE verifier, and stores the tokens under {principal, issuer, resource}.

The host must look pending up by the response's state and delete it first: state is single use.

Options

  • :client - the running client's name, or :config - its OAuth configuration (one is required). With :client the connection is told the tokens are stored (ConduitMcp.Client.authorized/1).
  • :principal - the identity completing the flow; must equal the one that started it. Default: the configured :principal.

refresh(client_or_config, principal \\ :default)

Refreshes the tokens of principal now (single-flight: a caller that waited for another's refresh gets that result). invalid_grant deletes them and returns :authorization_required.

token_for(client_or_config, principal \\ :default)

The current tokens for principal, refreshed first (single-flight) when they expire within refresh_skew. {:error, %OAuth.Error{reason: :authorization_required}} when there are none and the user must authorize. A client-credentials configuration requests a token instead.

With a ConduitMcp.Client.OAuth.Config, discovery runs on every call; with a running client's name, its discovered context is reused.