Skip to main content

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

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

Project details


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.1.tar.gz (25.4 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.1-py3-none-any.whl (22.3 kB view details)

Uploaded Python 3

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

Hashes for plain_oauthserver-0.1.1.tar.gz
Algorithm Hash digest
SHA256 fb300e178c2a61888ff728717f863bb4f3ff3590b7f9d9e0c58724593e778bb1
MD5 5cdfb3e70d65e725ca0107264edae1ce
BLAKE2b-256 13e4a8bb1588e559962f721af1c5dd5efbc58e349966e70d12373dec45ffe26f

See more details on using hashes here.

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

Hashes for plain_oauthserver-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 cea55f54586e7bd1b0f6a08eba2003a1b6b00e3748d02e1b0e66b5c531d5e231
MD5 9cb5baf3b4f78000e839b1f7d68e95dc
BLAKE2b-256 d5226a97f462acb0af5c52d4d75e98da24d755ab539bb3f4d5913148e052cd63

See more details on using hashes here.

Supported by

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