origo
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 (httpsonly, plus the loopback exemption and anycustom_redirect_uri_schemes, neverjavascript:/data:); PKCE andstatework unchanged; every wildcard-accepted URI is logged at INFO with itsclient_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
/authorizewill bounce a browser to anyhttpsURL for anyone who knows the publicclient_id(withauto_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 —./.origounder 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 ofORIGO_STORAGE_PATH.storage_path="/exact/path/to/origo.db"— persist to that exact file, ignoring the auto-derived default andORIGO_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
0600permissions, and SQLite's WAL sidecar files inherit that mode. - Because dynamic client secrets are hashed, they exist in plaintext only in the one
/registerresponse 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=Truedeployment themax_dynamic_clientscap can now fill up permanently instead of being cleared by the next restart. Setclient_ttlso 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=nonefor clients that should exchange authorization codes without a client secret. - CIMD clients can use an HTTPS metadata document URL as
client_id;origofetches it, validates redirect URIs, and treats it as a public PKCE client when the document requeststoken_endpoint_auth_method=none. - The optional OAuth
resourceparameter is preserved from/authorizeto/tokenand stored with the issued access token metadata, so applications can verify which MCP resource the token was minted for. /tokenissues arefresh_tokenalongside 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 oncetoken_ttlexpires. Refresh tokens are single-use — each/tokencall rotates in a new one — and are scoped to the sameclient_id/resourceas 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/tokenrefresh call after losing the response will trigger this and must re-authorize interactively.)WWW-Authenticatechallenges includeresource_metadataso ChatGPT can discover OAuth metadata when an unauthenticated tool call reaches the server.- Optional lightweight OIDC support exposes
/.well-known/openid-configuration, returns an unsignedid_tokenforopenidrequests, and serves/userinfowithsubplusemailwhenuser_emailis configured and the token has theemailscope.
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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file origo-0.3.0.tar.gz.
File metadata
- Download URL: origo-0.3.0.tar.gz
- Upload date:
- Size: 74.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4cfdc91f5003dc9ac4ff7354c2c59caf3a30b775e06780e5a20e158cb124dd82
|
|
| MD5 |
10008c40546b6caf65d4b31151945a7f
|
|
| BLAKE2b-256 |
c2da4108f67f14eb35d73b7fd4d168f2f9b0547b7d96f37c41fb5e34208d6770
|
Provenance
The following attestation bundles were made for origo-0.3.0.tar.gz:
Publisher:
release.yml on ieepirzy/origo
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
origo-0.3.0.tar.gz -
Subject digest:
4cfdc91f5003dc9ac4ff7354c2c59caf3a30b775e06780e5a20e158cb124dd82 - Sigstore transparency entry: 2496554269
- Sigstore integration time:
-
Permalink:
ieepirzy/origo@43af83ee43b47825521d3e3a01b20ec640c2d929 -
Branch / Tag:
refs/tags/v0.3.0 - Owner: https://github.com/ieepirzy
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@43af83ee43b47825521d3e3a01b20ec640c2d929 -
Trigger Event:
release
-
Statement type:
File details
Details for the file origo-0.3.0-py3-none-any.whl.
File metadata
- Download URL: origo-0.3.0-py3-none-any.whl
- Upload date:
- Size: 40.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c62cb1b53edf21d7959273f475cf8f8e942c235dcfd42233c5072134631692f1
|
|
| MD5 |
7998cf81203310837546ca4fb172b35e
|
|
| BLAKE2b-256 |
f937e6ed8e9fe63f8869a8fbf67d7c61e077dce9b1fbd761ee6ef2f0957da045
|
Provenance
The following attestation bundles were made for origo-0.3.0-py3-none-any.whl:
Publisher:
release.yml on ieepirzy/origo
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
origo-0.3.0-py3-none-any.whl -
Subject digest:
c62cb1b53edf21d7959273f475cf8f8e942c235dcfd42233c5072134631692f1 - Sigstore transparency entry: 2496554275
- Sigstore integration time:
-
Permalink:
ieepirzy/origo@43af83ee43b47825521d3e3a01b20ec640c2d929 -
Branch / Tag:
refs/tags/v0.3.0 - Owner: https://github.com/ieepirzy
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@43af83ee43b47825521d3e3a01b20ec640c2d929 -
Trigger Event:
release
-
Statement type: