barrel_mcp_client_auth_oauth (barrel_mcp v3.0.0)

View Source

OAuth 2.1 + PKCE authorization for barrel_mcp_client.

Implements the MCP authorization flow described in the spec and the underlying RFCs:

  • RFC 9728: Protected Resource Metadata (PRM)
  • RFC 8414: Authorization Server Metadata
  • RFC 7636: PKCE (S256)
  • RFC 8707: resource indicator on auth + token requests
  • RFC 9207: issuer identification on the authorization response
  • RFC 6749 / OAuth 2.1: authorization-code + refresh_token grants
  • draft-ietf-oauth-client-id-metadata-document-00: an HTTPS URL as the client_id

What this module does

Two responsibilities, kept separate so hosts can mix them as they need:

  1. **Discovery helpers** that hosts use during initial token acquisition: parse WWW-Authenticate, fetch PRM, fetch AS metadata, build authorization URLs with PKCE, exchange the returned code at the token endpoint.
  2. **barrel_mcp_client_auth behaviour implementation** that attaches the Authorization: Bearer ... header on every outgoing request and refreshes the token automatically on 401 (when a refresh_token was supplied).

The redirect step

Sending a person to the authorization URL and getting the callback back is the host's: a browser, a paste, a local listener. The host gives it as the authorize fun below, and the handle runs the rest from a 401: discovery, registration, PKCE, validation, exchange, refresh, step-up. A host that obtained tokens by other means gives them as access_token instead and the handle only refreshes.

Config shape

   {oauth, #{
     redirect_uri   := binary(),       %% the flow's registered redirect
     authorize      := fun((Url) -> {ok, CallbackUrl} | {error, _}),
     client_id      => binary(),       %% pre-registered; else CIMD or DCR
     client_secret  => binary(),
     client_id_metadata_url => binary(),
     client_metadata => map(),         %% the DCR document
     token_endpoint_auth_method => client_secret_basic | client_secret_post | none,
     scopes         => [binary()],
     resource       => binary(),
     store          => {module(), term()}, %% barrel_mcp_client_auth_store
     allow_insecure_oauth => boolean()
   }}

or, with tokens in hand:

   {oauth, #{
     access_token   := binary(),       %% required
     refresh_token  => binary(),       %% optional; enables refresh
     token_endpoint => binary(),       %% required if refresh_token set
     client_id      => binary(),       %% required if refresh_token set
     client_secret  => binary(),       %% optional confidential client
     resource       => binary(),       %% RFC 8707 canonical id
     scopes         => [binary()],     %% optional
     allow_insecure_oauth => boolean() %% see below
   }}

HTTPS

Every authorization-server URL, configured or discovered, must be https (MCP authorization security considerations, "Communication Security"). allow_insecure_oauth => true lifts that for a plaintext test server. It is noncompliant and never a production setting.

Sections, in file order

  • Behaviour callbacks: init/1, headers/1, refresh/2, challenge/2, settled/1, request_headers/3.
  • Discovery: WWW-Authenticate parsing, PRM, AS metadata, the HTTPS policy secure_url/2.
  • PKCE, authorization URL, token endpoint.
  • Choosing a registration mechanism, Client ID Metadata Documents, binding a client to an authorization server.
  • The authorization-code flow driven from a challenge: prm_flow, ensure_client, run_authorization, exchange, step_up; the non-interactive grants and DPoP proofs sit with it.
  • HTTP helpers and encoders.

The handle record

#h{} is the whole state. The fields that steer behaviour: mode (which grant), phase (flow runs the authorization-code flow from a 401, token only refreshes what the host supplied), tea_method (how the client authenticates at the token endpoint, persisted with the client), want_refresh (ask for offline_access when the AS lists it), insecure (the plaintext policy), dpop (proof key and the AS and RS nonces), token_type (dpop switches the Authorization scheme). prm, as_metadata and client are the discovered documents; requested_scope and granted_scope drive step-up on 403.

Processes

