OAuth 2.1 Resource Server configuration and logic for the MCP authorization spec (revision 2025-11-25).
Urchin acts purely as an OAuth 2.1 Resource Server (RS): it delegates inbound access-token decisions to an injected authorizer and advertises the location of its Authorization Server(s) through RFC 9728 Protected Resource Metadata. The Authorization Server - the token, authorization and registration endpoints, PKCE, consent - is out of scope and may be any external entity.
Authorization is optional and off by default. A transport mounted without :auth
serves MCP unauthenticated, exactly as before. Pass an Urchin.Auth (or a keyword
list coerced into one) to turn it on:
auth =
Urchin.Auth.new!(
resource: "https://mcp.example.com/mcp",
authorization_servers: ["https://auth.example.com"],
scopes_supported: ["mcp:tools", "files:read", "files:write"],
authorizer: &MyApp.Auth.authorize/3
)
# one-call runner (also serves the well-known metadata endpoint):
Urchin.start_link(MyServer, port: 4000, path: "/mcp", auth: auth)
# or mounted as a Plug pipeline:
plug Urchin.Auth.Metadata, auth: auth
plug Urchin.Auth.Plug, auth: auth
forward "/mcp", to: Urchin.Transport.StreamableHTTP, init_opts: [server: MyServer]This module is the single source of truth: it builds the metadata document and the
WWW-Authenticate challenges, and it invokes an injected authorizer. Request context is
passed through as an opaque conn term so tenant/realm-aware callbacks can resolve
per-request authorization data.
Options (new!/1)
:resource(required) - the canonical server URI, e.g."https://mcp.example.com/mcp". Used as the metadataresourcefield. The configured authorizer should enforce this as the token audience/resource binding when RFC 8707 applies. MUST be absolute and MUST NOT carry a fragment.:authorization_servers(required) - a non-empty list of AS issuer URLs, or a 1-arity functionfn conn -> [issuer] end, surfaced in the metadata document.:authorizer(required) - a module implementingUrchin.Auth.Authorizer, or a 3-arity functionfn token, auth, conn -> result end. It owns the full authorization decision: token validity, issuer, expiry, audience, scopes and tenant policy.:scopes_supported- optional list of scopes advertised in the metadata document.:required_scopes- scopes every request should carry. A list, or a 1-arity functionfn conn -> [scope] endfor per-request requirements. Urchin uses this for challenge hints; the authorizer decides whether and how to enforce it. Default[].:bearer_methods_supported- default["header"](MCP requires header tokens).:resource_name,:jwks_uri,:resource_documentation- optional metadata fields.:resource_metadata_url- optional absolute URL, orfn conn -> url end, used inWWW-Authenticate: resource_metadata. Defaults to the RFC 9728 well-known URL for:resource. Use a resolver to preserve tenant context in the challenge, e.g. by adding a query parameter (?realm=...). The metadata document is served only at the static well-known paths derived from:resource, so a resolver that points at a different path (e.g. a per-tenant path segment) must be served by your own route or an external host.:metadata- a map of extra RFC 9728 fields. Reserved fields managed by Urchin cannot be overridden.:allow_insecure_authorization_servers- permit non-HTTPS issuer URLs (localhost is always allowed). Defaultfalse.
Summary
Functions
Resolves the configured Authorization Server issuer URLs for the request.
Authorizes a bearer token (or its absence) against this configuration.
Builds the HTTP response for a failed authorization decision.
Coerces a transport/plug :auth option into an Urchin.Auth (or nil when disabled).
Returns the RFC 9728 Protected Resource Metadata document as a JSON-encodable map.
Builds an Urchin.Auth from options, raising ArgumentError on invalid input.
Resolves the scopes required for a request (static list or fn conn -> [...] end).
The absolute URL of the Protected Resource Metadata document (for resource_metadata).
The request paths at which the metadata document is served (canonical + root).
Types
@type authorizer() :: module() | (token :: String.t() | nil, auth :: t(), conn :: term() -> Urchin.Auth.Authorizer.result())
@type kind() ::
:missing
| :invalid_token
| :insufficient_scope
| :invalid_request
| :server_error
@type t() :: %Urchin.Auth{ allow_insecure_authorization_servers: boolean(), authorization_servers: authorization_servers(), authorizer: {:module, module()} | {:fun, fun()}, bearer_methods_supported: [String.t()], jwks_uri: String.t() | nil, metadata: map(), required_scopes: [String.t()] | (term() -> [String.t()]), resource: String.t(), resource_documentation: String.t() | nil, resource_metadata_url: resource_metadata_url(), resource_name: String.t() | nil, resource_uri: URI.t(), scopes_supported: [String.t()] | nil, well_known_paths: [String.t()] }
Functions
Resolves the configured Authorization Server issuer URLs for the request.
@spec authorize(t(), String.t() | nil, term()) :: {:ok, Urchin.Auth.Claims.t()} | {:error, kind(), String.t()}
Authorizes a bearer token (or its absence) against this configuration.
Delegates the final decision to the configured authorizer. Returns {:ok, claims} or
{:error, kind, message}, where kind selects the challenge (see challenge/5).
Builds the HTTP response for a failed authorization decision.
Returns {status, www_authenticate, body} where www_authenticate is the header
string (or nil for 500) and body is the OAuth 2.0 error object. The scope hint and
resource_metadata URL are resolved here from the (possibly per-request) configuration;
a resolver that raises degrades the header to a minimal challenge rather than escalating
the response to a 500 with no WWW-Authenticate.
Coerces a transport/plug :auth option into an Urchin.Auth (or nil when disabled).
Accepts an existing struct, a keyword list / map of options, or nil.
Returns the RFC 9728 Protected Resource Metadata document as a JSON-encodable map.
Like new!/1, but returns {:ok, auth} or {:error, message}.
Builds an Urchin.Auth from options, raising ArgumentError on invalid input.
See the module documentation for the option list.
Resolves the scopes required for a request (static list or fn conn -> [...] end).
The absolute URL of the Protected Resource Metadata document (for resource_metadata).
The request paths at which the metadata document is served (canonical + root).