Skip to content

Drop expired pending authorizations from the in-memory storage - #580

Merged
koic merged 1 commit into
modelcontextprotocol:mainfrom
koic:drop_expired_pending_authorizations_from_the_in_memory_storage
Sep 28, 2026
Merged

koic merged 1 commit into
modelcontextprotocol:mainfrom
koic:drop_expired_pending_authorizations_from_the_in_memory_storage

Conversation

@koic

@koic koic commented Sep 28, 2026

Copy link
Copy Markdown
Member

Motivation and Context

A provider without a callback_handler saves a pending authorization, holding the PKCE verifier and the authorization server metadata, every time Flow#run! sends the user to the authorization server, and Flow#finish! removes only the entry whose state its callback carries. An authorization the user abandoned gets no callback, so its entry stayed in InMemoryStorage for the life of the process, and since the transport starts a new one for every 401 it meets, a server that keeps answering 401 grew the storage by one entry per request. pending_authorization_max_age limited how long an entry could be redeemed, not how long it was kept, while the provider's comment said it kept verifiers from staying in storage indefinitely.

InMemoryStorage now takes a pending_authorization_max_age: of its own, which Provider.new sets to its own value when it builds the default storage, and drops every entry older than it whenever a pending authorization is saved, so what the storage keeps is bounded by the authorizations started within that window. The documentation asks custom storages to expire entries at the same age. The three pending-authorization methods now run under a mutex, so delete_pending_authorization removes and returns the entry in one step on every Ruby implementation, not only where a global interpreter lock makes Hash#delete atomic, which is what Flow#finish! relies on to let exactly one of two callbacks racing on the same state redeem the code. And since every hash the storage holds carries a secret, inspect now reports only whether each is present.

How Has This Been Tested?

New tests in test/mcp/client/oauth/provider_test.rb save entries older and younger than the age and check which ones a later save drops, both on a storage built by hand and on a provider's default storage, check the keyword's validation, and check that inspect shows no token, client secret, or verifier. Against the previous library an entry older than the age is still there after later saves.

Breaking Changes

None. InMemoryStorage#inspect no longer prints the stored hashes.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

@koic
koic force-pushed the drop_expired_pending_authorizations_from_the_in_memory_storage branch from 28a7115 to edcd38b Compare September 28, 2026 02:45
## Motivation and Context

A provider without a `callback_handler` saves a pending authorization, holding the PKCE verifier and
the authorization server metadata, every time `Flow#run!` sends the user to the authorization server,
and `Flow#finish!` removes only the entry whose `state` its callback carries. An authorization the user abandoned
gets no callback, so its entry stayed in `InMemoryStorage` for the life of the process, and since the transport starts
a new one for every `401` it meets, a server that keeps answering `401` grew the storage by one entry per request.
`pending_authorization_max_age` limited how long an entry could be redeemed, not how long it was kept,
while the provider's comment said it kept verifiers from staying in storage indefinitely.

`InMemoryStorage` now takes a `pending_authorization_max_age:` of its own, which `Provider.new` sets to its
own value when it builds the default storage, and drops every entry older than it whenever a pending authorization is saved,
so what the storage keeps is bounded by the authorizations started within that window.
The documentation asks custom storages to expire entries at the same age. The three pending-authorization methods
now run under a mutex, so `delete_pending_authorization` removes and returns the entry in one step on every Ruby implementation,
not only where a global interpreter lock makes `Hash#delete` atomic, which is what `Flow#finish!` relies on to let exactly one of
two callbacks racing on the same `state` redeem the code.
And since every hash the storage holds carries a secret, `inspect` now reports only whether each is present.

## How Has This Been Tested?

New tests in `test/mcp/client/oauth/provider_test.rb` save entries older and younger than the age and check
which ones a later save drops, both on a storage built by hand and on a provider's default storage,
check the keyword's validation, and check that `inspect` shows no token, client secret, or verifier.
Against the previous library an entry older than the age is still there after later saves.

## Breaking Changes

None. `InMemoryStorage#inspect` no longer prints the stored hashes.
@koic
koic force-pushed the drop_expired_pending_authorizations_from_the_in_memory_storage branch from edcd38b to c78d325 Compare September 28, 2026 02:45
@koic
koic merged commit 1811120 into modelcontextprotocol:main Sep 28, 2026
11 checks passed
@koic
koic deleted the drop_expired_pending_authorizations_from_the_in_memory_storage branch September 28, 2026 16:08
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