# `ConduitMcp.Plugs.OAuth`
[🔗](https://github.com/nyo16/conduit_mcp/blob/v0.11.0/lib/conduit_mcp/plugs/oauth.ex#L2)

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
401. `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:

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

# `forbidden`

```elixir
@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?`

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

# `has_scopes?`

Checks if the authenticated request has all required scopes.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
