Skip to content

fix: Google Workspace MCP connectors — durable refresh tokens + reliable OAuth popup#185

Open
hordruma wants to merge 2 commits into
willchen96:mainfrom
hordruma:fix/google-mcp-oauth-refresh-token
Open

fix: Google Workspace MCP connectors — durable refresh tokens + reliable OAuth popup#185
hordruma wants to merge 2 commits into
willchen96:mainfrom
hordruma:fix/google-mcp-oauth-refresh-token

Conversation

@hordruma

Copy link
Copy Markdown

Summary

Google Workspace MCP connectors (Drive / Gmail / Calendar on *.googleapis.com) were unusable past the first hour: they authorized once and then broke when the short-lived access token expired, and the OAuth connect popup could report a false failure. This PR makes the connection durable and the connect flow reliable.

Problems

  1. No refresh token was ever obtained. Google only issues a refresh token when the authorization request opts into offline access (access_type=offline) and is forced to re-prompt for consent (prompt=consent), and it does not implement the OIDC offline_access scope the MCP SDK would otherwise use. The SDK builds a spec-compliant authorization URL and exposes no hook for these proprietary params, so a Google connector got only an access token and broke as soon as it expired (the encrypted_refresh_token column stayed null, so there was nothing to renew with).

  2. OAuth popup completion was mis-detected under COOP. Google's consent page is served with Cross-Origin-Opener-Policy: same-origin, which severs window.opener and makes popup.closed unreadable from the opener. As a result the callback's window.opener.postMessage never reached the app, and a COOP-blocked popup.closed read reports a false "closed" — surfacing a spurious "OAuth authorization window was closed" error even though the backend had already exchanged the code and stored the tokens.

Changes

Backend — backend/src/lib/mcp/oauth.ts (commit f9b5bf1)

  • Add providerAuthorizationParams() policy returning { access_type: "offline", prompt: "consent" } for Google hosts, applied generically in redirectToAuthorization() (the SDK's only authorization-URL seam) — the redirect path carries no Google specifics, so future providers are a one-line policy change.
  • Add/share isGoogleOAuthHost() with tightened matching (=== googleapis.com or .googleapis.com), so look-alikes like notgoogleapis.com are no longer treated as Google.
  • Leave scope resolution to the SDK (documented): it falls back to the scopes the MCP server advertises; passing auth-server scopes would request the wrong (generic OIDC) scopes for Google.
  • Align the frontend isGoogleMcpConnector() host check.
  • Add backend tests (oauth.test.ts), an npm test script, and exclude test files from the build.

Frontend — connectors/page.tsx (commit bfb5c11)

  • Detect OAuth completion by polling the backend's oauthConnected flag (the source of truth) instead of relying on COOP-broken postMessage / popup.closed. Keep postMessage as a fast path for providers that don't sever the opener, and drop the false-positive popup.closed rejection.

Testing

  • Backend unit tests: 7/7 pass.
  • Verified end-to-end against a real Google Workspace account:
    • The live backend builds the Google authorization URL with access_type=offline + prompt=consent, the correct Drive/Gmail scopes, PKCE (S256), and the right redirect URI.
    • Completed real consent → refresh token obtained and stored (previously-null encrypted_refresh_token now populated) — confirmed via both the API and the UI.
    • Live tools/list against drivemcp.googleapis.com returns the Drive tools.
    • The UI connect flow now completes cleanly with the popup fix (no false "window closed").

Notes

  • Write / destructive tools (e.g. Gmail create_draft) remain intentionally gated behind the existing "confirmation required" flag; this PR does not change that behavior.

🤖 Generated with claude-flow

https://claude.ai/code/session_01HSWSVK1PQozcrmZGgBhFXS

hordruma and others added 2 commits June 17, 2026 09:38
Google only issues a refresh token when the authorization request opts into
offline access (access_type=offline) and is forced to re-prompt for consent
(prompt=consent). The MCP SDK builds a spec-compliant authorization URL and
exposes no hook for these proprietary parameters, and Google does not support
the OIDC offline_access scope the SDK handles on its own. As a result Google
connectors authorized once and then broke as soon as the short-lived access
token expired, with no refresh token for the SDK to renew with.

- Add providerAuthorizationParams() as the policy for non-standard auth params
  and apply it generically in DbMcpOAuthProvider.redirectToAuthorization (the
  SDK's only authorization-URL seam); the redirect path carries no Google
  specifics, so future providers are a one-line policy change.
- Tighten Google host detection to `=== googleapis.com || .googleapis.com`,
  share it via isGoogleOAuthHost, and align the frontend isGoogleMcpConnector
  so a host like notgoogleapis.com is no longer treated as Google.
- Leave scope resolution to the SDK (documented), which already falls back to
  the server's advertised scopes; passing auth-server scopes would have
  requested the wrong ones for Google.
- Add backend tests (node:test via tsx) for the policy, host matching, and the
  provider wiring; add an `npm test` script and exclude test files from build.

Co-Authored-By: claude-flow <ruv@ruv.net>
… signals

Google's OAuth consent page is served with
Cross-Origin-Opener-Policy: same-origin, which severs window.opener and
makes popup.closed unreadable from the opener window. That broke the
connector OAuth popup two ways: the callback's window.opener.postMessage
never reached the opener, and a COOP-blocked popup.closed read reports a
false "closed" — surfacing a spurious "OAuth authorization window was
closed" error even though the backend had already exchanged the code and
stored the tokens.

Poll the backend for the connector's oauthConnected flag as the source of
truth, keep postMessage as a fast path for providers that don't sever the
opener, and drop the unreliable popup.closed rejection.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01HSWSVK1PQozcrmZGgBhFXS
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.

1 participant