The handle is a value; the transport calls it. challenge/2 does network I/O and may block on the host's authorize fun, which is why barrel_mcp_client_http runs it in a worker.

Summary

Functions

Build an authorization-code+PKCE URL for the user to visit. Params must include client_id and redirect_uri; the function handles code_challenge/code_challenge_method for you given the verifier. state is generated automatically if not supplied.

Answer a 401 or a 403 insufficient_scope with a handle that holds a usable token, running discovery, registration, the host's redirect step and the code exchange as the challenge requires.

Check credentials against the authorization server about to be used.

Acquire an access token via the OAuth 2.1 client_credentials grant, for unattended / machine-to-machine flows where there is no human in the loop. Per the MCP ext-auth OAuth Client Credentials extension, callers may authenticate either with a client_secret (HTTP Basic, per RFC 6749) or a client_assertion (private_key_jwt per RFC 7523).

Build the metadata document a client serves at its own client_id URL.

Derive the S256 code challenge for a verifier.

Fetch and validate the Authorization Server Metadata for an issuer URL, trying the well-known locations in the order the MCP specification requires (authorization-server-discovery, "Authorization Server Metadata Discovery").

Fetch and parse the Protected Resource Metadata document.

Exchange an authorization code for tokens.

Generate a 64-byte random URL-safe code verifier (RFC 7636).

Whether a client_id is a Client ID Metadata Document URL.

RFC 7523 JWT Bearer access-token request. The second step of the EMA chain: present the ID-JAG to the MCP server's authorization-server token endpoint and receive a short-lived access token.

Extract the resource_metadata URL from a WWW-Authenticate header per RFC 9728. Returns undefined if not present.

Refresh an access token via the refresh_token grant.

Dynamic Client Registration ([RFC 7591][rfc7591]).

Variant of register_client/2 that accepts an options map. Currently the only option is initial_access_token (RFC 7591 section 3): an opaque bearer token issued out of band by the AS to gate registration. When present, the call adds Authorization: Bearer <token>.

Decide how to obtain a client_id for an authorization server.

RFC 9449 4: a proof per request, bound to the method and URL, carrying the token's hash and the nonce the resource server asked for. Nothing without a DPoP key or before a token exists.

The HTTPS policy every authorization-server URL passes through: configured, discovered, or the discovery URL itself. Only allow_insecure_oauth => true in Opts lifts it.

The transport reports an accepted request. Nothing to reset: the retry budget lives with the request, not here.

RFC 8693 OAuth 2.0 Token Exchange. Used by the MCP ext-auth Enterprise-Managed Authorization extension to exchange an IdP-issued ID Token (or SAML assertion) for an Identity Assertion JWT Authorization Grant (the "ID-JAG"), scoped to a specific MCP server resource.

Check an authorization response before redeeming its code.

Types

client_credentials_config/0

-type client_credentials_config() ::
          #{grant_type := client_credentials,
            token_endpoint := binary(),
            client_id := binary(),
            client_secret => binary(),
            client_assertion => binary(),
            resource => binary(),
            scopes => [binary()],
            allow_insecure_oauth => boolean()}.

config/0

-type config() ::
          #{access_token := binary(),
            refresh_token => binary(),
            token_endpoint => binary(),
            client_id => binary(),
            client_secret => binary(),
            resource => binary(),
            scopes => [binary()],
            allow_insecure_oauth => boolean()} |
          client_credentials_config() |
          enterprise_managed_config().

enterprise_managed_config/0

-type enterprise_managed_config() ::
          #{grant_type := enterprise_managed,
            idp_token_endpoint := binary(),
            as_token_endpoint := binary(),
            client_id := binary(),
            client_secret => binary(),
            client_assertion => binary(),
            subject_token := binary(),
            subject_token_type := binary(),
            audience := binary(),
            resource := binary(),
            scopes => [binary()],
            allow_insecure_oauth => boolean()}.

handle/0

