Skip to content

feat: Redis-backed OAuth state + stateless transport for multi-instance support#15

Merged
artiom merged 3 commits into
mainfrom
feat/redis-oauth-store
Apr 13, 2026
Merged

feat: Redis-backed OAuth state + stateless transport for multi-instance support#15
artiom merged 3 commits into
mainfrom
feat/redis-oauth-store

Conversation

@Adriansillo
Copy link
Copy Markdown
Contributor

@Adriansillo Adriansillo commented Apr 13, 2026

Summary

Enables the MCP server to run with multiple instances behind a load balancer by:

  1. Redis-backed OAuth state — New RedisOAuthProxy extends OAuthProxy and stores the two pieces of OAuth flow state that need cross-instance sharing (transactions at /authorize and authorization codes at /callback) in Redis with TTL-based expiry. The MCP client's token exchange can now land on any instance.

  2. Stateless HTTP transport — Enables FastMCP's stateless: true httpStream option. Each request creates a fresh server/transport, eliminating in-memory activeTransports tracking. No sticky sessions needed.

Config

  • REDIS_URL env var — when set, uses RedisOAuthProxy; otherwise falls back to default in-memory OAuthProxy.

Design notes

  • registerClient and handleConsent are not overridden: registeredClients Map is write-only (never read by other methods), and consent is disabled in our config.
  • PKCE validation is delegated to the parent class by temporarily placing the code in the in-memory Map for the duration of the super call.
  • Date fields are serialized/deserialized through JSON with custom handling to preserve Date types.

Test plan

  • Deployed to test environment — OAuth flow works end-to-end
  • No more "Session not found" errors with stateless transport
  • Multi-instance test (increase max_instances > 1 in terraform)
  • Monitor Redis key churn/TTL expiry in production

Closes PLT-953

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Optional Redis integration to persist OAuth state for multi-instance deployments
    • New REDIS_URL environment variable to configure the Redis connection
    • OAuth proxy now supports Redis-backed authorization flows for more robust distributed operation
    • HTTP stream transport now operates in stateless mode for improved scaling

Adriansillo and others added 2 commits April 10, 2026 16:17
Add RedisOAuthProxy that stores OAuth transactions and authorization
codes in Redis instead of in-memory Maps. This enables the OAuth flow
to work across multiple server instances behind a load balancer.

When REDIS_URL env var is set, the proxy uses Redis; otherwise falls
back to the default in-memory OAuthProxy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Stateless mode creates a fresh server/transport per request, eliminating
the need for sticky sessions. Each instance can handle any request
independently.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@linear
Copy link
Copy Markdown

linear Bot commented Apr 13, 2026

PLT-953 Redis-backed OAuth state for multi-instance MCP deployments

Problem

The OAuth flow (authorize → callback → token exchange) stores state in in-memory Maps inside OAuthProxy. In multi-instance deployments behind a load balancer, the callback from Supabase can land on a different instance than the one that initiated the authorize request, causing "Invalid or expired state" errors. Similarly, VS Code's token exchange request may hit a different instance than the one that handled the callback.

This was causing production 404 "Session not found" errors and failed OAuth flows.

Fix

Created RedisOAuthProxy that extends OAuthProxy and overrides the 3 methods that need cross-instance state:

  • authorize() — stores transaction in Redis
  • handleCallback() — reads transaction from Redis, stores authorization code in Redis
  • exchangeAuthorizationCode() — reads code from Redis, delegates PKCE validation to parent

When REDIS_URL env var is set, the proxy uses Redis with TTL-based expiry; otherwise falls back to the default in-memory OAuthProxy.

registerClient and handleConsent are NOT overridden because registeredClients is write-only (never read by other methods) and consent is disabled.

Branch

feat/redis-oauth-store — Docker image building from this branch for testing.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 13, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b4464a66-f633-4636-b11e-4fd05a074fe0

📥 Commits

Reviewing files that changed from the base of the PR and between 07d8293 and b08177a.

📒 Files selected for processing (1)
  • src/index.ts

Walkthrough

Adds Redis-backed OAuth state: new ioredis dependency, redisUrl config, a RedisOAuthProxy storing transactions and client codes in Redis, and conditional wiring in the app to use Redis when REDIS_URL is set.

Changes

