Skip to content

fix: prevent transient Plane API failures from invalidating OAuth sessions - #187

Draft
akhil-vamshi-konam wants to merge 3 commits into
mainfrom
fix-oauth-timeout-error
Draft

fix: prevent transient Plane API failures from invalidating OAuth sessions#187
akhil-vamshi-konam wants to merge 3 commits into
mainfrom
fix-oauth-timeout-error

Conversation

@akhil-vamshi-konam

@akhil-vamshi-konam akhil-vamshi-konam commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Description

Problem

The MCP connector intermittently drops to "needs authentication", forcing users through a full browser re-auth even though their token is still valid. Two mechanisms cause this:

  1. PlaneOAuthTokenVerifier.verify_token runs on every MCP request (two live calls to the Plane API) and collapses every failure into return None, which FastMCP treats as "token invalid" → HTTP 401 → the client discards its session. A 10s timeout, a 502 during an API deploy, a 429, or an unparseable response from /auth/o/app-installation/ (whose status code was never checked) all logged users out. Since verification runs per-request with no caching, a single short Plane API blip disconnected every active user at once.
  2. At token expiry, every active session/replica discovers the expired token at the same instant and races the refresh. With refresh-token rotation enabled upstream, the race losers get invalid_grant — which tells the client to discard all tokens and force interactive re-auth.

Fix 1 — verifier: classify failures instead of collapsing them

  • Definitive (Plane answers 401/403, or the app has no installation) → None → 401, and the cache entry is purged so a revoked token can never be served again.
  • Transient (timeout, connection error, 5xx, 429, malformed payload) → raise TransientVerificationError, never "token invalid".

On transient failure, the last successful verification is served from cache (stale window, default 15 min); with no cache available, one retry before giving up.

Successful verifications are cached (default 60s, sha256-keyed, bounded at 1024 entries) — a burst of MCP requests no longer becomes a burst of Plane API calls (~99% fewer verification calls), and worst-case revocation latency equals the cache TTL.

Both windows are env-tunable: PLANE_VERIFY_CACHE_TTL_SECONDS, PLANE_VERIFY_STALE_TTL_SECONDS. Setting both to 0 restores today's always-revalidate behavior (plus the retry) — a rollback lever without a redeploy.

Fix 2 — fastmcp 3.2.0 → 3.4.4 + proactive refresh

  • token_expiry_threshold_seconds (new in 3.4.0, wired here, default 300): the proxy refreshes the upstream Plane token 5 minutes before expiry, so sessions no longer race the refresh at the boundary. Env: PLANE_OAUTH_PROVIDER_TOKEN_EXPIRY_THRESHOLD_SECONDS.
  • access_token_expiry_seconds (optional, default unset = current behavior): decouples the FastMCP-issued token lifetime from the upstream expires_in, so clients refresh rarely. Safe by design — the FastMCP JWT is a reference token; the upstream token is still validated on every request, so revocation is unaffected. Env: PLANE_OAUTH_PROVIDER_ACCESS_TOKEN_EXPIRY_SECONDS.
  • Inherited fixes relevant to this bug: refresh-token cache misses are now loggntly (#4276), refresh-lock map is bounded (#3968), upstream refresh-token expiryis honored (#3990), upstream OAuth clients are closed after refresh (#4248), IdP auth errors are forwarded to the MCP client as proper OAuth errors (#4293).
  • Security-test update: fastmcp ≥ 3.4.3 rejects disallowed redirect URIs at DCR registration (400 invalid_redirect_uri) instead of accepting registration and blocking at /authorize
    — the same attack, blocked one step earlier. test_oauth_security.py now assers URI never receives a redirect) at whichever stage it is enforced, and passeson both old and new fastmcp.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • Improvement (change that would cause existing functionality to not work as expected)

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ef874b27-4759-47cb-84aa-38b87431e003

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Plane OAuth token verification now includes bounded SHA-256-keyed caching, stale-if-error retention, one retry for transient failures, definitive rejection handling, injectable transport, and stricter upstream response validation.

Changes

OAuth verification

Layer / File(s) Summary
Verification cache and configuration
plane_mcp/auth/plane_oauth_provider.py
Adds cache TTL, stale TTL, entry limits, SHA-256 token keys, bounded eviction, and TransientVerificationError.
Cached verification and retry flow
plane_mcp/auth/plane_oauth_provider.py
Checks fresh cache entries, serves stale results during transient failures, retries once when needed, and discards entries after definitive rejection.
Upstream verification semantics
plane_mcp/auth/plane_oauth_provider.py
Separates definitive and transient upstream failures, validates user and installation payloads, and builds AccessToken claims from verified data.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant OAuthClient
  participant PlaneOAuthTokenVerifier
  participant VerificationCache
  participant PlaneAPI
  OAuthClient->>PlaneOAuthTokenVerifier: verify_token
  PlaneOAuthTokenVerifier->>VerificationCache: read token result
  VerificationCache-->>PlaneOAuthTokenVerifier: fresh result or miss
  PlaneOAuthTokenVerifier->>PlaneAPI: verify user and installation
  PlaneAPI-->>PlaneOAuthTokenVerifier: success or failure
  PlaneOAuthTokenVerifier->>VerificationCache: store successful verification
  PlaneOAuthTokenVerifier-->>OAuthClient: AccessToken, stale result, or rejection
