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 client hits your protected endpoint with no token, discovers this server, registers itself, 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, 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:
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
That's the seam for any resource server. Protecting a plain.mcp endpoint? Its OAuthResourceServer mixin wraps this validator and handles the 401 challenge and resource-metadata document for you — see OAuth for MCP clients.
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.1.tar.gz.
File metadata
- Download URL: plain_oauthserver-0.1.1.tar.gz
- Upload date:
- Size: 25.4 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 |
fb300e178c2a61888ff728717f863bb4f3ff3590b7f9d9e0c58724593e778bb1
|
|
| MD5 |
5cdfb3e70d65e725ca0107264edae1ce
|
|
| BLAKE2b-256 |
13e4a8bb1588e559962f721af1c5dd5efbc58e349966e70d12373dec45ffe26f
|
File details
Details for the file plain_oauthserver-0.1.1-py3-none-any.whl.
File metadata
- Download URL: plain_oauthserver-0.1.1-py3-none-any.whl
- Upload date:
- Size: 22.3 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 |
cea55f54586e7bd1b0f6a08eba2003a1b6b00e3748d02e1b0e66b5c531d5e231
|
|
| MD5 |
9cb5baf3b4f78000e839b1f7d68e95dc
|
|
| BLAKE2b-256 |
d5226a97f462acb0af5c52d4d75e98da24d755ab539bb3f4d5913148e052cd63
|