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

Copy Markdown View Source

Parses WWW-Authenticate header values (RFC 9110 §11.6.1, RFC 6750 §3) and reads the Bearer challenge an MCP server sends with a 401 or 403.

iex> {:ok, [challenge]} =
...>   ConduitMcp.Client.OAuth.Challenge.parse(
...>     ~s(Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource", scope="files:read files:write")
...>   )
iex> ConduitMcp.Client.OAuth.Challenge.scopes(challenge)
["files:read", "files:write"]

Grammar

WWW-Authenticate = #challenge
challenge        = auth-scheme [ 1*SP ( token68 / #auth-param ) ]
auth-param       = token BWS "=" BWS ( token / quoted-string )

The parser is a hand-written recursive descent over the bytes:

  • One header value may hold several challenges, and parse/1 also takes a list of values (one per header line); the challenges are returned in order. Empty list elements (, ,) are skipped (RFC 9110 §5.6.1).
  • A comma separates both challenges and parameters. After a comma, the next element is a parameter when it starts with token BWS "=", and a new challenge otherwise.
  • After the scheme, a run of token68 characters (optionally ending in =s) followed by the end of the value or a comma is a token68 (Basic dXNlcjpwYXNz, and also Bearer realm= - a parameter needs a non-empty value, so that reads as token68 realm=).
  • Quoted strings honour \ escapes (quoted-pair); commas, = and spaces inside quotes are data. A bare control character (CR, LF, NUL, ...) anywhere is an error, so a header cannot smuggle a line break.
  • Scheme and parameter names are case-insensitive: both are stored lowercased (ASCII). Values are kept exactly as sent and must be valid UTF-8.

Malformed input never raises: it returns {:error, %OAuth.Error{reason: :invalid_challenge, data: detail}}, and the whole input is rejected (no partial result), so the caller falls back to well-known discovery.

Duplicates

RFC 9110 §11.2 says each parameter name "MUST only occur once per challenge" and RFC 6750 §3 says Bearer attributes "MUST NOT appear more than once". A repeated name (compared case-insensitively) is rejected with detail :duplicate_param rather than resolved first- or last-wins: two parties reading the same header must never disagree about, say, resource_metadata.

Limits

  • 8192 bytes across all values (detail :too_large);
  • 16 challenges in total (:too_many_challenges);
  • 32 parameters per challenge (:too_many_params).

Other details: :invalid_input (not a string or list of strings), :malformed, :unterminated_quote, :invalid_encoding.

Summary

Types

t()

One challenge. scheme and the params keys are lowercase; token68 is set (and params empty) for the token68 form.

Functions

The first challenge with the Bearer scheme, or nil. Takes the list from parse/1; RFC 6750 defines a single Bearer challenge, so any later ones are ignored.

The RFC 6750 §3.1 error code (for example "insufficient_scope"), or nil.

The error_description, or nil. Server text: never render it as trusted.

Whether the challenge is a step-up request (error="insufficient_scope").

The value of parameter name (matched case-insensitively), or nil.

Parses one WWW-Authenticate value, or a list of values (one per header).

The resource_metadata URL (RFC 9728 §5.1), or nil. It is server data: validate it and pass it through ConduitMcp.Client.URLPolicy before fetching.

The challenged scopes (RFC 6750 §3 scope, space-delimited), [] when absent.

Types

detail()

@type detail() ::
  :invalid_input
  | :too_large
  | :too_many_challenges
  | :too_many_params
  | :duplicate_param
  | :unterminated_quote
  | :invalid_encoding
  | :malformed

t()

@type t() :: %ConduitMcp.Client.OAuth.Challenge{
  params: %{optional(String.t()) => String.t()},
  scheme: String.t(),
  token68: String.t() | nil
}

One challenge. scheme and the params keys are lowercase; token68 is set (and params empty) for the token68 form.

Functions

bearer(challenges)

@spec bearer([t()]) :: t() | nil

The first challenge with the Bearer scheme, or nil. Takes the list from parse/1; RFC 6750 defines a single Bearer challenge, so any later ones are ignored.

error(challenge)

@spec error(t()) :: String.t() | nil

The RFC 6750 §3.1 error code (for example "insufficient_scope"), or nil.

error_description(challenge)

@spec error_description(t()) :: String.t() | nil

The error_description, or nil. Server text: never render it as trusted.

insufficient_scope?(challenge)

@spec insufficient_scope?(t()) :: boolean()

Whether the challenge is a step-up request (error="insufficient_scope").

param(challenge, name)

@spec param(t(), String.t()) :: String.t() | nil

The value of parameter name (matched case-insensitively), or nil.

parse(value)

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

Parses one WWW-Authenticate value, or a list of values (one per header).

iex> ConduitMcp.Client.OAuth.Challenge.parse([~s(Basic realm="a, b"), "Bearer"])
{:ok,
 [
   %ConduitMcp.Client.OAuth.Challenge{scheme: "basic", params: %{"realm" => "a, b"}},
   %ConduitMcp.Client.OAuth.Challenge{scheme: "bearer", params: %{}}
 ]}

resource_metadata(challenge)

@spec resource_metadata(t()) :: String.t() | nil

The resource_metadata URL (RFC 9728 §5.1), or nil. It is server data: validate it and pass it through ConduitMcp.Client.URLPolicy before fetching.

scopes(challenge)

@spec scopes(t()) :: [String.t()]

The challenged scopes (RFC 6750 §3 scope, space-delimited), [] when absent.