Skip to content

feat(notion): add OAuth 2.0 with PKCE for Notion MCP server#158

Open
aleksclark wants to merge 4 commits into
mainfrom
aleks/notion-integration
Open

feat(notion): add OAuth 2.0 with PKCE for Notion MCP server#158
aleksclark wants to merge 4 commits into
mainfrom
aleks/notion-integration

Conversation

@aleksclark
Copy link
Copy Markdown
Collaborator

Summary

  • 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, refresh token rotation)
  • Update web UI Notion setup page with "Sign in with Notion" OAuth button as recommended option

Changes

File What
remotemcp/oauth.go RefreshToken(), PollOAuthFull(), store refresh_token + expires_in
remotemcp/remotemcp.go Token refresh fields, tryRefreshToken(), tokenNeedsRefresh(), SetTokenRefreshCallback(), retry logic in Execute/Healthy
integrations/notion/notion.go Dual-mode: mcpServerURL, useRemote, IsRemoteMCP(), MCPServerURL(), RemoteIntegration()
cmd/server/main.go notionInt.New("https://mcp.notion.com")
web/web.go Import notionInt, check notionInt.MCPServerURL() in OAuth start, store refresh_token on callback
web/templates/pages/notion_setup.templ OAuth button + dual-mode status badges
Tests 10+ new tests covering dual-mode configure, token refresh success/failure/rotation

Test plan

  • make build passes
  • make test passes (all packages)
  • make vet passes
  • make lint passes (0 issues)
  • New tests for dual-mode Configure (remote MCP + fallback to token_v2)
  • New tests for RefreshToken (success, rotation, failure)
  • Manual: OAuth flow against Notion's MCP server

🐾 Generated with Crush

aleksclark and others added 2 commits May 8, 2026 17:11
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>
Copy link
Copy Markdown
Collaborator

@acmacalister acmacalister left a comment

Choose a reason for hiding this comment

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

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.

Comment thread remotemcp/remotemcp.go

// 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)) {
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.

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.

Comment thread web/templates/pages/notion_setup.templ Outdated
}

function showOAuthError(msg) {
document.getElementById('oauth-container').style.display = 'none';
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.

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;
}

Comment thread web/web.go Outdated
serverURL = linearInt.MCPServerURL(integration)
}
if serverURL == "" {
serverURL = notionInt.MCPServerURL(integration)
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.

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>
Copy link
Copy Markdown
Collaborator

@acmacalister acmacalister left a comment

Choose a reason for hiding this comment

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

Nice work on the dual-mode setup — the token refresh rotation, proactive expiry check, and PollOAuthFull API are all solid. Two small things inline.

Comment thread web/templates/pages/notion_setup.templ Outdated

function resetOAuth() {
document.getElementById('oauth-error').style.display = 'none';
document.getElementById('oauth-container').style.display = 'block';
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.

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 →';
}

Comment thread remotemcp/oauth.go Outdated
}
defer func() { _ = resp.Body.Close() }()

body, err := io.ReadAll(resp.Body)
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.

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>
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