fix(mcp): refresh OAuth tokens for discovered endpoints and registered clients - #1113
PierrunoYT wants to merge 1 commit into
Conversation
…d clients Login discovers the token endpoint (RFC 9728 / 8414) and may obtain a client_id/client_secret through dynamic registration, but persisted none of it. Refresh read only the config-file OAuthConfig, which is empty for the standard `auth: oauth` setup, so every expired access token failed with "no token endpoint configured for refresh" and required a new `zero mcp oauth login`. Store the discovered token endpoint (and whether it was server-advertised) and the dynamically registered client on the token, and fall back to them in Refresh. Explicit config still wins, and values already in config are not copied into the store. A stored endpoint is re-validated before use: the shared https/loopback rule always, and for a server-advertised endpoint the same public-network policy and pinned client Login used. The refreshed token keeps the stored fields so later refreshes keep working. Fixes Twigpine#1101 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The implementation appears sound, but issue #1101 lacks the required issue-approved label for a community contribution.
Review effort: Balanced
Findings: None
What changed in this PR
Fixes MCP OAuth refreshes by persisting and securely reusing discovered endpoints and dynamically registered clients.
Changes:
- Persists discovered OAuth endpoint and client credentials.
- Validates stored endpoints and preserves metadata across refreshes.
- Adds end-to-end, persistence, precedence, and security regression tests.
| File | Description |
|---|---|
internal/oauth/oauth.go |
Extends the shared token format with refresh metadata. |
internal/mcp/oauth.go |
Records, validates, merges, and preserves discovered settings. |
internal/mcp/oauth_store.go |
Persists the new MCP token fields. |
internal/mcp/oauth_refresh_stored_test.go |
Tests refresh behavior and endpoint safety. |
internal/mcp/network_client.go |
Applies stored settings before refreshing. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository: Twigpine/zero/.coderabbit.yaml Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (5)
Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 0 remain after this review. WalkthroughMCP OAuth tokens now retain discovered token endpoints and dynamically registered client credentials. Refresh resolves configured or stored values, validates stored endpoints, and preserves discovery details after token rotation. Tests cover login, persistence, refresh settings, unsafe endpoints, and status output redaction. ChangesMCP OAuth refresh
Priority: ➖ Normal Estimated code review effort: 3 (Moderate) | ~25 minutes Change: Bug fix · Severity of issue fixed: Medium Sequence Diagram(s)sequenceDiagram
participant storeTokenSource
participant refreshSettings
participant refreshAccessToken
participant TokenEndpoint
storeTokenSource->>refreshSettings: Resolve config and client using server URL, context, and stored token
refreshSettings-->>storeTokenSource: Return resolved config and HTTP client
storeTokenSource->>refreshAccessToken: Pass resolved config, client, and stored token
refreshAccessToken->>TokenEndpoint: Submit refresh request
TokenEndpoint-->>refreshAccessToken: Return refreshed token
refreshAccessToken-->>storeTokenSource: Return token with discovery fields preserved
Suggested reviewers: Merge Risk: ⚪ Minimal · up to Discovery-based OAuth refresh appears ready to merge after normal checks; no actionable issue remains from this review. Security Architecture ReviewSecurity architecture risk: 🟡 Moderate · up to Protected-resource discovery retains strong destination checks during refresh. However, legacy discovery can now establish a persistent refresh destination without those public-network checks. This creates a conditional network-boundary concern, not a demonstrated exploit. Retained concerns
Security review detailsSecurity Blast Radius
Security Findings and Attack Paths
Trust Boundaries and Controls
Resilience and Maintainability Implications
Hardening Proposals
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
Summary
Logindiscovers the token endpoint (RFC 9728 / 8414) and can obtain aclient_id/client_secretthrough dynamic registration, but none of it was persisted. On a 401,storeTokenSource.Refreshread only the config-fileOAuthConfig, whoseTokenEndpointandClientIDare empty for the standardauth: oauthsetup. Refresh then failed withoauth: no token endpoint configured for refresh, and users had to re-runzero mcp oauth loginevery time the access token expired.Changes:
oauth.Token/mcp.StoredTokengainTokenEndpoint,ProtectedTokenEndpoint,ClientIDandClientSecret(allomitempty, so existing stored tokens and provider logins are unchanged on disk).Loginrecords the discovered token endpoint (and whether it was server-advertised) and the dynamically registered client. It only records what config did not already supply, so configured secrets are not copied into the token store.Refreshfalls back to the stored endpoint and client through the newrefreshSettings. Explicit config always wins. A configured secret is kept when only the client id comes from the store.oauth.ValidateEndpointURLalways, and for a server-advertised endpoint alsovalidateProtectedDiscoveredEndpointswith the same pinned public-network clientLoginused.refreshAccessTokencarries the stored fields over to the refreshed token; the token response never contains them, so otherwise the second refresh would fail again.Linked issue
Fixes #1101
Note: #1101 does not currently carry the
issue-approvedlabel. Opening this anyway at the author's request; it can be held until the issue is approved.Verification
New tests in
internal/mcp/oauth_refresh_stored_test.go:TestStoreTokenSourceRefreshUsesStoredDiscoveryAndClientruns a real discovery login (protected-resource metadata, dynamic registration, token endpoint), saves the token, then refreshes throughstoreTokenSourcewith no configured endpoint or client. It checks the refresh form used the registered client, that the stored fields survive the refresh, and that a second refresh works. With the refresh wiring reverted it fails with the issue's exact error (oauth: no token endpoint configured for refresh); withLogin's recording reverted it fails because the client id is empty.TestLoginRecordsDiscoveredEndpointAndRegisteredClientandTestLoginDoesNotCopyConfiguredClientOrEndpointIntoStore.TestRefreshSettingsConfigOverridesStored,TestRefreshSettingsKeepsConfiguredSecretWithStoredClientID.TestRefreshSettingsRejectsUnsafeStoredEndpoint(cleartext http, advertised loopback, advertised private address) andTestStoreTokenSourceRefreshDoesNotPostToUnsafeStoredEndpoint, which checks a public MCP resource cannot send refresh to a loopback server (zero requests received; fails on the old code).TestStoredTokenPersistsDiscoveryFieldscovers the store round trip and thatStatus()output does not include the secret or endpoint.Checks:
go build ./...,go vetforinternal/mcp,internal/oauth,internal/cli,gofmt -l internal/mcp internal/oauthandgit diff HEAD --checkare clean;go test ./internal/mcp/... ./internal/oauth/...passes.Not run locally or not clean here:
go test ./internal/cli/has failures on this Windows machine unrelated to this change (for exampleTestRunExecLogsMCPRuntimeCloseError); I confirmed three of them fail the same way on unmodifiedmain.go test -race(cgo unavailable here), fullgo test ./..., smoke andmaketargets are left to CI.Checklist
issue-approvedlabel. (not yet)go build ./...,go vet ./..., andgo test ./...pass locally. (build passes; vet and tests only for the affected packages)gofmtclean.-racewhere relevant). (tests added;-racenot run locally)🤖 Generated with Claude Code
Summary by CodeRabbit