# `ConduitMcp.Client.OAuth`
[🔗](https://github.com/nyo16/conduit_mcp/blob/v0.11.0/lib/conduit_mcp/client/oauth.ex#L2)

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.

# `client_or_config`

```elixir
@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`).

# `authorize_url`

```elixir
@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` - 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`

```elixir
@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 `: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`

```elixir
@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`.

# `token_for`

```elixir
@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.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
