enlace_auth.auth.oauth_server#
OAuth 2.1 authorization server — issue tokens for MCP custom connectors.
The companion to enlace_auth.auth.oauth (which is a login client, “sign in
with Google”). This module makes enlace_auth an authorization server: it
issues and signs the bearer tokens a Claude.ai custom connector — an OAuth
resource server — validates. It reuses the platform’s existing user store and
session login, so a connector authenticates against the same accounts as the rest
of the platform.
The flow Claude.ai drives (OAuth 2.1 authorization-code + PKCE, RFC 7591 dynamic client registration, RFC 8707 resource indicators):
The connector replies 401, pointing at its protected-resource metadata, which names this server as the authorization server.
The client discovers our endpoints via
/.well-known/oauth-authorization-server.It registers itself (DCR) at
/auth/oauth/register.GET /auth/oauth/authorize— the user signs in (reusing/auth/login) and consents; we issue a short-lived, PKCE-bound authorization code.POST /auth/oauth/token— the code + PKCE verifier is exchanged for a signed JWT whoseaudis the connector (theresourceparameter).The connector’s FastMCP
JWTVerifiervalidates that JWT against/auth/oauth/jwks.Before the access token expires, the client exchanges its refresh token (
grant_type=refresh_token) for a fresh pair — no browser, no human. This is what keeps a connector alive: without it an access token’s expiry ends the session outright, and the only way back is for a person to re-run the whole browser authorization. Seemake_oauth_server_router()for the rotation and reuse-detection rules.
Endpoints (mounted on the platform root, so they sit at the issuer origin):
GET /.well-known/oauth-authorization-server metadata (RFC 8414)
GET /auth/oauth/jwks signing public keys
POST /auth/oauth/register dynamic client registration
GET /auth/oauth/authorize sign-in + consent → code
POST /auth/oauth/authorize consent submit → code
POST /auth/oauth/token code + PKCE → JWT
refresh_token → JWT
Functions
|
Build the OAuth 2.1 authorization-server router (see the module docstring). |
Classes
|
The server's RSA signing key — persisted, exposed as a JWKS. |
- class enlace_auth.auth.oauth_server.OAuthKeys(key_dir)[source]#
Bases:
objectThe server’s RSA signing key — persisted, exposed as a JWKS.
Generates a 2048-bit RSA key on first use under key_dir (
private_key.pem, mode 0600) and reuses it thereafter, so tokens stay verifiable across restarts. Signs JWTs (RS256) and publishes the public half as a one-key JWKS for the connector’s verifier to fetch.
- enlace_auth.auth.oauth_server.make_oauth_server_router(*, session_store, signing_key, cookie_name, session_max_age, client_store, code_store, keys, issuer=None, refresh_store=None, claim_once=None, release_claim=None, consent_claim_once=None, is_active=None, access_token_ttl=3600, refresh_token_ttl=2592000, refresh_reuse_grace=60, refresh_reuse_detection=86400, refresh_family_max_lifetime=7776000, client_ttl=15552000, code_ttl=120, consent_ttl=600, scopes_supported=('mcp:read',), require_consent=True, resource_allowlist=None, allow_unlisted_resources=False, resource_display_names=None)[source]#
Build the OAuth 2.1 authorization-server router (see the module docstring).
issuer pins the token
issand the discovery URLs; whenNoneit is derived from each request’s base URL (so the same code serves any domain). The consent step reuses the platform session — an unauthenticated/authorizeredirects to/auth/loginand returns.refresh_store backs the
refresh_tokengrant. Supply it and clients renew their own access tokens in the background, unattended; omit it and this server issues bare access tokens whose expiry silently ends the session, recoverable only by a human re-running the browser authorization. It is optional purely for backwards compatibility — always pass it in a deployment, and seeenlace_auth.diagnostics.check_oauth_server()which reports its absence as a fault. Tokens are stored hashed (_hash_refresh()), never verbatim.Refresh tokens rotate: each use consumes the presented token and returns a new one. Consumption goes through claim_once, which must be atomic across PROCESSES — several workers share one store, and a plain read-then-write lets two concurrent redemptions both mint a live successor, which is precisely the double-spend rotation exists to detect.
A spent token presented again is theft unless it looks like a retry. The token is consumed before the response is written, so a dropped response leaves an honest client holding a spent token; revoking there would strand the connector exactly as having no refresh grant does. Within refresh_reuse_grace, from the same client, with the successor still unused and the subject still authorized, it is reissued instead. Anything else revokes the whole family (OAuth 2.1 §4.3.1). Spent tokens are remembered for refresh_reuse_detection so a replay is recognised rather than merely unknown.
refresh_token_ttl is an IDLE timeout that each rotation resets; refresh_family_max_lifetime is the absolute ceiling, fixed when the session is authorized. Both resource_allowlist and is_active are re-evaluated on every refresh — with no denylist and no revocation endpoint, they are the only way a session ends before its own expiry, which is why access tokens should stay short.
resource_allowlist maps a connector resource URL to the emails permitted to authorize for it — per-connector access control (e.g. restrict a private connector to its own staff). Once it lists anything it fails closed: a resource that is not in the map is refused to everyone, at authorization, code exchange and every refresh, so a connector that is newly added, renamed or mounted at a second path stays shut until someone lists it. Opening a resource to every authenticated account is an explicit act: list it with
[ANY_ACCOUNT](["*"]). An empty or absent map would leave every resource open, so it raisesValueErrorunless allow_unlisted_resources asks for exactly that (and is then logged as a warning); a malformed map raises too. Seeenlace_auth.resource_policy. Secure to key on the resource: the connector only accepts tokens whoseaudis that exact resource, so a denied user can’t get a usable token another way. Theaudis minted from the same normalized resource the allowlist was checked against, so the two never disagree about a trailing slash.require_consent must stay
True;FalseraisesValueError. Registration is open to anyone, so a server that skipped the consent screen would hand any website a code for a signed-in visitor on a cross-site GET (seeenlace_auth.resource_policy.check_require_consent()).consent_ttl is how long, in seconds, a rendered consent form can be submitted. Its CSRF token approves exactly one request — this session, this client, redirect URI, PKCE challenge, state, scope and resource — and nothing after it expires (see
_consent_binding()). It must be positive. It is also one-shot: its first submit (approve or deny) spends it, through consent_claim_once, so a double-click or a replayed POST gets the stale-form page instead of a second code (#55). Like claim_once it must be atomic across processes. The key is the request’s binding, and a page reload re-mints a token for the same binding, so it must remember a claim for as long as the session can live (the platform usesmax(consent_ttl, session_max_age)); when it isNonean in-process set is used, which is one-shot only within one worker.resource_display_names maps the same resource URL to the human name the consent screen shows. One authorization server serves every connector on the platform, so this string must be per-connector: a name baked into the template is correct for one connector and wrong — and disclosing — for every other. A resource with no entry gets generic copy that names no product at all; never default to a specific connector’s name, or the next connector added inherits it.
- Return type:
APIRouter