Noizu.MCP.Auth.Server (Noizu MCP v0.1.6)

Copy Markdown View Source

OAuth 2.1 authorization-server facade for MCP hosts.

MCP clients need an authorization server that speaks RFC 7591 dynamic client registration or client-id metadata documents. Most enterprise IdPs — Authentik among them — do not. This facade sits in front of the IdP you already have: it owns the OAuth client registry, consent, authorization codes, and token issuance, and delegates authenticating the human to the host's existing login. The IdP never sees an MCP client, and no upstream token is ever passed through to a downstream caller.

Wiring it up

# lib/my_app_web/mcp_config.ex
def as_opts do
  Noizu.MCP.Auth.Server.config(
    issuer: issuer(),                        # origin, NO path
    store: {Noizu.MCP.Auth.Server.Store.Ecto, repo: MyApp.Repo},
    signing: {:hs256, {MyApp.MCPAuth, :secret}},
    scopes_supported: ["mcp"],
    resources: [
      [resource: issuer() <> "/mcp", name: "MyApp"],
      [resource: issuer() <> "/mcp/learning", name: "MyApp Learning"]
    ],
    dcr: [enabled: true, allowed_redirect_hosts: ["claude.ai"]],
    cimd: [enabled: true],
    upstream: {Noizu.MCP.Auth.Server.Upstream.HostSession,
               current_subject: {MyApp.Auth.MCPBridge, :current_subject},
               login_url: {MyApp.Auth.MCPBridge, :login_url}},
    api_keys: [validator: {MyApp.MCPKeys, :verify}]
  )
end

# router.ex
scope "/" do
  forward "/.well-known/oauth-authorization-server",
          Noizu.MCP.Auth.Server.MetadataPlug, MCPConfig.as_opts()
  forward "/.well-known/openid-configuration",
          Noizu.MCP.Auth.Server.MetadataPlug, MCPConfig.as_opts()
end

scope "/oauth" do
  pipe_through :browser_session          # NOT require_authenticated
  forward "/", Noizu.MCP.Auth.Server.Router, MCPConfig.as_opts()
end

scope "/api/mcp" do
  pipe_through :api                      # must skip protect_from_forgery
  forward "/token", Noizu.MCP.Auth.Server.ApiKeyTokenPlug, MCPConfig.as_opts()
end

/oauth/authorize needs the session pipeline (it redirects a browser through your login and renders a consent form) but must not require an authenticated user — resolving that is the flow's job. /oauth/token, /register and /revoke are called by a machine with no cookie and must skip CSRF protection.

Options

  • :issuer (required) — an origin with no path. Collapses the RFC 8414 path-insertion/suffix ambiguity to one URL.
  • :store (required) — {module, opts}, see Noizu.MCP.Auth.Server.Store.
  • :signing (required) — {:hs256, secret} where secret is a binary, {mod, fun} or 0-arity fun; or {:rs256, jwk: ..., kid: ...}, which also publishes a JWKS document.
  • :resources — the mounts this server may mint tokens for, each [resource: uri, name: ..., scopes: [...]]. An RFC 8707 resource outside this list is invalid_target. With one entry it is the default audience.
  • :resource_required — require the resource parameter rather than defaulting to the single configured mount. Default false.
  • :scopes_supported / :default_scope.
  • :access_token_ttl — seconds, default and maximum 900. A longer value is clamped: with no access-token table there is no way to revoke early.
  • :refresh_token_ttl (default 30 days), :refresh_family_ttl (default 90 days, nil for none) — the ceiling rotation cannot extend.
  • :authorization_code_ttl (default 60), :login_state_ttl (default 600).
  • :dcr[enabled: false, allowed_redirect_hosts: [...], initial_access_token: {mod, fun}].
  • :cimd[enabled: false, fetcher: {mod, opts}, ttl: 3600].
  • :upstream{module, opts}, see Noizu.MCP.Auth.Server.Upstream. Defaults to Upstream.HostSession, which reuses the host's own login.
  • :consent[enabled: true, renderer: {mod, fun}, skip_for: [...]]. Consent is required for DCR and CIMD clients; enabled: false only affects preconfigured ones.
  • :api_keys — options for ApiKeyTokenPlug, which trades a host API key for an access token. Omit to disable that endpoint.
  • :rate_limit{mod, fun} or 3-arity fun called as (endpoint, conn, config) returning :ok | {:error, retry_after_seconds}. The limiter itself is host-owned.
  • :track_access_tokens — persist a row per access token, enabling immediate revocation. Default false.
  • :paths — override any endpoint path.
  • :extra_metadata — merged into the metadata document.

Summary

Functions

Whether client-id metadata documents are accepted.

Whether dynamic client registration is enabled.

Invoke the host's rate-limit hook for an endpoint. :ok when no hook is set — the library ships the seam, the host ships the limiter (both apps have Hammer).

Resolve a client_id to a client: a stored one, or a CIMD document fetched and cached on the spot.

Resolve the RFC 8707 resource a request asked for.

Functions

cimd_enabled?(config)

@spec cimd_enabled?(Noizu.MCP.Auth.Server.Config.t()) :: boolean()

Whether client-id metadata documents are accepted.

config(config)

Build and validate a Noizu.MCP.Auth.Server.Config.

Raises ArgumentError on a misconfiguration. That is deliberate: every failure mode here (a path in the issuer, an unknown resource, a missing signing key) presents in production as every client refusing to authenticate, with nothing in the logs. Better to fail at boot.

dcr_enabled?(config)

@spec dcr_enabled?(Noizu.MCP.Auth.Server.Config.t()) :: boolean()

Whether dynamic client registration is enabled.

rate_limit(config, endpoint, conn)

@spec rate_limit(Noizu.MCP.Auth.Server.Config.t(), atom(), term()) ::
  :ok | {:error, non_neg_integer()}

Invoke the host's rate-limit hook for an endpoint. :ok when no hook is set — the library ships the seam, the host ships the limiter (both apps have Hammer).

resolve_client(config, client_id)

Resolve a client_id to a client: a stored one, or a CIMD document fetched and cached on the spot.

{:error, %Errors{}} for anything unresolvable — and the caller must render that error rather than redirect, since without a resolved client there is no redirect_uri it is safe to send anything to.

resolve_resource(config, requested)

@spec resolve_resource(Noizu.MCP.Auth.Server.Config.t(), String.t() | nil) ::
  {:ok, String.t() | nil} | {:error, :invalid_target}

Resolve the RFC 8707 resource a request asked for.

An absent resource falls back to the single configured mount, which is how a client that predates resource indicators still works. An absent resource with several mounts configured is an error rather than a guess — guessing hands out a token for the wrong audience.

A resource may narrow to a configured mount. It can never widen: the list is the ceiling.