Skip to main content

origo

CI PyPI version Python versions

Implements the OAuth2.1 + PKCE flow as a drop-in Starlette based middleware layer, with public and private registration modes.

Drop-in OAuth 2.1 provider, originally developed for use in custom/private MCP servers. Handles the full Authorization Code + PKCE flow with no external identity provider required.

Works with FastMCP, FastAPI, the raw MCP SDK, and MCP clients on the OpenAI platform such as ChatGPT connectors.

Install

pip install origo

Quickstart

FastMCP

from fastmcp import FastMCP
from origo import OAuthProvider, OAuthMiddleware
from starlette.routing import Mount
from starlette.applications import Starlette
import os

auth = OAuthProvider(
    base_url="https://mcp.yourdomain.com",
    clients={os.getenv("MCP_CLIENT_ID"): os.getenv("MCP_CLIENT_SECRET")},
)

mcp = FastMCP("my-server")

# ... define tools ...

mcp_app = mcp.streamable_http_app()
mcp_app.add_middleware(OAuthMiddleware, provider=auth)

# OAuth must be at root so /.well-known/ discovery works for MCP clients
root = Starlette(routes=[
    Mount("/mcp", app=mcp_app),      # protected MCP endpoint
    Mount("/", app=auth.asgi_app()), # /.well-known/, /authorize, /token, /register
])

FastAPI

from fastapi import FastAPI
from origo import OAuthProvider, OAuthMiddleware
from starlette.routing import Mount
from starlette.applications import Starlette
import os

auth = OAuthProvider(
    base_url="https://api.yourdomain.com",
    clients={os.getenv("OAUTH_CLIENT_ID"): os.getenv("OAUTH_CLIENT_SECRET")},
)

api = FastAPI()
api.add_middleware(OAuthMiddleware, provider=auth)

# ... define routes on api ...

app = Starlette(routes=[
    Mount("/api", app=api),          # protected API routes
    Mount("/", app=auth.asgi_app()), # OAuth at root
])

MCP over SSE (/sse) deployments

Some MCP clients and hosted connector surfaces expect an SSE MCP endpoint such as https://mcp.yourdomain.com/sse instead of a streamable HTTP endpoint such as /mcp. Origo does not implement the MCP transport itself; your MCP server or deployment chooses whether /sse, /mcp, or both exist. Origo should wrap that MCP ASGI app with OAuthMiddleware, and mcp_path must match the externally visible protected MCP route so /.well-known/oauth-protected-resource advertises the same resource URI the client will request.

from fastmcp import FastMCP
from origo import OAuthMiddleware, OAuthProvider
from starlette.applications import Starlette
from starlette.routing import Mount
import os

auth = OAuthProvider(
    base_url="https://mcp.yourdomain.com",
    clients={os.getenv("MCP_CLIENT_ID"): os.getenv("MCP_CLIENT_SECRET")},
    mcp_path="/sse",  # resource metadata becomes https://mcp.yourdomain.com/sse
)

mcp = FastMCP("my-server")

# Use your MCP framework's SSE ASGI app here. The exact constructor varies by
# framework/version; for FastMCP this may be `sse_app()` in SSE deployments.
sse_app = mcp.sse_app()
sse_app.add_middleware(OAuthMiddleware, provider=auth)

app = Starlette(routes=[
    Mount("/sse", app=sse_app),       # protected SSE MCP endpoint
    Mount("/", app=auth.asgi_app()), # OAuth and /.well-known/ discovery
])

If your server exposes both /mcp and /sse, create the protected-resource metadata for the endpoint your connector is configured to call. For multiple public MCP resources on one host, use separate OAuthProvider instances or deploy separate base URLs so each provider advertises one canonical resource value.

How this differs from enterprise OAuth

Traditional OAuth deployments separate the authorization server from the resource server — the MCP server asks a dedicated auth service "is this token valid?" on every request (RFC 7662 token introspection). This is correct for multi-tenant systems where tokens need to be revoked instantly across many services.

origo collapses this into a single process. Token validation is an in-process lookup. Fast, zero network overhead, no second service to run. The tradeoff is that there's no centralized auth service to share across multiple resource servers. This also introduces a single point of failure, and security relies on sharing a process with the application it is authenticating for.

