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 validates inbound access tokens 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"],
token_validator: &MyApp.Tokens.validate/1
)
# 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 is free of Plug.Conn: it builds the
metadata document and the WWW-Authenticate challenges, and it runs token validation
(verify_token/3). The plugs and the transport glue it to HTTP.
Options (new!/1)
:resource(required) - the canonical server URI, e.g."https://mcp.example.com/mcp". Used as the metadataresourcefield and as the expected token audience (RFC 8707). MUST be absolute and MUST NOT carry a fragment.:authorization_servers(required) - a non-empty list of AS issuer URLs, surfaced verbatim in the metadata document.:token_validator(required) - a module implementingUrchin.Auth.TokenValidator, or a 1-/2-arity function. See that module for the contract.:scopes_supported- optional list of scopes advertised in the metadata document.:required_scopes- scopes every request must carry. A list, or a 1-arity functionfn conn -> [scope] endfor per-request requirements. Default[].:audience_validation-:auto(default) enforces RFC 8707 binding: the token must carry an audience that includes:resource, and a token with no audience is rejected. An audience that is a parent path of:resourceon the same origin (e.g. the bare origin) is accepted, so do not distinguish multiple servers by path alone on a shared origin.:skipperforms no audience check and defers entirely to the validator (use it for opaque tokens whose audience the validator verifies itself).:bearer_methods_supported- default["header"](MCP requires header tokens).:resource_name,:jwks_uri,:resource_documentation- optional metadata fields.:metadata- a map of extra RFC 9728 fields merged into the document last.:allow_insecure_authorization_servers- permit non-HTTPS issuer URLs (localhost is always allowed). Defaultfalse.
Summary
Functions
Builds the HTTP response for a failed verify_token/3.
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).
Validates a bearer token (or its absence) against this configuration.
The request paths at which the metadata document is served (canonical + root).
Types
@type kind() ::
:missing
| :invalid_token
| :insufficient_scope
| :invalid_request
| :server_error
@type t() :: %Urchin.Auth{ audience_validation: :auto | :skip, authorization_servers: [String.t()], 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: String.t(), resource_name: String.t() | nil, resource_uri: URI.t(), scopes_supported: [String.t()] | nil, token_validator: {:module, module()} | {:fun, fun(), 1 | 2}, well_known_paths: [String.t()] }
@type validator() :: module() | (token :: String.t() -> Urchin.Auth.TokenValidator.result()) | (token :: String.t(), auth :: t() -> Urchin.Auth.TokenValidator.result())
Functions
Builds the HTTP response for a failed verify_token/3.
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.
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).
@spec verify_token(t(), String.t() | nil, [String.t()]) :: {:ok, Urchin.Auth.Claims.t()} | {:error, kind(), String.t()}
Validates a bearer token (or its absence) against this configuration.
Runs the validator, then enforces token expiry (when exposed), audience binding
(RFC 8707) and required_scopes. Returns {:ok, claims} or {:error, kind, message},
where kind selects the challenge (see challenge/4).
The request paths at which the metadata document is served (canonical + root).