MCP servers reached over Streamable HTTP may require OAuth 2.1 (the MCP
authorization spec, both protocol eras). ConduitMcp.Client handles the
protocol side of that: discovery, client registration, PKCE, token requests,
refresh, step-up and attaching the bearer token. Your application handles
the parts that involve a person: sending the user to the authorization
server, receiving the redirect, and storing what has to survive between the
two.
{ConduitMcp.Client,
name: :docs,
transport:
{ConduitMcp.Client.Transport.StreamableHTTP,
url: "https://mcp.example.com/mcp",
auth:
{:oauth,
redirect_uri: "https://app.example.com/oauth/mcp/callback",
client_metadata_url: "https://app.example.com/oauth/client.json",
on_authorization_required: &MyApp.MCPAuth.required/1}}}The OAuth modules need the optional :finch dependency, which the Streamable
HTTP transport needs anyway. private_key_jwt client authentication also
needs :jose. Each is checked when the options are validated, so a missing
dependency raises at boot. OAuth is not available over stdio, and servers
without Protected Resource Metadata (the 2025-03-26 fallback) are not
supported.
For the client in general see client.md. For the server side of OAuth (protecting your own MCP server) see authentication.md.
What the client does on its own
- Sends the bearer token on every request to the MCP server, including
server/discover, and only to it. Metadata, registration and token requests never carry it. Redirects are refused unless the transport hasredirect: :same_origin, which follows them only to the same origin. - Refreshes an access token that expires within
refresh_skewseconds before sending it, and again after a 401. Refreshes are single-flight across every process sharing the token store, and rotated refresh tokens are stored before the lock is released. - Discovers the authorization server: Protected Resource Metadata
(RFC 9728) from the challenge's
resource_metadataURL or the well-known URLs, then the authorization server's metadata (RFC 8414 / OpenID Connect Discovery). SeeConduitMcp.Client.OAuth.Discovery.Fetch. - Chooses a client identity: pre-registered, a Client ID Metadata Document, or dynamic registration (see "Client registration").
- Steps up on a 403
insufficient_scope(see "Step-up").
The client never opens a browser and never blocks a request waiting for a person.
Options
auth: {:oauth, opts} on ConduitMcp.Client.Transport.StreamableHTTP.
The options are validated into a ConduitMcp.Client.OAuth.Config when the
client's child spec is built, so a bad option raises an ArgumentError
naming it. The transport fills in :server_url from its :url.
| Option | Default | Meaning |
|---|---|---|
:redirect_uri | nil | Where the authorization server sends the user back: https, or http on a loopback host (RFC 8252); no fragment. Needed for the interactive flow unless authorize_url/2 or Loopback supplies one |
:client_metadata_url | nil | The https URL, with a path, of your Client ID Metadata Document (CIMD). Used as the client id when the authorization server supports CIMD |
:client_metadata | [] | Sent in a dynamic registration request: :client_name, :application_type (:native or :web; default :native for a loopback or custom-scheme redirect URI, else :web), :client_uri, :logo_uri |
:pre_registered | %{} | Clients registered out of band: %{issuer => [client_id: ..., client_secret: ..., ...]}, keyed by the authorization server's exact issuer |
:store | nil | The token store as {module, opts}. nil: an in-memory ConduitMcp.Client.OAuth.TokenStore.ETS started under the client's supervisor, or a hub's shared one |
:on_authorization_required | nil | fn challenge -> ... end, called in a task with a ConduitMcp.Client.OAuth.Challenge when the user must authorize |
:max_step_up_attempts | 2 | Step-up authorizations per resource and scope set |
:refresh_skew | 30 | Seconds before expiry at which an access token is refreshed |
:expected_issuers | nil | An allowlist of authorization-server issuers (exact strings). nil accepts the one the server names |
:principal | nil | The user or tenant this client acts for. Part of every token-store key and of the identity private cached results are keyed by; switch with ConduitMcp.Client.set_auth/3 (see "Private results and credentials" in client.md) |
:scopes | [] | Scopes to request when neither the challenge nor the server's metadata names any |
:allow_insecure_oauth | false | Development and tests: admit plain http metadata and authorization-server URLs on loopback hosts |
:allow_private | [] | CIDRs the URL policy admits for OAuth requests although they are private |
:resolver | nil | The DNS resolver the URL policy uses (tests) |
:transport_opts | [] | TLS options for OAuth requests (Mint :transport_opts, for example cacerts:). Certificates are always verified: verify: other than :verify_peer, and verify_fun:, are refused |
:request_timeout | 10_000 | Milliseconds for each OAuth HTTP request |
:clock | nil | fn -> unix_seconds end (tests) |
The client-credentials options (:client_id, :client_secret,
:private_key, :private_key_alg, :private_key_kid,
:token_endpoint_auth_method) are covered in "Machine to machine".
Interactive authorization
What happens on a 401
When the server answers 401, the connection enters :authorizing
(ConduitMcp.Client.status/1 reports state: :authorizing). A task runs
discovery and looks in the token store for a usable token: a newer one than
the server refused, or one it can refresh. If it finds one, the refused
request is sent again and the caller never sees the 401.
If there is none, the user must authorize:
- The refused request, and any that were waiting, fail with
{:error, %ConduitMcp.Client.Error{kind: :auth, reason: :authorization_required, data: %{challenge: challenge}}}. on_authorization_requiredis called with the challenge, in a task under the client'sTask.Supervisor, once per episode. A callback that raises is logged and otherwise ignored.- The connection stays in
:authorizing. Every new request fails at once with:authorization_required; nothing waits for a person.
Every request that arrives in :authorizing first re-reads the store, so
tokens stored by any means are picked up by the next request.
ConduitMcp.Client.authorized/1 triggers that check at once, and
handle_callback/3 with client: calls it for you. Once tokens are found,
the connection finishes its handshake (or returns to :ready) without a
reconnect.
Other OAuth failures end the episode differently. Unreachable metadata,
registration or token endpoints (:no_protected_resource_metadata,
:no_authorization_server_metadata, :registration_failed,
:token_request_failed) make the client reconnect with backoff. Refusals
such as a resource mismatch, a missing S256 or a blocked URL close the
connection with kind: :auth and that reason;
ConduitMcp.Client.reconnect/1 tries again.
The two steps
Interactive authorization is two calls with a redirect in between:
# 1. Start: discovery, registration, PKCE and state.
{:ok, url, pending} = ConduitMcp.Client.OAuth.authorize_url(:docs, challenge: challenge)
# store `pending` server-side, keyed by its state, then send the user to `url`
# 2. The redirect arrives: take `pending` back (and delete it), then finish.
{:ok, tokens} = ConduitMcp.Client.OAuth.handle_callback(pending, params, client: :docs)authorize_url/2 always runs discovery again, so if the server has moved to
another authorization server since the client last looked, the new one is
used (and pre-registered credentials for the old one are not; see "Client
registration"). Its options:
:challenge- the challenge the client reported. Itsresource_metadataURL andscopeare used.:scopes- the scopes to request. Default: the challenge's, else the server'sscopes_supported, else the configured:scopes. When all are empty, noscopeparameter is sent.:redirect_uri- overrides the configured one.:principal- the identity to bind the flow and its tokens to. Default: the configured:principal.
handle_callback/3 validates the response before any token request (see
ConduitMcp.Client.OAuth.Callback): state in constant time, the
principal, the expiry, and the RFC 9207 iss parameter. It then redeems
the code with the same redirect_uri, resource and PKCE verifier, stores
the tokens under {principal, issuer, resource}, and with client: tells
the connection. Pass config: (a ConduitMcp.Client.OAuth.Config)
instead of client: to complete a flow without a running client; tokens
then reach a client only through a store they share.
ConduitMcp.Client.OAuth.token_for/2 returns the current tokens for a
principal, refreshed first when they expire within refresh_skew, or
:authorization_required; a client-credentials configuration requests a
token instead. ConduitMcp.Client.OAuth.refresh/2 refreshes now. Both are
single-flight. The first argument of authorize_url/2, token_for/2 and
refresh/2 may be a running client's name (its discovered context is
reused), a ConduitMcp.Client.OAuth.Config, or OAuth options including
:server_url (discovery runs on every call).
Storing the pending authorization
ConduitMcp.Client.OAuth.Pending is plain data: the PKCE verifier,
state, the issuer, resource, redirect URI, scopes, client id, token
endpoint and principal. It expires after 10 minutes. Pending.to_map/1
turns it into a map of strings, integers, booleans and lists (and the
principal as given), and Pending.from_map/1 validates one and restores
the struct.
Two rules:
- The map holds secrets in plain text (
stateandcode_verifier). Keep it server-side, or encrypt it (Plug.Crypto.encrypt/4). Signing alone does not hide it. stateis single use, and enforcing that is your job.Pendingis data, so the library cannot see a second copy being presented. Look the entry up bystateand delete it in the same step, before callinghandle_callback/3, whether the callback then succeeds or fails.
A signed or encrypted cookie session does not give single use: the browser can present an older cookie again. Prefer a database row or another server-side store. If you JSON-encode the map, use a string principal.
A Phoenix example
One client per user and server, with the user's id as the principal and a persistent token store (see "Token stores"):
defmodule MyApp.MCP do
alias ConduitMcp.Client.Transport.StreamableHTTP
def client(user_id, server), do: {__MODULE__, user_id, server}
def start_client(user_id, "github") do
DynamicSupervisor.start_child(MyApp.MCPClients, {ConduitMcp.Client,
name: client(user_id, "github"),
server_id: "github",
transport:
{StreamableHTTP,
url: "https://mcp.github.example/mcp",
finch: MyApp.MCPFinch,
auth:
{:oauth,
principal: user_id,
redirect_uri: "https://app.example.com/oauth/mcp/callback",
client_metadata_url: "https://app.example.com/oauth/client.json",
store: {MyApp.MCPTokenStore, []},
on_authorization_required: &MyApp.MCPAuth.required(user_id, "github", &1)}}})
end
endon_authorization_required keeps the challenge (it carries step-up scopes)
and tells the user's UI to show a "Connect" link. The pending authorization
goes into a table keyed by a hash of state, encrypted:
defmodule MyApp.MCPAuth do
import Ecto.Query
alias ConduitMcp.Client.OAuth.Pending
alias MyApp.MCPAuth.PendingAuthorization
alias MyApp.Repo
@salt "mcp pending authorization"
def required(user_id, server, challenge) do
MyApp.MCPAuth.Challenges.put({user_id, server}, challenge)
Phoenix.PubSub.broadcast(MyApp.PubSub, "user:#{user_id}", {:mcp_authorization_required, server})
end
def challenge(user_id, server), do: MyApp.MCPAuth.Challenges.get({user_id, server})
def put_pending(server, %Pending{} = pending) do
map = Pending.to_map(pending)
Repo.insert!(%PendingAuthorization{
state_hash: :crypto.hash(:sha256, map["state"]),
server: server,
data: Plug.Crypto.encrypt(secret(), @salt, map)
})
end
# Delete-on-read: a second callback with the same state finds nothing.
def take_pending(state) when is_binary(state) do
hash = :crypto.hash(:sha256, state)
query = from p in PendingAuthorization, where: p.state_hash == ^hash, select: p
with {1, [row]} <- Repo.delete_all(query),
{:ok, map} <- Plug.Crypto.decrypt(secret(), @salt, row.data, max_age: 600),
{:ok, pending} <- Pending.from_map(map) do
{:ok, row.server, pending}
else
_ -> {:error, :unknown_state}
end
end
def take_pending(_state), do: {:error, :unknown_state}
defp secret, do: MyAppWeb.Endpoint.config(:secret_key_base)
endThe controller starts the flow and finishes it. Both routes sit behind your
login pipeline, and principal: in the callback makes a response that was
started by another user fail with :principal_mismatch:
defmodule MyAppWeb.MCPAuthController do
use MyAppWeb, :controller
alias ConduitMcp.Client.OAuth
# GET /mcp/connect/:server
def connect(conn, %{"server" => server}) do
user_id = conn.assigns.current_user.id
client = MyApp.MCP.client(user_id, server)
case OAuth.authorize_url(client, challenge: MyApp.MCPAuth.challenge(user_id, server)) do
{:ok, url, pending} ->
MyApp.MCPAuth.put_pending(server, pending)
redirect(conn, external: url)
{:error, _error} ->
conn
|> put_flash(:error, "Could not start the authorization.")
|> redirect(to: ~p"/settings/connections")
end
end
# GET /oauth/mcp/callback
def callback(conn, params) do
user_id = conn.assigns.current_user.id
with {:ok, server, pending} <- MyApp.MCPAuth.take_pending(params["state"]),
{:ok, _tokens} <-
OAuth.handle_callback(pending, params,
client: MyApp.MCP.client(user_id, server),
principal: user_id
) do
conn |> put_flash(:info, "Connected.") |> redirect(to: ~p"/settings/connections")
else
_error ->
conn
|> put_flash(:error, "The authorization did not complete.")
|> redirect(to: ~p"/settings/connections")
end
end
endThe %ConduitMcp.Client.OAuth.Error{} from handle_callback/3 is safe to
log: its message never contains a token, code, state or verifier. Its
:data may hold the authorization server's error_description, which is
server text; do not render it as trusted.
Serve the Client ID Metadata Document at :client_metadata_url with
ConduitMcp.Client.OAuth.CIMD.document/1. Its client_id must be that URL,
and redirect_uris must list every redirect URI you use:
# GET /oauth/client.json
def client_metadata(conn, _params) do
json(conn, ConduitMcp.Client.OAuth.CIMD.document(
client_id: "https://app.example.com/oauth/client.json",
client_name: "My App",
redirect_uris: ["https://app.example.com/oauth/mcp/callback"],
application_type: :web,
client_uri: "https://app.example.com"
))
endThe document always declares grant_types: ["authorization_code", "refresh_token"], response_types: ["code"] and
token_endpoint_auth_method: "none". Other string-valued RFC 7591 fields
(:logo_uri, :tos_uri, :policy_uri, ...) are copied as given.
CLI and desktop apps: Loopback
ConduitMcp.Client.OAuth.Loopback.run/2 runs both steps with a one-shot
callback server on 127.0.0.1 (RFC 8252 loopback redirect). The library
never launches a browser; you pass open:, which receives the URL:
cli = self()
{:ok, _pid} =
ConduitMcp.Client.start_link(
name: :docs,
transport:
{ConduitMcp.Client.Transport.StreamableHTTP,
url: "https://mcp.example.com/mcp",
auth:
{:oauth,
client_metadata_url: "https://cli.example.com/oauth/client.json",
on_authorization_required: &send(cli, {:authorize, &1})}}
)
receive do
{:authorize, challenge} ->
{:ok, _tokens} =
ConduitMcp.Client.OAuth.Loopback.run(:docs,
challenge: challenge,
open: fn url ->
IO.puts("Opening #{url}")
System.cmd("open", [url])
end
)
after
5_000 -> :already_authorized
end
:ok = ConduitMcp.Client.await_ready(:docs, 10_000)run/2 starts Bandit on the loopback address only (port 0, any free
port, unless you give :port; path :path, default "/callback"), builds
the URL with redirect URI http://127.0.0.1:<port><path>, calls open,
waits up to :timeout (5 minutes) for the redirect, answers it with a
short page and stops the server. A second request gets 410 Gone. The
pending authorization never leaves the calling process, so state is used
once by construction. :challenge, :scopes and :principal are passed to
authorize_url/2.
Whether a random port is accepted depends on the authorization server
(RFC 8252 lets it ignore the port for loopback redirect URIs). If yours
matches exactly, pass port: and list that URI in your CIMD document. With
dynamic registration, a stored registration is reused only while its
redirect_uris include the redirect URI, so a new random port registers a
new client each time: give a fixed port: there too.
Loopback.start/1, await/2 and stop/1 are the server's halves, for a
host that runs the flow itself.
Step-up
A 403 with error="insufficient_scope" fails that request with
:authorization_required and a challenge whose scope is the union of
the scopes the current token holds and the challenged ones (MCP 2026-07-28;
applied to both eras). on_authorization_required is called with it; you
authorize again as above, passing the challenge so the union is requested.
The connection stays :ready meanwhile, because other requests may be
within scope.
Step-ups are counted per resource and scope set, order and duplicates
ignored. After max_step_up_attempts (2) for the same set, the request
fails with kind: :auth, reason: :step_up_limit and the host is not asked
again. This stops a server from looping the client through the
authorization server. A client-credentials client steps up by itself: it
requests a token for the union and sends the request again.
Client registration
For each authorization server, the client picks its identity in this order
(ConduitMcp.Client.OAuth.Registration):
- Pre-registered:
pre_registered[issuer], matched on the exact issuer string. - Client ID Metadata Document: when the authorization server's
metadata has
client_id_metadata_document_supported: trueand:client_metadata_urlis set, that URL is the client id. Nothing is registered, and the same id works with every authorization server that supports CIMD. This is the MCP spec's preferred method. - Dynamic client registration (RFC 7591; deprecated in MCP 2026-07-28,
kept for older authorization servers): when the metadata has a
registration_endpoint. The request declaresapplication_type,grant_types: ["authorization_code", "refresh_token"],response_types: ["code"]andtoken_endpoint_auth_method: "none", plus your:client_metadata. The registered client is stored in the token store under the issuer and reused while itsredirect_urisinclude the redirect URI. - Otherwise the flow fails with
:no_registration_method.
Credentials are bound to their issuer. When you configured
pre_registered clients but none is for the issuer the server now names,
the flow stops with :authorization_server_changed instead of falling back
to CIMD or registration, which would silently give the client a new
identity. Registered clients are stored by issuer, so a client registered
with one authorization server is never presented to another. Add
expected_issuers to refuse any authorization server you did not list
(:issuer_not_allowed).
auth:
{:oauth,
redirect_uri: "https://app.example.com/oauth/mcp/callback",
pre_registered: %{
"https://auth.example.com" => [
client_id: "my-app",
client_secret: {:system, "MCP_CLIENT_SECRET"}
]
},
expected_issuers: ["https://auth.example.com"]}A pre-registered client takes the same credential options as a client-credentials client (below); with neither a secret nor a key it is a public client.
Machine to machine
auth: {:client_credentials, opts} uses the OAuth client_credentials
grant (the MCP io.modelcontextprotocol/oauth-client-credentials
extension): no user and no browser.
{ConduitMcp.Client,
name: :reports,
transport:
{ConduitMcp.Client.Transport.StreamableHTTP,
url: "https://mcp.example.com/mcp",
auth:
{:client_credentials,
client_id: "reporting-service",
client_secret: {:system, "MCP_CLIENT_SECRET"},
scopes: ["reports:read"]}}}| Option | Default | Meaning |
|---|---|---|
:client_id | required | The client id |
:client_secret | nil | A string, a ConduitMcp.Client.Secret, or {:system, "VAR"}: read from the environment on every token request and never kept. An unset variable fails the request with :client_secret_unavailable |
:private_key | nil | A private JWK (map) or PEM string for private_key_jwt (RFC 7523). Needs :jose |
:private_key_alg | "RS256" | The JWS algorithm of the client assertion |
:private_key_kid | nil | The kid header of the client assertion |
:token_endpoint_auth_method | inferred | :private_key_jwt with a key, :client_secret_basic with a secret; :client_secret_post on request |
One of :client_secret and :private_key is required. With a key, the
client signs an assertion whose aud is the token endpoint:
auth:
{:client_credentials,
client_id: "reporting-service",
private_key: File.read!("/run/secrets/mcp_client_key.pem"),
private_key_alg: "ES256",
private_key_kid: "2026-10"}Discovery is the same as for the interactive flow, except that S256 and an
authorization_endpoint are not required. The token request carries
resource and scope (the configured :scopes, else the server's
scopes_supported). Tokens are stored like any other and replaced by a new
token request when they expire, single-flight. The other OAuth options
(:store, :principal, :expected_issuers, :refresh_skew,
:max_step_up_attempts, and the development options) apply as well.
Token stores
Tokens are stored under {principal, issuer, resource}, so a token is never
sent to another server, issued by another authorization server, or used for
another user, even when two servers trust the same authorization server.
Registered clients are stored under the issuer alone.
The default ConduitMcp.Client.OAuth.TokenStore.ETS is in memory. A client
without :store starts one under its own supervisor; it is lost when the
client stops. Its table is :protected: any process may read it, only the
store process writes it. To share one between standalone clients, start it
yourself and name it in each:
children = [
{ConduitMcp.Client.OAuth.TokenStore.ETS, name: :mcp_tokens},
{ConduitMcp.Client,
name: :docs,
transport:
{ConduitMcp.Client.Transport.StreamableHTTP,
url: "https://docs.example.com/mcp",
auth: {:oauth, store: {ConduitMcp.Client.OAuth.TokenStore.ETS, name: :mcp_tokens}}}}
]Writing a persistent store
Implement ConduitMcp.Client.OAuth.TokenStore. Each callback gets the
opts from store: {module, opts} as its last argument:
| Callback | Returns |
|---|---|
fetch_tokens(key, opts) | {:ok, %ConduitMcp.Client.OAuth.Tokens{}} or :error |
put_tokens(key, tokens, opts) | :ok or {:error, reason} |
fetch_client(issuer, opts) | {:ok, client_map} or :error |
put_client(issuer, client_map, opts) | :ok or {:error, reason} |
delete(key_or_issuer, :tokens | :client, opts) | :ok |
transaction(key, fun, opts) (optional) | what fun.() returns |
ConduitMcp.Client.OAuth.Tokens holds access_token and refresh_token as
ConduitMcp.Client.Secrets, expires_at (Unix seconds or nil), scopes
and token_type; Tokens.new/1 wraps plain strings. A client map is
string-keyed: "client_id", "client_secret" (a Secret or nil),
"token_endpoint_auth_method" and "redirect_uris". Expose secrets with
ConduitMcp.Client.Secret.expose/1 only to encrypt them, and wrap them
again on the way back.
An Ecto sketch, with MyApp.Vault standing for your encryption (Cloak, a
KMS, ...) and string principals:
defmodule MyApp.MCPTokenStore do
@behaviour ConduitMcp.Client.OAuth.TokenStore
import Ecto.Query
alias ConduitMcp.Client.OAuth.Tokens
alias ConduitMcp.Client.Secret
alias MyApp.MCP.{OAuthClient, OAuthToken}
alias MyApp.{Repo, Vault}
@impl true
def fetch_tokens({principal, issuer, resource}, _opts) do
case Repo.get_by(OAuthToken, principal: to_string(principal), issuer: issuer, resource: resource) do
nil ->
:error
row ->
{:ok,
Tokens.new(
access_token: Vault.decrypt!(row.access_token),
refresh_token: row.refresh_token && Vault.decrypt!(row.refresh_token),
expires_at: row.expires_at,
scopes: row.scopes
)}
end
end
@impl true
def put_tokens({principal, issuer, resource}, %Tokens{} = tokens, _opts) do
row = %{
principal: to_string(principal),
issuer: issuer,
resource: resource,
access_token: Vault.encrypt!(Secret.expose(tokens.access_token)),
refresh_token: tokens.refresh_token && Vault.encrypt!(Secret.expose(tokens.refresh_token)),
expires_at: tokens.expires_at,
scopes: tokens.scopes
}
{1, _} =
Repo.insert_all(OAuthToken, [row],
on_conflict: {:replace, [:access_token, :refresh_token, :expires_at, :scopes]},
conflict_target: [:principal, :issuer, :resource]
)
:ok
end
@impl true
def fetch_client(issuer, _opts) do
case Repo.get_by(OAuthClient, issuer: issuer) do
nil ->
:error
row ->
{:ok,
%{
"client_id" => row.client_id,
"client_secret" => row.client_secret && Secret.new(Vault.decrypt!(row.client_secret)),
"token_endpoint_auth_method" => row.token_endpoint_auth_method,
"redirect_uris" => row.redirect_uris
}}
end
end
@impl true
def put_client(issuer, client, _opts) do
secret = client["client_secret"]
row = %{
issuer: issuer,
client_id: client["client_id"],
client_secret: secret && Vault.encrypt!(Secret.expose(secret)),
token_endpoint_auth_method: client["token_endpoint_auth_method"],
redirect_uris: client["redirect_uris"]
}
{1, _} =
Repo.insert_all(OAuthClient, [row],
on_conflict: {:replace_all_except, [:issuer]},
conflict_target: [:issuer]
)
:ok
end
@impl true
def delete({principal, issuer, resource}, :tokens, _opts) do
Repo.delete_all(
from t in OAuthToken,
where: t.principal == ^to_string(principal) and t.issuer == ^issuer and t.resource == ^resource
)
:ok
end
def delete(issuer, :client, _opts) do
Repo.delete_all(from c in OAuthClient, where: c.issuer == ^issuer)
:ok
end
# Single-flight across nodes: a transaction-scoped advisory lock on the key.
@impl true
def transaction({principal, issuer, resource}, fun, _opts) do
lock = Enum.join([to_string(principal), issuer, resource], "\n")
{:ok, result} =
Repo.transaction(fn ->
Repo.query!("SELECT pg_advisory_xact_lock(hashtext($1))", [lock])
fun.()
end)
result
end
endSingle-flight refresh
When many processes find the same expired token, only one should refresh
it: an authorization server that rotates refresh tokens invalidates the old
one on first use. The client runs every refresh (and every
client-credentials token request) through transaction/3. Inside the lock
it reads the store again, and uses a token another process has just stored
instead of refreshing. A rotated refresh token is written before the lock is
released.
transaction/3 is optional. Without it the lock is a node-local
:global.trans/4 on the key, which is enough for the ETS store and for any
store used from one node. A store shared by several nodes should implement
it with its own lock, like the advisory lock above or
SELECT ... FOR UPDATE. The callback runs in the requesting process, and
fun performs the token request, so the lock is held for one HTTP round
trip.
A refused refresh (invalid_grant) deletes the tokens, and the user must
authorize again. If put_tokens/3 returns an error, a warning is logged and
the new tokens still serve the current request; the next request
re-authorizes.
Using OAuth in a Hub
A ConduitMcp.Client.Hub starts one in-memory token store and gives it to
every server whose OAuth options name no :store. Tokens stay per resource,
but servers behind the same authorization server share one dynamic
registration (when they use the same redirect URI), and a server client the
hub restarts, or one removed and added again, finds its tokens and needs no
new authorization. The store lives as long as the hub; for tokens that
outlive it, give each server a persistent :store.
Each server is a client named {:hub_server, hub, server_id}
(ConduitMcp.Client.Hub.client/2), so the flow is the same:
{ConduitMcp.Client.Hub,
name: {MyApp.Hub, user_id},
policy: MyApp.Policy.for(user_id),
servers: [
github: [
transport:
{ConduitMcp.Client.Transport.StreamableHTTP,
url: "https://mcp.github.example/mcp",
auth:
{:oauth,
principal: user_id,
redirect_uri: "https://app.example.com/oauth/mcp/callback",
client_metadata_url: "https://app.example.com/oauth/client.json",
on_authorization_required: &MyApp.MCPAuth.required(user_id, "github", &1)}}
]
]}
client = ConduitMcp.Client.Hub.client({MyApp.Hub, user_id}, "github")
{:ok, url, pending} = ConduitMcp.Client.OAuth.authorize_url(client, challenge: challenge)While a server awaits authorization, ConduitMcp.Client.Hub.status/1
reports it as :authorizing and its tools are left out of
ConduitMcp.Client.Hub.list_tools/2, which only offers :ready servers.
Run one hub per user, as for any per-user credential (see
client_hub.md).
Development and tests
allow_insecure_oauth: trueadmits plainhttpmetadata and authorization-server URLs on loopback hosts, connected by name without the address check.httpsURLs are always checked.allow_private: ["10.0.0.0/8"]admits private addresses for OAuth requests, for an authorization server on your own network.resolver:replaces DNS for the URL policy.transport_opts: [cacerts: ...]trusts a private CA. The transport'sinsecure_skip_tls_verifydoes not apply to OAuth requests.clock:fixes the time for expiry checks.- The transport refuses
auth:over plainhttpto a non-loopback host at start.
Security properties
| Area | Behaviour |
|---|---|
| PKCE | S256 only, with a 32-byte verifier. An authorization server that does not list S256 in code_challenge_methods_supported is refused before any authorization request (:pkce_unsupported) |
state | 32 random bytes, bound to the principal, 10 minute lifetime, compared in constant time. Single use is the host's job (see "Storing the pending authorization") |
| Authorization response | RFC 9207 iss checked by exact comparison before any token request; required when the authorization server advertises it. On a state, principal or iss failure nothing from the response is surfaced |
| Resource | The canonical server URI is sent as resource (RFC 8707) in authorization and token requests. Protected Resource Metadata must name exactly that resource (:resource_mismatch) |
| Issuer | Authorization-server metadata must name exactly the issuer its URL was built from (:issuer_mismatch); expected_issuers limits which issuers are accepted; credentials are bound to their issuer (:authorization_server_changed) |
| Endpoints | Every endpoint in the metadata must be https (:insecure_endpoint), which also refuses javascript:, file: and data: |
| Metadata and token fetches | Every URL passes ConduitMcp.Client.URLPolicy :public_only: the host is resolved once and refused if any address is internal (:url_blocked), then the request connects to exactly that address, so DNS rebinding cannot redirect it. No redirects, no compressed bodies, 256 KiB body cap, :request_timeout per request |
| Bearer token | Sent only to the MCP server's URL, never to metadata, registration or token endpoints, never across a redirect to another origin, never over plain http to a non-loopback host |
| Secrets in memory | Tokens, client secrets, private keys, state, the verifier and the code are ConduitMcp.Client.Secrets (#Secret<redacted> in inspect/1). Crash reports of the connection and of the ETS store redact OAuth state |
| Token store | The default ETS table is :protected: other processes can read it but not plant or swap tokens |
| Errors and telemetry | Never carry a token, code, state, verifier, secret, URL or issuer |
Errors
The ConduitMcp.Client.OAuth functions return
{:error, %ConduitMcp.Client.OAuth.Error{reason: reason, data: data}}, or a
ConduitMcp.Client.Error when the named client cannot be reached. Client
requests return %ConduitMcp.Client.Error{kind: :auth, reason: reason}:
:authorization_required and :step_up_limit carry
data: %{challenge: challenge}, other OAuth reasons carry the
OAuth.Error, and ConduitMcp.Client.authorized/1 on a client without
OAuth returns :oauth_not_configured.
ConduitMcp.Client.OAuth.Error.reasons/0 is the closed set:
| Reason | Meaning |
|---|---|
:invalid_challenge | A WWW-Authenticate header is malformed or over a limit |
:invalid_url | A URL does not parse, has no host, carries user info, or an issuer has a query or fragment; also a missing redirect URI |
:insecure_url | A URL that must be https is not |
:resource_mismatch | Protected Resource Metadata names another resource |
:no_authorization_server | Protected Resource Metadata lists no authorization server |
:issuer_not_allowed | The authorization server is not in expected_issuers |
:invalid_metadata | A metadata document is not an object, or a field has the wrong type |
:issuer_mismatch | Authorization-server metadata names another issuer |
:pkce_unsupported | The authorization server does not support S256 |
:insecure_endpoint | A metadata endpoint is not https |
:invalid_state | The response's state is missing or wrong |
:principal_mismatch | The callback is completed by another principal than the one that started it |
:state_expired | The pending authorization is older than 10 minutes |
:iss_mismatch | The response's iss is not the issuer |
:iss_missing | The authorization server advertises iss but sent none |
:missing_code | The response has neither code nor error |
:invalid_pending | A stored Pending map could not be restored; :data names the field |
:access_denied, :invalid_request, :unauthorized_client, :unsupported_response_type, :invalid_scope, :server_error, :temporarily_unavailable | The authorization server returned that error |
:authorization_error | The authorization server returned another error code (in :data) |
:authorization_required | The user must authorize |
:no_protected_resource_metadata | No Protected Resource Metadata URL answered |
:no_authorization_server_metadata | No authorization-server metadata URL answered |
:authorization_server_changed | Pre-registered credentials exist, but none for the issuer the server names now |
:no_registration_method | No pre-registered client, CIMD or dynamic registration applies |
:registration_failed | Dynamic registration was refused |
:invalid_grant | The token endpoint rejected the code or refresh token |
:token_request_failed | The token endpoint failed otherwise; data: %{reason: :invalid_token_response} when its answer is malformed, an access token holding anything but visible ASCII (0x21-0x7E) included |
:step_up_limit | max_step_up_attempts reached for this resource and scope set |
:url_blocked | The URL policy refused a server-supplied URL before connecting |
:client_secret_unavailable | A {:system, "VAR"} client secret names an unset variable |
Exception.message/1 renders only the reason and a fixed description, so it
is safe to log.
Telemetry
| Event | Measurements | Metadata |
|---|---|---|
[:conduit_mcp, :client, :oauth, op, :start] | :monotonic_time, :system_time | :client, :server_id, :principal |
[:conduit_mcp, :client, :oauth, op, :stop] | :duration, :monotonic_time | as :start, plus :status (:ok, :error) and :reason (an OAuth.Error reason or nil) |
[:conduit_mcp, :client, :oauth, op, :exception] | :duration, :monotonic_time | as :start, plus :kind |
[:conduit_mcp, :client, :oauth, :authorization_required] | :count | :client, :server_id, :principal, :reason (:unauthorized, :step_up) |
op is :discovery, :registration (:stop adds :method:
:pre_registered, :cimd, :dcr or nil), :token_exchange (adds
:grant: :authorization_code or :client_credentials), :refresh or
:step_up. :client is nil for a standalone configuration, and
:principal is whatever you configured, so do not use it as a metric tag
unless it is low-cardinality. Spans run in the process doing the work: a
request process, the connection's authorization task, or your caller;
:step_up and :authorization_required run in the connection, so handlers
must be fast. See ConduitMcp.Telemetry for the full reference.