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)

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 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

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

plain_oauthserver-0.1.3.tar.gz (25.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

plain_oauthserver-0.1.3-py3-none-any.whl (22.8 kB view details)

Uploaded Python 3

File details

Details for the file plain_oauthserver-0.1.3.tar.gz.

File metadata

  • Download URL: plain_oauthserver-0.1.3.tar.gz
  • Upload date:
  • Size: 25.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.12.1 {"installer":{"name":"uv","version":"0.12.1","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

Hashes for plain_oauthserver-0.1.3.tar.gz
Algorithm Hash digest
SHA256 f6462e453e4687753b60b6608738b5edd2885743deb6aaebc64356ab79fedb75
MD5 537e11a0a40354cfb85350c43ad7b4f8
BLAKE2b-256 816fa5c25e7facc23e73b3188fa50e2f8b16c3c839347a7d407bbacfb00e19a8

See more details on using hashes here.

File details

Details for the file plain_oauthserver-0.1.3-py3-none-any.whl.

File metadata

  • Download URL: plain_oauthserver-0.1.3-py3-none-any.whl
  • Upload date:
  • Size: 22.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.12.1 {"installer":{"name":"uv","version":"0.12.1","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

Hashes for plain_oauthserver-0.1.3-py3-none-any.whl
Algorithm Hash digest
SHA256 29c302532021b63754c689d98bce8272b45adea71a32170873292832fd6d7647
MD5 afbeff1a01b227ef60bf8f227aa2c300
BLAKE2b-256 15921fbbf601d6a5ce7a4382884c373f67e9fccbf9dc385c540c01ca2785c78e

See more details on using hashes here.

Release history Release notifications | RSS feed

0.1.4

2 files

This release

0.1.3 This release

2 files

0.1.2

2 files

0.1.1

2 files

0.1.0

2 files

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page