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/1also 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 alsoBearer realm=- a parameter needs a non-empty value, so that reads as token68realm=). - 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
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
@type detail() ::
:invalid_input
| :too_large
| :too_many_challenges
| :too_many_params
| :duplicate_param
| :unterminated_quote
| :invalid_encoding
| :malformed
@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
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.
@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: %{}}
]}
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.