OAuth 2.1 authorization server for Plain apps.
Project description
plain.oauthserver
An OAuth 2.1 authorization server for Plain apps — enough to let an MCP client like Claude connect as one of your users.
- Overview
- Connecting an MCP client
- Clients are public
- Dynamic client registration
- Protecting a resource
- Endpoints
- Consent template
- Models
- Settings
- FAQs
- Installation
Overview
You can turn any Plain app into an OAuth 2.1 authorization server. Mount two routers — the server endpoints (anywhere) and the metadata document (at the domain root, where clients look for it):
# app/urls.py
from plain.oauthserver.urls import OAuthServerRouter, OAuthWellKnownRouter
from plain.urls import Router, include
class AppRouter(Router):
namespace = ""
urls = [
include("oauth/", OAuthServerRouter),
include(".well-known/", OAuthWellKnownRouter),
]
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 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 full handshake is automatic once both halves are in place:
- The client hits your protected MCP endpoint with no token and gets a
401whoseWWW-Authenticateheader points at the resource's metadata (see Protecting a resource). - The client reads that metadata, finds this authorization server, and fetches
/.well-known/oauth-authorization-server. - It registers itself as a public client via dynamic client registration — no manual setup.
- It opens a browser to
/oauth/authorize; the user logs in and approves. - It exchanges the code (with PKCE) at
/oauth/tokenfor an access + refresh token, then calls the MCP endpoint withAuthorization: Bearer <token>.
You don't write any of that — you mount the routers, protect the resource, and the client drives the rest.
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:
from plain.oauthserver.models import OAuthApplication
app = OAuthApplication(
name="My CLI",
redirect_uris="http://127.0.0.1/callback", # space-separate multiple URIs
)
app.create()
print(app.client_id)
Redirect URIs must be HTTPS or loopback. Loopback URIs (http://127.0.0.1/..., http://localhost/...) match regardless of port, since a CLI's port isn't knowable at registration time (RFC 8252).
Dynamic client registration
RegisterView implements RFC 7591 at /oauth/register. A client POSTs its redirect_uris (and optional client_name) and gets back a client_id — always a public one. This is what lets a user paste only a URL into Claude — the client registers itself.
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.
Protecting a resource
The server issues tokens; validating them is the resource server's job. validate_access_token resolves a bearer value to its live AccessToken (returning None for unknown, expired, or revoked tokens, and enforcing audience binding when a resource is given):
from plain.oauthserver import validate_access_token
token = validate_access_token(bearer, resource="https://myapp.com/mcp")
if token is not None:
user = token.user
For a plain.mcp endpoint, compose OAuthResourceServer and wire it to this validator:
# app/mcp.py
from plain.mcp import MCPView, OAuthResourceServer, TokenInfo
from plain.oauthserver import validate_access_token
class AppMCP(OAuthResourceServer, MCPView):
name = "myapp"
tools = [...]
def authenticate_token(self, token):
at = validate_access_token(token, resource=self.oauth_resource)
return TokenInfo(at.user, at.scopes) if at else None
plain.mcp handles the 401 challenge and the resource-metadata document; see its README for the routing.
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) |
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.
Models
- OAuthApplication — a registered public client (no secret).
- AuthorizationCode — single-use code carrying the PKCE challenge and bound
resource. - AccessToken — bearer token, stored as a SHA-256 hash so a database leak can't be replayed. Carries the granted
scopeand boundresource. - 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 |
All settings can be set via PLAIN_-prefixed environment variables.
FAQs
Why is PKCE mandatory?
OAuth 2.1 requires PKCE for every authorization-code grant to prevent code-interception attacks. Only the S256 method is accepted; plain is rejected.
How are tokens stored?
Access and refresh tokens are generated, returned to the client once, and persisted only as a SHA-256 hash. Validation re-hashes the incoming bearer and looks it up — the plaintext is never on disk. Authorization codes are stored directly since they're single-use and short-lived.
How does refresh rotation work?
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.
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.
How do expired tokens get cleaned up?
Refresh rotation issues a fresh pair on every use, so spent codes and revoked/expired tokens accumulate. The ClearExpiredOAuthTokens chore deletes them — run it on a schedule with plain chores run. It keeps an expired access token alive while a still-valid refresh token points at it, so refreshing never breaks.
Installation
Install the plain.oauthserver package from PyPI:
uv add plain-oauthserver
Add it to INSTALLED_PACKAGES (it needs plain.auth and plain.templates):
# app/settings.py
INSTALLED_PACKAGES = [
"plain.auth",
"plain.sessions",
"plain.postgres",
"plain.templates",
"plain.oauthserver",
...
]
Then sync the database:
uv run plain postgres sync
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file plain_oauthserver-0.1.0.tar.gz.
File metadata
- Download URL: plain_oauthserver-0.1.0.tar.gz
- Upload date:
- Size: 25.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.25 {"installer":{"name":"uv","version":"0.11.25","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f4082fc24832841577d7862658f9cd9417eef48a13c5ad7437cf0de0b4729556
|
|
| MD5 |
10d7f20004f498aec9b23cfd0428379a
|
|
| BLAKE2b-256 |
16a51bdd5cd4102542efb78c3192cbf93f0e081f6429d903dbc2d4ba245b6aee
|
File details
Details for the file plain_oauthserver-0.1.0-py3-none-any.whl.
File metadata
- Download URL: plain_oauthserver-0.1.0-py3-none-any.whl
- Upload date:
- Size: 22.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.25 {"installer":{"name":"uv","version":"0.11.25","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5357c9aab2646872ac0429a75984fc266f4890d6f7c69b2d9a89e976a35432c9
|
|
| MD5 |
8cc1e801d6775891b5897548ebf2faf6
|
|
| BLAKE2b-256 |
d0bd934801edf5d7275f56709d958cdfd820bab2e9514b9fa9db6335c3a797b1
|