-type handle() ::
          #h{insecure :: boolean(),
             access_token :: binary() | undefined,
             refresh_token :: binary() | undefined,
             token_endpoint :: binary() | undefined,
             client_id :: binary() | undefined,
             client_secret :: binary() | undefined,
             client_assertion :: binary() | undefined,
             resource :: binary() | undefined,
             scopes :: [binary()] | undefined,
             mode :: auth_code | client_credentials | enterprise_managed | jwt_bearer,
             assertion :: binary() | undefined,
             private_key :: {barrel_mcp_jwt:key(), binary()} | undefined,
             dpop ::
                 undefined |
                 #{key := term(), as_nonce := binary() | undefined, rs_nonce := binary() | undefined},
             token_type :: bearer | dpop,
             phase :: token | flow,
             redirect_uri :: binary() | undefined,
             authorize :: fun((binary()) -> {ok, binary()} | {error, term()}) | undefined,
             client_metadata :: map() | undefined,
             client_id_metadata_url :: binary() | undefined,
             tea_method :: client_secret_basic | client_secret_post | none | undefined,
             store :: barrel_mcp_client_auth_store:store(),
             want_refresh :: boolean(),
             server_url :: binary() | undefined,
             prm :: map() | undefined,
             as_metadata :: map() | undefined,
             client :: map() | undefined,
             requested_scope :: [binary()] | undefined,
             granted_scope :: [binary()] | undefined,
             idp_token_endpoint :: binary() | undefined,
             subject_token :: binary() | undefined,
             subject_token_type :: binary() | undefined,
             audience :: binary() | undefined}.

Functions

build_authorization_url(AuthEndpoint, Params)

-spec build_authorization_url(binary(), map()) -> {binary(), binary(), binary()}.

Build an authorization-code+PKCE URL for the user to visit. Params must include client_id and redirect_uri; the function handles code_challenge/code_challenge_method for you given the verifier. state is generated automatically if not supplied.

challenge(H, Challenge)

-spec challenge(handle(), barrel_mcp_client_auth:challenge()) -> {ok, handle()} | {error, term()}.

Answer a 401 or a 403 insufficient_scope with a handle that holds a usable token, running discovery, registration, the host's redirect step and the code exchange as the challenge requires.

check_issuer_binding(Credentials, Issuer)

-spec check_issuer_binding(map(), binary()) -> ok | {error, term()}.

Check credentials against the authorization server about to be used.

A client_id from pre-registration or dynamic registration belongs to the server that issued it and means nothing at another. The server can change under a client without warning, since it comes from the resource's metadata and that is refetched; sending the old credentials to the new one leaks a client identity to a party that was never given it, and fails in a way that reads like a bad token.

Credentials is whatever you persisted, and must record the issuer it was obtained from, binary- or atom-keyed. Issuer is the one from the metadata you are about to use.

A CIMD client_id passes against any issuer: it is a URL the server resolves itself, so it is not bound to one and needs no re-registration when the server changes.

client_credentials(TokenEndpoint, Params)

-spec client_credentials(binary(), map()) -> {ok, map()} | {error, term()}.

Acquire an access token via the OAuth 2.1 client_credentials grant, for unattended / machine-to-machine flows where there is no human in the loop. Per the MCP ext-auth OAuth Client Credentials extension, callers may authenticate either with a client_secret (HTTP Basic, per RFC 6749) or a client_assertion (private_key_jwt per RFC 7523).

client_id_metadata_document(Metadata)

-spec client_id_metadata_document(map()) -> {ok, map()} | {error, term()}.

Build the metadata document a client serves at its own client_id URL.

With CIMD the client_id is an HTTPS URL, and the authorization server fetches this document from it at authorization time. That removes the registration round trip, and with it the credential a client would otherwise have to store per authorization server: the same URL works everywhere, because whoever needs the metadata goes and reads it.

Metadata is binary-keyed, like register_client/2, and must carry client_id, client_name and redirect_uris. The client_id must be the exact URL you serve the document from, https, with a path: servers compare the two and reject a document that names a different identity than the one they fetched.

grant_types, response_types and token_endpoint_auth_method default to the public-client-with-PKCE shape. Set them yourself for anything else, including refresh_token in grant_types if you want refresh tokens.

