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: Bearerto every request to the MCP server (includingserver/discover), and only to it: never to metadata, registration or token requests, never across a redirect. - Refreshes a token that expires within
refresh_skewseconds 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:
- The request fails with
{:error, %ConduitMcp.Client.Error{kind: :auth, reason: :authorization_required, data: %{challenge: challenge}}}, the connection enters:authorizing, andon_authorization_requiredreceives theConduitMcp.Client.OAuth.Challenge(in a task). - The host calls
authorize_url/2with the client's name and the challenge, stores the returnedConduitMcp.Client.OAuth.Pending(server-side or encrypted: it holds the PKCE verifier andstate) and sends the user to the URL. - The authorization server redirects to the redirect URI. The host
looks the pending authorization up by
stateand deletes it (single use), then callshandle_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
@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
@spec authorize_url(client_or_config(), keyword()) :: {:ok, String.t(), ConduitMcp.Client.OAuth.Pending.t()} | {:error, ConduitMcp.Client.OAuth.Error.t() | ConduitMcp.Client.Error.t()}
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- theConduitMcp.Client.OAuth.Challengethe client reported; itsresource_metadataandscopeare used.:scopes- the scopes to request. Default: the challenge's, else the server'sscopes_supported, else the configured:scopes(none ⇒ noscopeparameter).:redirect_uri- overrides the configured one.:principal- the identity to bind the flow and its tokens to. Default: the configured:principal.
@spec handle_callback(ConduitMcp.Client.OAuth.Pending.t(), map(), keyword()) :: {:ok, ConduitMcp.Client.OAuth.Tokens.t()} | {:error, ConduitMcp.Client.OAuth.Error.t() | ConduitMcp.Client.Error.t()}
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:clientthe 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.
@spec refresh(client_or_config(), term()) :: {:ok, ConduitMcp.Client.OAuth.Tokens.t()} | {:error, ConduitMcp.Client.OAuth.Error.t() | ConduitMcp.Client.Error.t()}
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.
@spec token_for(client_or_config(), term()) :: {:ok, ConduitMcp.Client.OAuth.Tokens.t()} | {:error, ConduitMcp.Client.OAuth.Error.t() | ConduitMcp.Client.Error.t()}
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.