Conversation
17cd95c to
59dfc68
Compare
A provider without a callback_handler now stops after the redirect: Flow#run! saves a pending authorization in storage, keyed by state, hands the authorization URL to redirect_handler, and returns :redirect, and MCP::Client::HTTP raises Flow::AuthorizationPendingError instead of retrying. Flow#finish! completes the authorization in the request that receives the redirect, which may run in another process. finish! looks the pending authorization up by state before any request, validates the RFC 9207 iss against the recorded issuer before consuming it, reads an error response only after that check, and redeems the code at the recorded token endpoint with the recorded client registration, resource, and redirect_uri, without running discovery again.
587670e to
e42bc7d
Compare
| iss_provided: true, | ||
| ) | ||
|
|
||
| @provider.delete_pending_authorization(state) |
There was a problem hiding this comment.
The read in pending_authorization and the delete here are separate calls, so two callbacks with the same state arriving close together (a retried redirect) can both pass every check and redeem the code twice. Reproduced with a storage that sleeps 200 ms in pending_authorization: two concurrent finish! calls both returned :authorized. The second exchange is then denied and the first one's tokens may be revoked.
Since the storage contract is new, it is worth closing now: have delete_pending_authorization(state) return the entry it removed (nil when there was none, as Hash#delete already does), and let finish! proceed only when it gets one back. The iss-before-consume ordering stays as it is.
| # True when the authorization finishes in a later request: the flow saves a pending authorization, | ||
| # hands the authorization URL to `redirect_handler`, and returns, and `Flow#finish!` completes it | ||
| # in the request that receives the redirect. | ||
| def two_leg? |
There was a problem hiding this comment.
"Two-leg" collides with the OAuth community's vocabulary, where two-legged means the flow with no user involved and three-legged the user-authorization flow this PR splits. A reader who knows that vocabulary will read it as the opposite of what the option does.
The mode may not need a name at all. two_leg? is @callback_handler.nil? and nothing more, so Flow can read callback_handler directly and the predicate can go, which also keeps one less name out of the public API; every authorization-code provider already responds to it. The docs heading can name the audience instead, such as "Authorization in Web Applications", and the test file can use the PR's own noun, pending_authorization_flow_test.rb; the PR title and changelog line would follow. finish!, :redirect, AuthorizationPendingError, and pending_authorization read fine as they are.
What do you think about that?
Closes #572.
Motivation and Context
OAuth::Flow#run!runs the whole authorization code flow in one call and blocks oncallback_handlerfor the code, which suits CLI and desktop clients but not a client hosted in a web application: there, authorization starts in one HTTP request and the redirect lands on another, often in a different process. The verifier andstateexist only on the first request's stack, and assembling the flow outside the SDK means reimplementing registration, issuer binding,issvalidation, and the token exchange that are private toFlow.This follows the TypeScript SDK's shape (
auth()returning'REDIRECT', thenfinishAuth()), whichProviderwas already modeled on, and adds the Rust SDK's binding of pending state tostate(StateStore), consuming it only after the issuer check.With
callback_handler(unchanged): the flow blocks until the code comes back.sequenceDiagram participant App participant HTTP as MCP::Client::HTTP participant Flow as OAuth::Flow participant User as User (browser) participant AS as Authorization server participant MCP as MCP server App->>HTTP: tools/list HTTP->>MCP: POST MCP-->>HTTP: 401 + WWW-Authenticate HTTP->>Flow: run! activate Flow Flow->>MCP: Protected Resource Metadata Flow->>AS: AS metadata, registration Note over Flow: PKCE verifier and state live on this stack Flow->>User: redirect_handler(authorization_url) rect rgba(255, 170, 0, 0.12) Note over Flow,AS: Flow blocks on callback_handler for as long as the user takes User->>AS: sign in and consent AS-->>User: redirect to redirect_uri User-->>Flow: callback_handler returns [code, state, iss] end Flow->>Flow: check state and iss Flow->>AS: token request (code, verifier) AS-->>Flow: tokens Flow-->>HTTP: :authorized deactivate Flow HTTP->>MCP: retry with Bearer token MCP-->>HTTP: 200 HTTP-->>App: resultWithout
callback_handler: each leg returns, and the pending authorization lives instoragebetween them.sequenceDiagram participant A as Request A participant HTTP as MCP::Client::HTTP participant FlowA as Flow (leg 1) participant Store as Shared storage participant User as User (browser) participant AS as Authorization server participant B as Request B (redirect_uri) participant FlowB as Flow (leg 2) rect rgba(80, 120, 255, 0.08) Note over A,FlowA: Leg 1, in whichever process serves request A A->>HTTP: tools/list Note over HTTP: MCP server answers 401 HTTP->>FlowA: run! FlowA->>AS: discovery, registration FlowA->>Store: save_pending_authorization(state, pending) FlowA-->>HTTP: redirect_handler(url), then :redirect HTTP-->>A: raise AuthorizationPendingError(authorization_url) A-->>User: 302 to authorization_url Note over A: Request A ends, nothing waits end User->>AS: sign in and consent AS-->>User: redirect to redirect_uri rect rgba(80, 120, 255, 0.08) Note over B,FlowB: Leg 2, possibly another process User->>B: GET redirect_uri?code&state&iss B->>FlowB: finish!(server_url, callback_params) FlowB->>Store: pending_authorization(state) Note over FlowB: check age, server_url, endpoints, iss FlowB->>Store: delete_pending_authorization(state) FlowB->>AS: token request at recorded endpoint (code, verifier) AS-->>FlowB: tokens FlowB->>Store: save_tokens B-->>User: done end Note over A,HTTP: Later requests send the stored Bearer tokencallback_handler:becomes optional. Without it,Flow#run!saves a pending authorization instoragekeyed bystate, callsredirect_handler, and returns:redirect; the flow'sauthorization_urlreader returns the URL. Providers that passcallback_handler:behave exactly as before.storagegains three optional methods,save_pending_authorization(state, pending),pending_authorization(state), anddelete_pending_authorization(state), required only withoutcallback_handler(Provider::PendingAuthorizationStorageErrorotherwise).InMemoryStorageimplements them. A pending authorization is a JSON-compatible Hash: the PKCE verifier, the canonical MCP server URL,resource,redirect_uri,client_id, the validated authorization server metadata, andcreated_at. The client secret is not copied into it.Flow#finish!(server_url:, callback_params:)takes the redirect's whole query, so the presence ofisscomes from the query itself. It:statebefore any request (unknown, used, malformed, or oversizedstateis refused), and discards and refuses one older thanpending_authorization_max_age(newProviderkeyword, default 600 seconds);server_urlto name the server the authorization began with, and re-checks the recorded endpoints, since the metadata made a round trip through application storage;issagainst the recorded issuer, and requires it when the recorded metadata advertises support, before consuming the entry, so a forged callback with a validstatecannot burn the real verifier;errorresponse (bounded like token endpoint errors) only after the issuer check;resource, andredirect_uri, without running discovery again (SEP-2352).MCP::Client::HTTPwith such a provider raisesFlow::AuthorizationPendingErrorafter the first leg instead of retrying, on both the401and the403 insufficient_scopestep-up paths. It sits outsideAuthorizationErrorso the refresh fallback does not catch it, and it exposesauthorization_urlwhile keeping the URL (which carriesstate) out of the message.Binding the callback to the user who started the authorization stays with the application; the docs call this out and recommend per-user storage.
Open questions from #572, left as proposed here:
finish!lives onFlowonly. A callback endpoint usually has no live transport, so I did not add a convenience onMCP::Client::HTTP; happy to add one if we want parity with TypeScript'stransport.finishAuth.How Has This Been Tested?
test/mcp/client/oauth/two_leg_flow_test.rbdrives both legs through a storage that round-trips every value through JSON, with a fresh provider and flow on the second leg standing in for another process. It covers the saved entry and the PKCE commitment, the token request made from the recorded state without rediscovery, single use, unknown/missing/oversized/Arraystate, expiry, a different MCP server, a replaced registration, a CIMDclient_id, tampered recorded endpoints, a malformed entry, a missing code, and a one-leg provider. Oniss, it checks that a mismatch keeps the entry (and the legitimate callback then completes), that a missingissis refused when advertised, that an error response is surfaced after a matchingiss, and that an error response with a mismatchedissis not surfaced.test/mcp/client/oauth/http_oauth_test.rb: a401with a two-leg provider raisesAuthorizationPendingErrorwithout retrying, and the next request afterfinish!succeeds with the stored token.test/mcp/client/oauth/provider_test.rb: optionalcallback_handler, the storage requirement,pending_authorization_max_agevalidation, andInMemoryStorage.isscheck fails three of the new tests.bundle exec rake test(1913 runs, 0 failures) andbundle exec rake rubocoppass locally on Ruby 3.4.8.Breaking Changes
None.
callback_handler:moves from required to optional; providers that pass it, and custom storages used with them, are unaffected.Types of changes
Checklist