Skip to content

fix(auth): replay duplicate refresh rotations - #4911

Draft
zzstoatzz wants to merge 2 commits into
mainfrom
nate--refresh-token-grace-period
Draft

fix(auth): replay duplicate refresh rotations#4911
zzstoatzz wants to merge 2 commits into
mainfrom
nate--refresh-token-grace-period

Conversation

@zzstoatzz

Copy link
Copy Markdown
Collaborator

A client-facing refresh token is currently deleted as soon as it rotates, so a lost-response retry or overlapping /token request can turn a successful refresh into invalid_grant and force a new authorization flow. This adds an opt-in, bounded idempotency window that returns the exact response from the original rotation rather than refreshing upstream or minting another descendant.

The secure default remains strict one-time use. Operators that observe duplicate refreshes can enable a window of at most 60 seconds on OAuthProxy, OIDCProxy, or any built-in proxy provider:

from fastmcp.server.auth.providers.azure import AzureProvider

auth = AzureProvider(
    tenant_id="your-tenant-id",
    client_id="your-client-id",
    client_secret="your-client-secret",
    base_url="https://mcp.example.com",
    refresh_token_grace_period_seconds=30,
)
Security model

The window defaults to zero because accepting a consumed bearer token weakens replay detection. An eligible retry must use the same client and requested scopes, and its immediate successor must still have valid refresh-token metadata and a live JTI mapping. Consuming or revoking the successor invalidates the predecessor's cached response, preventing an older A → B response from being replayed after B → C. Explicitly revoking the predecessor also removes its cached response, and the cache lifetime never exceeds the predecessor's own expiry.

The exact response is retained in client_storage only for the configured window. FastMCP's default storage is encrypted; custom stores already hold upstream bearer credentials and must provide equivalent protection.

Concurrency boundary

A per-token lock serializes overlapping refreshes within one process. The short-lived response record is stored before the predecessor is invalidated, so retries and other workers arriving after that commit observe one successor. The generic key-value interface has no compare-and-swap primitive, so this does not claim global serialization when two different processes both pass the record check before either commits.

Fixes #4901

🤖 Generated with OpenAI Codex

🤖 Generated with OpenAI Codex
@marvin-context-protocol marvin-context-protocol Bot added auth Related to authentication (Bearer, JWT, OAuth, WorkOS) for client or server. bug Something isn't working. Reports of errors, unexpected behavior, or broken functionality. labels Aug 26, 2026
🤖 Generated with OpenAI Codex
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auth Related to authentication (Bearer, JWT, OAuth, WorkOS) for client or server. bug Something isn't working. Reports of errors, unexpected behavior, or broken functionality.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OAuthProxy: client refresh tokens are rotated with no grace period, causing permanent session loss under concurrent /token requests

1 participant