code_challenge(Verifier)

-spec code_challenge(binary()) -> binary().

Derive the S256 code challenge for a verifier.

discover_authorization_server(Issuer)

-spec discover_authorization_server(binary()) -> {ok, map()} | {error, term()}.

Fetch and validate the Authorization Server Metadata for an issuer URL, trying the well-known locations in the order the MCP specification requires (authorization-server-discovery, "Authorization Server Metadata Discovery").

discover_authorization_server(Issuer, Opts)

-spec discover_authorization_server(binary(), map()) -> {ok, map()} | {error, term()}.

discover_authorization_server/1 with options (allow_insecure_oauth).

A URL that cannot be fetched or does not hold a metadata document falls through to the next one. The first document found is final: its issuer must equal Issuer, it must advertise S256 PKCE, and its endpoints must be https. A document failing those is an error, not a reason to try the next URL: the specification says it must not be used, and a later URL cannot make it safe.

discover_protected_resource(Url)

-spec discover_protected_resource(binary()) -> {ok, map()} | {error, term()}.

Fetch and parse the Protected Resource Metadata document.

discover_protected_resource(Url, Opts)

-spec discover_protected_resource(binary(), map()) -> {ok, map()} | {error, term()}.

discover_protected_resource/1 with options (allow_insecure_oauth).

exchange_code(TokenEndpoint, Params)

-spec exchange_code(binary(), map()) -> {ok, map()} | {error, term()}.

Exchange an authorization code for tokens.

gen_code_verifier()

-spec gen_code_verifier() -> binary().

Generate a 64-byte random URL-safe code verifier (RFC 7636).

header(H)

init(Cfg)

is_client_id_metadata_url(Other)

-spec is_client_id_metadata_url(term()) -> boolean().

Whether a client_id is a Client ID Metadata Document URL.

Https with a path. The path is what the draft requires and what keeps a bare origin from being read as an identity.

jwt_bearer(TokenEndpoint, Params)

-spec jwt_bearer(binary(), map()) -> {ok, map()} | {error, term()}.

RFC 7523 JWT Bearer access-token request. The second step of the EMA chain: present the ID-JAG to the MCP server's authorization-server token endpoint and receive a short-lived access token.

parse_www_authenticate(Header)

-spec parse_www_authenticate(binary() | undefined) -> binary() | undefined.

Extract the resource_metadata URL from a WWW-Authenticate header per RFC 9728. Returns undefined if not present.

refresh(H, Www)

refresh_token(TokenEndpoint, Params)

-spec refresh_token(binary(), map()) -> {ok, map()} | {error, term()}.

Refresh an access token via the refresh_token grant.

register_client(RegistrationEndpoint, Metadata)

-spec register_client(RegistrationEndpoint :: binary(), Metadata :: map()) ->
                         {ok, ClientInfo :: map()} | {error, term()}.

Dynamic Client Registration ([RFC 7591][rfc7591]).

Deprecated by MCP 2026-07-28 in favour of Client ID Metadata Documents, and kept for authorization servers that do not support them. It is the only registration mechanism that mints a credential you then have to store and bind to an issuer. Let registration_strategy/2 choose rather than reaching for this directly; see client_id_metadata_document/1.

Posts the supplied client metadata to the AS's registration_endpoint and returns the AS's response unchanged: typically including client_id, optionally client_secret, client_id_issued_at, client_secret_expires_at, plus any client-metadata echo the AS chose to include.

Hosts that receive a fresh client_id (and client_secret, if issued) feed it into a subsequent {oauth, ...}, {oauth_client_credentials, ...}, or {oauth_enterprise, ...} connect spec. This stays a standalone exchanger; auto-wiring would require persisting credentials, which is host policy.

[rfc7591]: https://datatracker.ietf.org/doc/html/rfc7591

register_client(RegistrationEndpoint, Metadata, Opts)

