ConduitMcp.Plugs.OAuth (ConduitMCP v0.11.0)

Copy Markdown View Source

OAuth 2.1 authentication plug for MCP servers.

Validates JWT Bearer tokens against a configured authorization server. Implements the MCP specification's OAuth 2.1 Resource Server requirements.

Features

  • JWT signature verification via pluggable key providers
  • Issuer and audience validation
  • Mandatory exp (a token without one is rejected, not accepted forever) and nbf enforcement when present
  • Scope extraction and enforcement: a request lacking a tool's, resource's or prompt's declared scope is answered 403 with an insufficient_scope challenge (forbidden/4) so the client can step up
  • WWW-Authenticate headers per RFC 9728
  • Protected Resource Metadata endpoint support

Configuration

# Basic OAuth with JWKS auto-discovery
{ConduitMcp.Transport.StreamableHTTP,
  server_module: MyServer,
  auth: [
    strategy: :oauth,
    issuer: "https://auth.example.com",
    audience: "https://mcp.example.com",
    scopes_supported: ["read", "write", "admin"],
    key_provider: {ConduitMcp.OAuth.KeyProvider.JWKS,
      jwks_uri: "https://auth.example.com/.well-known/jwks.json"}
  ]}

# Static keys for testing
{ConduitMcp.Transport.StreamableHTTP,
  server_module: MyServer,
  auth: [
    strategy: :oauth,
    issuer: "test-issuer",
    audience: "test-audience",
    key_provider: {ConduitMcp.OAuth.KeyProvider.Static,
      keys: [test_key_map]}
  ]}

Token Claims and the principal

After successful validation the token claims are stored in conn.assigns[:oauth_claims] (or :assign_as) and the scopes in conn.assigns[:oauth_scopes].

The canonical ConduitMcp.Principal is assigned to conn.assigns[:mcp_principal], with :id built from the first claim in :subject_claims (default ["sub", "client_id"]) that holds a non-empty string or an integer, prefixed by that claim's name: "sub:user-123", "client_id:svc-42". The prefix keeps a client-chosen client_id from aliasing a user's sub (see "Id formats" in ConduitMcp.Principal). Task ownership and per-user rate limiting key on that scalar — never on the claims map, which changes every request (exp, iat, jti).

A verified token carrying none of those claims is rejected with

  1. sub is optional in a JWT and absent from many client-credentials access tokens, and assigning id: nil would produce an authenticated principal that every consumer reads as anonymous: tasks created by that caller would be unowned (visible to every caller unless :tasks_require_owner is set), and rate limiting would fall back to the shared IP bucket. Point :subject_claims at whatever your authorization server does emit rather than accepting an unidentifiable bearer.

Telemetry

Every verification emits [:conduit_mcp, :auth, :verify] with a :duration measurement and %{strategy: :oauth, status: :ok | :error} metadata. A failure adds :reason, always one of these atoms — never the token, a header value, or a key provider's error term:

reasonMeaning
:expiredexp is in the past, or absent (it is mandatory)
:not_yet_validnbf is in the future, or malformed
:invalid_issueriss does not equal :issuer
:invalid_audienceaud does not equal or contain :audience
:invalid_signaturethe signature did not verify
:malformed_tokenthe token or its header could not be decoded
:missing_algthe header has no alg
:alg_not_allowedthe header alg is not in :algorithms
:alg_mismatchthe header alg does not fit the signing key
:unsupported_key_typethe signing key has no usable kty
:invalid_keythe signing key is malformed
:key_not_foundthe key provider has no key for the token's kid
:key_unavailablethe key provider returned an error
:missing_subjectthe token has none of the :subject_claims
:verification_failedany other verification failure

A failure without its own 401 message ("Token verification failed") also logs its detailed reason as a Logger warning, clamped and stripped of control characters.

Summary

Functions

Sends a 403 Forbidden response with an insufficient_scope Bearer challenge (RFC 6750 §3.1) naming required_scopes, which is what lets a client step up its authorization.

Checks if the authenticated request has the required scope. Returns true if the scope is present, false otherwise.

Checks if the authenticated request has all required scopes.

Functions

forbidden(conn, opts, required_scopes, body \\ nil)

@spec forbidden(Plug.Conn.t(), map(), [String.t()], map() | nil) :: Plug.Conn.t()

Sends a 403 Forbidden response with an insufficient_scope Bearer challenge (RFC 6750 §3.1) naming required_scopes, which is what lets a client step up its authorization.

Both transports call this when a request fails a tool, resource or prompt scope under strategy: :oauth, passing the JSON-RPC error response as body so the client can still correlate it. body defaults to %{"error" => "Forbidden", "message" => "Insufficient scope. Required: ..."}.

has_scope?(conn, required_scope)

Checks if the authenticated request has the required scope. Returns true if the scope is present, false otherwise.

has_scopes?(conn, required_scopes)

Checks if the authenticated request has all required scopes.