barrel_mcp_client_auth_oauth (barrel_mcp v3.0.0)
View SourceOAuth 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:
resourceindicator 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:
- **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. - **
barrel_mcp_client_authbehaviour implementation** that attaches theAuthorization: Bearer ...header on every outgoing request and refreshes the token automatically on 401 (when arefresh_tokenwas 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-Authenticateparsing, PRM, AS metadata, the HTTPS policysecure_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").
discover_authorization_server/1 with options (allow_insecure_oauth).
Fetch and parse the Protected Resource Metadata document.
discover_protected_resource/1 with options (allow_insecure_oauth).
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
-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().
-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()}.
-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 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.
-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 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.
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.
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.
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").
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.
Fetch and parse the Protected Resource Metadata document.
discover_protected_resource/1 with options (allow_insecure_oauth).
Exchange an authorization code for tokens.
-spec gen_code_verifier() -> binary().
Generate a 64-byte random URL-safe code verifier (RFC 7636).
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.
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.
-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
-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>.
-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 hostThe order is the specification's, and the reasons are worth keeping in mind when overriding it:
{pre_registered, ClientId}when you already have one. It names a relationship that exists; nothing discovered can improve on that.{client_id_metadata_document, Url}when the server advertisesclient_id_metadata_document_supportedand you host a document. Theclient_idis the URL itself, so there is no credential to store and none to go stale.{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.prompt_userwhen none of the above applies: the client cannot invent an identity, so a person has to supply one.
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.
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).
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_supportedistrue: 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.