mcp(oauth): delegate the OAuth engine to internal/oauth (dedup) - #212
Conversation
OAuth Phase 2: the MCP OAuth code now reuses the shared internal/oauth engine instead of carrying its own copy of the transport/identity-agnostic primitives. internal/mcp/oauth.go drops ~110 lines (526 -> 418): newPKCE, newState, discoverAuthorizationServer, the authorize-URL builder, token exchange/refresh, the token-response parser, and joinWellKnown now delegate to internal/oauth. Behavior-preserving: MCP keeps its own OAuthConfig/StoredToken types, LoginOptions/Login orchestration, loopback handling, registerClient (dynamic client registration — not part of the shared engine), CLI output, and on-disk token format (mcp-oauth-tokens.json, raw server-name keys). The existing mcp oauth tests pass UNCHANGED. The only behavioral delta is a hardening inherited from the shared engine: the token endpoint must be https (loopback exempt) — a credential is no longer sent to a plaintext non-loopback endpoint. Stacks on #210 (the internal/oauth package). Gates: gofmt/vet/build(host+ linux+windows)/test -race/staticcheck(no new)/govulncheck(0)/deadcode(no new vs base) all pass; `zero mcp oauth status` output is identical.
Zero automated PR reviewVerdict: No blockers found Blockers
Validation
ScopeHead: This deterministic review checks validation status and basic diff hygiene. A human reviewer still owns product judgment and design quality. |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
Walkthrough
ChangesMCP OAuth delegation to shared engine
Estimated code review effort🎯 2 (Simple) | ⏱️ ~10 minutes 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
Vasanthdev2004
left a comment
There was a problem hiding this comment.
Verdict: APPROVED ✅
Reviewed locally in worktree review-pr212 at 1a9591f (1 commit on top of feat/oauth-module). +79 / −187, single file (internal/mcp/oauth.go), base feat/oauth-module ← feat/oauth-mcp-dedup. Merge order: #210 first, then this rebases cleanly onto main.
CI gates (clean): gofmt -l . empty, go vet ./... clean, go build ./... clean, go test -count=1 ./internal/mcp/... (1.7s), go test -count=1 ./internal/oauth/... (7.1s).
What got delegated to the shared engine:
newPKCE→oauth.NewPKCE()(32 bytes RawURLEncoding, S256 challenge — byte-for-byte identical)newState→oauth.NewState()(32 bytes, base64url)discoverAuthorizationServer→oauth.DiscoverAuthorizationServer()(RFC 8414 well-known, with type conversion back to MCP'sauthServerMetadatasince the MCP metadata type omits the device/registration-extension fields the shared engine surfaces)authorizationURL→oauth.BuildAuthorizationURL()(now also protected by the reserved-param guard from the shared engine)exchangeCode→oauth.ExchangeCode()(form-encoded token POST with the right grant_type and verifier)refreshAccessToken→oauth.Refresh()(refresh-token grant, preserves prior refresh token if the response omits one, mapserror/error_description)joinWellKnown, the localtokenResponsestruct, andpostTokenRequest— all removed from MCP, replaced by calls into the shared engine
What MCP keeps (correctly):
- Its own
OAuthConfig/StoredTokentypes (different fromoauth.Config/oauth.Token— theServerNameand other MCP-specific fields stay local) LoginOptionsand the fullLoginorchestration (one deadline bounds discovery + optional client registration + callback wait + token exchange, the loopback listener is bound first so the redirect URI is known before client registration)- The inline
http.Serverloopback handler (MCP serves/callbackon its own; not using the sharedLoopbackListenerbecause the callback flow is different) registerClient(RFC 7591 dynamic client registration — explicitly noted as not part of the shared engine)- The CLI surface
- The on-disk token format (
mcp-oauth-tokens.jsonwith raw server-name keys, no shared-engine namespacing)
Conversion helpers are package-private and used only inside the file — pkceToParams, pkceToOAuth, configFor, tokenToStored. No external callers depended on the removed names (grepped the whole module).
Inherited hardening (intentional): the token endpoint must be https or loopback. MCP tests all use loopback, so the behavior is unchanged, but the new path will refuse a credential-leaking plaintext token endpoint that the old path would have silently sent credentials to.
One minor observation (not a blocker):
The MCP loopback in Login (line 320) is net.Listen("tcp", "127.0.0.1:0") + an inline http.Server — this is the MCP-specific redirect path and stays put. It binds 127.0.0.1 only (correct) and is single-use by the resultChan handshake, but it doesn't have the same single-use / idempotent-Close guarantees as the shared LoopbackListener. That's OK because the server.Shutdown is always called in defer, but if a future change ever wants the same security-property guarantee the OAuth provider login has, switching to the shared listener would do it. Cosmetic.
Overall: this is exactly the right shape for "delegate the transport-agnostic core, keep the identity-specific surface." No behavior change for users, no token-store migration (deferred to a separate, transparent PR), the MCP test suite is untouched and still passes, and MCP inherits the engine's hardening. Merging once #210 lands.
🤖 Generated with Claude Code
* feat(oauth): reusable provider OAuth engine + `zero auth` CLI
Add internal/oauth: a transport/identity-agnostic OAuth 2.0 engine that powers
provider login alongside the MCP-server OAuth zero already has. It is additive
and provider-neutral — NO providers, endpoints, or client identities are baked
into the binary; every provider is configured entirely via ZERO_OAUTH_<NAME>_*
env vars, so the engine works with any OAuth 2.0 / OIDC server.
Engine (internal/oauth):
- PKCE (S256 mandatory; "plain" refused) + per-flow CSRF state.
- Authorization-code flow: BuildAuthorizationURL / ExchangeCode / Refresh, with
an https-only token-endpoint guard (loopback exempt) and error bodies that
carry only error/error_description (never token material).
- Loopback callback server: 127.0.0.1-only, OS-assigned port, single use, state
verified, then closed.
- Device-authorization grant (RFC 8628): RequestDeviceCode + PollDeviceToken
honoring authorization_pending / slow_down / interval / expiry (headless/SSH).
- RFC 8414 + OIDC discovery (issuer-driven endpoint resolution).
- Namespaced token store ("provider:<name>" / "mcp:<name>"), 0600, file-locked
(ownership-aware), atomic writes, malformed-fails-closed; GetFresh (on-demand
refresh) + Handle401 (forced refresh).
- Opt-in proactive RefreshScheduler.
CLI (zero auth):
- login <provider> [--device] [--scope ...], logout, status [provider],
refresh <provider> [--watch]. Status/output never print token material.
- Registered in app.go dispatch + top-level help.
MCP OAuth (internal/mcp) is left byte-for-byte unchanged (zero regression);
sharing the engine with MCP and encrypted-at-rest/keyring storage are documented
follow-ups. Tests cover every path (PKCE, auth URL, https-guard + redaction,
exchange/refresh, loopback capture + state-mismatch + timeout, device pending/
slow_down/expired, store namespacing/0600/locking, provider env resolution,
manager login (loopback+device)/GetFresh/Handle401, scheduler). Gates: gofmt /
vet / build (host+linux+windows) / test -race / staticcheck (no new) /
govulncheck (0) / deadcode (no new) all pass.
* oauth: fix Windows smoke — use OS-appropriate path in store-path test
TestResolveStorePathHonorsOverride hardcoded a unix "/tmp/custom/tok.json"
literal, which is not absolute on Windows (no drive), so ResolveStorePath
correctly resolves it against the current drive and the verbatim comparison
failed (D:\tmp\custom\tok.json). The production code is correct; the test now
builds the override with filepath.Join(t.TempDir(), ...) so it is absolute and
clean on every OS.
* oauth: address CodeRabbit review (11 findings)
Security / correctness:
- flow: caller-supplied ExtraAuthParams/extraParams can no longer override the
reserved OAuth/PKCE fields (state, redirect_uri, code_challenge[_method],
response_type, client_id) — e.g. forcing method=plain.
- device: a device-authorization response without expires_in now gets a bounded
default lifetime (fail closed), and the poll loop re-checks expiry AFTER the
interval sleep so it never polls past the deadline (e.g. after slow_down).
- lock: handle WriteString/Close errors on the token lock file — a partial write
no longer strands an undeletable lock.
- store: a non-nil env map is now authoritative (hermetic) — a controlled map
never falls back to ambient ZERO_OAUTH_* / HOME / XDG_CONFIG_HOME.
- scheduler: a transient loadForKey error backs off and retries (bounded) instead
of permanently stopping proactive refresh.
- cli auth: reject flags a subcommand does not accept (e.g. login --watch,
status --device) and reject empty --scope values — fail fast.
Lint blockers:
- manager: restructured the discovery fallback so the non-nil-err branch no longer
returns nil (nilerr).
- providers: dropped the ineffectual flow initializer (ineffassign).
Tests: assert Save/Load errors in the scheduler test; the slow_down device test
no longer depends on post-expiry polling. Added tests for reserved-param
stripping, hermetic env, device default-expiry, and CLI flag validation. The
store-path test was already made OS-portable in the Windows-smoke fix.
Gates: gofmt/vet/build(host+linux+windows)/test -race/staticcheck(no new)/
govulncheck(0)/deadcode(no new) all pass.
* swarm: de-flake TestMailboxConcurrentSends on Windows CI
The 200-way concurrent-send stress test inherited from the swarm package timed
out on Windows CI (103/200 sends hit the 10s lock timeout): the lock's 20ms
retry sleep starves waiters when Windows file ops are slow under heavy
contention. Shorten the retry to 2ms (a freed lock is re-acquired promptly) and
raise the test's lock timeout to 60s so a legitimately-slow CI never times a send
out. Passes repeatedly under -race. (CI de-flake of a test that surfaced on
#210's Windows smoke; functionally unrelated to the OAuth changes.)
* oauth: address CodeRabbit re-review (4 findings)
- loopback (security): NewLoopbackListener refuses an empty CSRF state — an empty
state would match a callback carrying no state at all, defeating the check.
- device (correctness): RequestDeviceCode now sends client_secret when set, so a
confidential client authenticates on the device-authorization endpoint too
(consistent with the token poll).
- cli auth: document --watch in the help Flags section.
- scheduler test: assert the seed Save error instead of ignoring it.
Also scrubbed a stray third-party reference from a loopback.go comment.
Tests added: empty-state rejection, device client_secret is sent. Gates:
gofmt/vet/build/test -race/staticcheck(no new)/govulncheck(0)/deadcode(no new)
all pass.
* mcp(oauth): delegate the OAuth engine to internal/oauth (dedup) (#212)
OAuth Phase 2: the MCP OAuth code now reuses the shared internal/oauth engine
instead of carrying its own copy of the transport/identity-agnostic primitives.
internal/mcp/oauth.go drops ~110 lines (526 -> 418): newPKCE, newState,
discoverAuthorizationServer, the authorize-URL builder, token exchange/refresh,
the token-response parser, and joinWellKnown now delegate to internal/oauth.
Behavior-preserving: MCP keeps its own OAuthConfig/StoredToken types,
LoginOptions/Login orchestration, loopback handling, registerClient (dynamic
client registration — not part of the shared engine), CLI output, and on-disk
token format (mcp-oauth-tokens.json, raw server-name keys). The existing mcp
oauth tests pass UNCHANGED. The only behavioral delta is a hardening inherited
from the shared engine: the token endpoint must be https (loopback exempt) —
a credential is no longer sent to a plaintext non-loopback endpoint.
Stacks on #210 (the internal/oauth package). Gates: gofmt/vet/build(host+
linux+windows)/test -race/staticcheck(no new)/govulncheck(0)/deadcode(no new
vs base) all pass; `zero mcp oauth status` output is identical.
Summary
OAuth Phase 2 — MCP OAuth now reuses the shared
internal/oauthengine (from #210) instead of carrying its own copy of the transport/identity-agnostic primitives.internal/mcp/oauth.godrops 526 → 418 LOC (−187/+79).What now delegates to
internal/oauthnewPKCE,newState,discoverAuthorizationServer(RFC 8414), the authorize-URL builder,exchangeCode,refreshAccessToken, the token-response parser, andjoinWellKnown— all removed from MCP and delegated to the shared engine via thin type conversions.Behavior-preserving
MCP keeps its own
OAuthConfig/StoredTokentypes,LoginOptions/Loginorchestration, loopback handling,registerClient(dynamic client registration — not part of the shared engine), CLI output, and on-disk token format (mcp-oauth-tokens.json, raw server-name keys). The existinginternal/mcp/oauth_test.go+oauth_store_test.go+ CLI tests pass unchanged.One intentional hardening inherited from the shared engine: the token endpoint must be https (loopback exempt), so a credential is no longer sent to a plaintext non-loopback endpoint. All existing tests use loopback and are unaffected.
Not changed
The token store (
internal/mcp/oauth_store.go) is untouched — unifying it withinternal/oauth's namespaced store would change the on-disk format and is a separate, transparent-migration follow-up.Verification
gofmt · vet · build (host + linux + windows) ·
go test ./...incl.-race(mcp tests unchanged) · staticcheck (no new) · govulncheck (0) · deadcode (no new vs branch base). Local run check:zero mcp oauth --help/status/status --jsonproduce identical output.Summary by CodeRabbit