Urchin.Auth (Urchin v0.2.0)

Copy Markdown View Source

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 metadata resource field 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 implementing Urchin.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 function fn conn -> [scope] end for 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 :resource on the same origin (e.g. the bare origin) is accepted, so do not distinguish multiple servers by path alone on a shared origin. :skip performs 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). Default false.

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.

Like new!/1, but returns {:ok, auth} or {:error, message}.

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

kind()

@type kind() ::
  :missing
  | :invalid_token
  | :insufficient_scope
  | :invalid_request
  | :server_error

t()

@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()]
}

validator()

@type validator() ::
  module()
  | (token :: String.t() -> Urchin.Auth.TokenValidator.result())
  | (token :: String.t(), auth :: t() -> Urchin.Auth.TokenValidator.result())

Functions

challenge(auth, kind, message, required_scopes)

@spec challenge(t(), kind(), String.t(), [String.t()]) ::
  {100..599, String.t() | nil, map()}

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.

coerce!(auth)

@spec coerce!(t() | keyword() | map() | nil) :: t() | nil

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.

metadata_document(auth)

@spec metadata_document(t()) :: map()

Returns the RFC 9728 Protected Resource Metadata document as a JSON-encodable map.

new(opts)

@spec new(keyword() | map()) :: {:ok, t()} | {:error, String.t()}

Like new!/1, but returns {:ok, auth} or {:error, message}.

new!(opts)

@spec new!(keyword() | map()) :: t()

Builds an Urchin.Auth from options, raising ArgumentError on invalid input.

See the module documentation for the option list.

required_scopes(auth, conn)

@spec required_scopes(t(), term()) :: [String.t()]

Resolves the scopes required for a request (static list or fn conn -> [...] end).

resource_metadata_url(auth)

@spec resource_metadata_url(t()) :: String.t()

The absolute URL of the Protected Resource Metadata document (for resource_metadata).

verify_token(auth, token, required_scopes)

@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).

well_known_paths(auth)

@spec well_known_paths(t()) :: [String.t()]

The request paths at which the metadata document is served (canonical + root).