feat(notion): add OAuth 2.0 with PKCE for Notion MCP server#158
feat(notion): add OAuth 2.0 with PKCE for Notion MCP server#158aleksclark wants to merge 4 commits into
Conversation
Add dual-mode support to the Notion integration: native token_v2 (internal v3 API) or remote MCP via OAuth 2.0 Authorization Code flow with PKCE, connecting to https://mcp.notion.com. - Extend remotemcp package with token refresh handling (proactive refresh 5 min before expiry, retry-on-failure with automatic refresh, refresh token rotation support) - Add PollOAuthFull/RefreshToken/SetTokenRefreshCallback APIs - Modify Notion adapter for dual-mode (mirrors Linear pattern) - Update web UI setup page with "Sign in with Notion" OAuth button - Store refresh_token in credentials on OAuth callback - Register with MCP server URL in main.go 🐾 Generated with Crush Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
Resolves govulncheck findings: - GO-2026-4982 (html/template) - GO-2026-4980 (html/template) - GO-2026-4971 (net) - GO-2026-4918 (net/http) 🐾 Generated with Crush Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
acmacalister
left a comment
There was a problem hiding this comment.
Solid work — dual-mode support is well-structured and the test coverage for the new Configure paths, token refresh, and PKCE is thorough. Three things worth looking at inline.
|
|
||
| // SetTokenRefreshCallback sets a callback that is invoked when a token is refreshed. | ||
| // The callback receives the new access token, refresh token, and expiry in seconds. | ||
| func SetTokenRefreshCallback(i mcp.Integration, cb func(accessToken, refreshToken string, expiresIn int)) { |
There was a problem hiding this comment.
SetTokenRefreshCallback is exported and has a test, but it's never called anywhere in production. When tryRefreshToken succeeds — either proactively at line 207/261 or on a retry — the new tokens are written into the in-memory remote struct, but nothing persists them to config. After a process restart (or the next Configure call with stale credentials from disk), the refreshed tokens are lost and the next request will fail.
To wire this up, handleRemoteMCPOAuthCallback (and the Notion-specific Configure path) should register a callback that writes the new tokens back to config:
remoteMCPInteg := notionInt.RemoteIntegration(integration)
if remoteMCPInteg != nil {
remotemcp.SetTokenRefreshCallback(remoteMCPInteg, func(accessToken, refreshToken string, expiresIn int) {
ic, _ := w.services.Config.GetIntegration("notion")
if ic == nil {
return
}
ic.Credentials["mcp_access_token"] = accessToken
if refreshToken != "" {
ic.Credentials["refresh_token"] = refreshToken
}
_ = w.services.Config.SetIntegration("notion", ic)
})
}Without this, the proactive refresh silently succeeds in memory but the persisted config goes stale, defeating token rotation entirely.
| } | ||
|
|
||
| function showOAuthError(msg) { | ||
| document.getElementById('oauth-container').style.display = 'none'; |
There was a problem hiding this comment.
showOAuthError unconditionally hides #oauth-container, but that element only exists in the DOM when RemoteMCPHealthy is false (the else branch at line 74–80). When the user is already connected and clicks "Re-authorize", the button is rendered in a plain <div> with no id — so getElementById('oauth-container') returns null and null.style throws a TypeError, swallowing the error message entirely.
The simplest fix is a null check:
function showOAuthError(msg) {
var container = document.getElementById('oauth-container');
if (container) { container.style.display = 'none'; }
document.getElementById('oauth-error').style.display = 'block';
document.getElementById('oauth-error-msg').textContent = msg;
}| serverURL = linearInt.MCPServerURL(integration) | ||
| } | ||
| if serverURL == "" { | ||
| serverURL = notionInt.MCPServerURL(integration) |
There was a problem hiding this comment.
This pattern — check remotemcp.ServerURL, then linearInt.MCPServerURL, then notionInt.MCPServerURL — needs to grow every time a new remote MCP integration is added. Since linear and notion both already expose MCPServerURL through the same pattern, consider a small interface:
type remoteMCPIntegration interface {
MCPServerURL() string
}...and define MCPServerURL() as a method on the integration structs rather than as a package-level function taking mcp.Integration. Then handleRemoteMCPOAuthStart can do a single type assertion and avoid the waterfall entirely. Not blocking — this is linearInt's existing pattern too — but worth addressing before a third integration gets added.
- Wire up SetTokenRefreshCallback to persist refreshed tokens to config, preventing token loss on process restart - Fix showOAuthError null check when re-authorizing (oauth-container element only exists in the not-connected state) - Extract remoteMCPServerURL helper to avoid growing waterfall of package-specific MCPServerURL checks 🐾 Generated with Crush Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
acmacalister
left a comment
There was a problem hiding this comment.
Nice work on the dual-mode setup — the token refresh rotation, proactive expiry check, and PollOAuthFull API are all solid. Two small things inline.
|
|
||
| function resetOAuth() { | ||
| document.getElementById('oauth-error').style.display = 'none'; | ||
| document.getElementById('oauth-container').style.display = 'block'; |
There was a problem hiding this comment.
resetOAuth() calls getElementById('oauth-container').style.display = 'block' directly, but #oauth-container only exists in the DOM when RemoteMCPHealthy is false (the else-branch at line 75). When the user is already connected and hits re-authorize, that element isn't there — the same null-dereference that was just fixed in showOAuthError. The fix is consistent with the pattern you used there:
function resetOAuth() {
document.getElementById('oauth-error').style.display = 'none';
var container = document.getElementById('oauth-container');
if (container) { container.style.display = 'block'; }
var btn = document.getElementById('oauth-start-btn');
btn.disabled = false;
btn.textContent = 'Sign in with Notion →';
}| } | ||
| defer func() { _ = resp.Body.Close() }() | ||
|
|
||
| body, err := io.ReadAll(resp.Body) |
There was a problem hiding this comment.
The response body from the token refresh endpoint is read without a size limit. The project pattern (used in agents/http.go, snowflake, etc.) is io.LimitReader:
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))A misconfigured or adversarial server returning a very large response here would cause an unbounded allocation.
- Add null guard to resetOAuth() for oauth-container element - Use io.LimitReader(64KB) on token refresh response body 🐾 Generated with Crush Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
Summary
token_v2(internal v3 API) or remote MCP via OAuth 2.0 Authorization Code flow with PKCE, connecting tohttps://mcp.notion.comremotemcppackage with token refresh handling (proactive refresh 5 min before expiry, retry-on-failure, refresh token rotation)Changes
remotemcp/oauth.goRefreshToken(),PollOAuthFull(), store refresh_token + expires_inremotemcp/remotemcp.gotryRefreshToken(),tokenNeedsRefresh(),SetTokenRefreshCallback(), retry logic inExecute/Healthyintegrations/notion/notion.gomcpServerURL,useRemote,IsRemoteMCP(),MCPServerURL(),RemoteIntegration()cmd/server/main.gonotionInt.New("https://mcp.notion.com")web/web.gonotionInt.MCPServerURL()in OAuth start, store refresh_token on callbackweb/templates/pages/notion_setup.templTest plan
make buildpassesmake testpasses (all packages)make vetpassesmake lintpasses (0 issues)🐾 Generated with Crush