v0.151.2
  1"""OAuth 2.1 resource-server support for MCP endpoints (RFC 9728).
  2
  3`plain.mcp` stays agnostic about who issues tokens — implement
  4`authenticate_token` to validate the bearer against your authorization server
  5(for example `plain.oauthserver.validate_access_token`). This is the
  6server side of the flow an MCP client like Claude's custom connector runs:
  7an unauthenticated request gets a 401 whose `WWW-Authenticate` header points
  8at the protected-resource metadata, which names the authorization server.
  9"""
 10
 11from __future__ import annotations
 12
 13from dataclasses import dataclass, field
 14from typing import Any
 15from urllib.parse import urlparse, urlunparse
 16
 17from plain.http import JsonResponse, Request, Response
 18from plain.views import View
 19
 20from .exceptions import MCPUnauthorized
 21
 22_WELL_KNOWN_PRM = "/.well-known/oauth-protected-resource"
 23
 24
 25def _canonical_resource(url: str) -> str:
 26    """Canonical audience identifier: lowercase scheme + host, no trailing slash.
 27
 28    Keeps the advertised `resource` and the validated audience byte-identical
 29    regardless of trailing-slash config or client-side normalization (RFC 8707).
 30    """
 31    parsed = urlparse(url)
 32    return urlunparse(
 33        parsed._replace(
 34            scheme=parsed.scheme.lower(),
 35            netloc=parsed.netloc.lower(),
 36            path=parsed.path.rstrip("/"),
 37        )
 38    )
 39
 40
 41@dataclass
 42class TokenInfo:
 43    """Who a validated bearer token belongs to, and what it may do."""
 44
 45    user: Any
 46    scopes: frozenset[str] = field(default_factory=frozenset)
 47
 48
 49def _bearer_token(request: Request) -> str:
 50    header = request.headers.get("Authorization", "")
 51    scheme, _, value = header.partition(" ")
 52    # RFC 7235: the auth-scheme is matched case-insensitively.
 53    return value.strip() if scheme.lower() == "bearer" else ""
 54
 55
 56class OAuthResourceServer:
 57    """Mixin for an `MCPView` that authenticates requests with OAuth bearer tokens.
 58
 59    Subclass alongside `MCPView` and implement `authenticate_token`:
 60
 61        class AppMCP(OAuthResourceServer, MCPView):
 62            name = "myapp"
 63            tools = [...]
 64
 65            def authenticate_token(self, token):
 66                at = validate_access_token(token, resource=self.oauth_resource)
 67                return TokenInfo(at.user, at.scopes) if at else None
 68
 69    Name the authorization server on the companion `MCPProtectedResourceView`,
 70    not here. On success `self.user` and `self.scopes` are set for tools to read
 71    via `self.mcp`. On failure the request gets a 401 with an RFC 9728
 72    `WWW-Authenticate` challenge pointing at the protected-resource metadata.
 73    """
 74
 75    user: Any = None
 76    scopes: frozenset[str] = frozenset()
 77
 78    # Provided by the MCPView this mixin is combined with.
 79    request: Request
 80
 81    def authenticate_token(self, token: str) -> TokenInfo | None:
 82        raise NotImplementedError
 83
 84    def before_request(self) -> None:
 85        token = _bearer_token(self.request)
 86        info = self.authenticate_token(token) if token else None
 87        if info is None:
 88            # RFC 6750: a token that was supplied but rejected is invalid_token;
 89            # a missing token just gets the bare discovery challenge.
 90            error = "invalid_token" if token else None
 91            raise MCPUnauthorized(
 92                "Authentication required", www_authenticate=self._challenge(error)
 93            )
 94        self.user = info.user
 95        self.scopes = info.scopes
 96
 97    @property
 98    def oauth_resource(self) -> str:
 99        """The canonical URI of this MCP endpoint — the token audience."""
100        return _canonical_resource(self.request.build_absolute_uri(self.request.path))
101
102    def _challenge(self, error: str | None = None) -> str:
103        metadata_url = self.request.build_absolute_uri(
104            _WELL_KNOWN_PRM + self.request.path
105        )
106        challenge = f'Bearer resource_metadata="{metadata_url}"'
107        if error:
108            challenge += f', error="{error}"'
109        return challenge
110
111
112class MCPProtectedResourceView(View):
113    """Serves RFC 9728 protected-resource metadata for an MCP endpoint.
114
115    Mount at `.well-known/oauth-protected-resource/<mcp-path>` (the path the
116    `OAuthResourceServer` challenge points to). `authorization_servers` defaults
117    to this app's own origin — set it only when an external IdP issues the
118    tokens. The `resource` is derived from the request path, so one view can sit
119    in front of any MCP mount point.
120    """
121
122    authorization_servers: list[str] = []
123    oauth_scopes_supported: list[str] = []
124
125    def get(self) -> Response:
126        resource_path = self.request.path.removeprefix(_WELL_KNOWN_PRM) or "/"
127        # Same app issues the tokens by default; override for an external IdP.
128        origin = f"{self.request.scheme}://{self.request.host}"
129        servers = self.authorization_servers or [origin]
130        metadata: dict[str, Any] = {
131            "resource": _canonical_resource(
132                self.request.build_absolute_uri(resource_path)
133            ),
134            "authorization_servers": list(servers),
135            "bearer_methods_supported": ["header"],
136        }
137        if self.oauth_scopes_supported:
138            metadata["scopes_supported"] = list(self.oauth_scopes_supported)
139        return JsonResponse(metadata)