diff --git a/plain-mcp/plain/mcp/README.md b/plain-mcp/plain/mcp/README.md index 32e7d3da9b..113531da5a 100644 --- a/plain-mcp/plain/mcp/README.md +++ b/plain-mcp/plain/mcp/README.md @@ -488,7 +488,7 @@ Clients send the token in their config: ### OAuth for MCP clients -Hosted MCP clients (Claude's custom connectors, etc.) authenticate over OAuth 2.1 — they discover your authorization server, register, and complete a browser login, with no token to paste. Compose [`OAuthResourceServer`](./oauth.py#OAuthResourceServer) with `MCPView` and implement `authenticate_token` to validate the bearer against whatever issued it: +Hosted MCP clients (Claude's custom connectors, etc.) authenticate over OAuth 2.1 — they discover your authorization server, identify themselves, and complete a browser login, with no token to paste. Compose [`OAuthResourceServer`](./oauth.py#OAuthResourceServer) with `MCPView` and implement `authenticate_token` to validate the bearer against whatever issued it: ```python # app/mcp.py @@ -545,7 +545,7 @@ Behind the scenes the client drives the whole handshake — you don't write any 1. Calls your MCP endpoint with no token → gets the `401` + `WWW-Authenticate` challenge. 2. Reads the protected-resource metadata it points to → finds your authorization server. -3. Fetches the server's metadata (`/.well-known/oauth-authorization-server`) and **registers itself** — no manual setup. +3. Fetches the server's metadata (`/.well-known/oauth-authorization-server`) and **identifies itself** — either by presenting a hosted metadata document as its `client_id` (Claude's default) or by registering dynamically. No manual setup either way. 4. Opens a browser to the authorize endpoint; the user logs in and approves. 5. Exchanges the code (with PKCE) for an access + refresh token, then re-calls the endpoint with `Authorization: Bearer `. diff --git a/plain-oauthserver/plain/oauthserver/README.md b/plain-oauthserver/plain/oauthserver/README.md index f246d38ea1..1ab0193b36 100644 --- a/plain-oauthserver/plain/oauthserver/README.md +++ b/plain-oauthserver/plain/oauthserver/README.md @@ -6,6 +6,7 @@ - [Connecting an MCP client](#connecting-an-mcp-client) - [Clients are public](#clients-are-public) - [Dynamic client registration](#dynamic-client-registration) +- [Client ID Metadata Documents](#client-id-metadata-documents) - [Protecting a resource](#protecting-a-resource) - [Endpoints](#endpoints) - [Consent template](#consent-template) @@ -32,19 +33,19 @@ class AppRouter(Router): ) ``` -After `uv run plain postgres sync` you have authorization-code + PKCE, refresh-token rotation, revocation, dynamic client registration, and discovery metadata. The authorization flow reuses your existing [`plain.auth`](../../plain-auth/plain/auth/README.md) login — the user signs in and approves on a consent screen. +After `uv run plain postgres sync` you have authorization-code + PKCE, refresh-token rotation, revocation, client registration (hosted metadata documents or dynamic registration), and discovery metadata. The authorization flow reuses your existing [`plain.auth`](../../plain-auth/plain/auth/README.md) login — the user signs in and approves on a consent screen. The driving use case is an **end-user-facing MCP server**: a customer adds your app as a custom connector in Claude, signs in, and the connector acts on their behalf. That flow needs OAuth — there is no bearer-token-paste path in the connector UI. ## Connecting an MCP client -MCP clients self-configure over OAuth: the client hits your protected endpoint with no token, discovers this server, [registers itself](#dynamic-client-registration), and completes a browser login + consent — you mount the routers and the client drives the rest. The endpoint-side wiring (the resource server and the discovery challenge) lives in [`plain.mcp`](../../plain-mcp/plain/mcp/README.md#oauth-for-mcp-clients), which walks the full handshake. +MCP clients self-configure over OAuth: the client hits your protected endpoint with no token, discovers this server, identifies itself (by [hosted metadata document](#client-id-metadata-documents) or by [registering](#dynamic-client-registration)), and completes a browser login + consent — you mount the routers and the client drives the rest. The endpoint-side wiring (the resource server and the discovery challenge) lives in [`plain.mcp`](../../plain-mcp/plain/mcp/README.md#oauth-for-mcp-clients), which walks the full handshake. ## Clients are public Every client is a **public client** — it has no `client_secret`. That's the norm for MCP connectors and CLIs, which run on the user's machine and can't keep a secret. Clients are proven by PKCE on the code exchange (and by the refresh token on refresh), not a secret — so the token endpoint only advertises `token_endpoint_auth_method: "none"`. -You rarely create clients by hand — registration is dynamic — but you can: +You rarely create clients by hand — Claude presents a [hosted metadata document](#client-id-metadata-documents) and other clients [register themselves](#dynamic-client-registration) — but you can: ```python from plain.oauthserver.models import OAuthApplication @@ -65,6 +66,25 @@ Redirect URIs must be HTTPS or loopback. Loopback URIs (`http://127.0.0.1/...`, Registration is open, which is safe: a freshly registered client can do nothing until a real user completes the login + consent flow. Disable it with `OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION = False` if you'd rather register clients yourself. +MCP has deprecated dynamic registration in favor of [Client ID Metadata Documents](#client-id-metadata-documents); it stays on by default here because clients that don't support metadata documents yet still fall back to it. + +## Client ID Metadata Documents + +Instead of registering, a client can present a URL as its `client_id` — an HTTPS address where it hosts a small JSON document describing itself (`client_name`, `redirect_uris`). That's a [Client ID Metadata Document](https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/) (CIMD, MCP SEP-991), and it's what **Claude's custom connector uses by default**: Claude's document lives at `https://claude.ai/oauth/mcp-oauth-client-metadata`, so there's nothing registered per user and no `/oauth/register` traffic. + +Nothing to set up. The metadata document advertises `client_id_metadata_document_supported`, and when an authorize request arrives with a URL `client_id`, [`cimd.py`](./cimd.py) fetches the document, checks that the document's own `client_id` is exactly that URL, and stores it as an [`OAuthApplication`](./models.py#OAuthApplication) whose `client_id` is the URL — one row per client product, shared by everyone who uses that client. The requested `redirect_uri` is checked against the document's list like any other registration, and the consent screen shows the host the document came from, since the `client_name` inside it is self-asserted. + +The fetch is the risky part — the server is fetching a URL a stranger chose — so it's deliberately narrow: HTTPS only, hostnames only (no IP literals, no `localhost`), the host must resolve to public addresses, the connection is pinned to the address that was checked (a DNS answer can't change underneath it), redirects are never followed, the body is capped at 5 KB, and the whole fetch has a 5-second deadline. The token and revocation endpoints never fetch; only `/oauth/authorize` does. + +Documents are cached on the row for their `Cache-Control` lifetime, clamped to between 5 minutes and 24 hours (1 hour when there's no directive). If a refetch fails, the stored copy keeps working for 7 days, so an outage at the client's host doesn't lock your users out. + +Two settings control it: + +- `OAUTH_SERVER_ALLOW_CLIENT_ID_METADATA_DOCUMENTS = False` turns it off — URL `client_id`s become unknown clients and the flag disappears from the metadata document. +- `OAUTH_SERVER_CLIENT_ID_METADATA_ALLOWED_HOSTS = ["claude.ai"]` restricts fetching to specific hosts. The default (`None`) fetches from any public host. + +Only public clients are accepted (`token_endpoint_auth_method` absent or `"none"`, proven by PKCE); a document that asks for `private_key_jwt` or carries a client secret is rejected. + ## Protecting a resource The server issues tokens; validating them is the resource server's job. [`validate_access_token`](./resource_server.py#validate_access_token) resolves a bearer value to its live [`AccessToken`](./models.py#AccessToken) (returning `None` for unknown, expired, or revoked tokens, and enforcing audience binding when a `resource` is given): @@ -81,35 +101,37 @@ That's the seam for any resource server. Protecting a [`plain.mcp`](../../plain- ## Endpoints -| Endpoint | Method | Description | -| ----------------------------------------- | ------ | ---------------------------------------- | -| `/.well-known/oauth-authorization-server` | GET | Authorization server metadata (RFC 8414) | -| `/oauth/authorize` | GET | Consent screen (login required) | -| `/oauth/authorize` | POST | Record the approve/deny decision | -| `/oauth/token` | POST | Code exchange and refresh (rotation) | -| `/oauth/register` | POST | Dynamic client registration (RFC 7591) | -| `/oauth/revoke` | POST | Revoke a token (RFC 7009) | +| Endpoint | Method | Description | +| ----------------------------------------- | ------ | ------------------------------------------------------------------- | +| `/.well-known/oauth-authorization-server` | GET | Authorization server metadata (RFC 8414) | +| `/oauth/authorize` | GET | Consent screen (login required); a URL `client_id` is resolved here | +| `/oauth/authorize` | POST | Record the approve/deny decision | +| `/oauth/token` | POST | Code exchange and refresh (rotation) | +| `/oauth/register` | POST | Dynamic client registration (RFC 7591) | +| `/oauth/revoke` | POST | Revoke a token (RFC 7009) | ## Consent template -Override `oauthserver/authorize.html` in your app's templates to restyle the approval screen. It receives `application`, `scope`, and a `params` dict of the original request fields (`client_id`, `redirect_uri`, `scope`, `state`, `resource`, `code_challenge`, `code_challenge_method`) to re-submit as hidden inputs. +Override `oauthserver/authorize.html` in your app's templates to restyle the approval screen. It receives `application`, `scope`, and a `params` dict of the original request fields (`client_id`, `redirect_uri`, `scope`, `state`, `resource`, `code_challenge`, `code_challenge_method`) to re-submit as hidden inputs. It also gets `client_host` (the host a [metadata document](#client-id-metadata-documents) came from, or `None`), `redirect_host` (where the user will be sent back to), and `loopback_only` (`True` when every redirect URI points at the user's own machine — worth a warning, since nothing proves which local program is asking). ## Models -- [**OAuthApplication**](./models.py#OAuthApplication) — a registered public client (no secret). +- [**OAuthApplication**](./models.py#OAuthApplication) — a registered public client (no secret). For a [metadata-document client](#client-id-metadata-documents) the `client_id` is the document URL and `metadata_fetched_at` / `metadata_expires_at` track the cached copy. - [**AuthorizationCode**](./models.py#AuthorizationCode) — single-use code carrying the PKCE challenge and bound `resource`. - [**AccessToken**](./models.py#AccessToken) — bearer token, **stored as a SHA-256 hash** so a database leak can't be replayed. Carries the granted `scope` and bound `resource`. - [**RefreshToken**](./models.py#RefreshToken) — hashed, expiring, and rotated on every use. Scope and resource come from its linked `AccessToken`. ## Settings -| Setting | Default | Description | -| ----------------------------------------- | -------------------- | ----------------------------------------- | -| `OAUTH_SERVER_CODE_EXPIRY` | `600` | Authorization code lifetime (seconds) | -| `OAUTH_SERVER_ACCESS_TOKEN_EXPIRY` | `3600` | Access token lifetime (seconds) | -| `OAUTH_SERVER_REFRESH_TOKEN_EXPIRY` | `2592000` | Refresh token lifetime (seconds, 30 days) | -| `OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION` | `True` | Enable RFC 7591 registration | -| `OAUTH_SERVER_SCOPES_SUPPORTED` | `["offline_access"]` | Scopes advertised in metadata | +| Setting | Default | Description | +| ------------------------------------------------- | -------------------- | ------------------------------------------------------- | +| `OAUTH_SERVER_CODE_EXPIRY` | `600` | Authorization code lifetime (seconds) | +| `OAUTH_SERVER_ACCESS_TOKEN_EXPIRY` | `3600` | Access token lifetime (seconds) | +| `OAUTH_SERVER_REFRESH_TOKEN_EXPIRY` | `2592000` | Refresh token lifetime (seconds, 30 days) | +| `OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION` | `True` | Enable RFC 7591 registration | +| `OAUTH_SERVER_ALLOW_CLIENT_ID_METADATA_DOCUMENTS` | `True` | Accept URL `client_id`s (CIMD) | +| `OAUTH_SERVER_CLIENT_ID_METADATA_ALLOWED_HOSTS` | `None` | Hosts to fetch metadata from (`None` = any public host) | +| `OAUTH_SERVER_SCOPES_SUPPORTED` | `["offline_access"]` | Scopes advertised in metadata | All settings can be set via `PLAIN_`-prefixed environment variables. @@ -127,6 +149,14 @@ Access and refresh tokens are generated, returned to the client once, and persis Using a refresh token issues a new access + refresh pair and revokes the old pair. Refresh tokens also expire. This is required for public clients and limits exposure if a token leaks. +#### Why does every Claude user share one client? + +With a [metadata document](#client-id-metadata-documents), the `client_id` is Claude's URL, so every person connecting from Claude presents the same `client_id` and shares one `OAuthApplication` row. That's by design — it's what removes the per-user registrations DCR accumulates. It also means "revoke this client" has to be scoped to a user: revoke by `(user, application)`, never by application alone, or you'd log every Claude user out at once. + +#### What if the client's metadata host is down? + +The stored document keeps serving for 7 days after a failed refetch, so a short outage at `claude.ai` doesn't stop anyone from connecting. A client the server has never seen before can't be resolved during the outage — the consent screen explains why instead of redirecting. + #### Do I need to exempt OAuth paths from CSRF? No. Non-browser clients don't send `Origin` / `Sec-Fetch-Site`, so Plain's CSRF protection skips them. The browser-driven consent POST is same-origin and protected normally. diff --git a/plain-oauthserver/plain/oauthserver/cimd.py b/plain-oauthserver/plain/oauthserver/cimd.py new file mode 100644 index 0000000000..46b71f3969 --- /dev/null +++ b/plain-oauthserver/plain/oauthserver/cimd.py @@ -0,0 +1,421 @@ +"""Client ID Metadata Documents (CIMD). + +A `client_id` may be an HTTPS URL pointing at a small JSON document the client +hosts on its own domain (draft-ietf-oauth-client-id-metadata-document, adopted +by MCP as SEP-991). Instead of registering, the client presents the URL; we +fetch the document, check that it claims that exact URL as its `client_id`, +and use its `redirect_uris` as the registration. Claude's connector does this +by default — its document lives at https://claude.ai/oauth/mcp-oauth-client-metadata. + +The whole risk of this feature is that it makes the server fetch an +attacker-supplied URL, so the fetch is deliberately narrow: HTTPS only, the +host must resolve to public addresses, the connection is pinned to the address +we checked (DNS rebinding can't swap it), redirects are never followed, the +body is capped at 5 KB, and the whole thing has a short deadline. + +Fetched documents are stored on the `OAuthApplication` row keyed by the URL — +one row per client product, not per install — with a TTL from the document's +`Cache-Control` and a grace period during which a failed refetch serves the +last good copy (Claude's metadata endpoints have had outages). +""" + +from __future__ import annotations + +import ipaddress +import json +import logging +import socket +import time +from dataclasses import dataclass +from datetime import timedelta +from typing import Any +from urllib.parse import unquote, urlsplit + +import httpx +from plain.runtime import settings +from plain.utils import timezone + +from .models import ( + _LOOPBACK_HOSTS, + OAuthApplication, + _is_allowed_redirect_uri, +) + +logger = logging.getLogger(__name__) + +# Draft -02 recommends a 5 KB read cap. Claude's document is ~350 bytes. +MAX_DOCUMENT_BYTES = 5 * 1024 + +# Total deadline for the fetch, including reading the body. +FETCH_TIMEOUT_SECONDS = 5.0 + +# The document's Cache-Control is honored within these bounds. No directive +# (or no-store / no-cache) gets the default. +MIN_CACHE_SECONDS = 5 * 60 +MAX_CACHE_SECONDS = 24 * 60 * 60 +DEFAULT_CACHE_SECONDS = 60 * 60 + +# After the cached document expires, a failed refetch keeps serving the +# stored copy for this long — then the failure is the client's problem. +STALE_GRACE_PERIOD = timedelta(days=7) + +MAX_CLIENT_ID_LENGTH = 2048 + +# Hosts under these TLDs are never public (RFC 2606, RFC 6761), so a URL naming +# one is rejected before any DNS lookup. +_NON_PUBLIC_TLDS = {"localhost", "local", "test", "invalid", "example", "internal"} + +_USER_AGENT = "plain.oauthserver (+https://plainframework.com)" + +# NAT64 (RFC 6052) embeds an IPv4 address that `is_global` doesn't see through. +_NAT64_PREFIX = ipaddress.IPv6Network("64:ff9b::/96") + + +class ClientMetadataError(Exception): + """A client_id URL or its metadata document can't be used. + + The message is safe to show to the end user on the consent screen — it + names what was wrong, never the fetched content. + """ + + +@dataclass(frozen=True) +class ClientMetadata: + """What we keep from a validated document.""" + + name: str + redirect_uris: list[str] + + +def is_client_id_url(client_id: str) -> bool: + """Whether a client_id is a metadata-document URL rather than a registered id.""" + return client_id.startswith("https://") + + +def validate_client_id_url(client_id: str) -> str: + """Enforce the draft's Client Identifier URL rules before we go anywhere near it. + + Returns the URL unchanged — it's compared and stored by simple string + comparison, never normalized. + """ + if len(client_id) > MAX_CLIENT_ID_LENGTH: + raise ClientMetadataError("client_id URL is too long") + if not client_id.startswith("https://"): + raise ClientMetadataError("client_id URL must use https") + if any(ord(c) <= 0x20 or 0x7F <= ord(c) <= 0x9F or c == "\\" for c in client_id): + raise ClientMetadataError( + "client_id URL must not contain whitespace, control characters, or backslashes" + ) + if "#" in client_id: + raise ClientMetadataError("client_id URL must not contain a fragment") + if "?" in client_id: + raise ClientMetadataError("client_id URL must not contain a query string") + + split = urlsplit(client_id) + if split.username is not None or split.password is not None: + raise ClientMetadataError("client_id URL must not contain credentials") + try: + port = split.port + except ValueError: + raise ClientMetadataError("client_id URL has an invalid port") from None + if port is not None and not 1 <= port <= 65535: + raise ClientMetadataError("client_id URL has an invalid port") + + host = split.hostname + if not host: + raise ClientMetadataError("client_id URL must have a host") + try: + ipaddress.ip_address(host) + except ValueError: + pass + else: + raise ClientMetadataError( + "client_id URL must use a hostname, not an IP address" + ) + labels = host.rstrip(".").split(".") + if len(labels) < 2 or labels[-1] in _NON_PUBLIC_TLDS: + raise ClientMetadataError("client_id URL must use a public hostname") + + # Check the raw path: a parser that normalizes "." / ".." would hide them. + if not split.path or split.path == "/": + raise ClientMetadataError("client_id URL must have a path") + for segment in split.path.split("/"): + if unquote(segment) in (".", ".."): + raise ClientMetadataError("client_id URL must not contain dot segments") + + return client_id + + +def is_public_address(address: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: + """Whether an address is globally routable — the only kind we'll connect to.""" + if isinstance(address, ipaddress.IPv6Address): + # See through the encodings that carry an IPv4 address inside IPv6. + if address.ipv4_mapped is not None: + return is_public_address(address.ipv4_mapped) + if address.sixtofour is not None: + return is_public_address(address.sixtofour) + if address in _NAT64_PREFIX: + return is_public_address(ipaddress.IPv4Address(int(address) & 0xFFFFFFFF)) + return address.is_global and not address.is_multicast + + +def resolve_public_address(*, host: str, port: int) -> str: + """Resolve a hostname and return one address, refusing if any answer isn't public. + + Every answer has to pass, not just the first: a host that mixes a public + address with an internal one is exactly what a rebinding attack looks like. + """ + try: + infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM) + except socket.gaierror: + raise ClientMetadataError(f"Could not resolve {host}") from None + if not infos: + raise ClientMetadataError(f"Could not resolve {host}") + + addresses = [] + for info in infos: + # Link-local IPv6 answers carry a "%scope" suffix ip_address won't parse. + raw = str(info[4][0]).split("%", 1)[0] + addresses.append(ipaddress.ip_address(raw)) + for address in addresses: + if not is_public_address(address): + raise ClientMetadataError(f"{host} does not resolve to a public address") + return str(addresses[0]) + + +def cache_ttl_seconds(cache_control: str) -> int: + """How long to trust a document, from its Cache-Control header, within our bounds.""" + directives: dict[str, str] = {} + for part in cache_control.split(","): + name, _, value = part.strip().partition("=") + directives[name.strip().lower()] = value.strip().strip('"') + + if "no-store" in directives or "no-cache" in directives: + return DEFAULT_CACHE_SECONDS + + for name in ("s-maxage", "max-age"): + value = directives.get(name) + if value is not None and value.isdigit(): + return max(MIN_CACHE_SECONDS, min(int(value), MAX_CACHE_SECONDS)) + + return DEFAULT_CACHE_SECONDS + + +def fetch_metadata_document( + url: str, *, transport: httpx.BaseTransport | None = None +) -> tuple[dict[str, Any], int]: + """GET a metadata document with the SSRF guard on. Returns (document, ttl seconds). + + `transport` exists so tests can hand in an `httpx.MockTransport` — the + request is otherwise built exactly as it would be in production. + """ + split = urlsplit(url) + host = split.hostname or "" + port = split.port or 443 + + address = resolve_public_address(host=host, port=port) + # Connect to the address we just checked, not to the hostname again — a DNS + # answer that changes between check and connect can't redirect us. The + # hostname still goes out as the Host header and the TLS server name, so + # the certificate is verified against the real host. + pinned_host = f"[{address}]" if ":" in address else address + pinned_url = split._replace(netloc=f"{pinned_host}:{port}").geturl() + headers = { + "Host": split.netloc, + "Accept": "application/json", + "User-Agent": _USER_AGENT, + } + + started = time.monotonic() + try: + with ( + httpx.Client( + transport=transport, + follow_redirects=False, + timeout=httpx.Timeout(FETCH_TIMEOUT_SECONDS), + ) as client, + client.stream( + "GET", + pinned_url, + headers=headers, + extensions={"sni_hostname": host}, + ) as response, + ): + if response.status_code != 200: + raise ClientMetadataError( + f"Metadata document responded with status {response.status_code}" + ) + content_type = ( + response.headers.get("content-type", "") + .split(";", 1)[0] + .strip() + .lower() + ) + if content_type != "application/json" and not content_type.endswith( + "+json" + ): + raise ClientMetadataError("Metadata document is not JSON") + declared_length = response.headers.get("content-length", "") + if declared_length.isdigit() and int(declared_length) > MAX_DOCUMENT_BYTES: + raise ClientMetadataError("Metadata document is too large") + + body = bytearray() + for chunk in response.iter_bytes(): + body.extend(chunk) + if len(body) > MAX_DOCUMENT_BYTES: + raise ClientMetadataError("Metadata document is too large") + if time.monotonic() - started > FETCH_TIMEOUT_SECONDS: + raise ClientMetadataError("Metadata document fetch timed out") + cache_control = response.headers.get("cache-control", "") + except httpx.TimeoutException: + raise ClientMetadataError("Metadata document fetch timed out") from None + except httpx.HTTPError as exc: + raise ClientMetadataError( + f"Could not fetch metadata document ({type(exc).__name__})" + ) from None + + try: + document = json.loads(bytes(body)) + except ValueError: + raise ClientMetadataError("Metadata document is not valid JSON") from None + if not isinstance(document, dict): + raise ClientMetadataError("Metadata document must be a JSON object") + + return document, cache_ttl_seconds(cache_control) + + +def validate_metadata_document(*, url: str, document: dict[str, Any]) -> ClientMetadata: + """Check a fetched document and pull out what we register. + + The rules are the draft's plus MCP's: the document must claim this exact + URL, name itself, list HTTPS-or-loopback redirect URIs, and be a public + client. Anything a private-key or shared-secret client would carry is + rejected — those aren't supported (yet). + """ + if document.get("client_id") != url: + raise ClientMetadataError( + "Metadata document's client_id does not match its URL" + ) + + if any(key.startswith("client_secret") for key in document): + raise ClientMetadataError("Metadata document must not contain a client secret") + + name = document.get("client_name") + if not isinstance(name, str) or not name.strip(): + raise ClientMetadataError("Metadata document must have a client_name") + + redirect_uris = document.get("redirect_uris") + if not isinstance(redirect_uris, list) or not redirect_uris: + raise ClientMetadataError("Metadata document must list redirect_uris") + if not all( + isinstance(u, str) and _is_allowed_redirect_uri(u) for u in redirect_uris + ): + raise ClientMetadataError("redirect_uris must be HTTPS or loopback") + if len(" ".join(redirect_uris)) > 2000: + raise ClientMetadataError("redirect_uris are too long") + + auth_method = document.get("token_endpoint_auth_method", "none") + if auth_method != "none": + raise ClientMetadataError( + f"token_endpoint_auth_method {auth_method!r} is not supported (only 'none')" + ) + + grant_types = document.get("grant_types") + if grant_types is not None and ( + not isinstance(grant_types, list) or "authorization_code" not in grant_types + ): + raise ClientMetadataError("grant_types must include authorization_code") + + response_types = document.get("response_types") + if response_types is not None and ( + not isinstance(response_types, list) or "code" not in response_types + ): + raise ClientMetadataError("response_types must include code") + + return ClientMetadata(name=name.strip()[:255], redirect_uris=redirect_uris) + + +def resolve_client_metadata(client_id: str) -> OAuthApplication: + """The one entry point: a URL client_id in, a current `OAuthApplication` out. + + Uses the stored row while it's fresh, refetches when it has expired, and + serves the stored row through a failed refetch for a grace period. Raises + `ClientMetadataError` when there's nothing usable. + """ + url = validate_client_id_url(client_id) + host = urlsplit(url).hostname or "" + allowed_hosts = settings.OAUTH_SERVER_CLIENT_ID_METADATA_ALLOWED_HOSTS + if allowed_hosts is not None and host not in allowed_hosts: + raise ClientMetadataError(f"{host} is not an allowed client metadata host") + + try: + application = OAuthApplication.query.get(client_id=url) + except OAuthApplication.DoesNotExist: + application = None + + now = timezone.now() + if ( + application is not None + and application.metadata_expires_at is not None + and application.metadata_expires_at > now + ): + return application + + try: + document, ttl = fetch_metadata_document(url) + metadata = validate_metadata_document(url=url, document=document) + except ClientMetadataError as exc: + if ( + application is not None + and application.metadata_fetched_at is not None + and now - application.metadata_fetched_at < STALE_GRACE_PERIOD + ): + logger.warning( + "Client metadata refetch failed, serving the cached document", + extra={"client_id": url, "reason": str(exc)}, + ) + return application + logger.warning( + "Client metadata rejected", + extra={"client_id": url, "reason": str(exc)}, + ) + raise + + fields = { + "name": metadata.name, + "redirect_uris": " ".join(metadata.redirect_uris), + "metadata_fetched_at": now, + "metadata_expires_at": now + timedelta(seconds=ttl), + } + + if application is None: + # Two first-time authorizations for the same URL can race here; the + # unique constraint on client_id makes the loser get the winner's row. + application, created = OAuthApplication.query.get_or_create( + client_id=url, defaults=fields + ) + if created: + logger.info("Client metadata registered", extra={"client_id": url}) + return application + + if application.redirect_uris != fields["redirect_uris"]: + logger.info( + "Client metadata redirect_uris changed", + extra={"client_id": url, "redirect_uris": fields["redirect_uris"]}, + ) + for field, value in fields.items(): + setattr(application, field, value) + application.update(fields=list(fields)) + return application + + +def is_loopback_only(application: OAuthApplication) -> bool: + """Whether every redirect URI points at the user's own machine. + + MCP asks the consent screen to warn in that case: nothing about a metadata + document (or a dynamic registration) proves *which* local program is asking. + """ + return all( + urlsplit(uri).hostname in _LOOPBACK_HOSTS + for uri in application.get_redirect_uris() + ) diff --git a/plain-oauthserver/plain/oauthserver/default_settings.py b/plain-oauthserver/plain/oauthserver/default_settings.py index 628d6ec9da..8fb03d63d0 100644 --- a/plain-oauthserver/plain/oauthserver/default_settings.py +++ b/plain-oauthserver/plain/oauthserver/default_settings.py @@ -14,3 +14,13 @@ # Scopes advertised in authorization server metadata. `offline_access` signals # that refresh tokens are available. OAUTH_SERVER_SCOPES_SUPPORTED: list[str] = ["offline_access"] + +# Whether to accept a client_id that is an HTTPS URL to a hosted metadata +# document (Client ID Metadata Documents, MCP SEP-991). Claude's connector +# uses this by default, so it is on by default. +OAUTH_SERVER_ALLOW_CLIENT_ID_METADATA_DOCUMENTS: bool = True + +# Hostnames whose metadata documents may be fetched. `None` allows any public +# host; a list restricts the server to fetching only from those hosts +# (e.g. ["claude.ai"]). +OAUTH_SERVER_CLIENT_ID_METADATA_ALLOWED_HOSTS: list[str] | None = None diff --git a/plain-oauthserver/plain/oauthserver/migrations/0002_oauthapplication_metadata_expires_at_and_more.py b/plain-oauthserver/plain/oauthserver/migrations/0002_oauthapplication_metadata_expires_at_and_more.py new file mode 100644 index 0000000000..4aa12a1b34 --- /dev/null +++ b/plain-oauthserver/plain/oauthserver/migrations/0002_oauthapplication_metadata_expires_at_and_more.py @@ -0,0 +1,22 @@ +# Generated by Plain 0.161.0 on 2026-09-03 01:48 + +from plain.postgres import migrations + +from plain import postgres + + +class Migration(migrations.Migration): + dependencies = (("plainoauthserver", "0001_initial"),) + + operations = ( + migrations.AddField( + model_name="oauthapplication", + name="metadata_expires_at", + field=postgres.DateTimeField(allow_null=True, required=False), + ), + migrations.AddField( + model_name="oauthapplication", + name="metadata_fetched_at", + field=postgres.DateTimeField(allow_null=True, required=False), + ), + ) diff --git a/plain-oauthserver/plain/oauthserver/models.py b/plain-oauthserver/plain/oauthserver/models.py index ffb9cc2501..1eab63a1f7 100644 --- a/plain-oauthserver/plain/oauthserver/models.py +++ b/plain-oauthserver/plain/oauthserver/models.py @@ -38,18 +38,39 @@ def _normalize_redirect_uri(uri: str) -> str: return uri +def _is_allowed_redirect_uri(uri: str) -> bool: + """OAuth 2.1: redirect URIs must be HTTPS, or loopback for native clients.""" + # Reject whitespace and fragments: redirect_uris are stored space-joined, so a + # value containing whitespace would smuggle in a second, unvalidated URI. + if any(c.isspace() for c in uri): + return False + parsed = urlparse(uri) + if parsed.fragment: + return False + if parsed.scheme == "https": + return True + return parsed.scheme == "http" and parsed.hostname in _LOOPBACK_HOSTS + + @postgres.register_model class OAuthApplication(postgres.Model): """A registered OAuth client (Claude's connector, a CLI). Always a public client — proven by PKCE on the code exchange and by the refresh token on refresh, never a client secret. + + Registered clients get a random `client_id`. A client that presents a + metadata document instead (see `cimd.py`) has its document URL as the + `client_id`, and the `metadata_*` fields say when that document was + fetched and how long it's trusted. """ client_id = types.RandomStringField(length=32) name = types.TextField(max_length=255, default="", required=False) redirect_uris = types.TextField(max_length=2000) created_at = types.DateTimeField(create_now=True) + metadata_fetched_at = types.DateTimeField(allow_null=True, required=False) + metadata_expires_at = types.DateTimeField(allow_null=True, required=False) query: postgres.QuerySet[OAuthApplication] = postgres.QuerySet() diff --git a/plain-oauthserver/plain/oauthserver/templates/oauthserver/authorize.html b/plain-oauthserver/plain/oauthserver/templates/oauthserver/authorize.html index fb3a41ac62..6cb96ebbf4 100644 --- a/plain-oauthserver/plain/oauthserver/templates/oauthserver/authorize.html +++ b/plain-oauthserver/plain/oauthserver/templates/oauthserver/authorize.html @@ -11,6 +11,8 @@ h1 { font-size: 1.25rem; margin-bottom: 1rem; } .app-name { font-weight: 600; color: #111; } .scope { background: #f0f0f0; border-radius: 4px; padding: 0.5rem 0.75rem; margin: 1rem 0; font-size: 0.875rem; color: #555; } + .detail { font-size: 0.875rem; color: #555; margin-top: 0.5rem; } + .warning { background: #fff7e6; border-radius: 4px; padding: 0.5rem 0.75rem; margin-top: 1rem; font-size: 0.875rem; color: #7a4b00; } .actions { display: flex; gap: 0.75rem; margin-top: 1.5rem; } button { flex: 1; padding: 0.75rem; border: none; border-radius: 6px; font-size: 0.875rem; font-weight: 500; cursor: pointer; } .approve { background: #111; color: #fff; } @@ -25,6 +27,16 @@ {% elif application %}

