Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions plain-mcp/plain/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <token>`.

Expand Down
70 changes: 50 additions & 20 deletions plain-oauthserver/plain/oauthserver/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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):
Expand All @@ -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.

Expand All @@ -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.
Expand Down
Loading
Loading