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) andnbfenforcement when present - Scope extraction and enforcement: a request lacking a tool's,
resource's or prompt's declared
scopeis answered 403 with aninsufficient_scopechallenge (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
subis optional in a JWT and absent from many client-credentials access tokens, and assigningid: nilwould 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_owneris set), and rate limiting would fall back to the shared IP bucket. Point:subject_claimsat 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:
reason | Meaning |
|---|---|
:expired | exp is in the past, or absent (it is mandatory) |
:not_yet_valid | nbf is in the future, or malformed |
:invalid_issuer | iss does not equal :issuer |
:invalid_audience | aud does not equal or contain :audience |
:invalid_signature | the signature did not verify |
:malformed_token | the token or its header could not be decoded |
:missing_alg | the header has no alg |
:alg_not_allowed | the header alg is not in :algorithms |
:alg_mismatch | the header alg does not fit the signing key |
:unsupported_key_type | the signing key has no usable kty |
:invalid_key | the signing key is malformed |
:key_not_found | the key provider has no key for the token's kid |
:key_unavailable | the key provider returned an error |
:missing_subject | the token has none of the :subject_claims |
:verification_failed | any 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
@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: ..."}.
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.