{{ application.name }} wants to access your account

+ {% if client_host %} +

Client hosted at {{ client_host }}

+ {% endif %} + {% if redirect_host %} +

You will be sent back to {{ redirect_host }}

+ {% endif %} + {% if loopback_only %} +

This client runs on your own computer. Only approve it if you started it yourself.

+ {% endif %} + {% if scope %}
Requested scope: {{ scope }}
{% endif %} diff --git a/plain-oauthserver/plain/oauthserver/views.py b/plain-oauthserver/plain/oauthserver/views.py index fa8b7acbda..2af19b57e1 100644 --- a/plain-oauthserver/plain/oauthserver/views.py +++ b/plain-oauthserver/plain/oauthserver/views.py @@ -5,6 +5,7 @@ - Authorization server metadata (RFC 8414) - Dynamic client registration (RFC 7591) — public clients, PKCE +- Client ID Metadata Documents (SEP-991) — URL client_ids, see cimd.py - Authorization code grant with PKCE (RFC 7636), audience-bound (RFC 8707) - Token endpoint with refresh-token rotation (RFC 6749, OAuth 2.1) - Token revocation (RFC 7009) @@ -25,14 +26,20 @@ from plain.utils import timezone from plain.views import View +from .cimd import ( + ClientMetadataError, + is_client_id_url, + is_loopback_only, + resolve_client_metadata, +) from .models import ( - _LOOPBACK_HOSTS, AccessToken, AuthorizationCode, OAuthApplication, RefreshToken, _generate_token, _hash_token, + _is_allowed_redirect_uri, ) _GRANT_TYPES = ["authorization_code", "refresh_token"] @@ -44,20 +51,6 @@ def _issuer(request: Request) -> str: return f"{request.scheme}://{request.host}" -def _is_allowed_redirect_uri(uri: str) -> bool: - """OAuth 2.1: redirect URIs must be HTTPS, or loopback for native clients.""" - # Reject whitespace and fragments: redirect_uris are stored space-joined, so a - # value containing whitespace would smuggle in a second, unvalidated URI. - if any(c.isspace() for c in uri): - return False - parsed = urlparse(uri) - if parsed.fragment: - return False - if parsed.scheme == "https": - return True - return parsed.scheme == "http" and parsed.hostname in _LOOPBACK_HOSTS - - class AuthorizationServerMetadataView(View): """RFC 8414 — served at /.well-known/oauth-authorization-server.""" @@ -76,6 +69,10 @@ def get(self) -> JsonResponse: } if settings.OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION: metadata["registration_endpoint"] = issuer + reverse("oauthserver:register") + # Claude picks CIMD over DCR only when this is true *and* "none" is in + # token_endpoint_auth_methods_supported (it always is — see above). + if settings.OAUTH_SERVER_ALLOW_CLIENT_ID_METADATA_DOCUMENTS: + metadata["client_id_metadata_document_supported"] = True return JsonResponse(metadata) @@ -139,10 +136,22 @@ def get(self) -> Response: if error: return self._render({"error": error}) + assert application is not None # a None error implies a valid client params = self.request.query_params + client_id = params.get("client_id", "") + redirect_uri = params.get("redirect_uri", "") return self._render( { "application": application, + # A metadata document's client_name is self-asserted, so the + # consent screen also shows where the document is hosted. + "client_host": ( + urlparse(client_id).hostname + if is_client_id_url(client_id) + else None + ), + "redirect_host": urlparse(redirect_uri).hostname or "", + "loopback_only": is_loopback_only(application), "scope": params.get("scope", ""), "params": { "response_type": "code", @@ -200,6 +209,9 @@ def _render(self, context: dict[str, Any]) -> Response: "request": self.request, "error": None, "application": None, + "client_host": None, + "redirect_host": "", + "loopback_only": False, "scope": "", "params": {}, **context, @@ -222,10 +234,21 @@ def _validate_request( client_id = params.get("client_id", "") if not client_id: return None, "Missing client_id" - try: - application = OAuthApplication.query.get(client_id=client_id) - except OAuthApplication.DoesNotExist: - return None, f"Unknown client_id: {client_id}" + if ( + is_client_id_url(client_id) + and settings.OAUTH_SERVER_ALLOW_CLIENT_ID_METADATA_DOCUMENTS + ): + # A URL client_id names a hosted metadata document — fetched (or + # served from the stored copy) and registered on the fly. + try: + application = resolve_client_metadata(client_id) + except ClientMetadataError as e: + return None, f"Could not use client_id {client_id}: {e}" + else: + try: + application = OAuthApplication.query.get(client_id=client_id) + except OAuthApplication.DoesNotExist: + return None, f"Unknown client_id: {client_id}" # Validate the redirect target before anything else can act on it — # OAuth 2.1 §4.1.2.1 says to inform the user here, not redirect. @@ -373,7 +396,13 @@ def post(self) -> Response: def _resolve_client(request: Request) -> OAuthApplication | JsonResponse: - """Look up the public client by client_id (PKCE / the refresh token is the proof).""" + """Look up the public client by client_id (PKCE / the refresh token is the proof). + + A metadata-document client is looked up the same way — its row was created + at /authorize, and nothing here depends on the document (the redirect_uri + is bound to the code, not re-checked against the registration), so the + token and revocation endpoints never fetch. + """ client_id = request.form_data.get("client_id", "") if not client_id: return _oauth_error("invalid_client", "Missing client_id", status_code=401) diff --git a/plain-oauthserver/pyproject.toml b/plain-oauthserver/pyproject.toml index 51f8eb1292..a4c3b309c5 100644 --- a/plain-oauthserver/pyproject.toml +++ b/plain-oauthserver/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [ "plain.auth>=0.29.0,<1.0.0", "plain.postgres>=0.106.0,<1.0.0", "plain.templates>=0.1.0,<1.0.0", + "httpx>=0.27", ] [dependency-groups] diff --git a/plain-oauthserver/tests/conformance/README.md b/plain-oauthserver/tests/conformance/README.md index f5f2a5971f..c74a096eaa 100644 --- a/plain-oauthserver/tests/conformance/README.md +++ b/plain-oauthserver/tests/conformance/README.md @@ -55,6 +55,19 @@ In the conformance suite UI: Click "Run" in the conformance suite. It will test each endpoint against the OAuth 2.1 specification. +## Client ID Metadata Documents (CIMD) + +The OpenID suite has no CIMD test plan. The [MCP conformance CLI](https://github.com/modelcontextprotocol/conformance) does have an authorization-server mode: it checks that `client_id_metadata_document_supported` is advertised and runs a real PKCE authorization-code flow with whatever `client_id` you give it. Claude Code's hosted document lists `http://127.0.0.1/callback` without a port, and this server matches loopback redirect URIs regardless of port, so the runner's `http://127.0.0.1:3000/callback` is accepted with no hosted document of your own: + +```bash +npx @modelcontextprotocol/conformance authorization \ + --url https://.localhost:8443 \ + --client-id https://claude.ai/oauth/claude-code-client-metadata \ + -p 3000 +``` + +`doctor.py` also probes the CIMD path: it asserts the two metadata fields Claude checks before choosing CIMD, then sends an authorize request with Claude's `client_id` and confirms the server resolves it instead of reporting an unknown client. + ## What's tested The conformance suite verifies: diff --git a/plain-oauthserver/tests/conformance/doctor.py b/plain-oauthserver/tests/conformance/doctor.py index a88f4ceae7..2942684f46 100755 --- a/plain-oauthserver/tests/conformance/doctor.py +++ b/plain-oauthserver/tests/conformance/doctor.py @@ -5,8 +5,9 @@ Turns "the connector won't connect" into a precise failure point: it probes the exact sequence Claude's custom connector follows — 401 challenge → protected -resource metadata → authorization server metadata → dynamic registration — and -prints which step breaks. Standard library only, so it runs anywhere. +resource metadata → authorization server metadata → client metadata document +(CIMD, Claude's default) or dynamic registration — and prints which step +breaks. Standard library only, so it runs anywhere. """ from __future__ import annotations @@ -16,7 +17,11 @@ import sys import urllib.error import urllib.request -from urllib.parse import urlparse +from urllib.parse import urlencode, urlparse + +# Claude's hosted Client ID Metadata Document and the redirect URI it lists. +_CLAUDE_CLIENT_ID = "https://claude.ai/oauth/mcp-oauth-client-metadata" +_CLAUDE_REDIRECT_URI = "https://claude.ai/api/mcp/auth_callback" _OK = "\033[32m ok \033[0m" _FAIL = "\033[31mFAIL \033[0m" @@ -35,6 +40,21 @@ def _get_json(url: str) -> tuple[int, dict]: return e.code, {} +class _NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +def _get_without_redirects(url: str) -> tuple[int, str, str]: + """Return (status, Location header, body) without following redirects.""" + opener = urllib.request.build_opener(_NoRedirect) + try: + with opener.open(url, timeout=10) as resp: + return resp.status, "", resp.read().decode(errors="replace") + except urllib.error.HTTPError as e: + return e.code, e.headers.get("Location", ""), e.read().decode(errors="replace") + + def _post_json(url: str, payload: dict) -> urllib.request.Request: return urllib.request.Request( url, @@ -89,12 +109,50 @@ def main(mcp_url: str) -> int: "none" in (meta.get("token_endpoint_auth_methods_supported") or []), "public clients (auth method 'none') advertised", ) + # Claude uses CIMD only when both of these hold; otherwise it falls back to DCR. + cimd = meta.get("client_id_metadata_document_supported") is True + passed &= _check( + cimd, "client_id_metadata_document_supported (CIMD, Claude's default)" + ) reg = meta.get("registration_endpoint") - passed &= _check(bool(reg), "registration_endpoint present (DCR)", str(reg)) + passed &= _check( + bool(reg) or cimd, "registration_endpoint present (DCR fallback)", str(reg) + ) - # 4. Dynamic client registration (RFC 7591). + # 4. Client ID Metadata Document (SEP-991): the authorize endpoint must + # resolve Claude's hosted document instead of reporting an unknown client. + if cimd and meta.get("authorization_endpoint"): + print("\n4. Client ID Metadata Document (SEP-991)") + query = urlencode( + { + "response_type": "code", + "client_id": _CLAUDE_CLIENT_ID, + "redirect_uri": _CLAUDE_REDIRECT_URI, + "state": "oauth-doctor", + "code_challenge": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + "code_challenge_method": "S256", + } + ) + status, location, body = _get_without_redirects( + f"{meta['authorization_endpoint']}?{query}" + ) + # Either the consent screen (200) or a redirect to log in (3xx) means + # the client resolved; a redirect back to Claude carrying an error, or + # an error page, means it didn't. + resolved = ( + status == 200 + and "Could not use client_id" not in body + and "Unknown client_id" not in body + ) or (300 <= status < 400 and not location.startswith(_CLAUDE_REDIRECT_URI)) + passed &= _check( + resolved, + "authorize resolves Claude's client_id URL", + f"status {status}" + (f" → {location}" if location else ""), + ) + + # 5. Dynamic client registration (RFC 7591) — the fallback path. if reg: - print("\n4. Dynamic client registration (RFC 7591)") + print("\n5. Dynamic client registration (RFC 7591)") try: req = _post_json( reg, diff --git a/plain-oauthserver/tests/internal/test_cimd_internals.py b/plain-oauthserver/tests/internal/test_cimd_internals.py new file mode 100644 index 0000000000..658ed50d1e --- /dev/null +++ b/plain-oauthserver/tests/internal/test_cimd_internals.py @@ -0,0 +1,433 @@ +"""Unit tests for the Client ID Metadata Document pieces below the HTTP contract. + +The fetch is exercised through `httpx.MockTransport` with DNS resolution +stubbed to a public address, so the request that reaches the transport is +exactly the one production would send — pinned IP, Host header, SNI name. +""" + +from __future__ import annotations + +import ipaddress +import json +import socket + +import httpx +import pytest +from plain.oauthserver import cimd +from plain.oauthserver.cimd import ( + ClientMetadataError, + cache_ttl_seconds, + fetch_metadata_document, + is_client_id_url, + is_public_address, + resolve_public_address, + validate_client_id_url, + validate_metadata_document, +) + +CLAUDE_URL = "https://claude.ai/oauth/mcp-oauth-client-metadata" +CLAUDE_CODE_URL = "https://claude.ai/oauth/claude-code-client-metadata" + +# Frozen from the live documents on 2026-09-03. +CLAUDE_DOCUMENT = { + "client_id": CLAUDE_URL, + "client_name": "Claude", + "client_uri": "https://claude.ai", + "redirect_uris": ["https://claude.ai/api/mcp/auth_callback"], + "grant_types": [ + "authorization_code", + "refresh_token", + "urn:ietf:params:oauth:grant-type:jwt-bearer", + ], + "response_types": ["code"], + "token_endpoint_auth_method": "none", +} +CLAUDE_CODE_DOCUMENT = { + "client_id": CLAUDE_CODE_URL, + "client_name": "Claude Code", + "client_uri": "https://claude.ai", + "redirect_uris": ["http://localhost/callback", "http://127.0.0.1/callback"], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none", +} +# ChatGPT's document only offers private_key_jwt, which we don't support. +CHATGPT_URL = "https://chatgpt.com/oauth/IbUR3zxyNQ16/client.json" +CHATGPT_DOCUMENT = { + "client_id": CHATGPT_URL, + "client_name": "ChatGPT", + "redirect_uris": ["https://chatgpt.com/connector/oauth/IbUR3zxyNQ16"], + "token_endpoint_auth_method": "private_key_jwt", + "jwks_uri": "https://chatgpt.com/oauth/jwks.json", +} + + +class TestClientIdUrl: + def test_detection(self): + assert is_client_id_url(CLAUDE_URL) + assert not is_client_id_url("a1b2c3d4e5f6") + assert not is_client_id_url("http://example.com/client") + + @pytest.mark.parametrize( + "url", + [ + CLAUDE_URL, + "https://example.com/client", + "https://example.com:8443/oauth/client.json", + "https://example.com/oauth-client-metadata.json", + ], + ) + def test_accepts(self, url): + assert validate_client_id_url(url) == url + + @pytest.mark.parametrize( + ("url", "reason"), + [ + ("http://example.com/client", "https"), + ("https://example.com", "path"), + ("https://example.com/", "path"), + ("https://example.com/a/../client", "dot segments"), + ("https://example.com/a/%2e%2e/client", "dot segments"), + ("https://example.com/./client", "dot segments"), + ("https://user:pw@example.com/client", "credentials"), + ("https://example.com/client#frag", "fragment"), + ("https://example.com/client?x=1", "query"), + ("https://93.184.216.34/client", "IP address"), + ("https://[2606:4700::1111]/client", "IP address"), + ("https://localhost/client", "public hostname"), + ("https://app.localhost/client", "public hostname"), + ("https://client.local/metadata", "public hostname"), + ("https://client.internal/metadata", "public hostname"), + ("https://intranet/client", "public hostname"), + ("https://example.com/cli ent", "whitespace"), + ("https://example.com/cli\\ent", "backslash"), + ("https://example.com:99999/client", "port"), + ("https://example.com/" + "x" * 2048, "too long"), + ], + ) + def test_rejects(self, url, reason): + with pytest.raises(ClientMetadataError, match=reason): + validate_client_id_url(url) + + +class TestPublicAddress: + @pytest.mark.parametrize( + "address", + [ + "127.0.0.1", + "10.0.0.1", + "172.16.5.5", + "192.168.1.1", + "169.254.169.254", # cloud metadata + "100.64.0.1", # carrier-grade NAT + "0.0.0.0", + "192.0.2.1", # documentation + "224.0.0.1", # multicast + "255.255.255.255", + "::1", + "::", + "fc00::1", # unique local + "fe80::1", # link local + "ff02::1", # multicast + "::ffff:10.0.0.1", # IPv4-mapped + "2002:0a00:0001::", # 6to4 wrapping 10.0.0.1 + "64:ff9b::a00:1", # NAT64 wrapping 10.0.0.1 + ], + ) + def test_blocked(self, address): + assert not is_public_address(ipaddress.ip_address(address)) + + @pytest.mark.parametrize( + "address", + [ + "93.184.216.34", + "160.79.104.1", # Claude's egress range + "2606:4700::1111", + "::ffff:93.184.216.34", + "64:ff9b::5db8:d822", # NAT64 wrapping 93.184.216.34 + ], + ) + def test_allowed(self, address): + assert is_public_address(ipaddress.ip_address(address)) + + def test_resolution_requires_every_answer_public(self, monkeypatch): + def fake_getaddrinfo(host, port, **kwargs): + return [ + (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 443)), + (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("10.0.0.5", 443)), + ] + + monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo) + with pytest.raises(ClientMetadataError, match="public address"): + resolve_public_address(host="example.com", port=443) + + def test_resolution_returns_first_address(self, monkeypatch): + def fake_getaddrinfo(host, port, **kwargs): + return [ + ( + socket.AF_INET6, + socket.SOCK_STREAM, + 6, + "", + ("2606:4700::1111", 443, 0, 0), + ), + (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 443)), + ] + + monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo) + assert resolve_public_address(host="example.com", port=443) == "2606:4700::1111" + + def test_resolution_failure(self, monkeypatch): + def fake_getaddrinfo(host, port, **kwargs): + raise socket.gaierror("nope") + + monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo) + with pytest.raises(ClientMetadataError, match="resolve"): + resolve_public_address(host="example.com", port=443) + + +class TestCacheTtl: + @pytest.mark.parametrize( + ("header", "expected"), + [ + ("", 3600), + ("public, max-age=60", 300), + ("max-age=7200", 7200), + ("max-age=999999", 86400), + ("max-age=7200, s-maxage=600", 600), + ("no-store", 3600), + ("no-cache, max-age=7200", 3600), + ("max-age=abc", 3600), + ('max-age="900"', 900), + ], + ) + def test_ttl(self, header, expected): + assert cache_ttl_seconds(header) == expected + + +def _json_response(document, **kwargs): + headers = {"content-type": "application/json", **kwargs.pop("headers", {})} + return httpx.Response(200, content=json.dumps(document).encode(), headers=headers) + + +@pytest.fixture +def public_dns(monkeypatch): + monkeypatch.setattr( + cimd, "resolve_public_address", lambda *, host, port: "203.0.113.10" + ) + + +class TestFetch: + def test_request_is_pinned_to_the_resolved_address(self, public_dns): + seen = {} + + def handler(request): + seen["url"] = str(request.url) + seen["host"] = request.headers["host"] + seen["sni"] = request.extensions.get("sni_hostname") + seen["accept"] = request.headers["accept"] + return _json_response( + CLAUDE_DOCUMENT, headers={"cache-control": "max-age=600"} + ) + + document, ttl = fetch_metadata_document( + CLAUDE_URL, transport=httpx.MockTransport(handler) + ) + assert document == CLAUDE_DOCUMENT + assert ttl == 600 + assert seen["url"] == "https://203.0.113.10/oauth/mcp-oauth-client-metadata" + assert seen["host"] == "claude.ai" + assert seen["sni"] == "claude.ai" + assert seen["accept"] == "application/json" + + def test_ipv6_address_is_bracketed(self, monkeypatch): + monkeypatch.setattr( + cimd, "resolve_public_address", lambda *, host, port: "2606:4700::1111" + ) + seen = {} + + def handler(request): + seen["url"] = str(request.url) + return _json_response(CLAUDE_DOCUMENT) + + fetch_metadata_document(CLAUDE_URL, transport=httpx.MockTransport(handler)) + assert seen["url"].startswith("https://[2606:4700::1111]/") + + def test_non_default_port_is_kept(self, public_dns): + seen = {} + + def handler(request): + seen["url"] = str(request.url) + seen["host"] = request.headers["host"] + return _json_response({**CLAUDE_DOCUMENT, "client_id": "x"}) + + fetch_metadata_document( + "https://example.com:8443/client", transport=httpx.MockTransport(handler) + ) + assert seen["url"] == "https://203.0.113.10:8443/client" + assert seen["host"] == "example.com:8443" + + @pytest.mark.parametrize( + ("response", "reason"), + [ + (httpx.Response(302, headers={"location": "https://elsewhere/"}), "302"), + (httpx.Response(404), "404"), + (httpx.Response(500), "500"), + ( + httpx.Response( + 200, content=b"{}", headers={"content-type": "text/html"} + ), + "not JSON", + ), + ( + httpx.Response( + 200, content=b"{", headers={"content-type": "application/json"} + ), + "valid JSON", + ), + ( + httpx.Response( + 200, content=b"[]", headers={"content-type": "application/json"} + ), + "JSON object", + ), + ( + httpx.Response( + 200, + content=b"{" + b" " * 6000 + b"}", + headers={"content-type": "application/json"}, + ), + "too large", + ), + ], + ) + def test_rejected_responses(self, public_dns, response, reason): + with pytest.raises(ClientMetadataError, match=reason): + fetch_metadata_document( + CLAUDE_URL, transport=httpx.MockTransport(lambda request: response) + ) + + def test_declared_length_over_cap_is_refused_before_reading(self, public_dns): + def handler(request): + return httpx.Response( + 200, + headers={ + "content-type": "application/json", + "content-length": "100000", + }, + stream=httpx.ByteStream(b"{}"), + ) + + with pytest.raises(ClientMetadataError, match="too large"): + fetch_metadata_document(CLAUDE_URL, transport=httpx.MockTransport(handler)) + + def test_json_subtype_is_accepted(self, public_dns): + def handler(request): + return httpx.Response( + 200, + content=json.dumps(CLAUDE_DOCUMENT).encode(), + headers={ + "content-type": "application/oauth-client+json; charset=utf-8" + }, + ) + + document, _ = fetch_metadata_document( + CLAUDE_URL, transport=httpx.MockTransport(handler) + ) + assert document["client_name"] == "Claude" + + def test_transport_errors_become_metadata_errors(self, public_dns): + def handler(request): + raise httpx.ConnectError("refused") + + with pytest.raises(ClientMetadataError, match="Could not fetch"): + fetch_metadata_document(CLAUDE_URL, transport=httpx.MockTransport(handler)) + + def test_timeout_becomes_metadata_error(self, public_dns): + def handler(request): + raise httpx.ReadTimeout("slow") + + with pytest.raises(ClientMetadataError, match="timed out"): + fetch_metadata_document(CLAUDE_URL, transport=httpx.MockTransport(handler)) + + def test_dns_check_runs_before_any_request(self, monkeypatch): + def blocked(*, host, port): + raise ClientMetadataError("claude.ai does not resolve to a public address") + + monkeypatch.setattr(cimd, "resolve_public_address", blocked) + calls = [] + + def handler(request): + calls.append(request) + return _json_response(CLAUDE_DOCUMENT) + + with pytest.raises(ClientMetadataError, match="public address"): + fetch_metadata_document(CLAUDE_URL, transport=httpx.MockTransport(handler)) + assert calls == [] + + +class TestDocumentValidation: + def test_claude_document(self): + metadata = validate_metadata_document(url=CLAUDE_URL, document=CLAUDE_DOCUMENT) + assert metadata.name == "Claude" + assert metadata.redirect_uris == ["https://claude.ai/api/mcp/auth_callback"] + + def test_claude_code_document_keeps_portless_loopback_uris(self): + metadata = validate_metadata_document( + url=CLAUDE_CODE_URL, document=CLAUDE_CODE_DOCUMENT + ) + assert metadata.redirect_uris == [ + "http://localhost/callback", + "http://127.0.0.1/callback", + ] + + def test_chatgpt_document_is_rejected_for_private_key_jwt(self): + with pytest.raises(ClientMetadataError, match="private_key_jwt"): + validate_metadata_document(url=CHATGPT_URL, document=CHATGPT_DOCUMENT) + + def test_client_name_is_truncated(self): + document = {**CLAUDE_DOCUMENT, "client_name": "x" * 300} + assert ( + len(validate_metadata_document(url=CLAUDE_URL, document=document).name) + == 255 + ) + + @pytest.mark.parametrize( + ("changes", "reason"), + [ + ({"client_id": "https://claude.ai/oauth/other"}, "does not match"), + ({"client_id": CLAUDE_URL + "/"}, "does not match"), + ({"client_name": ""}, "client_name"), + ({"client_name": None}, "client_name"), + ({"client_name": ["Claude"]}, "client_name"), + ({"redirect_uris": []}, "redirect_uris"), + ({"redirect_uris": "https://claude.ai/cb"}, "redirect_uris"), + ({"redirect_uris": ["http://claude.ai/cb"]}, "HTTPS or loopback"), + ({"redirect_uris": ["javascript:alert(1)"]}, "HTTPS or loopback"), + ( + {"redirect_uris": ["https://claude.ai/cb https://evil/cb"]}, + "HTTPS or loopback", + ), + ({"redirect_uris": ["https://claude.ai/cb#frag"]}, "HTTPS or loopback"), + ({"redirect_uris": ["https://claude.ai/" + "x" * 2000]}, "too long"), + ({"token_endpoint_auth_method": "client_secret_basic"}, "not supported"), + ({"client_secret": "shh"}, "client secret"), + ({"client_secret_expires_at": 0}, "client secret"), + ({"grant_types": ["refresh_token"]}, "authorization_code"), + ({"grant_types": "authorization_code"}, "authorization_code"), + ({"response_types": ["token"]}, "code"), + ], + ) + def test_rejects(self, changes, reason): + document = {**CLAUDE_DOCUMENT, **changes} + with pytest.raises(ClientMetadataError, match=reason): + validate_metadata_document(url=CLAUDE_URL, document=document) + + def test_optional_fields_may_be_absent(self): + document = { + "client_id": CLAUDE_URL, + "client_name": "Minimal", + "redirect_uris": ["https://claude.ai/cb"], + } + metadata = validate_metadata_document(url=CLAUDE_URL, document=document) + assert metadata.name == "Minimal" diff --git a/plain-oauthserver/tests/public/test_cimd.py b/plain-oauthserver/tests/public/test_cimd.py new file mode 100644 index 0000000000..faaa99f2cf --- /dev/null +++ b/plain-oauthserver/tests/public/test_cimd.py @@ -0,0 +1,407 @@ +"""Contract tests for Client ID Metadata Document clients (MCP SEP-991). + +A client_id that is an HTTPS URL names a hosted metadata document. These +tests drive the full flow through the HTTP endpoints with the document fetch +stubbed at the network seam, using Claude's real documents as the fixtures. +""" + +from __future__ import annotations + +from datetime import timedelta + +import pytest +from oauth_helpers import generate_pkce_pair +from plain.oauthserver import cimd +from plain.oauthserver.cimd import ClientMetadataError +from plain.oauthserver.models import OAuthApplication +from plain.test import Client +from plain.utils import timezone + +CLAUDE_URL = "https://claude.ai/oauth/mcp-oauth-client-metadata" +CLAUDE_REDIRECT = "https://claude.ai/api/mcp/auth_callback" +CLAUDE_DOCUMENT = { + "client_id": CLAUDE_URL, + "client_name": "Claude", + "client_uri": "https://claude.ai", + "redirect_uris": [CLAUDE_REDIRECT], + "grant_types": [ + "authorization_code", + "refresh_token", + "urn:ietf:params:oauth:grant-type:jwt-bearer", + ], + "response_types": ["code"], + "token_endpoint_auth_method": "none", +} + +CLAUDE_CODE_URL = "https://claude.ai/oauth/claude-code-client-metadata" +CLAUDE_CODE_DOCUMENT = { + "client_id": CLAUDE_CODE_URL, + "client_name": "Claude Code", + "redirect_uris": ["http://localhost/callback", "http://127.0.0.1/callback"], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none", +} + + +class FakeFetch: + """Stands in for the network fetch: serves documents by URL and counts calls.""" + + def __init__(self, documents: dict[str, dict], *, ttl: int = 3600): + self.documents = documents + self.ttl = ttl + self.calls: list[str] = [] + self.failure: ClientMetadataError | None = None + + def __call__(self, url: str) -> tuple[dict, int]: + self.calls.append(url) + if self.failure is not None: + raise self.failure + if url not in self.documents: + raise ClientMetadataError("Metadata document responded with status 404") + return self.documents[url], self.ttl + + +@pytest.fixture +def fetch(monkeypatch): + fake = FakeFetch( + {CLAUDE_URL: CLAUDE_DOCUMENT, CLAUDE_CODE_URL: CLAUDE_CODE_DOCUMENT} + ) + monkeypatch.setattr(cimd, "fetch_metadata_document", fake) + return fake + + +def _authorize_params(client_id, redirect_uri, challenge, **extra): + return { + "response_type": "code", + "client_id": client_id, + "redirect_uri": redirect_uri, + "scope": "offline_access", + "state": "s", + "code_challenge": challenge, + "code_challenge_method": "S256", + **extra, + } + + +class TestMetadata: + def test_advertised_by_default(self, db): + data = Client().get("/.well-known/oauth-authorization-server").json() + # Claude requires both of these before it will use CIMD. + assert data["client_id_metadata_document_supported"] is True + assert "none" in data["token_endpoint_auth_methods_supported"] + + def test_omitted_when_disabled(self, db, monkeypatch): + from plain.runtime import settings + + monkeypatch.setattr( + settings, "OAUTH_SERVER_ALLOW_CLIENT_ID_METADATA_DOCUMENTS", False + ) + data = Client().get("/.well-known/oauth-authorization-server").json() + assert "client_id_metadata_document_supported" not in data + + +class TestEndToEnd: + def test_claude_full_flow_without_registration( + self, db, authenticated_client, user, fetch + ): + verifier, challenge = generate_pkce_pair() + + consent = authenticated_client.get( + "/oauth/authorize", + data=_authorize_params(CLAUDE_URL, CLAUDE_REDIRECT, challenge), + ) + assert consent.status_code == 200 + body = consent.content.decode() + assert "Claude" in body + assert "Client hosted at claude.ai" in body + assert "sent back to claude.ai" in body + assert "runs on your own computer" not in body + assert fetch.calls == [CLAUDE_URL] + + approve = authenticated_client.post( + "/oauth/authorize", + data=_authorize_params( + CLAUDE_URL, + CLAUDE_REDIRECT, + challenge, + action="approve", + resource="https://mcp.example.com/mcp", + ), + ) + assert approve.status_code == 302 + assert approve.headers["Location"].startswith(CLAUDE_REDIRECT + "?") + code = approve.headers["Location"].split("code=")[1].split("&")[0] + # The stored document is fresh, so the POST didn't refetch. + assert fetch.calls == [CLAUDE_URL] + + tokens = Client().post( + "/oauth/token", + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": CLAUDE_REDIRECT, + "client_id": CLAUDE_URL, + "code_verifier": verifier, + }, + ) + assert tokens.status_code == 200 + access_token = tokens.json()["access_token"] + refresh_token = tokens.json()["refresh_token"] + + from plain.oauthserver import validate_access_token + + stored = validate_access_token( + access_token, resource="https://mcp.example.com/mcp" + ) + assert stored is not None + assert stored.user.id == user.id + assert stored.application.client_id == CLAUDE_URL + + refreshed = Client().post( + "/oauth/token", + data={ + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": CLAUDE_URL, + }, + ) + assert refreshed.status_code == 200 + + revoke = Client().post( + "/oauth/revoke", + data={"token": refreshed.json()["access_token"], "client_id": CLAUDE_URL}, + ) + assert revoke.status_code == 200 + + # One row for the client, and the token endpoints never fetched. + assert OAuthApplication.query.filter(client_id=CLAUDE_URL).count() == 1 + assert fetch.calls == [CLAUDE_URL] + + def test_claude_code_loopback_with_ephemeral_port( + self, db, authenticated_client, fetch + ): + _, challenge = generate_pkce_pair() + redirect_uri = "http://localhost:3118/callback" + + consent = authenticated_client.get( + "/oauth/authorize", + data=_authorize_params(CLAUDE_CODE_URL, redirect_uri, challenge), + ) + assert consent.status_code == 200 + body = consent.content.decode() + assert "Claude Code" in body + assert "runs on your own computer" in body + + approve = authenticated_client.post( + "/oauth/authorize", + data=_authorize_params( + CLAUDE_CODE_URL, redirect_uri, challenge, action="approve" + ), + ) + assert approve.status_code == 302 + assert approve.headers["Location"].startswith(redirect_uri + "?code=") + + def test_second_client_reuses_stored_document( + self, db, authenticated_client, fetch + ): + _, challenge = generate_pkce_pair() + for _ in range(3): + response = authenticated_client.get( + "/oauth/authorize", + data=_authorize_params(CLAUDE_URL, CLAUDE_REDIRECT, challenge), + ) + assert response.status_code == 200 + assert fetch.calls == [CLAUDE_URL] + assert OAuthApplication.query.filter(client_id=CLAUDE_URL).count() == 1 + + +class TestRefresh: + def _stored(self, *, fetched_ago: timedelta, expired: bool = True): + now = timezone.now() + return OAuthApplication.query.create( + client_id=CLAUDE_URL, + name="Claude", + redirect_uris=CLAUDE_REDIRECT, + metadata_fetched_at=now - fetched_ago, + metadata_expires_at=now - timedelta(seconds=1) + if expired + else now + timedelta(hours=1), + ) + + def test_expired_document_is_refetched_and_changes_applied( + self, db, authenticated_client, fetch + ): + self._stored(fetched_ago=timedelta(hours=2)) + fetch.documents[CLAUDE_URL] = { + **CLAUDE_DOCUMENT, + "client_name": "Claude (renamed)", + "redirect_uris": [ + CLAUDE_REDIRECT, + "https://claude.ai/api/mcp/auth_callback2", + ], + } + _, challenge = generate_pkce_pair() + + response = authenticated_client.get( + "/oauth/authorize", + data=_authorize_params( + CLAUDE_URL, "https://claude.ai/api/mcp/auth_callback2", challenge + ), + ) + assert response.status_code == 200 + assert "Claude (renamed)" in response.content.decode() + assert fetch.calls == [CLAUDE_URL] + + application = OAuthApplication.query.get(client_id=CLAUDE_URL) + assert application.metadata_expires_at is not None + assert application.metadata_expires_at > timezone.now() + assert len(application.get_redirect_uris()) == 2 + + def test_failed_refetch_serves_the_stored_document( + self, db, authenticated_client, fetch + ): + self._stored(fetched_ago=timedelta(days=2)) + fetch.failure = ClientMetadataError( + "Metadata document responded with status 503" + ) + _, challenge = generate_pkce_pair() + + response = authenticated_client.get( + "/oauth/authorize", + data=_authorize_params(CLAUDE_URL, CLAUDE_REDIRECT, challenge), + ) + assert response.status_code == 200 + assert "Claude" in response.content.decode() + assert "Could not use client_id" not in response.content.decode() + + def test_stale_beyond_grace_period_fails(self, db, authenticated_client, fetch): + self._stored(fetched_ago=timedelta(days=8)) + fetch.failure = ClientMetadataError( + "Metadata document responded with status 503" + ) + _, challenge = generate_pkce_pair() + + response = authenticated_client.get( + "/oauth/authorize", + data=_authorize_params(CLAUDE_URL, CLAUDE_REDIRECT, challenge), + ) + body = response.content.decode() + assert "Could not use client_id" in body + assert "status 503" in body + assert "code_challenge" not in body # no consent form rendered + + +class TestRejections: + def test_unfetchable_document_is_shown_not_redirected( + self, db, authenticated_client, fetch + ): + unknown = "https://claude.ai/oauth/does-not-exist" + _, challenge = generate_pkce_pair() + + response = authenticated_client.get( + "/oauth/authorize", + data=_authorize_params(unknown, CLAUDE_REDIRECT, challenge), + ) + assert response.status_code == 200 + body = response.content.decode() + assert "Could not use client_id https://claude.ai/oauth/does-not-exist" in body + assert "status 404" in body + assert not OAuthApplication.query.filter(client_id=unknown).exists() + + def test_redirect_uri_not_in_document(self, db, authenticated_client, fetch): + _, challenge = generate_pkce_pair() + response = authenticated_client.get( + "/oauth/authorize", + data=_authorize_params( + CLAUDE_URL, "https://evil.example.com/cb", challenge + ), + ) + assert "Invalid redirect_uri" in response.content.decode() + + def test_invalid_url_never_fetched(self, db, authenticated_client, fetch): + _, challenge = generate_pkce_pair() + response = authenticated_client.get( + "/oauth/authorize", + data=_authorize_params( + "https://claude.ai/oauth/../metadata", CLAUDE_REDIRECT, challenge + ), + ) + assert "dot segments" in response.content.decode() + assert fetch.calls == [] + + def test_token_endpoint_never_fetches(self, db, fetch): + response = Client().post( + "/oauth/token", + data={ + "grant_type": "authorization_code", + "code": "x", + "redirect_uri": CLAUDE_REDIRECT, + "client_id": CLAUDE_URL, + "code_verifier": "y", + }, + ) + assert response.status_code == 401 + assert response.json()["error"] == "invalid_client" + assert fetch.calls == [] + + def test_disabled_setting_treats_url_as_unknown( + self, db, authenticated_client, fetch, monkeypatch + ): + from plain.runtime import settings + + monkeypatch.setattr( + settings, "OAUTH_SERVER_ALLOW_CLIENT_ID_METADATA_DOCUMENTS", False + ) + _, challenge = generate_pkce_pair() + response = authenticated_client.get( + "/oauth/authorize", + data=_authorize_params(CLAUDE_URL, CLAUDE_REDIRECT, challenge), + ) + assert "Unknown client_id" in response.content.decode() + assert fetch.calls == [] + + def test_allowed_hosts(self, db, authenticated_client, fetch, monkeypatch): + from plain.runtime import settings + + monkeypatch.setattr( + settings, "OAUTH_SERVER_CLIENT_ID_METADATA_ALLOWED_HOSTS", ["example.com"] + ) + _, challenge = generate_pkce_pair() + response = authenticated_client.get( + "/oauth/authorize", + data=_authorize_params(CLAUDE_URL, CLAUDE_REDIRECT, challenge), + ) + assert ( + "claude.ai is not an allowed client metadata host" + in response.content.decode() + ) + assert fetch.calls == [] + + monkeypatch.setattr( + settings, "OAUTH_SERVER_CLIENT_ID_METADATA_ALLOWED_HOSTS", ["claude.ai"] + ) + response = authenticated_client.get( + "/oauth/authorize", + data=_authorize_params(CLAUDE_URL, CLAUDE_REDIRECT, challenge), + ) + assert response.status_code == 200 + assert fetch.calls == [CLAUDE_URL] + + +class TestRegisteredClientsUnaffected: + def test_random_client_id_still_looked_up( + self, db, authenticated_client, public_app, fetch + ): + _, challenge = generate_pkce_pair() + response = authenticated_client.get( + "/oauth/authorize", + data=_authorize_params( + public_app.client_id, "http://localhost:3000/callback", challenge + ), + ) + assert response.status_code == 200 + body = response.content.decode() + assert "Test App" in body + assert "Client hosted at" not in body + assert fetch.calls == [] diff --git a/uv.lock b/uv.lock index 8937cf316f..0c7d3637f2 100644 --- a/uv.lock +++ b/uv.lock @@ -1206,6 +1206,7 @@ name = "plain-oauthserver" version = "0.1.4" source = { editable = "plain-oauthserver" } dependencies = [ + { name = "httpx" }, { name = "plain" }, { name = "plain-auth" }, { name = "plain-postgres" }, @@ -1219,6 +1220,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "httpx", specifier = ">=0.27" }, { name = "plain", editable = "plain" }, { name = "plain-auth", editable = "plain-auth" }, { name = "plain-postgres", editable = "plain-postgres" },