Cohort / File(s) Summary
Dependency Management
package.json
Added runtime dependency ioredis (^5.10.1).
Configuration
src/config.ts
Added optional redisUrl?: string to McpConfig; getConfig() reads process.env.REDIS_URL.
Application bootstrap
src/index.ts
Creates Redis client when config.redisUrl is set (with error/ready logging). Expands allowed redirect patterns and instantiates RedisOAuthProxy(..., redisClient) when available. Adds stateless: true to httpStream config.
Redis-backed OAuth
src/lib/redis-oauth-proxy.ts
New exported RedisOAuthProxy extending OAuthProxy. Persists OAuth transactions and client authorization codes in Redis with TTLs. Overrides authorize(), handleCallback(), and exchangeAuthorizationCode() to read/write Redis and enforce one-time use and PKCE validation. Adds JSON (de)serialization preserving Date fields.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant MCP as MCP Server (RedisOAuthProxy)
    participant Redis
    participant Provider as OAuth Provider (Upstream)

    Client->>MCP: /authorize request
    MCP->>MCP: validate params & PKCE
    MCP->>Redis: store OAuthTransaction (with TTL)
    MCP->>Provider: redirect to upstream authorize
    Provider-->>Client: redirect back with code

    Client->>MCP: /callback with code & state
    MCP->>Redis: load OAuthTransaction
    MCP->>Provider: exchange upstream code for tokens
    Provider-->>MCP: return tokens
    MCP->>Redis: store client authorization code (with TTL)
    MCP->>Redis: delete transaction key
    MCP-->>Client: redirect with client code & state

    Client->>MCP: /token exchange (authorization_code + code_verifier)
    MCP->>Redis: load client code
    MCP->>MCP: validate PKCE (may delegate to parent in-memory map)
    MCP->>Redis: delete client code (one-time use)
    MCP-->>Client: return TokenResponse
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related issues

  • PLT-953: Implements the Redis-backed OAuth state approach described in the issue by adding RedisOAuthProxy and conditional REDIS_URL wiring to support multi-instance deployments.

Poem

🐰 With hops and keys I store the flow,
Transactions safe where red rivers go.
Codes bloom, TTLs keep the beat,
No lost states when instances meet.
Hooray — redispatched OAuth, neat! 🎉

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. 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 accurately describes the main changes: adding Redis-backed OAuth state persistence and enabling stateless transport for multi-instance support.
Linked Issues check ✅ Passed All coding objectives from PLT-953 are met: RedisOAuthProxy persists transactions/codes in Redis, stateless transport is enabled, Redis is conditionally used based on REDIS_URL, and PKCE validation is delegated to parent.
Out of Scope Changes check ✅ Passed All changes align with the PR objectives: ioredis dependency, Redis configuration, RedisOAuthProxy implementation, and stateless transport configuration directly address multi-instance OAuth support requirements.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

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

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (3)
src/lib/redis-oauth-proxy.ts (3)

253-255: Consider documenting Redis client lifecycle.

