Skip to main content

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

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)

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 scope and bound resource.
  • 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

Metadata

Release files for plain.oauthserver 0.1.5

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for plain.oauthserver 0.1.5
File Size Uploaded
plain_oauthserver-0.1.5.tar.gz 26.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for plain.oauthserver 0.1.5
File Interpreter ABI Platform
plain_oauthserver-0.1.5-py3-none-any.whl Python 3 none any Details

Total release size: 49.5 kB

Release files / plain_oauthserver-0.1.5.tar.gz

Download URL plain_oauthserver-0.1.5.tar.gz
Size 26.3 kB
Tags Source
SHA-256 checksum
How to use checksums
db7c3be19dc26ae3d667ad82c3bf4c3dea3558ae097bf791d5d48ba74bdda7f2
BLAKE2b-256 checksum
How to use checksums
c5667ed6845b1461b503a540901274e2d84489e36160ba6628a44828c643ebc0
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.17 {"installer":{"name":"uv","version":"0.12.17","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}

Release files / plain_oauthserver-0.1.5-py3-none-any.whl

Download URL plain_oauthserver-0.1.5-py3-none-any.whl
Size 23.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
0d119c00e1de7d6096989797fefc72e7dda1322c66501b40b32d6c57d229faf4
BLAKE2b-256 checksum
How to use checksums
2e41d388a4f64cbda0deec5f703850b08a1028cf20003e0c24ee2c887c8589c3
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.17 {"installer":{"name":"uv","version":"0.12.17","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}

Release history Release notifications | RSS feed

0.2.0

2 release files

0.1.6

2 release files

This release

0.1.5 This release

2 release files

0.1.4

2 release files

0.1.3

2 release files

0.1.2

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page