By default, origo persists state to a local SQLite file, so tokens survive restarts and redeploys — revocation means deleting rows from (or simply deleting) that file, not restarting. Pass storage_path=None for the old in-memory-only behavior instead, where a restart revokes everything (and forces every client through interactive re-authorization). See Token persistence for the default file location and how to relocate or disable it.

Use this when:

  • You're running a personal or private server (ex. MCP server) with simple OAuth requirements
  • You control who gets client credentials
  • Operational simplicity matters more than enterprise auth guarantees

Use a proper auth server (Keycloak, Auth0, etc.) when:

  • Multiple users need independent identities
  • You need instant token revocation
  • You're sharing one auth service across many servers (ex. MCP servers)
  • Compliance requirements mandate it

Two Modes

Private (default)

Only pre-registered clients can authenticate. Pass a clients dict:

auth = OAuthProvider(
    base_url="https://mcp.yourdomain.com",
    clients={"my-client-id": "my-client-secret"},
    public_registration=False,  # default
)

Public

Anyone can register as a client dynamically (DCR). A consent page is shown before access is granted:

auth = OAuthProvider(
    base_url="https://mcp.yourdomain.com",
    public_registration=True,
)

Dynamically registered clients must supply redirect_uris at registration time. The /authorize endpoint validates the redirect_uri parameter against that registered list and rejects any URI not on it. Pre-registered clients (supplied via clients=) get their allowlist from client_redirect_uris and are held to the same exact-match, fail-closed rule: a pre-registered client with no configured URIs rejects every redirect_uri.

Redirect URIs for pre-registered clients

Every rejected redirect_uri is logged on the origo logger (WARNING level) together with its client_id, so when a connector with an undocumented callback URL fails at /authorize, the exact value to add to client_redirect_uris is one log line away.

A pre-registered confidential client (one with a real secret) can opt out of exact matching entirely with the ANY_REDIRECT_URI sentinel, as the whole allowlist:

from origo import ANY_REDIRECT_URI, OAuthProvider

auth = OAuthProvider(
    base_url="https://mcp.yourdomain.com",
    clients={"my-client-id": "my-client-secret"},
    client_redirect_uris={"my-client-id": ANY_REDIRECT_URI},  # or [ANY_REDIRECT_URI]
)

This exists for single-operator deployments facing connector surfaces (ChatGPT, Grok, …) whose callback URLs are undocumented or change, where the alternative would be the strictly more open public_registration=True. Know what it trades away before using it:

  • What still holds: the client secret gates /token, so an authorization code that leaks to an attacker's redirect URI cannot be exchanged for a token; scheme validation still applies (https only, plus the loopback exemption and any custom_redirect_uri_schemes, never javascript:/data:); PKCE and state work unchanged; every wildcard-accepted URI is logged at INFO with its client_id, so you can harvest real connector callbacks and later pin them down to an exact list.
  • What it gives up: exact redirect URI matching is a defense-in-depth layer required by the OAuth Security BCP (RFC 9700) — with it off, everything rests on the secret staying secret, and /authorize will bounce a browser to any https URL for anyone who knows the public client_id (with auto_approve=True, without even a consent page in between).

The sentinel is rejected at startup for clients without a secret, when mixed with explicit URIs, and is never available to dynamically registered clients.

Redirect URIs are validated for header safety independently of the fronting server: a redirect_uri containing a C0 control character (CR/LF response-splitting, NUL, tab, …), DEL, or a lone surrogate is rejected at /authorize (and at dynamic registration) rather than reflected into a Location header or allowed to raise an encoding error. origo does not rely on the ASGI server in front of it to strip such input.