The destroy() method delegates to the parent but doesn't close the Redis connection. This is correct since the Redis client is shared and managed externally (in src/index.ts). Consider adding a brief comment clarifying this intentional design.

   override destroy(): void {
+    // Redis client is shared and managed externally; not closed here
     super.destroy();
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/redis-oauth-proxy.ts` around lines 253 - 255, Add a short inline
comment to the override destroy() method in src/lib/redis-oauth-proxy.ts
explaining that the method intentionally delegates to super.destroy() and does
not close the Redis connection because the Redis client is shared and
lifecycle-managed externally (referencing the shared client initialization in
src/index.ts); mention that closing the client here would be unsafe to avoid
confusion for future maintainers.

61-80: Fragile reliance on private OAuthProxy internals.

Accessing TypeScript-private members via type casting (_internal) is inherently fragile. If fastmcp renames or restructures these internal methods (createTransaction, generateAuthorizationCode, etc.) in a minor version update, this code will break at runtime without compile-time warnings.

Consider adding a version check or documenting the specific fastmcp version this was tested against:

// Tested with fastmcp@3.33.0 - internal API may change in future versions

Alternatively, consider opening an issue with the fastmcp maintainers to expose stable APIs for custom storage backends.

Also applies to: 90-92

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/redis-oauth-proxy.ts` around lines 61 - 80, This code relies on
private internals of OAuthProxy via the OAuthProxyInternals cast and the
_internal property (including createTransaction, generateAuthorizationCode,
exchangeUpstreamCode, redirectToUpstream, clientCodes, config), which is
fragile; update the implementation to add a runtime guard and documentation:
detect and assert presence and expected types of the _internal members at
startup (throw a clear error if missing or shape differs), log or throw a
descriptive message that includes the fastmcp version, and add a short comment
noting the tested fastmcp version (e.g., tested with fastmcp@X.Y.Z) and/or open
an issue with fastmcp to request a stable public API; use these checks before
calling createTransaction/generateAuthorizationCode/redirectToUpstream so the
failure is explicit rather than a silent runtime break.

166-177: Add defensive check for missing codeData.

If generateAuthorizationCode succeeds but somehow the code isn't in the Map (unlikely but possible in edge cases or future fastmcp changes), the authorization code won't be persisted to Redis. This would cause a silent failure where the callback succeeds but subsequent token exchange fails.

🛡️ Suggested defensive handling
     const clientCode = this._internal.generateAuthorizationCode(
       transaction,
       upstreamTokens,
     );
     const codeData = this._internal.clientCodes.get(clientCode);
-    if (codeData) {
+    if (!codeData) {
+      throw new OAuthProxyError(
+        'server_error',
+        'Failed to generate authorization code',
+      );
+    }
     const codeTtl =
       this._internal.config.authorizationCodeTtl || DEFAULT_CODE_TTL;
     await this.redis.set(
       `${CODE_PREFIX}${clientCode}`,
       serialize(codeData),
       'EX',
       codeTtl,
     );
     this._internal.clientCodes.delete(clientCode);
-    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/redis-oauth-proxy.ts` around lines 166 - 177, Add a defensive check
after reading from this._internal.clientCodes: if codeData is undefined, log a
clear error including the clientCode (use this._internal.logger.error or
equivalent) and throw an error (or return a rejected promise) so the caller of
generateAuthorizationCode does not silently succeed without persisting the code;
otherwise proceed with computing codeTtl
(this._internal.config.authorizationCodeTtl || DEFAULT_CODE_TTL), call
this.redis.set(`${CODE_PREFIX}${clientCode}`, serialize(codeData), 'EX',
codeTtl), and delete the entry with
this._internal.clientCodes.delete(clientCode).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/index.ts`:
- Around line 68-74: The "Redis connected" message is misleading because ioredis
connects lazily; update the logic around the redisClient creation: instead of
logging connection immediately after new Redis(...), attach a listener for the
Redis client's actual connection event (e.g., 'connect' or 'ready') and log
"Redis connected for OAuth state storage" inside that handler, or change the
immediate log to a neutral message like "Redis client instantiated for OAuth
state storage"; reference the redisClient variable and its 'on' event handler
used for 'error' to add the new 'connect'/'ready' handler or to alter the
existing log text.

---

Nitpick comments:
In `@src/lib/redis-oauth-proxy.ts`:
- Around line 253-255: Add a short inline comment to the override destroy()
method in src/lib/redis-oauth-proxy.ts explaining that the method intentionally
delegates to super.destroy() and does not close the Redis connection because the
Redis client is shared and lifecycle-managed externally (referencing the shared
client initialization in src/index.ts); mention that closing the client here
would be unsafe to avoid confusion for future maintainers.
- Around line 61-80: This code relies on private internals of OAuthProxy via the
OAuthProxyInternals cast and the _internal property (including
createTransaction, generateAuthorizationCode, exchangeUpstreamCode,
redirectToUpstream, clientCodes, config), which is fragile; update the
implementation to add a runtime guard and documentation: detect and assert
presence and expected types of the _internal members at startup (throw a clear
error if missing or shape differs), log or throw a descriptive message that
includes the fastmcp version, and add a short comment noting the tested fastmcp
version (e.g., tested with fastmcp@X.Y.Z) and/or open an issue with fastmcp to
request a stable public API; use these checks before calling
createTransaction/generateAuthorizationCode/redirectToUpstream so the failure is
explicit rather than a silent runtime break.
- Around line 166-177: Add a defensive check after reading from
this._internal.clientCodes: if codeData is undefined, log a clear error
including the clientCode (use this._internal.logger.error or equivalent) and
throw an error (or return a rejected promise) so the caller of
generateAuthorizationCode does not silently succeed without persisting the code;
otherwise proceed with computing codeTtl
(this._internal.config.authorizationCodeTtl || DEFAULT_CODE_TTL), call
this.redis.set(`${CODE_PREFIX}${clientCode}`, serialize(codeData), 'EX',
codeTtl), and delete the entry with
this._internal.clientCodes.delete(clientCode).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 75812b3e-8aea-49c0-9fa5-a70581909a9a

📥 Commits

Reviewing files that changed from the base of the PR and between 0e81586 and 07d8293.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (4)
  • package.json
  • src/config.ts
  • src/index.ts
  • src/lib/redis-oauth-proxy.ts

Comment thread src/index.ts
ioredis connects lazily — logging immediately after instantiation is
misleading. Move the log to the 'ready' event so it reflects actual
connection status.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@artiom artiom merged commit 8f67b8a into main Apr 13, 2026
1 check passed
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