ConduitMcp.Client.OAuth.Discovery (ConduitMCP v0.11.0)

Copy Markdown View Source

Pure parts of authorization-server discovery (MCP authorization spec, Authorization Server Discovery): the metadata URLs to try, in order, and validation of the decoded metadata documents. Nothing here does I/O; the HTTP layer fetches each candidate (without an Authorization header, with every URL passing ConduitMcp.Client.URLPolicy :public_only) and stops at the first usable document.

Protected Resource Metadata (RFC 9728)

prm_candidates/3 for server https://ex.com/public/mcp:

  1. the resource_metadata URL from the WWW-Authenticate challenge, when given;
  2. https://ex.com/.well-known/oauth-protected-resource/public/mcp;
  3. https://ex.com/.well-known/oauth-protected-resource.

validate_prm/2 then requires resource to equal the canonical server URI exactly (RFC 9728 §3.3; no prefix or same-origin relaxation, OA-4) and a non-empty authorization_servers; select_issuer/2 picks the first one.

Authorization-server metadata (RFC 8414, OpenID Connect Discovery)

as_metadata_candidates/2 for issuer https://auth.ex.com/tenant1:

  1. https://auth.ex.com/.well-known/oauth-authorization-server/tenant1
  2. https://auth.ex.com/.well-known/openid-configuration/tenant1
  3. https://auth.ex.com/tenant1/.well-known/openid-configuration

and for an issuer without a path, /.well-known/oauth-authorization-server then /.well-known/openid-configuration.

validate_as_metadata/3 then requires (OA-1, OA-4, OA-7):

  • issuer to equal the issuer the URLs were built from, by exact string comparison (RFC 8414 §3.3): https://AS.example is not https://as.example, and a trailing slash matters;
  • "S256" in code_challenge_methods_supported;
  • authorization_endpoint and token_endpoint to be present;
  • every endpoint - each top-level field ending in _endpoint, plus jwks_uri - to be an https URL with a host, no user info and no fragment. https is the whole scheme allowlist, so javascript:, file:, data: and custom schemes are refused as :insecure_endpoint.

Every builder and validator accepts allow_insecure: true, which admits http on loopback hosts only, for local development and tests.

Summary

Functions

The authorization-server metadata URLs to try for issuer, in order.

The Protected Resource Metadata URLs to try for server_url, in order.

Chooses the authorization server from validated PRM: the first entry of authorization_servers.

Validates decoded authorization-server metadata fetched for issuer and returns it unchanged. See the moduledoc for the rules; they are checked in this order: object, issuer, S256, required endpoints, every endpoint.

Validates decoded Protected Resource Metadata against the canonical server URI (from ConduitMcp.Client.OAuth.Resource.canonical/2) and returns it unchanged.

Functions

as_metadata_candidates(issuer, opts \\ [])

@spec as_metadata_candidates(String.t(), keyword()) ::
  {:ok, [String.t(), ...]} | {:error, ConduitMcp.Client.OAuth.Error.t()}

The authorization-server metadata URLs to try for issuer, in order.

The issuer must be an https URL without a query or fragment (RFC 8414 §2). A terminating slash is removed before the well-known path is inserted (RFC 8414 §3.1), so https://as.example/ has no path.

iex> ConduitMcp.Client.OAuth.Discovery.as_metadata_candidates("https://auth.ex.com")
{:ok,
 [
   "https://auth.ex.com/.well-known/oauth-authorization-server",
   "https://auth.ex.com/.well-known/openid-configuration"
 ]}

prm_candidates(server_url, resource_metadata \\ nil, opts \\ [])

@spec prm_candidates(String.t(), String.t() | nil, keyword()) ::
  {:ok, [String.t(), ...]} | {:error, ConduitMcp.Client.OAuth.Error.t()}

The Protected Resource Metadata URLs to try for server_url, in order.

resource_metadata (from ConduitMcp.Client.OAuth.Challenge.resource_metadata/1) goes first, unchanged; it is server data and must pass the URL policy when fetched. The well-known URLs are built from the canonical server URI (ConduitMcp.Client.OAuth.Resource.canonical/2), keeping any query after the inserted path. Duplicates are removed.

iex> ConduitMcp.Client.OAuth.Discovery.prm_candidates("https://ex.com/public/mcp")
{:ok,
 [
   "https://ex.com/.well-known/oauth-protected-resource/public/mcp",
   "https://ex.com/.well-known/oauth-protected-resource"
 ]}

select_issuer(prm, opts \\ [])

@spec select_issuer(map(), keyword()) ::
  {:ok, String.t()} | {:error, ConduitMcp.Client.OAuth.Error.t()}

Chooses the authorization server from validated PRM: the first entry of authorization_servers.

Options

  • :expected_issuers - when a list, the chosen issuer must be in it (exact string match), else :issuer_not_allowed. nil (default) accepts any.

validate_as_metadata(metadata, issuer, opts \\ [])

@spec validate_as_metadata(term(), String.t(), keyword()) ::
  {:ok, map()} | {:error, ConduitMcp.Client.OAuth.Error.t()}

Validates decoded authorization-server metadata fetched for issuer and returns it unchanged. See the moduledoc for the rules; they are checked in this order: object, issuer, S256, required endpoints, every endpoint.

Errors: :invalid_metadata (data %{field: name}), :issuer_mismatch, :pkce_unsupported, :insecure_endpoint (data %{field: name, scheme: scheme}).

Options

  • :allow_insecure - admit http endpoints on loopback hosts.
  • :grant - :authorization_code (default) or :client_credentials. A machine-to-machine client never sends the user to the authorization endpoint, so for :client_credentials neither "S256" nor authorization_endpoint is required; token_endpoint still is, and every endpoint present is still checked.

validate_prm(prm, canonical_resource)

@spec validate_prm(term(), String.t()) ::
  {:ok, map()} | {:error, ConduitMcp.Client.OAuth.Error.t()}

Validates decoded Protected Resource Metadata against the canonical server URI (from ConduitMcp.Client.OAuth.Resource.canonical/2) and returns it unchanged.

Errors: :invalid_metadata (not an object; authorization_servers or scopes_supported not a list of strings; data names the field), :resource_mismatch, :no_authorization_server.