Skip to content

One OAuth engine: OauthClient in druks.services, MCP rewired onto it - #285

Merged
czpython merged 1 commit into
mainfrom
oauth-client-primitive
Aug 19, 2026
Merged

One OAuth engine: OauthClient in druks.services, MCP rewired onto it#285
czpython merged 1 commit into
mainfrom
oauth-client-primitive

Conversation

@czpython

@czpython czpython commented Aug 19, 2026

Copy link
Copy Markdown
Owner

What

OauthClient, exported from druks.services: a reusable OAuth 2.0 authorization-code + PKCE flow with rotation-safe refresh, for service providers with fixed endpoints and a pre-registered client. The MCP subsystem's OAuth connect and mint now run on this engine, so the security-sensitive part — the Redis token cache, the single-refresher lock election (SET NX + poll), and refresh-token rotation persisted before the cache fill — exists exactly once. mcp/oauth.py keeps its discovery (RFC 9728/8414) and dynamic-registration (RFC 7591) head and its model writes, and contains no cache/lock/rotation logic of its own.

Contracts

  • Rotation commits independently. The provider invalidates the old refresh token the moment it rotates, so save_refresh_token(token) must have committed before the engine fills the cache — a consumer minting inside a durable step that later rolls back can no longer brick the grant. MCP's wrapper writes on its own session and keeps the enclosing transaction's copy true.
  • load_refresh_token() runs under the refresh lock and must observe rotations other processes committed; a naive re-select can return identity-mapped stale attributes, so MCP re-reads with populate_existing.
  • Nothing provider-specific in the engine. extra_authorize_params / extra_token_params carry audience binding (MCP's RFC 8707 resource) through the authorize query and both token bodies; basic_auth switches HTTP Basic vs body credentials on exchange and refresh.
  • complete_connect rejects a token response without refresh_token — offline grants only; single-use state via Redis GETDEL, TTL'd. Completion needs only provider: the begun flow's client identity rides the stashed state.
  • MCP's Redis key strings stay byte-identical (mcp:oauth:connect: / mcp:oauth:access_token: / mcp:oauth:refresh_lock:), derived from the provider namespace, so a rolling deploy cannot elect two refreshers for one grant across old and new processes.

Also

  • Service.get_oauth_client(). A service that declares authorization_endpoint / token_endpoint / basic_auth on the class hands back the connected identity as a configured OauthClient, keyed by the service name; its Settings must declare client_id and client_secret fields (loud TypeError at class definition otherwise). Scopes are per authorization, not per client — begin_connect(scopes=...) asks for each flow's own, and the grant keeps what the user approved, so one registered app serves consumers with different scopes.
  • Typed errors OauthExchangeError / OauthRefreshError in druks.services; MCP translates them into its own operator-facing errors, unchanged on the wire.
  • Docs: an OauthClient section in the extension author guide, written example-first; druks.secrets.fields' EncryptedJsonField / SecretsMapping join the stable-import table for storing grants ciphertext at rest.
  • Engine tests with a fake token endpoint: cache hit skips the network; cold mint persists the rotation before the cache fill and fills with expires_in-minus-skew TTL; lock losers poll for the winner's token and time out loudly; refresh rejection evicts and raises typed; a grant without a refresh token is rejected; basic_auth switches auth style on both exchange and refresh.

Existing MCP tests pass unchanged, except the author-surface pin, which gains the three new exported names.

Verification

  • uv run ruff check backend / uv run ruff format --check backend clean
  • proof extension installed editable; uv run pytest backend/ — 1257 passed

🤖 Generated with Claude Code

@czpython
czpython force-pushed the oauth-client-primitive branch 3 times, most recently from cf656a7 to 0522dd5 Compare August 19, 2026 07:12
The MCP subsystem's OAuth 2.0 authorization-code + PKCE flow — the token
cache, the single-refresher lock election, and refresh-token rotation —
generalizes into a reusable primitive for any provider with fixed endpoints
and a pre-registered client. The engine exists exactly once: mcp/oauth.py
keeps its discovery/registration head and its model writes, and delegates
the PKCE/state/consent tail, the code exchange, and the whole mint path.

Rotation now commits independently of the enclosing transaction: the
provider invalidates the old refresh token the moment it rotates, so
save_refresh_token writes on its own session before the cache fills — a
step that rolls back later can no longer brick the grant. load_refresh_token
runs under the refresh lock and re-reads past the identity map, observing
rotations other processes committed.

MCP's Redis key strings stay byte-identical, so a rolling deploy's old and
new processes elect one refresher per grant.
@czpython
czpython force-pushed the oauth-client-primitive branch from 0522dd5 to 18f4304 Compare August 19, 2026 07:15
@czpython
czpython merged commit 7dfb92b into main Aug 19, 2026
1 check passed
@czpython
czpython deleted the oauth-client-primitive branch August 19, 2026 07:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant