Protect an HTTP MCP endpoint in one plug.
The MCP authorization spec treats a protected HTTP MCP server as an OAuth
resource server (RFC 9728). Guarding such an endpoint correctly takes two
ordered steps: authenticate the access token (and any DPoP/mTLS sender
constraint), then enforce the scopes the route requires. ProtectResource
composes AttestoMCP.Plug.Authenticate followed by
AttestoMCP.Plug.RequireScopes into a single, correctly ordered,
halt-respecting pipeline so the host does not hand-wire and re-order the two
plugs (and the WWW-Authenticate resource_metadata challenge) on every
route.
plug AttestoMCP.Plug.ProtectResource,
config: &MyApp.Attesto.config/0,
replay_check: &MyApp.DPoPReplay.check_and_record/2,
resource: "/mcp",
scopes: [AttestoMCP.Scopes.tools_call()]init/1 returns no closures, so the plug works as a compile-time router
pipeline plug (plug_init_mode: :compile, the production default). As Plug
requires under :compile mode, any callback the host passes (:config,
:replay_check, :send_error, …) must be a remote capture (&Mod.fun/n) or
an MFA tuple ({Mod, :fun}), not an anonymous fn.
This is exactly equivalent to:
plug AttestoMCP.Plug.Authenticate,
config: &MyApp.Attesto.config/0,
replay_check: &MyApp.DPoPReplay.check_and_record/2,
resource_path: "/mcp"
plug AttestoMCP.Plug.RequireScopes,
scopes: [AttestoMCP.Scopes.tools_call()]Dynamically classified requests
Some protected resources cannot know the required scope until a bounded request envelope has been classified. For that case, use the explicit two-phase API:
protection =
AttestoMCP.Plug.ProtectResource.prepare(
config: &MyApp.Attesto.config/0,
resource: "/mcp"
)
conn = AttestoMCP.Plug.ProtectResource.authenticate(conn, protection)
if conn.halted do
conn
else
# Classify only bounded, side-effect-free request metadata here.
scopes = scopes_for_request(conn)
conn = AttestoMCP.Plug.ProtectResource.authorize(conn, protection, scopes)
if conn.halted, do: conn, else: dispatch(conn)
endauthenticate/2 performs the token and sender-constraint verification once.
It also binds the authenticated connection to that prepared boundary, so
authorize/3 rejects a connection authenticated by a different boundary or
one carrying only pre-existing assigns. authorize/3 then consumes the
verified assigns and enforces the supplied scopes through RequireScopes.
An empty scope list means the classified operation requires authentication
but no additional OAuth scope. The host MUST call authorize/3 before
dispatch; a prepared split boundary is deliberately not accepted by call/2.
Options
:scopes(or:scope) - the scope(s) a normal Plug route requires, forwarded toAttestoMCP.Plug.RequireScopes. At least one scope is required byinit/1; omit this option fromprepare/1and supply the classified list toauthorize/3instead.:step_up- an optional RFC 9470 step-up requirement for the route ([acr_values: ["phr"], max_age: 300]or an%Attesto.StepUp.Requirement{}). After the token is verified, itsacr/auth_timeclaims must satisfy the requirement or the request is refused 401insufficient_user_authentication, naming theacr_values/max_agethe client must re-request at the authorization endpoint. A token from a machine grant (noauth_time) always challenges a freshness requirement, so step-up routes are for end-user grants.:resource(or:resource_path) - the MCP endpoint path, for example"/mcp"or"/mcp/brokers". It drives the RFC 9728resource_metadataauth-param appended toWWW-Authenticatechallenges, derived from the live request origin viaAttestoMCP.Metadata.protected_resource_url/2. Both names mean the same thing;:resourcereads naturally here while:resource_pathmatchesAttestoMCP.Plug.Authenticate.:base_url(or:origin) - pin the origin of theresource_metadatachallenge URL behind a TLS-terminating proxy (aString.t()or(conn -> url)), instead of trusting the proxy-rewritten request. When omitted, the live request origin is used. This keeps the challenge URL aligned with a pinned metadataresourceand closes theX-Forwarded-Hostspoofing vector. Seeguides/proxy_origin.md.:resource_audience- confines access tokens to this protected resource;:resourcederives its audience identifier from the resource path and resolved origin. A scalar token audience must match, and every member of an array-valued audience must match. This option is mutually exclusive with Attesto core's:trusted_audiences; configuring both raises.
Every other option is passed through to AttestoMCP.Plug.Authenticate:
:config, :replay_check, :nonce_check, :nonce_issue, :cert_der,
:trusted_audiences, :htu, :credential_from_conn, :bearer_methods, :send_error,
:www_authenticate, :no_store, :principal, :principal_key,
:claims_key, :scopes_key, :sender_key, and :resource_metadata_url.
MCP defaults to bearer_methods: [:header]; set
bearer_methods: [:header, :body] only when the resource intentionally
accepts RFC 6750 §2.2 form-body access tokens. The transport hooks
(:send_error, :www_authenticate, :no_store) and the assign keys
(:claims_key, :scopes_key) are also shared with
AttestoMCP.Plug.RequireScopes so a scope rejection renders through the same
host-controlled error envelope.
Summary
Types
An opaque, prepared protected-resource boundary.
Functions
Authenticates a request through a prepared ProtectResource boundary.
Enforces dynamically selected scopes after authenticate/2 and before dispatch.
Prepares an authentication-first boundary whose scopes are supplied to authorize/3.
Types
Functions
@spec authenticate(Plug.Conn.t(), protection() | map()) :: Plug.Conn.t()
Authenticates a request through a prepared ProtectResource boundary.
@spec authorize(Plug.Conn.t(), protection(), [String.t()]) :: Plug.Conn.t()
Enforces dynamically selected scopes after authenticate/2 and before dispatch.
@spec prepare(keyword()) :: protection()
Prepares an authentication-first boundary whose scopes are supplied to authorize/3.