By default, dynamically registered redirect_uris must use https (or http://localhost/127.0.0.1/::1 for the RFC 8252 §7.3 native-app loopback exemption). Native/mobile app clients that use a private-use URI scheme instead (RFC 8252 §7.1, e.g. myapp://callback) are rejected unless the operator explicitly opts in:

auth = OAuthProvider(
    base_url="https://mcp.yourdomain.com",
    public_registration=True,
    custom_redirect_uri_schemes=["myapp"],
)

Only schemes listed here are accepted — arbitrary foo:// schemes are always rejected, since an unclaimed scheme could be registered by another app on the same device.

Token persistence

origo persists OAuth state to SQLite by default. Every restart, redeploy, or crash used to silently log out every connected client — each one had to go through interactive re-authorization, which for long-lived MCP connectors presented as "auth randomly breaks". As of this version, that's no longer the default: with no code changes at all, an existing OAuthProvider(...) call now writes tokens to a SQLite file instead of keeping them only in memory. It stays a drop-in: same process, no extra service.

auth = OAuthProvider(
    base_url="https://mcp.yourdomain.com",
    clients={os.getenv("MCP_CLIENT_ID"): os.getenv("MCP_CLIENT_SECRET")},
    # storage_path omitted -> persists automatically, see below
)

Where it persists to. With storage_path omitted, origo writes to ./.origo/<hash>.db relative to the process's working directory, where <hash> is derived from base_url + mcp_path (so multiple OAuthProvider instances, or restarts of the same deployment, land on the same file without a collision). Two ways to change that without touching code:

  • ORIGO_STORAGE_PATH=/data/origo (env var) — persists under that directory instead. Point this at a mounted volume so tokens survive a full redeploy, not just an in-process restart — ./.origo under an ephemeral container filesystem only survives a crash/reload of the same container.
  • ORIGO_STORAGE_PATH= (set to the empty string) — forces in-memory storage, restoring the pre-persistence behavior without editing code. Useful for test suites and CI.

And two ways in code:

  • storage_path=None — the permanent, explicit opt-out: always in-memory, regardless of ORIGO_STORAGE_PATH.
  • storage_path="/exact/path/to/origo.db" — persist to that exact file, ignoring the auto-derived default and ORIGO_STORAGE_PATH.

If the default path can't be created or opened (read-only filesystem, permission error), origo warns and falls back to in-memory storage for that run rather than failing to start — persistence quietly degrading to the old behavior is the safe failure mode for something that was never explicitly requested. An explicit storage_path that can't be opened raises instead, since a caller who asked for persistence by name should find out immediately if they didn't get it.

What persists: access tokens, refresh tokens, pending auth codes, and dynamically-registered (DCR/CIMD) clients. What doesn't: pre-registered clients= (your config re-seeds them every boot, so they are never written to disk) and the per-process RSA signing key (ID tokens are verified at delivery, so a restart only rotates the JWKS).

Security properties of the file:

  • Every credential — auth codes, access tokens, refresh tokens, dynamic client secrets — is stored only as a SHA-256 hash. A copy of the database file yields no replayable credential. (This works because origo generates all of these as high-entropy random values; there is nothing guessable to attack offline.)
  • The file is created with 0600 permissions, and SQLite's WAL sidecar files inherit that mode.
  • Because dynamic client secrets are hashed, they exist in plaintext only in the one /register response that delivered them.

Two operational consequences worth knowing:

  • Revocation: with in-memory storage a restart revoked everything; with persistence it deliberately doesn't. To revoke, delete rows from the SQLite file (or delete the file) — tokens are keyed by SHA-256 hash of the token value.
  • Registration flooding: dynamically-registered clients also survive restarts, so on a public_registration=True deployment the max_dynamic_clients cap can now fill up permanently instead of being cleared by the next restart. Set client_ttl so abandoned registrations expire; origo warns at startup if you don't.

If you run multiple workers/processes against the same file, SQLite's WAL mode plus origo's transactional single-use exchanges (including reuse-detection's revoked-family marker) keep codes and refresh tokens atomic across processes — but note each process still generates its own RSA signing key, so run a single process if you rely on ID tokens.

Upgrading from an in-memory-only origo version: if your test suite constructs more than one OAuthProvider with the same base_url/mcp_path pair across different test cases and expects each to start with an empty store (a common pattern — reusing a fixed test base_url everywhere), those instances now share one persisted file by default and will leak state between tests. Set ORIGO_STORAGE_PATH="" for your test run (a single environment variable, e.g. in your test suite's setup or CI env) to keep the old fully-isolated-in-memory behavior; individual tests that want to exercise real persistence can still pass storage_path= explicitly, which always overrides the environment variable.

Options

Parameter Type Default Description
base_url str required Public base URL, no trailing slash
clients dict None Pre-registered {client_id: client_secret}
client_redirect_uris dict None Optional redirect URI allowlist for pre-registered clients (exact-match, fail-closed). A confidential client may map to ANY_REDIRECT_URI instead of a list — see Redirect URIs for pre-registered clients
public_registration bool False Allow dynamic client registration
auto_approve bool False Skip consent page, auto-approve all valid clients
token_ttl int 3600 Access token lifetime in seconds
refresh_token_ttl int 2592000 (30 days) Refresh token lifetime in seconds. Refresh tokens are single-use and rotated on every /token request; replaying a used one revokes the whole token family
storage_path str auto (./.origo/<hash>.db, or $ORIGO_STORAGE_PATH) Path to a SQLite file for persistent storage (see Token persistence). Pass None explicitly to force in-memory storage
client_ttl int None Lifetime in seconds for dynamically-registered clients (DCR /register or CIMD). None means no expiration. Pre-registered clients= are always permanent
max_dynamic_clients int 1000 Max number of dynamically-registered clients (DCR/CIMD) kept at once; registrations past the cap are rejected (HTTP 429) until existing ones expire via client_ttl. Pre-registered clients= don't count against this cap
mcp_path str "/mcp" Path where MCP endpoint is mounted
scopes_supported list[str] [] OAuth/OIDC scopes advertised in metadata
resource_documentation str None Optional URL added to protected resource metadata
user_email str None Optional static email claim returned by lightweight OIDC /userinfo
allow_private_cimd bool False Allow CIMD client_id documents to be fetched from private/loopback/link-local hosts (see CIMD and SSRF hardening)
custom_redirect_uri_schemes list[str] None Private-use URI schemes (RFC 8252 §7.1, e.g. ["myapp"]) accepted as redirect_uris during dynamic registration, for native app clients

OAuth Endpoints

Endpoint Description
GET /.well-known/oauth-authorization-server OAuth discovery
GET /.well-known/openid-configuration OpenID Connect discovery
GET /.well-known/oauth-protected-resource Resource metadata
POST /register Dynamic client registration (public mode only)
GET /authorize Show consent page (or redirect immediately if auto_approve=True)
POST /authorize Submit consent form
POST /token Token exchange
GET/POST /userinfo Lightweight OIDC userinfo endpoint for openid tokens

OpenAI platform compatibility

origo includes the OAuth behavior needed by OpenAI platform MCP clients that connect to protected MCP servers:

  • Protected resource metadata advertises the canonical MCP resource and optional scopes/documentation.
  • OAuth discovery advertises PKCE (S256), Client ID Metadata Document (CIMD) support, Dynamic Client Registration (DCR), OpenID Connect discovery, and token endpoint authentication methods including public PKCE clients (none).
  • Dynamic client registration accepts token_endpoint_auth_method=none for clients that should exchange authorization codes without a client secret.
  • CIMD clients can use an HTTPS metadata document URL as client_id; origo fetches it, validates redirect URIs, and treats it as a public PKCE client when the document requests token_endpoint_auth_method=none.
  • The optional OAuth resource parameter is preserved from /authorize to /token and stored with the issued access token metadata, so applications can verify which MCP resource the token was minted for.
  • /token issues a refresh_token alongside every access token. Long-lived MCP clients can exchange it (grant_type=refresh_token) for a new access token without a full interactive re-authorization once token_ttl expires. Refresh tokens are single-use — each /token call rotates in a new one — and are scoped to the same client_id/resource as the token they replaced. Replaying an already-used refresh token is treated as theft (per the OAuth 2.1 rotation guidance): the entire token family descended from that grant — live refresh tokens and access tokens both — is revoked on the spot, so a stolen-and-rotated chain dies the moment the legitimate holder's token resurfaces. (Corollary: a client that retries a /token refresh call after losing the response will trigger this and must re-authorize interactively.)
  • WWW-Authenticate challenges include resource_metadata so ChatGPT can discover OAuth metadata when an unauthenticated tool call reaches the server.
  • Optional lightweight OIDC support exposes /.well-known/openid-configuration, returns an unsigned id_token for openid requests, and serves /userinfo with sub plus email when user_email is configured and the token has the email scope.

For ChatGPT connectors, register the redirect URI shown in ChatGPT (for example, https://chatgpt.com/connector/oauth/{callback_id}) and use your public MCP endpoint as the resource value, typically https://your-domain.example/mcp.

CIMD and SSRF hardening

A CIMD client_id is a URL supplied by whoever is calling /authorize — it isn't something origo chose, so it's attacker-controlled input. By default origo refuses to fetch a CIMD document from a hostname that resolves to a private, loopback, link-local, reserved, or multicast address, and it never follows HTTP redirects when fetching one. Both protections close the same class of bug: a malicious client_id URL trying to make your server issue a request to something on its internal network (cloud metadata endpoints, internal admin panels, etc.) instead of a legitimate public client registry.

Some deployments genuinely need to relax the host check — for example, an agent runtime and its origo instance colocated on the same private network or host, where the agent's CIMD document is intentionally served from an internal address rather than a public one. For that case, set allow_private_cimd=True:

auth = OAuthProvider(
    base_url="https://mcp.internal.example.com",
    public_registration=True,
    allow_private_cimd=True,  # only if your CIMD documents are meant to live on your private network
)

allow_private_cimd only lifts the private-host restriction — the redirect-refusing fetch still applies unconditionally, so a CIMD host (private or public) still can't retarget the request via a 302 after the fact. Only enable it when you control, or otherwise trust, every host reachable from wherever origo runs; on a shared or multi-tenant network it reopens the SSRF surface the default configuration exists to close.

Secure MCP tunnels

OpenAI Secure MCP Tunnel support is transport-level: run OpenAI's tunnel-client next to your private MCP server and point it at the local Origo-protected MCP URL. Origo's OAuth endpoints still need to be reachable for browser-facing authorization flows, either publicly or from wherever tunnel-client can forward discovery and MCP requests. Origo does not vendor or replace tunnel-client; it provides the OAuth/MCP metadata behavior the tunnel path preserves.

For the issue-by-issue compatibility triage behind this guidance, see docs/openai-connector-triage.md.

Download files

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

Source Distribution

origo-0.2.1.tar.gz (72.5 kB view details)

Uploaded Source

Built Distribution

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

origo-0.2.1-py3-none-any.whl (39.3 kB view details)

Uploaded Python 3

File details

Details for the file origo-0.2.1.tar.gz.

File metadata

  • Download URL: origo-0.2.1.tar.gz
  • Upload date:
  • Size: 72.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for origo-0.2.1.tar.gz
Algorithm Hash digest
SHA256 c2d054a16f3cd205ff01e006ae8de21d8ba74b344f97963c3db7713955cf2408
MD5 9a18e8c9103ca32f6cb0c57068fb6ed0
BLAKE2b-256 edd43f516855edaa405cfe61a7250adae0eec472f3a31a83c16c57ecbca22877

See more details on using hashes here.

Provenance

The following attestation bundles were made for origo-0.2.1.tar.gz:

Publisher: release.yml on ieepirzy/origo

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file origo-0.2.1-py3-none-any.whl.

File metadata

  • Download URL: origo-0.2.1-py3-none-any.whl
  • Upload date:
  • Size: 39.3 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for origo-0.2.1-py3-none-any.whl
Algorithm Hash digest
SHA256 b5f381aec1610d37265b741aaee284d31ff6644f0eb883f7bdbccc08eb83a19c
MD5 fb946219033b942d5f7b963aa9ac0e25
BLAKE2b-256 1047a514dac0690d6fa6e8465c1bf4642914a216f00b208acaca2bf6b3464502

See more details on using hashes here.

Provenance

The following attestation bundles were made for origo-0.2.1-py3-none-any.whl:

Publisher: release.yml on ieepirzy/origo

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

0.3.0

2 files

This release

0.2.1 This release

2 files

0.2.0

2 files

0.1.12

2 files

0.1.11

2 files

0.1.10

2 files

0.1.9

2 files

0.1.8

2 files

0.1.7

2 files

0.1.6

2 files

0.1.5

2 files

0.1.4

2 files

0.1.3

2 files

0.1.2

2 files

0.1.1

2 files

0.1.0

2 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