Skip to content

mcp(oauth): delegate the OAuth engine to internal/oauth (dedup) - #212

Merged
gnanam1990 merged 3 commits into
feat/oauth-modulefrom
feat/oauth-mcp-dedup
Jun 15, 2026
Merged

mcp(oauth): delegate the OAuth engine to internal/oauth (dedup)#212
gnanam1990 merged 3 commits into
feat/oauth-modulefrom
feat/oauth-mcp-dedup

Conversation

@gnanam1990

@gnanam1990 gnanam1990 commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator

Summary

OAuth Phase 2 — MCP OAuth now reuses the shared internal/oauth engine (from #210) instead of carrying its own copy of the transport/identity-agnostic primitives. internal/mcp/oauth.go drops 526 → 418 LOC (−187/+79).

Stacks on #210 — base is feat/oauth-module. Merge #210 first; this then targets main cleanly.

What now delegates to internal/oauth

newPKCE, newState, discoverAuthorizationServer (RFC 8414), the authorize-URL builder, exchangeCode, refreshAccessToken, the token-response parser, and joinWellKnown — all removed from MCP and delegated to the shared engine via thin type conversions.

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 internal/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 with internal/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 --json produce identical output.

Summary by CodeRabbit

  • Refactor
    • Internal OAuth and PKCE authentication handling has been refactored to use shared components across the system. This consolidates duplicate logic and improves code maintainability. All existing user-facing authentication behavior and APIs remain unchanged with no impact on functionality.

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.
@github-actions

github-actions Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Zero automated PR review

Verdict: No blockers found

Blockers

  • None found.

Validation

  • [pass] Diff hygiene: git diff --check
  • [pass] Tests: go test ./...
  • [pass] Build: go run ./cmd/zero-release build
  • [pass] Smoke build: go run ./cmd/zero-release smoke

Scope

Head: deffa02322ac
Changed files (1): internal/mcp/oauth.go

This deterministic review checks validation status and basic diff hygiene. A human reviewer still owns product judgment and design quality.

@coderabbitai

coderabbitai Bot commented Jun 15, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 4727f90e-d613-4fd2-9dcf-6acc0587c969

📥 Commits

Reviewing files that changed from the base of the PR and between 3864909 and bfee4bc.

📒 Files selected for processing (1)
  • internal/mcp/oauth.go

Walkthrough

internal/mcp/oauth.go removes all local OAuth2/PKCE implementation — PKCE/state generation, RFC 8414 metadata fetch, authorization URL construction, token form POST, and response parsing — delegating each operation to internal/oauth. New local helpers (pkceToParams, pkceToOAuth, configFor, tokenToStored) translate between the two type systems.

Changes

MCP OAuth delegation to shared engine

Layer / File(s) Summary
Import cleanup and type translation helpers
internal/mcp/oauth.go
Removes crypto/base64/hashing imports and introduces defaultLoginTimeout plus four translation helpers (pkceToParams, pkceToOAuth, configFor, tokenToStored) that map MCP-local types to internal/oauth types.
Discovery, auth URL, code exchange, and token refresh delegated
internal/mcp/oauth.go
discoverAuthorizationServer calls oauth.DiscoverAuthorizationServer; authorizationURL calls oauth.NewPKCE, oauth.NewState, oauth.BuildAuthorizationURL; exchangeCode calls oauth.ExchangeCode; refreshAccessToken calls oauth.Refresh — removing all inlined HTTP/form/JSON logic.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: delegating OAuth implementation details from MCP to a shared internal/oauth engine to reduce code duplication.
Docstring Coverage ✅ Passed Docstring coverage is 90.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/oauth-mcp-dedup

Comment @coderabbitai help to get the list of available commands and usage tips.

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-modulefeat/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:

  • newPKCEoauth.NewPKCE() (32 bytes RawURLEncoding, S256 challenge — byte-for-byte identical)
  • newStateoauth.NewState() (32 bytes, base64url)
  • discoverAuthorizationServeroauth.DiscoverAuthorizationServer() (RFC 8414 well-known, with type conversion back to MCP's authServerMetadata since the MCP metadata type omits the device/registration-extension fields the shared engine surfaces)
  • authorizationURLoauth.BuildAuthorizationURL() (now also protected by the reserved-param guard from the shared engine)
  • exchangeCodeoauth.ExchangeCode() (form-encoded token POST with the right grant_type and verifier)
  • refreshAccessTokenoauth.Refresh() (refresh-token grant, preserves prior refresh token if the response omits one, maps error / error_description)
  • joinWellKnown, the local tokenResponse struct, and postTokenRequest — all removed from MCP, replaced by calls into the shared engine

What MCP keeps (correctly):

  • Its own OAuthConfig / StoredToken types (different from oauth.Config / oauth.Token — the ServerName and other MCP-specific fields stay local)
  • LoginOptions and the full Login orchestration (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.Server loopback handler (MCP serves /callback on its own; not using the shared LoopbackListener because 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.json with raw server-name keys, no shared-engine namespacing)

Conversion helpers are package-private and used only inside the filepkceToParams, 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

@gnanam1990
gnanam1990 merged commit 474a40a into feat/oauth-module Jun 15, 2026
7 checks passed
gnanam1990 added a commit that referenced this pull request Jun 15, 2026
* 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.
@Vasanthdev2004
Vasanthdev2004 deleted the feat/oauth-mcp-dedup branch June 28, 2026 08:27
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.

2 participants