Skip to main content

lib/urchin/auth.ex

defmodule Urchin.Auth do
  @moduledoc """
  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`.
  """

  require Logger

  alias Urchin.Auth.Claims

  @well_known "/.well-known/oauth-protected-resource"
  @allowed_options [
    :resource,
    :authorization_servers,
    :authorizer,
    :scopes_supported,
    :required_scopes,
    :bearer_methods_supported,
    :resource_name,
    :jwks_uri,
    :resource_documentation,
    :resource_metadata_url,
    :metadata,
    :allow_insecure_authorization_servers
  ]
  @deprecated_options [:token_validator, :audience_validation]
  @kinds [:missing, :invalid_token, :insufficient_scope, :invalid_request, :server_error]

  defstruct [
    :resource,
    :resource_uri,
    :resource_metadata_url,
    :resource_name,
    :jwks_uri,
    :resource_documentation,
    authorization_servers: [],
    authorizer: nil,
    scopes_supported: nil,
    required_scopes: [],
    well_known_paths: [],
    metadata: %{},
    bearer_methods_supported: ["header"],
    allow_insecure_authorization_servers: false
  ]

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

  @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 :: %__MODULE__{
          resource: String.t(),
          resource_uri: URI.t(),
          resource_metadata_url: resource_metadata_url(),
          resource_name: String.t() | nil,
          jwks_uri: String.t() | nil,
          resource_documentation: String.t() | nil,
          authorization_servers: authorization_servers(),
          authorizer: {:module, module()} | {:fun, fun()},
          scopes_supported: [String.t()] | nil,
          required_scopes: [String.t()] | (term() -> [String.t()]),
          well_known_paths: [String.t()],
          metadata: map(),
          bearer_methods_supported: [String.t()],
          allow_insecure_authorization_servers: boolean()
        }

  ## Construction

  @doc """
  Builds an `Urchin.Auth` from options, raising `ArgumentError` on invalid input.

  See the module documentation for the option list.
  """
  @spec new!(keyword() | map()) :: t()
  def new!(opts) when is_map(opts), do: new!(Map.to_list(opts))

  def new!(opts) when is_list(opts) do
    validate_options!(opts)

    resource = require_opt!(opts, :resource)
    resource_uri = parse_resource!(resource)

    allow_insecure = Keyword.get(opts, :allow_insecure_authorization_servers, false)

    servers =
      opts
      |> require_opt!(:authorization_servers)
      |> resolve_authorization_servers!(allow_insecure)

    authorizer = opts |> require_opt!(:authorizer) |> resolve_authorizer!()
    suffix = path_suffix(resource_uri)
    resource_metadata_url = resolve_resource_metadata_url!(opts, resource_uri, suffix)
    metadata = opts |> Keyword.get(:metadata, %{}) |> validate_metadata!()
    required_scopes = opts |> Keyword.get(:required_scopes, []) |> resolve_required_scopes!()

    %__MODULE__{
      resource: resource,
      resource_uri: resource_uri,
      resource_metadata_url: resource_metadata_url,
      resource_name: Keyword.get(opts, :resource_name),
      jwks_uri: Keyword.get(opts, :jwks_uri),
      resource_documentation: Keyword.get(opts, :resource_documentation),
      authorization_servers: servers,
      authorizer: authorizer,
      scopes_supported: Keyword.get(opts, :scopes_supported),
      required_scopes: required_scopes,
      well_known_paths: Enum.uniq([@well_known <> suffix, @well_known]),
      metadata: metadata,
      bearer_methods_supported: Keyword.get(opts, :bearer_methods_supported, ["header"]),
      allow_insecure_authorization_servers: allow_insecure
    }
  end

  @doc "Like `new!/1`, but returns `{:ok, auth}` or `{:error, message}`."
  @spec new(keyword() | map()) :: {:ok, t()} | {:error, String.t()}
  def new(opts) do
    {:ok, new!(opts)}
  rescue
    e in ArgumentError -> {:error, Exception.message(e)}
  end

  @doc """
  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`.
  """
  @spec coerce!(t() | keyword() | map() | nil) :: t() | nil
  def coerce!(nil), do: nil
  def coerce!(%__MODULE__{} = auth), do: auth
  def coerce!(opts) when is_list(opts) or is_map(opts), do: new!(opts)

  def coerce!(other) do
    raise ArgumentError,
          ":auth must be an %Urchin.Auth{}, a keyword list or map of options, or nil, " <>
            "got: #{inspect(other)}"
  end

  ## Metadata (RFC 9728 discovery)

  @doc "Returns the RFC 9728 Protected Resource Metadata document as a JSON-encodable map."
  @spec metadata_document(t(), term()) :: map()
  def metadata_document(%__MODULE__{} = auth, conn) do
    %{
      resource: auth.resource,
      authorization_servers: authorization_servers(auth, conn),
      bearer_methods_supported: auth.bearer_methods_supported
    }
    |> put_optional(:scopes_supported, auth.scopes_supported)
    |> put_optional(:resource_name, auth.resource_name)
    |> put_optional(:jwks_uri, auth.jwks_uri)
    |> put_optional(:resource_documentation, auth.resource_documentation)
    |> Map.merge(auth.metadata)
  end

  @doc "Resolves the configured Authorization Server issuer URLs for the request."
  @spec authorization_servers(t(), term()) :: [String.t()]
  def authorization_servers(%__MODULE__{authorization_servers: fun} = auth, conn)
      when is_function(fun, 1) do
    fun.(conn)
    |> normalize_authorization_servers!(auth.allow_insecure_authorization_servers)
  end

  def authorization_servers(%__MODULE__{authorization_servers: servers}, _conn), do: servers

  @doc "The absolute URL of the Protected Resource Metadata document (for `resource_metadata`)."
  @spec resource_metadata_url(t(), term()) :: String.t()
  def resource_metadata_url(%__MODULE__{resource_metadata_url: fun}, conn)
      when is_function(fun, 1) do
    fun.(conn)
    |> validate_resource_metadata_url!()
  end

  def resource_metadata_url(%__MODULE__{resource_metadata_url: url}, _conn), do: url

  @doc "The request paths at which the metadata document is served (canonical + root)."
  @spec well_known_paths(t(), term()) :: [String.t()]
  def well_known_paths(%__MODULE__{well_known_paths: paths}, _conn), do: paths

  ## Request-time helpers

  @doc "Resolves the scopes required for a request (static list or `fn conn -> [...] end`)."
  @spec required_scopes(t(), term()) :: [String.t()]
  def required_scopes(%__MODULE__{required_scopes: fun}, conn) when is_function(fun, 1) do
    fun.(conn) |> normalize_scopes!(":required_scopes resolver")
  end

  def required_scopes(%__MODULE__{required_scopes: list}, _conn) when is_list(list), do: list

  @doc """
  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`).
  """
  @spec authorize(t(), String.t() | nil, term()) ::
          {:ok, Claims.t()} | {:error, kind(), String.t()}
  def authorize(%__MODULE__{} = auth, token, conn) do
    case blank_to_nil(token) do
      nil ->
        # The SDK resolves a missing/blank bearer token, never the authorizer, so the
        # unauthenticated discovery bootstrap always gets the spec 401 challenge instead of a
        # 500 from an authorizer that lacks a nil clause.
        {:error, :missing, "Authorization required"}

      token ->
        auth.authorizer
        |> invoke_authorizer(token, auth, conn)
        |> normalize_result()
        |> warn_uncovered_resource(auth)
    end
  rescue
    error ->
      # A crashing authorizer must not leak internals or 200 a bad token; surface a 500.
      Logger.error("Urchin.Auth authorizer raised: #{Exception.message(error)}")
      {:error, :server_error, "Internal Server Error"}
  catch
    kind, reason ->
      Logger.error("Urchin.Auth authorizer threw: #{inspect({kind, reason})}")
      {:error, :server_error, "Internal Server Error"}
  end

  @doc """
  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`.
  """
  @spec challenge(t(), kind(), String.t(), term()) :: {100..599, String.t() | nil, map()}
  def challenge(%__MODULE__{} = auth, kind, message, conn) do
    {status_for(kind), safe_www_authenticate(auth, kind, message, conn),
     %{error: body_error_code(kind), error_description: message}}
  end

  ## Authorization pipeline

  defp invoke_authorizer({:module, mod}, token, auth, conn), do: mod.authorize(token, auth, conn)
  defp invoke_authorizer({:fun, fun}, token, auth, conn), do: fun.(token, auth, conn)

  defp normalize_result({:ok, %Claims{} = claims}), do: {:ok, claims}

  # Only a plain (non-struct) map is a token payload; a foreign struct is a programming error
  # and falls through to the unexpected-value clause instead of crashing inside from_map/1.
  defp normalize_result({:ok, map}) when is_map(map) and not is_struct(map),
    do: {:ok, Claims.from_map(map)}

  defp normalize_result({:error, kind, message})
       when kind in @kinds and is_binary(message),
       do: {:error, kind, message}

  defp normalize_result({:error, reason}), do: map_reason(reason)

  defp normalize_result(other) do
    Logger.error("Urchin.Auth authorizer returned an unexpected value: #{inspect(other)}")
    {:error, :server_error, "Internal Server Error"}
  end

  defp map_reason(:missing), do: {:error, :missing, "Authorization required"}
  defp map_reason(:expired), do: {:error, :invalid_token, "Token has expired"}
  defp map_reason(:invalid_audience), do: {:error, :invalid_token, "Token audience is invalid"}

  defp map_reason({:invalid_audience, _}),
    do: {:error, :invalid_token, "Token audience is invalid"}

  defp map_reason(:insufficient_scope), do: {:error, :insufficient_scope, "Insufficient scope"}

  defp map_reason({:insufficient_scope, _}),
    do: {:error, :insufficient_scope, "Insufficient scope"}

  defp map_reason(:invalid_request), do: {:error, :invalid_request, "Invalid request"}

  defp map_reason(reason) when reason in [:invalid_token, :invalid, :unauthorized],
    do: {:error, :invalid_token, "Invalid access token"}

  # A bare binary reason is a generic invalid-token rejection and is NOT reflected to the
  # client (it may carry internals). Use the {:error, kind, message} form to surface a
  # deliberately client-visible description.
  defp map_reason(message) when is_binary(message) do
    Logger.debug("Urchin.Auth authorizer rejected a token: #{message}")
    {:error, :invalid_token, "Invalid access token"}
  end

  defp map_reason(_other), do: {:error, :invalid_token, "Invalid access token"}

  defp warn_uncovered_resource({:ok, %Claims{audience: []} = claims}, _auth), do: {:ok, claims}

  defp warn_uncovered_resource({:ok, %Claims{} = claims}, auth) do
    unless Claims.covers_resource?(claims, auth.resource_uri) do
      Logger.warning(
        "Urchin.Auth authorizer returned claims whose audience does not cover #{auth.resource}"
      )
    end

    {:ok, claims}
  end

  defp warn_uncovered_resource(other, _auth), do: other

  ## WWW-Authenticate building

  # Per-request resolvers (`:resource_metadata_url`, `:required_scopes`) run while building
  # the error response. A raised or thrown resolver must not turn a 401/403 challenge into a
  # 500 with no header, so failures here degrade to a minimal but valid challenge.
  defp safe_www_authenticate(auth, kind, message, conn) do
    www_authenticate(auth, kind, message, safe_required_scopes(auth, conn), conn)
  rescue
    error ->
      Logger.error("Urchin.Auth challenge construction failed: #{Exception.message(error)}")
      minimal_challenge(kind)
  catch
    thrown, reason ->
      Logger.error("Urchin.Auth challenge construction threw: #{inspect({thrown, reason})}")
      minimal_challenge(kind)
  end

  # A failing scope resolver only costs the scope hint; keep the rest of the challenge.
  defp safe_required_scopes(auth, conn) do
    required_scopes(auth, conn)
  rescue
    error ->
      Logger.error(
        "Urchin.Auth :required_scopes resolver failed during challenge: #{Exception.message(error)}"
      )

      []
  catch
    thrown, reason ->
      Logger.error(
        "Urchin.Auth :required_scopes resolver threw during challenge: #{inspect({thrown, reason})}"
      )

      []
  end

  # Keep a failed challenge a valid 401/403 (never a 500 with no header) by dropping the
  # discovery hints rather than the whole response.
  defp minimal_challenge(:server_error), do: nil
  defp minimal_challenge(:missing), do: "Bearer"
  defp minimal_challenge(kind), do: build_bearer([{"error", body_error_code(kind)}])

  # No-credentials 401: per RFC 6750 ยง3.1 (and the MCP ยง6.1 example) the challenge omits
  # `error` when the request carried no authentication information.
  defp www_authenticate(auth, :missing, _message, scopes, conn) do
    build_bearer(
      [{"resource_metadata", resource_metadata_url(auth, conn)}] ++ scope_param(scopes)
    )
  end

  defp www_authenticate(auth, :invalid_token, message, scopes, conn) do
    build_bearer(
      [
        {"error", "invalid_token"},
        {"error_description", message},
        {"resource_metadata", resource_metadata_url(auth, conn)}
      ] ++ scope_param(scopes)
    )
  end

  defp www_authenticate(auth, :insufficient_scope, message, scopes, conn) do
    # The 403 SHOULD advertise the scopes needed for the request; if none were resolved for
    # this request (e.g. a validator-driven insufficient_scope), fall back to scopes_supported.
    hint = if scopes == [], do: auth.scopes_supported || [], else: scopes

    build_bearer(
      [{"error", "insufficient_scope"}] ++
        scope_param(hint) ++
        [
          {"resource_metadata", resource_metadata_url(auth, conn)},
          {"error_description", message}
        ]
    )
  end

  defp www_authenticate(auth, :invalid_request, message, _scopes, conn) do
    build_bearer([
      {"error", "invalid_request"},
      {"error_description", message},
      {"resource_metadata", resource_metadata_url(auth, conn)}
    ])
  end

  # Server errors are not the client's fault and carry no discovery hint.
  defp www_authenticate(_auth, :server_error, _message, _scopes, _conn), do: nil

  defp scope_param([]), do: []
  defp scope_param(scopes), do: [{"scope", Enum.join(scopes, " ")}]

  defp build_bearer(params) do
    "Bearer " <> Enum.map_join(params, ", ", fn {k, v} -> ~s(#{k}="#{escape(v)}") end)
  end

  # auth-param values are quoted-strings (RFC 7235). Strip control characters first (a
  # bare CR/LF would otherwise make put_resp_header raise and turn the 401 into a 500),
  # then escape backslash and double-quote.
  defp escape(value) do
    value
    |> String.replace(~r/[\x00-\x1f\x7f]/, " ")
    |> String.replace("\\", "\\\\")
    |> String.replace("\"", "\\\"")
  end

  defp status_for(:missing), do: 401
  defp status_for(:invalid_token), do: 401
  defp status_for(:insufficient_scope), do: 403
  defp status_for(:invalid_request), do: 400
  defp status_for(:server_error), do: 500

  defp body_error_code(:missing), do: "invalid_token"
  defp body_error_code(:invalid_token), do: "invalid_token"
  defp body_error_code(:insufficient_scope), do: "insufficient_scope"
  defp body_error_code(:invalid_request), do: "invalid_request"
  defp body_error_code(:server_error), do: "server_error"

  ## URI helpers

  # Shared acceptance rule for every configured URI: an absolute http(s) URI with a
  # non-empty host. Callers layer their own fragment/query rules and error messages on top.
  defp parse_http_uri(value) when is_binary(value) do
    case URI.new(value) do
      {:ok, %URI{scheme: scheme, host: host} = uri}
      when scheme in ["http", "https"] and is_binary(host) and host != "" ->
        {:ok, uri}

      _ ->
        :error
    end
  end

  defp parse_http_uri(_other), do: :error

  defp parse_resource!(resource) do
    case parse_http_uri(resource) do
      {:ok, uri} ->
        if uri.fragment, do: raise(ArgumentError, ":resource MUST NOT contain a fragment")
        uri

      :error ->
        raise ArgumentError,
              ":resource must be an absolute http(s) URI, got: #{inspect(resource)}"
    end
  end

  defp validate_issuer!(issuer, allow_insecure) do
    case parse_http_uri(issuer) do
      {:ok, uri} ->
        cond do
          uri.fragment ->
            raise ArgumentError,
                  "authorization server #{inspect(issuer)} MUST NOT contain a fragment"

          uri.query ->
            raise ArgumentError,
                  "authorization server #{inspect(issuer)} MUST NOT contain a query"

          uri.scheme == "http" and not (allow_insecure or localhost?(uri.host)) ->
            raise ArgumentError,
                  "authorization server #{inspect(issuer)} must be HTTPS " <>
                    "(set allow_insecure_authorization_servers: true to override)"

          true ->
            :ok
        end

      :error ->
        raise ArgumentError,
              "authorization server must be an absolute URI, got: #{inspect(issuer)}"
    end
  end

  defp resolve_authorization_servers!(fun, _allow_insecure) when is_function(fun, 1), do: fun

  defp resolve_authorization_servers!(servers, allow_insecure) do
    normalize_authorization_servers!(servers, allow_insecure)
  end

  defp normalize_authorization_servers!(servers, allow_insecure) do
    servers = List.wrap(servers)

    if servers == [],
      do: raise(ArgumentError, ":authorization_servers must list at least one issuer")

    Enum.each(servers, &validate_issuer!(&1, allow_insecure))
    servers
  end

  defp resolve_authorizer!(mod) when is_atom(mod) and not is_nil(mod) do
    if Code.ensure_loaded?(mod) and function_exported?(mod, :authorize, 3) do
      {:module, mod}
    else
      raise ArgumentError,
            ":authorizer module must implement authorize/3, got: #{inspect(mod)}"
    end
  end

  defp resolve_authorizer!(fun) when is_function(fun, 3), do: {:fun, fun}

  defp resolve_authorizer!(other) do
    raise ArgumentError,
          ":authorizer must be a module or a 3-arity function, got: #{inspect(other)}"
  end

  defp resolve_required_scopes!(fun) when is_function(fun, 1), do: fun

  defp resolve_required_scopes!(fun) when is_function(fun) do
    raise ArgumentError, ":required_scopes must be a list or a 1-arity function"
  end

  defp resolve_required_scopes!(scopes), do: normalize_scopes!(scopes, ":required_scopes")

  defp normalize_scopes!(scopes, name) do
    scopes = List.wrap(scopes)

    if Enum.all?(scopes, &is_binary/1) do
      scopes
    else
      raise ArgumentError, "#{name} must contain only strings, got: #{inspect(scopes)}"
    end
  end

  defp resolve_resource_metadata_url!(opts, resource_uri, suffix) do
    case Keyword.fetch(opts, :resource_metadata_url) do
      {:ok, fun} when is_function(fun, 1) ->
        fun

      {:ok, url} ->
        validate_resource_metadata_url!(url)

      :error ->
        build_metadata_url(resource_uri, suffix)
    end
  end

  defp validate_resource_metadata_url!(url) do
    case parse_http_uri(url) do
      {:ok, uri} ->
        if uri.fragment,
          do: raise(ArgumentError, ":resource_metadata_url MUST NOT contain a fragment")

        url

      :error ->
        raise ArgumentError,
              ":resource_metadata_url must be an absolute http(s) URI, got: #{inspect(url)}"
    end
  end

  # RFC 9728 ยง3.1 path insertion: the well-known suffix mirrors the resource path.
  defp path_suffix(%URI{path: path}) when path in [nil, "", "/"], do: ""
  defp path_suffix(%URI{path: path}), do: path

  # Rebuild from the parsed URI so IPv6 host bracketing, default-port elision and userinfo
  # stripping are handled by URI.to_string/1 (a hand-built authority mangles all three).
  defp build_metadata_url(%URI{} = uri, suffix) do
    %URI{uri | userinfo: nil, path: @well_known <> suffix, query: nil, fragment: nil}
    |> URI.to_string()
  end

  defp localhost?(host), do: host in ["localhost", "127.0.0.1", "::1", "[::1]"]

  ## Misc

  @reserved_metadata_keys ~w[
    resource
    authorization_servers
    bearer_methods_supported
    scopes_supported
    resource_name
    jwks_uri
    resource_documentation
  ]

  defp validate_metadata!(metadata) when is_map(metadata) do
    case Enum.find(Map.keys(metadata), &(to_string(&1) in @reserved_metadata_keys)) do
      nil ->
        metadata

      key ->
        raise ArgumentError, ":metadata cannot override reserved field #{inspect(key)}"
    end
  end

  defp validate_metadata!(other),
    do: raise(ArgumentError, ":metadata must be a map, got: #{inspect(other)}")

  defp blank_to_nil(""), do: nil
  defp blank_to_nil(token), do: token

  defp validate_options!(opts) do
    unless Keyword.keyword?(opts) do
      raise ArgumentError, "Urchin.Auth options must be a keyword list or an atom-keyed map"
    end

    case Enum.find(Keyword.keys(opts), &(&1 in @deprecated_options)) do
      nil ->
        :ok

      :token_validator ->
        raise ArgumentError, ":token_validator was removed; use :authorizer instead"

      :audience_validation ->
        raise ArgumentError,
              ":audience_validation was removed; enforce audience/resource binding in :authorizer"
    end

    case Enum.find(Keyword.keys(opts), &(&1 not in @allowed_options)) do
      nil -> :ok
      key -> raise ArgumentError, "unknown Urchin.Auth option #{inspect(key)}"
    end
  end

  defp require_opt!(opts, key) do
    case Keyword.fetch(opts, key) do
      {:ok, value} -> value
      :error -> raise ArgumentError, "Urchin.Auth requires the #{inspect(key)} option"
    end
  end

  defp put_optional(map, _key, nil), do: map
  defp put_optional(map, key, value), do: Map.put(map, key, value)
end