Skip to content

fix: require a durable OAuth token store for http/sse - #213

Open
sriramveeraghanta wants to merge 1 commit into
mainfrom
fix/durable-oauth-token-store
Open

fix: require a durable OAuth token store for http/sse#213
sriramveeraghanta wants to merge 1 commit into
mainfrom
fix/durable-oauth-token-store

Conversation

@sriramveeraghanta

@sriramveeraghanta sriramveeraghanta commented Aug 26, 2026

Copy link
Copy Markdown
Member

Why

Users connecting Claude Code to the MCP server over OAuth get logged out after a
few hours. Investigating that turned up two independent causes; this PR closes
the second one and a bug next to it.

The token store holds the state behind the tokens clients already hold — the
DCR client registration and the JTI mappings. Losing it doesn't expire a
session, it strands one. The refresh grant then answers invalid_grant, or
invalid_client once the registration is gone too, and those are two of the
three codes that make a compliant MCP client erase its stored credentials
rather than retry. The user is logged out with nothing to distinguish it from a
real revocation.

Reproduced against the pinned fastmcp 3.2.0 with a stub upstream:

[D] after restart, get_client()                 -> None   (=> invalid_client)
    after restart, load_access_token(valid JWT) -> None   (=> 401)

REDIS_HOST/REDIS_PORT are optional today and set nowhere in the Dockerfile,
README, or CI — so the in-memory fallback is what a default deployment gets,
announced only by a logger.warning at boot.

What changed

storage.py — the in-memory fallback is now opt-in, not a fallback.
http/sse refuse to start without a store that survives the process. Set
PLANE_ALLOW_EPHEMERAL_TOKEN_STORE=true to accept the trade locally.

storage.pyREDIS_SSL now reaches RedisStore. It reached the startup
PING and the log line but not the store. Against a TLS-only Redis that is the
worst shape a misconfiguration can take: the eager PING succeeds, boot looks
healthy, the log says ssl=True, and every store operation afterwards dials
plaintext and fails.

server.py / __main__.py — one store, shared. It was built once per
get_oauth_mcp() call and __main__ calls it twice, so /http and /sse each
held their own. Under Redis they happened to converge on one keyspace; under the
in-memory store they never agreed at all.

Deploy note

⚠️ This makes the next http/sse deploy fail if REDIS_HOST/REDIS_PORT aren't
set.
That's the intent — but the env has to be set first. Startup now prints
either Token store: Redis (...) or refuses outright, which also makes the
first diagnostic question answerable from the boot log.

Tests

18 failed, 1170 passed, 25 skipped
baseline on clean main: 18 failed, 1163 passed

Same 18 pre-existing failures (all in tests/tools/, unrelated to this path),
+7 new tests, no regressions. ruff check and ruff format --check clean.

The test that pinned the old fallback now pins the refusal. New coverage for the
opt-in, for non-truthy opt-in values, and for TLS reaching the store — that last
one was verified to fail without the fix.

Not in this PR

The primary cause is separate: PlaneOAuthTokenVerifier.verify_token makes two
uncached Plane API calls per MCP request and turns any transient failure into a
401, which the client answers with a refresh, which proxy.py:1268-1270 reports
as invalid_grant — same credential erasure, no restart required. That is what
fix-oauth-timeout-error addresses, and it should land too.

https://claude.ai/code/session_01GkDqALzi8u1FZcMybxqBoQ

Summary by CodeRabbit

  • New Features

    • HTTP and SSE connections now share OAuth registrations, improving consistency across transport methods.
    • Added an explicit option to enable in-memory token storage for local development.
  • Bug Fixes

    • Applications now use the configured Redis SSL setting for password-based token storage.
  • Documentation

    • Clarified Redis requirements, startup behavior, and the restart-related limitations of ephemeral storage.
  • Tests

    • Added coverage for token-store configuration, opt-in validation, and Redis SSL settings.

An in-memory token store is not a safe default for a transport that hands
out OAuth credentials. The store holds the state *behind* the tokens clients
already have — the DCR client registration and the JTI mappings — so losing
it does not expire a session, it strands one.

