Skip to content

OAuth refresh dedup is scoped per execution stack, allowing cross-session token reuse #1520

Description

@brendanlees

Summary

On self-hosted Executor v1.5.37, concurrent OAuth refreshes are deduplicated within one scoped Executor instance, but not across separate MCP execution stacks or sessions. With providers that rotate refresh tokens, two sessions can submit the same refresh token. The provider then rejects reuse and may revoke the entire token family.

This appears to be a cross-session gap in the concurrency fix from #367.

Environment

  • Official self-hosted v1.5.37 image
  • Single Executor container and single SQLite database
  • Encrypted-secrets credential provider
  • Several toolkit MCP endpoints consumed by multiple long-lived and short-lived MCP clients
  • One Executor replica, with no database restore during the reproduction

Observed behavior

Four separate Todoist MCP connections were reauthorized. Each connection has its own DCR client and user partition; no OAuth client is shared between those connections.

For every connection:

  1. The initial access token worked.
  2. The first post-expiry refresh worked.
  3. Executor persisted the returned rotated refresh token and advanced expires_at.
  4. A later refresh cycle failed with:
invalid_grant: refresh token reuse detected; tokens for this client/user/resource revoked

All four connections eventually reached the same state independently.

A separate self-hosted n8n MCP OAuth connection also refreshed successfully once, then later returned HTTP 401 from its refresh endpoint. A static-token MCP connection remains healthy. Notion OAuth currently continues to refresh, likely because Notion documents a one-token grace window for refresh rotation.

Why this looks cross-session

The v1.5.37 implementation creates refreshInFlight inside the Executor instance:

  • packages/core/sdk/src/executor.ts

A scoped Executor is built by makeScopedExecutor, and MCP server or session construction builds an execution stack containing a new scoped Executor:

  • packages/core/api/src/server/scoped-executor.ts
  • packages/core/api/src/server/mcp-build.ts

As a result, the per-connection refreshInFlight map is not shared by separate MCP execution stacks. Two stacks can both read the same stored refresh token and each believe they are the refresh winner.

This is particularly dangerous for rotating refresh tokens. Notion's own MCP documentation warns that refreshes must use a mutex or distributed lock across workers or replicas and that reuse can revoke the connection:

https://developers.notion.com/guides/mcp/build-mcp-client

Reproduction outline

  1. Run self-hosted Executor v1.5.37.
  2. Add a remote MCP integration using rotating OAuth refresh tokens, such as hosted Todoist MCP.
  3. Create one saved connection and expose it through a toolkit.
  4. Connect two or more independent MCP sessions to that toolkit.
  5. Let the access token expire.
  6. Trigger tool discovery and/or tool calls from the sessions around the same time.
  7. The first refresh may succeed, but a competing stack can reuse the retired refresh token.
  8. On a later call, observe invalid_grant, refresh-token reuse, and required reauthorization.

The race may be easier to trigger when stale remote tool catalogs are also refreshing.

Expected behavior

  • At most one refresh request per tenant, owner, subject, integration, and connection across all MCP sessions in the self-host process.
  • After acquiring the lock, reload the connection and refresh-token value before deciding to refresh.
  • For multi-replica deployments, use database-backed coordination or compare-and-swap token rotation rather than a process-local map.
  • Waiters should use the newly persisted token instead of replaying the previous token.

Actual behavior

Refresh deduplication is scoped to one Executor instance or execution stack, so independent MCP sessions can still reuse a rotating refresh token and permanently invalidate the connection.

Additional notes

  • No credentials, tokens, private URLs, or user identifiers are included here.
  • The first successful refresh can make the issue appear resolved until a later expiry cycle.
  • Provider error reporting varies: Todoist clearly reports token reuse, while other providers may return only 401 or 404 responses.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions