Urchin.Auth (Urchin v0.4.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 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 metadata resource field. 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 function fn conn -> [issuer] end, surfaced in the metadata document.
  • :authorizer (required) - a module implementing Urchin.Auth.Authorizer, or a 3-arity function fn 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 function fn conn -> [scope] end for 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, or fn conn -> url end, used in WWW-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). Default false.

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.

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

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

Types

authorization_servers()

@type authorization_servers() :: [String.t()] | (term() -> [String.t()])

authorizer()

@type authorizer() ::
  module()
  | (token :: String.t() | nil, auth :: t(), conn :: term() ->
       Urchin.Auth.Authorizer.result())

kind()

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

resource_metadata_url()

@type resource_metadata_url() :: String.t() | (term() -> String.t())

t()

@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

authorization_servers(auth, conn)

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

Resolves the configured Authorization Server issuer URLs for the request.

authorize(auth, token, conn)

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

challenge(auth, kind, message, conn)

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

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.

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, conn)

@spec metadata_document(t(), term()) :: 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, conn)

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

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

well_known_paths(auth, conn)

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

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