AttestoMCP.Plug.ProtectResource (AttestoMCP v1.3.0)

Copy Markdown View Source

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)
end

authenticate/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 to AttestoMCP.Plug.RequireScopes. At least one scope is required by init/1; omit this option from prepare/1 and supply the classified list to authorize/3 instead.
  • :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, its acr / auth_time claims must satisfy the requirement or the request is refused 401 insufficient_user_authentication, naming the acr_values / max_age the client must re-request at the authorization endpoint. A token from a machine grant (no auth_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 9728 resource_metadata auth-param appended to WWW-Authenticate challenges, derived from the live request origin via AttestoMCP.Metadata.protected_resource_url/2. Both names mean the same thing; :resource reads naturally here while :resource_path matches AttestoMCP.Plug.Authenticate.
  • :base_url (or :origin) - pin the origin of the resource_metadata challenge URL behind a TLS-terminating proxy (a String.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 metadata resource and closes the X-Forwarded-Host spoofing vector. See guides/proxy_origin.md.
  • :resource_audience - confines access tokens to this protected resource; :resource derives 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

protection()

@opaque protection()

An opaque, prepared protected-resource boundary.

Functions

authenticate(conn, protection)

@spec authenticate(Plug.Conn.t(), protection() | map()) :: Plug.Conn.t()

Authenticates a request through a prepared ProtectResource boundary.

authorize(conn, protection, scopes)

@spec authorize(Plug.Conn.t(), protection(), [String.t()]) :: Plug.Conn.t()

Enforces dynamically selected scopes after authenticate/2 and before dispatch.

prepare(opts)

@spec prepare(keyword()) :: protection()

Prepares an authentication-first boundary whose scopes are supplied to authorize/3.