Loading

Suggested reviewers: prashant-surya

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: transient Plane API failures no longer invalidate OAuth sessions.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix-oauth-timeout-error

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@akhil-vamshi-konam
akhil-vamshi-konam marked this pull request as ready for review July 24, 2026 13:15

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
plane_mcp/auth/plane_oauth_provider.py (2)

102-124: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Prefer time.monotonic() over time.time() for TTL/age measurement.

get, put, and _evict all measure elapsed time with the wall clock. An NTP correction or manual clock change can jump time.time() backward, making cached entries appear fresher than intended and serving a (possibly already-revoked) token past cache_ttl/stale_ttl; a forward jump silently expires the cache. Use a monotonic clock for elapsed-time comparisons.

♻️ Use monotonic clock for age/TTL
-    def get(self, token: str, *, max_age_seconds: float) -> AccessToken | None:
-        entry = self._entries.get(self._key(token))
-        if entry and (time.time() - entry.verified_at) <= max_age_seconds:
+    def get(self, token: str, *, max_age_seconds: float) -> AccessToken | None:
+        entry = self._entries.get(self._key(token))
+        if entry and (time.monotonic() - entry.verified_at) <= max_age_seconds:
             return entry.access_token
         return None

     def put(self, token: str, access_token: AccessToken) -> None:
         if len(self._entries) >= self._max_entries:
             self._evict()
-        self._entries[self._key(token)] = _CacheEntry(access_token=access_token, verified_at=time.time())
+        self._entries[self._key(token)] = _CacheEntry(access_token=access_token, verified_at=time.monotonic())
@@
     def _evict(self) -> None:
         # Drop entries past the stale window first, then the oldest.
-        now = time.time()
+        now = time.monotonic()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plane_mcp/auth/plane_oauth_provider.py` around lines 102 - 124, Replace
wall-clock usage with time.monotonic() for cache age tracking throughout the
token cache: update CacheEntry timestamps in put and use monotonic values in get
and _evict comparisons. Keep the existing TTL, stale-entry removal, and
oldest-entry eviction behavior unchanged.

234-269: 🚀 Performance & Scalability | 🔵 Trivial

Consider single-flight coalescing on the cache-miss path.

The stated goal is that "a burst of MCP requests must not become a burst of Plane API calls," but that only holds once an entry is cached. On a cold cache or right after cache_ttl expiry, concurrent verify_token calls for the same token each execute _verify_upstream (and, on transient failure, each retry) since nothing coalesces in-flight verifications. Under load this can still fan out to Plane. Coalescing per-token verifications (e.g. an in-flight asyncio.Future/lock keyed by the token hash) would fully realize the burst-suppression intent.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plane_mcp/auth/plane_oauth_provider.py` around lines 234 - 269, Update
verify_token to coalesce concurrent cache-miss verifications per token, using an
in-flight Future or lock keyed by a token hash so only one caller runs
_verify_upstream and its transient-failure retry sequence while others await the
same result. Preserve existing cache hits, stale-cache fallback, rejection
handling, and cleanup of in-flight state after completion or failure.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@plane_mcp/auth/plane_oauth_provider.py`:
- Around line 102-124: Replace wall-clock usage with time.monotonic() for cache
age tracking throughout the token cache: update CacheEntry timestamps in put and
use monotonic values in get and _evict comparisons. Keep the existing TTL,
stale-entry removal, and oldest-entry eviction behavior unchanged.
- Around line 234-269: Update verify_token to coalesce concurrent cache-miss
verifications per token, using an in-flight Future or lock keyed by a token hash
so only one caller runs _verify_upstream and its transient-failure retry
sequence while others await the same result. Preserve existing cache hits,
stale-cache fallback, rejection handling, and cleanup of in-flight state after
completion or failure.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6da7d9fa-dad5-4587-b7a4-6b3769e537ca

📥 Commits

Reviewing files that changed from the base of the PR and between 96cf4d5 and 4f8fa8f.

📒 Files selected for processing (1)
  • plane_mcp/auth/plane_oauth_provider.py

@akhil-vamshi-konam
akhil-vamshi-konam marked this pull request as draft July 24, 2026 13:22
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