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 full handshake is automatic once both halves are in place:

  1. The client hits your protected MCP endpoint with no token and gets a 401 whose WWW-Authenticate header points at the resource's metadata (see Protecting a resource).
  2. The client reads that metadata, finds this authorization server, and fetches /.well-known/oauth-authorization-server.
  3. It registers itself as a public client via dynamic client registration — no manual setup.
  4. It opens a browser to /oauth/authorize; the user logs in and approves.
  5. It exchanges the code (with PKCE) at /oauth/token for an access + refresh token, then calls the MCP endpoint with Authorization: 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 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.0.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.0-py3-none-any.whl (22.7 kB view details)

Uploaded Python 3

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

Hashes for plain_oauthserver-0.1.0.tar.gz
Algorithm Hash digest
SHA256 f4082fc24832841577d7862658f9cd9417eef48a13c5ad7437cf0de0b4729556
MD5 10d7f20004f498aec9b23cfd0428379a
BLAKE2b-256 16a51bdd5cd4102542efb78c3192cbf93f0e081f6429d903dbc2d4ba245b6aee

See more details on using hashes here.

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

Hashes for plain_oauthserver-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 5357c9aab2646872ac0429a75984fc266f4890d6f7c69b2d9a89e976a35432c9
MD5 8cc1e801d6775891b5897548ebf2faf6
BLAKE2b-256 d0bd934801edf5d7275f56709d958cdfd820bab2e9514b9fa9db6335c3a797b1

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