What the client sees after a restart is not a prompt to sign in again. The
refresh grant answers invalid_grant, or invalid_client once the registration
is gone too, and those are two of the three codes that make a compliant MCP
client erase its stored credentials outright rather than retry. The user is
logged out with nothing to distinguish it from a real revocation, and a
`logger.warning` at boot is the only trace. So http/sse now refuse to start
without a store that survives the process, and PLANE_ALLOW_EPHEMERAL_TOKEN_STORE
is the way to accept the trade for local development.

Two related fixes in the same path:

REDIS_SSL reached the startup PING and the log line but never RedisStore
itself. Against a TLS-only Redis that is the worst shape a misconfiguration
can take: the eager PING succeeds, boot looks healthy, and every store
operation afterwards dials plaintext and fails. The log even said ssl=True.

The store was built once per get_oauth_mcp() call and __main__ calls it twice,
so /http and /sse each held their own. Under Redis they happened to converge
on one keyspace; under the in-memory store they never agreed at all. It is now
built once and passed in, so the mounts share state by construction rather
than by coincidence.

Claude-Session: https://claude.ai/code/session_01GkDqALzi8u1FZcMybxqBoQ
@coderabbitai

coderabbitai Bot commented Aug 26, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 991228c5-9c57-4ddc-93a8-b63d3343c25d

📥 Commits

Reviewing files that changed from the base of the PR and between 61bb4fd and 095a677.

📒 Files selected for processing (7)
  • .env.test
  • CLAUDE.md
  • README.md
  • plane_mcp/__main__.py
  • plane_mcp/server.py
  • plane_mcp/storage.py
  • tests/test_aws_secrets.py

Included review availability: Your plan provides up to 2 included reviews per hour; 1 remains after this review.


📝 Walkthrough

Walkthrough

HTTP/SSE startup now requires configured token storage by default. An explicit environment variable enables ephemeral in-memory storage. HTTP startup shares one token store across OAuth MCP instances.

Changes

OAuth token storage

Layer / File(s) Summary
Token-store policy and validation
plane_mcp/storage.py, tests/test_aws_secrets.py, .env.test, CLAUDE.md, README.md
Unconfigured storage now raises an error unless PLANE_ALLOW_EPHEMERAL_TOKEN_STORE has a truthy value. Tests cover opt-in parsing and Redis SSL propagation. Documentation describes restart-related token loss.
Shared OAuth storage wiring
plane_mcp/server.py, plane_mcp/__main__.py
get_oauth_mcp accepts optional client storage. HTTP startup passes one token store to both OAuth MCP instances.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: ⚪ Minimal · up to 095a6

The change makes token storage and sharing behavior explicit for HTTP/SSE deployments, with no actionable merge-blocking risk remaining after normal checks and review.

Sequence Diagram(s)

sequenceDiagram
  participant HTTPStartup
  participant TokenStoreBuilder
  participant OAuthMCP
  participant SharedTokenStore
  HTTPStartup->>TokenStoreBuilder: Build one token store
  TokenStoreBuilder-->>HTTPStartup: Return shared token store
  HTTPStartup->>OAuthMCP: Create /http instance with shared storage
  HTTPStartup->>OAuthMCP: Create SSE instance with shared storage
  OAuthMCP->>SharedTokenStore: Resolve OAuth client registrations
Loading

Suggested reviewers: pratapalakshmi

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 72.73% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 11 functions across 4 files. (3 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the primary change: requiring a durable OAuth token store for HTTP/SSE deployments.
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.
Full details: Docstring Coverage

Explanation

Docstring coverage is 72.73% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 11 functions across 4 files. (3 skipped: 3 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/durable-oauth-token-store

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.

@lokicik

lokicik commented Aug 27, 2026

Copy link
Copy Markdown

I reproduced a startup regression in the static-password Redis path on the current PR head (095a677).

From a clean checkout:

uv sync --extra dev

uv run pytest tests/test_aws_secrets.py -q

Result: 43 passed, 3 failed.

All three failures come from passing ssl=use_ssl to RedisStore. The clean install resolves py-key-value-aio==0.4.4, which is within the declared >=0.4.4,<0.5.0 range, and its constructor does not accept that argument. As a result, the HTTP/SSE server fails during startup whenever REDIS_PASSWORD is configured. This happens with both REDIS_SSL=true and the default false value.

One possible fix is to apply the TLS setting to a redis.asyncio.Redis client and pass that client through RedisStore(client=...).

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