-spec register_client(RegistrationEndpoint :: binary(),
                      Metadata :: map(),
                      Opts :: #{initial_access_token => binary(), _ => _}) ->
                         {ok, ClientInfo :: map()} | {error, term()}.

Variant of register_client/2 that accepts an options map. Currently the only option is initial_access_token (RFC 7591 section 3): an opaque bearer token issued out of band by the AS to gate registration. When present, the call adds Authorization: Bearer <token>.

registration_strategy(AsMetadata, Opts)

-spec registration_strategy(map(), map()) ->
                               {pre_registered, binary()} |
                               {client_id_metadata_document, binary()} |
                               {dynamic_registration, binary()} |
                               prompt_user.

Decide how to obtain a client_id for an authorization server.

AsMetadata is the document from discover_authorization_server/1. Opts says what this client already has:

  #{client_id              => binary(),   %% pre-registered
    client_id_metadata_url => binary()}   %% a CIMD document you host

The order is the specification's, and the reasons are worth keeping in mind when overriding it:

  1. {pre_registered, ClientId} when you already have one. It names a relationship that exists; nothing discovered can improve on that.
  2. {client_id_metadata_document, Url} when the server advertises client_id_metadata_document_supported and you host a document. The client_id is the URL itself, so there is no credential to store and none to go stale.
  3. {dynamic_registration, Endpoint} when the server offers one. Deprecated, kept for servers without CIMD, and the only branch that mints a credential you then have to keep.
  4. prompt_user when none of the above applies: the client cannot invent an identity, so a person has to supply one.

request_headers(H, Method, Url)

-spec request_headers(handle(), binary(), binary()) -> {[{binary(), binary()}], handle()}.

RFC 9449 4: a proof per request, bound to the method and URL, carrying the token's hash and the nonce the resource server asked for. Nothing without a DPoP key or before a token exists.

secure_url(Url, Opts)

-spec secure_url(binary(), map()) -> ok | {error, {insecure_url, binary()}}.

The HTTPS policy every authorization-server URL passes through: configured, discovered, or the discovery URL itself. Only allow_insecure_oauth => true in Opts lifts it.

settled(H)

-spec settled(handle()) -> handle().

The transport reports an accepted request. Nothing to reset: the retry budget lives with the request, not here.

token_exchange(TokenEndpoint, Params)

-spec token_exchange(binary(), map()) -> {ok, binary()} | {error, term()}.

RFC 8693 OAuth 2.0 Token Exchange. Used by the MCP ext-auth Enterprise-Managed Authorization extension to exchange an IdP-issued ID Token (or SAML assertion) for an Identity Assertion JWT Authorization Grant (the "ID-JAG"), scoped to a specific MCP server resource.

Returns {ok, IdJag} where IdJag is the binary token extracted from the response's access_token field, or an error describing the failure. A 4xx with invalid_grant surfaces the typed {error, subject_token_expired} (the RFC 8693 error semantic for an expired or revoked subject token).

validate_callback(Params, Expected)

-spec validate_callback(map(), map()) -> ok | {error, term()}.

Check an authorization response before redeeming its code.

Params is the query the authorization server sent back to the redirect URI. Expected carries the state this client generated, the issuer it recorded when it discovered the authorization server, and optionally that server's as_metadata document.

state must match, which is what ties the response to the request this client started. iss is then checked per RFC 9207, which exists because a client talking to several authorization servers can otherwise be handed a code minted by one of them at another's endpoint and cannot tell:

  • present, and the server's authorization_response_iss_parameter_supported is true: compared
  • present, and the server says nothing or false: compared
  • absent, and the server advertises it: rejected
  • absent, and the server says nothing or false: accepted

A present iss is compared whatever the metadata says, to accommodate servers that emit it before advertising it. An absent one is only fatal when the server said it would send one, which makes its absence a signal rather than an omission.

The comparison is exact. RFC 3986 normalisation (case folding, default-port elision, trailing slash, percent-encoding) must not be applied first, since each of those turns two distinct issuers into one.

Call this for error responses too: on mismatch the error, error_description and error_uri the response carries are not yours to act on or show, because you cannot tell who wrote them.